diff --git a/service/config/sample.yml b/service/config/sample.yml index f04b62525..9f0b48860 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -102,6 +102,8 @@ dynamoDbTables: messages: tableName: Example_Messages expiration: P30D # Duration of time until rows expire + onetimeDonations: + tableName: Example_OnetimeDonations phoneNumberIdentifiers: tableName: Example_PhoneNumberIdentifiers profiles: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e5e45da9b..d270215f5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -187,6 +187,7 @@ import org.whispersystems.textsecuregcm.storage.KeysManager; import org.whispersystems.textsecuregcm.storage.MessagesCache; import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; import org.whispersystems.textsecuregcm.storage.MessagesManager; +import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; import org.whispersystems.textsecuregcm.storage.Profiles; import org.whispersystems.textsecuregcm.storage.ProfilesManager; @@ -547,6 +548,8 @@ public class WhisperServerService extends Application { - Instant expiration = paymentDetails.created() + .thenCompose(unused -> oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created())) + .thenApply(paidAt -> { + Instant expiration = paidAt .plus(levelExpiration) .truncatedTo(ChronoUnit.DAYS) .plus(1, ChronoUnit.DAYS); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/OneTimeDonationsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/OneTimeDonationsManager.java new file mode 100644 index 000000000..24bf29a65 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/OneTimeDonationsManager.java @@ -0,0 +1,66 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import static com.codahale.metrics.MetricRegistry.name; + +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.Metrics; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import org.whispersystems.textsecuregcm.util.AttributeValues; +import org.whispersystems.textsecuregcm.util.Util; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; + +public class OneTimeDonationsManager { + public static final String KEY_PAYMENT_INTENT_ID = "P"; // S + public static final String ATTR_PAID_AT = "A"; // N + private static final String ONETIME_DONATION_NOT_FOUND_COUNTER_NAME = name(OneTimeDonationsManager.class, "onetimeDonationNotFound"); + private final String table; + private final DynamoDbAsyncClient dynamoDbAsyncClient; + + public OneTimeDonationsManager( + @Nonnull String table, + @Nonnull DynamoDbAsyncClient dynamoDbAsyncClient) { + this.table = Objects.requireNonNull(table); + this.dynamoDbAsyncClient = Objects.requireNonNull(dynamoDbAsyncClient); + } + + public CompletableFuture getPaidAt(final String paymentIntentId, final Instant fallbackTimestamp) { + final GetItemRequest getItemRequest = GetItemRequest.builder() + .consistentRead(Boolean.TRUE) + .tableName(table) + .key(Map.of(KEY_PAYMENT_INTENT_ID, AttributeValues.fromString(paymentIntentId))) + .projectionExpression(ATTR_PAID_AT) + .build(); + + return dynamoDbAsyncClient.getItem(getItemRequest).thenApply(getItemResponse -> { + if (!getItemResponse.hasItem()) { + Metrics.counter(ONETIME_DONATION_NOT_FOUND_COUNTER_NAME).increment(); + return fallbackTimestamp; + } + + return Instant.ofEpochSecond(AttributeValues.getLong(getItemResponse.item(), ATTR_PAID_AT, fallbackTimestamp.getEpochSecond())); + }); + } + + @VisibleForTesting + CompletableFuture putPaidAt(final String paymentIntentId, final Instant paidAt) { + return dynamoDbAsyncClient.putItem(PutItemRequest.builder() + .tableName(table) + .item(Map.of( + KEY_PAYMENT_INTENT_ID, AttributeValues.fromString(paymentIntentId), + ATTR_PAID_AT, AttributeValues.fromLong(paidAt.getEpochSecond()))) + .build()) + .thenRun(Util.NOOP); + + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 37aef1e5b..a4157e275 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -68,6 +68,7 @@ import org.whispersystems.textsecuregcm.entities.BadgeSvg; import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper; import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; +import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager; import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; @@ -98,12 +99,13 @@ class SubscriptionControllerTest { private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class); private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class); private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); + private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class); private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController( CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, - ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, BANK_MANDATE_TRANSLATOR); + ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR, BANK_MANDATE_TRANSLATOR); private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) .addProvider(AuthHelper.getAuthFilter()) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java index 86d73847d..1fb403f6f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java @@ -242,6 +242,15 @@ public final class DynamoDbExtensionSchema { .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) .build())), + ONETIME_DONATIONS("onetime_donations_test", + OneTimeDonationsManager.KEY_PAYMENT_INTENT_ID, + null, + List.of(AttributeDefinition.builder() + .attributeName(OneTimeDonationsManager.KEY_PAYMENT_INTENT_ID) + .attributeType(ScalarAttributeType.S) + .build()), + List.of(), List.of()), + PROFILES("profiles_test", Profiles.KEY_ACCOUNT_UUID, Profiles.ATTR_VERSION, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/OnetimeDonationsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/OnetimeDonationsManagerTest.java new file mode 100644 index 000000000..96587c07d --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/OnetimeDonationsManagerTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class OnetimeDonationsManagerTest { + @RegisterExtension + static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.ONETIME_DONATIONS); + private OneTimeDonationsManager oneTimeDonationsManager; + + @BeforeEach + void beforeEach() { + oneTimeDonationsManager = new OneTimeDonationsManager( + DynamoDbExtensionSchema.Tables.ONETIME_DONATIONS.tableName(), + DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient()); + } + + @Test + void testGetPaidAtTimestamp() { + final String validPaymentIntentId = "abc"; + final Instant paidAt = Instant.ofEpochSecond(1_000_000); + final Instant fallBackTimestamp = Instant.ofEpochSecond(2_000_000); + oneTimeDonationsManager.putPaidAt(validPaymentIntentId, paidAt).join(); + + assertThat(oneTimeDonationsManager.getPaidAt(validPaymentIntentId, fallBackTimestamp).join()).isEqualTo(paidAt); + assertThat(oneTimeDonationsManager.getPaidAt("invalidPaymentId", fallBackTimestamp).join()).isEqualTo(fallBackTimestamp); + } +}