diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index b9188b5f7..868340b16 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -273,6 +273,7 @@ import org.whispersystems.textsecuregcm.workers.BackupMetricsCommand; import org.whispersystems.textsecuregcm.workers.BackupUsageRecalculationCommand; import org.whispersystems.textsecuregcm.workers.CertificateCommand; import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand; +import org.whispersystems.textsecuregcm.workers.ClearIssuedReceiptRedemptionsCommand; import org.whispersystems.textsecuregcm.workers.DeleteUserCommand; import org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory; import org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand; @@ -342,6 +343,7 @@ public class WhisperServerService extends Application clearIssuance(String processorItemId, PaymentProvider processor) { + final AttributeValue key = dynamoDbKey(processor, processorItemId); + final DeleteItemRequest deleteItemRequest = DeleteItemRequest.builder() + .tableName(table) + .key(Map.of(KEY_PROCESSOR_ITEM_ID, key)) + .returnValues(ReturnValue.ALL_OLD) + .build(); + return dynamoDbAsyncClient.deleteItem(deleteItemRequest) + .thenApply(item -> item.hasAttributes() && !item.attributes().isEmpty()); + } + @VisibleForTesting static AttributeValue dynamoDbKey(final PaymentProvider processor, String processorItemId) { if (processor == PaymentProvider.STRIPE) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ClearIssuedReceiptRedemptionsCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ClearIssuedReceiptRedemptionsCommand.java new file mode 100644 index 000000000..99f2ff960 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ClearIssuedReceiptRedemptionsCommand.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.workers; + +import io.dropwizard.core.Application; +import io.dropwizard.core.setup.Environment; +import java.time.Clock; +import java.util.Optional; +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.WhisperServerConfiguration; +import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; +import org.whispersystems.textsecuregcm.storage.SubscriberCredentials; +import org.whispersystems.textsecuregcm.storage.SubscriptionManager; +import org.whispersystems.textsecuregcm.storage.Subscriptions; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor; + +public class ClearIssuedReceiptRedemptionsCommand extends AbstractCommandWithDependencies { + + private final Logger logger = LoggerFactory.getLogger(ClearIssuedReceiptRedemptionsCommand.class); + + public ClearIssuedReceiptRedemptionsCommand() { + super(new Application<>() { + @Override + public void run(WhisperServerConfiguration configuration, Environment environment) { + + } + }, "clear-issued-receipt-redemptions", "Clear issued receipt redemptions"); + } + + @Override + public void configure(Subparser subparser) { + super.configure(subparser); + subparser.addArgument("-s", "--subscriber-id") + .dest("subscriberId") + .type(String.class) + .required(true) + .help("The subscriber-id whose receipt redemptions should be clear"); + } + + @Override + protected void run(Environment environment, Namespace namespace, WhisperServerConfiguration configuration, + CommandDependencies deps) throws Exception { + try { + final String subscriberId = namespace.getString("subscriberId"); + + final SubscriberCredentials creds = SubscriberCredentials + .process(Optional.empty(), subscriberId, Clock.systemUTC()); + + final IssuedReceiptsManager issuedReceiptsManager = deps.issuedReceiptsManager(); + final SubscriptionManager subscriptionManager = deps.subscriptionManager(); + + final Subscriptions.Record subscriber = subscriptionManager.getSubscriber(creds); + final PaymentProvider processorType = subscriber.getProcessorCustomer() + .orElseThrow(() -> new IllegalArgumentException("susbcriber did not have a subscription")) + .processor(); + final SubscriptionPaymentProcessor processor = switch (processorType) { + case APPLE_APP_STORE -> deps.appleAppStoreManager(); + case GOOGLE_PLAY_BILLING -> deps.googlePlayBillingManager(); + default -> + throw new IllegalStateException("Cannot clear issued receipts for a non-IAP processor: " + processorType); + }; + final SubscriptionPaymentProcessor.ReceiptItem receiptItem = processor.getReceiptItem(subscriber.subscriptionId); + final boolean deleted = issuedReceiptsManager.clearIssuance(receiptItem.itemId(), processorType).join(); + logger.info("Deleted issuances for receiptItem: {}, subscriberId: {}, hadExistingIssuances: {}", + receiptItem.itemId(), subscriberId, deleted); + } catch (Exception ex) { + logger.warn("Removal Exception", ex); + throw new RuntimeException(ex); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java index c4a80c959..b6e790183 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java @@ -11,16 +11,23 @@ import com.codahale.metrics.MetricRegistry; import com.fasterxml.jackson.databind.DeserializationFeature; import io.dropwizard.core.setup.Environment; import io.lettuce.core.resource.ClientResources; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.time.Clock; +import java.util.Collections; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.SynchronousQueue; import org.signal.libsignal.zkgroup.GenericServerSecretParams; import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.WhisperServerConfiguration; import org.whispersystems.textsecuregcm.WhisperServerService; import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator; @@ -72,6 +79,10 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; import org.whispersystems.textsecuregcm.storage.ReportMessageManager; import org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore; import org.whispersystems.textsecuregcm.storage.SingleUseKEMPreKeyStore; +import org.whispersystems.textsecuregcm.storage.SubscriptionManager; +import org.whispersystems.textsecuregcm.storage.Subscriptions; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; +import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; import org.whispersystems.textsecuregcm.util.ManagedAwsCrt; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; @@ -101,6 +112,9 @@ record CommandDependencies( ClientResources.Builder redisClusterClientResourcesBuilder, BackupManager backupManager, IssuedReceiptsManager issuedReceiptsManager, + GooglePlayBillingManager googlePlayBillingManager, + AppleAppStoreManager appleAppStoreManager, + SubscriptionManager subscriptionManager, DynamicConfigurationManager dynamicConfigurationManager, DynamoDbAsyncClient dynamoDbAsyncClient, PhoneNumberIdentifiers phoneNumberIdentifiers, @@ -110,7 +124,7 @@ record CommandDependencies( final String name, final Environment environment, final WhisperServerConfiguration configuration) - throws IOException, CertificateException, NoSuchAlgorithmException, InvalidKeyException { + throws IOException, GeneralSecurityException, InvalidInputException { Clock clock = Clock.systemUTC(); environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -310,6 +324,30 @@ record CommandDependencies( configuration.getDynamoDbTables().getIssuedReceipts().getGenerator(), configuration.getDynamoDbTables().getIssuedReceipts().getmaxIssuedReceiptsPerPaymentId()); + final ServerSecretParams zkSecretParams = new ServerSecretParams(configuration.getZkConfig().serverSecret().value()); + final ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams); + GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager( + new ByteArrayInputStream(configuration.getGooglePlayBilling().credentialsJson().value().getBytes(StandardCharsets.UTF_8)), + configuration.getGooglePlayBilling().packageName(), + configuration.getGooglePlayBilling().applicationName(), + configuration.getGooglePlayBilling().productIdToLevel()); + AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager( + configuration.getAppleAppStore().env(), + configuration.getAppleAppStore().bundleId(), + configuration.getAppleAppStore().appAppleId(), + configuration.getAppleAppStore().issuerId(), + configuration.getAppleAppStore().keyId(), + configuration.getAppleAppStore().encodedKey().value(), + configuration.getAppleAppStore().subscriptionGroupId(), + configuration.getAppleAppStore().productIdToLevel(), + configuration.getAppleAppStore().appleRootCerts(), + configuration.getAppleAppStore().retryConfigurationName()); + final SubscriptionManager subscriptionManager = new SubscriptionManager( + new Subscriptions(configuration.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient), + List.of(googlePlayBillingManager, appleAppStoreManager), + zkReceiptOperations, + issuedReceiptsManager); + APNSender apnSender = new APNSender(apnSenderExecutor, configuration.getApnConfiguration()); FcmSender fcmSender = new FcmSender(fcmSenderExecutor, configuration.getFcmConfiguration().credentials().value()); PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster, @@ -346,6 +384,9 @@ record CommandDependencies( redisClientResourcesBuilder, backupManager, issuedReceiptsManager, + googlePlayBillingManager, + appleAppStoreManager, + subscriptionManager, dynamicConfigurationManager, dynamoDbAsyncClient, phoneNumberIdentifiers, diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommandTest.java index 689e5d53f..e1cfb8d71 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommandTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommandTest.java @@ -83,6 +83,9 @@ class FinishPushNotificationExperimentCommandTest { null, null, null, + null, + null, + null, null); //noinspection unchecked diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java index b0d3733bd..6d793ea6a 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java @@ -65,6 +65,9 @@ class NotifyIdleDevicesCommandTest { null, null, null, + null, + null, + null, null); this.idleDeviceNotificationScheduler = idleDeviceNotificationScheduler; diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommandTest.java index 472b423a0..e2cfb5244 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommandTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommandTest.java @@ -50,6 +50,9 @@ class RegenerateSecondaryDynamoDbTableDataCommandTest { null, null, null, + null, + null, + null, dynamoDbRecoveryManager); namespace = new Namespace(Map.of( diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommandTest.java index 443bc196e..b5643e411 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommandTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommandTest.java @@ -72,6 +72,9 @@ class StartPushNotificationExperimentCommandTest { null, null, null, + null, + null, + null, null); }