Calculate onetime badge expiration from payment success timestamp

This commit is contained in:
Katherine 2023-12-14 15:39:46 -05:00 committed by GitHub
parent 1167d0ac2e
commit 3548c3df15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 138 additions and 5 deletions

View File

@ -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:

View File

@ -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<WhisperServerConfiguration
config.getDynamoDbTables().getIssuedReceipts().getExpiration(),
dynamoDbAsyncClient,
config.getDynamoDbTables().getIssuedReceipts().getGenerator());
OneTimeDonationsManager oneTimeDonationsManager = new OneTimeDonationsManager(
config.getDynamoDbTables().getOnetimeDonations().getTableName(), dynamoDbAsyncClient);
RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,
config.getDynamoDbTables().getRedeemedReceipts().getTableName(),
dynamoDbAsyncClient,
@ -832,8 +835,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter,
resourceBundleLevelTranslator, bankMandateTranslator));
subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager,
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
}
for (Object controller : commonControllers) {

View File

@ -59,6 +59,7 @@ public class DynamoDbTables {
private final Table kemKeys;
private final Table kemLastResortKeys;
private final TableWithExpiration messages;
private final Table onetimeDonations;
private final Table phoneNumberIdentifiers;
private final Table profiles;
private final Table pushChallenge;
@ -82,6 +83,7 @@ public class DynamoDbTables {
@JsonProperty("pqKeys") final Table kemKeys,
@JsonProperty("pqLastResortKeys") final Table kemLastResortKeys,
@JsonProperty("messages") final TableWithExpiration messages,
@JsonProperty("onetimeDonations") final Table onetimeDonations,
@JsonProperty("phoneNumberIdentifiers") final Table phoneNumberIdentifiers,
@JsonProperty("profiles") final Table profiles,
@JsonProperty("pushChallenge") final Table pushChallenge,
@ -104,6 +106,7 @@ public class DynamoDbTables {
this.kemKeys = kemKeys;
this.kemLastResortKeys = kemLastResortKeys;
this.messages = messages;
this.onetimeDonations = onetimeDonations;
this.phoneNumberIdentifiers = phoneNumberIdentifiers;
this.profiles = profiles;
this.pushChallenge = pushChallenge;
@ -187,6 +190,12 @@ public class DynamoDbTables {
return messages;
}
@NotNull
@Valid
public Table getOnetimeDonations() {
return onetimeDonations;
}
@NotNull
@Valid
public Table getPhoneNumberIdentifiers() {

View File

@ -85,6 +85,7 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
@ -117,6 +118,7 @@ public class SubscriptionController {
private final BraintreeManager braintreeManager;
private final ServerZkReceiptOperations zkReceiptOperations;
private final IssuedReceiptsManager issuedReceiptsManager;
private final OneTimeDonationsManager oneTimeDonationsManager;
private final BadgeTranslator badgeTranslator;
private final LevelTranslator levelTranslator;
private final BankMandateTranslator bankMandateTranslator;
@ -137,6 +139,7 @@ public class SubscriptionController {
@Nonnull BraintreeManager braintreeManager,
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
@Nonnull IssuedReceiptsManager issuedReceiptsManager,
@Nonnull OneTimeDonationsManager oneTimeDonationsManager,
@Nonnull BadgeTranslator badgeTranslator,
@Nonnull LevelTranslator levelTranslator,
@Nonnull BankMandateTranslator bankMandateTranslator) {
@ -148,6 +151,7 @@ public class SubscriptionController {
this.braintreeManager = Objects.requireNonNull(braintreeManager);
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager);
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
this.levelTranslator = Objects.requireNonNull(levelTranslator);
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
@ -852,8 +856,9 @@ public class SubscriptionController {
final long finalLevel = level;
return issuedReceiptsManager.recordIssuance(paymentDetails.id(), manager.getProcessor(),
receiptCredentialRequest, clock.instant())
.thenApply(unused -> {
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);

View File

@ -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<Instant> 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<Void> 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);
}
}

View File

@ -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())

View File

@ -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,

View File

@ -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);
}
}