Add a metric for duplicate receipt redemption

This commit is contained in:
Ravi Khadiwala authored and ravi-signal committed 2026-09-16 12:43:22 -05:00
1 parent 4f4bd431f5
commit b30fc7aa8b
4 files changed
+26 -6

No files matched your search

@@ -756,7 +756,8 @@ public class SubscriptionController {
try {
final SubscriptionManager.ReceiptResult receiptCredential = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.receiptCredentialRequest(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r),
userAgent);
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
@@ -441,7 +441,8 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
try {
final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials(
subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(),
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r),
RequestAttributesUtil.getUserAgent().orElse(null));
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
Tags.of(
Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()),
@@ -4,6 +4,9 @@
*/
package org.whispersystems.textsecuregcm.storage;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.Tags;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
@@ -15,17 +18,21 @@ import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.ReceiptLevel;
import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation;
@@ -53,6 +60,9 @@ import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
*/
public class SubscriptionManager {
private static final String RECEIPT_ALREADY_REDEEMED_COUNTER_NAME = MetricsUtil.name(SubscriptionManager.class,
"receiptAlreadyRedeemed");
private final Subscriptions subscriptions;
private final EnumMap<PaymentProvider, SubscriptionPaymentProcessor> processors;
private final ServerZkReceiptOperations zkReceiptOperations;
@@ -177,6 +187,7 @@ public class SubscriptionManager {
* @param expiration A function that takes a
* {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} and returns the
* expiration time of the receipt
* @param userAgent The requesting client's user agent
* @return the requested ZK receipt credential
* @throws SubscriptionForbiddenException if the subscriber credentials were incorrect
* @throws SubscriptionNotFoundException if the subscriber did not exist or did not have a
@@ -193,7 +204,8 @@ public class SubscriptionManager {
public ReceiptResult createReceiptCredentials(
final SubscriberCredentials subscriberCredentials,
final byte[] receiptCredentialRequestBytes,
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration)
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration,
@Nullable final String userAgent)
throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException, SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionReceiptAlreadyRedeemedException {
final Subscriptions.Record record = getSubscriber(subscriberCredentials);
if (record.subscriptionId == null) {
@@ -222,6 +234,12 @@ public class SubscriptionManager {
} catch (final VerificationFailedException e) {
throw new SubscriptionInvalidArgumentsException("receipt credential request failed verification", e);
} catch (final WriteConflictException _) {
Metrics.counter(RECEIPT_ALREADY_REDEEMED_COUNTER_NAME, Tags.of(
Tag.of("receiptLevel", ReceiptLevel.lookupLevel(receipt.level())
.map(ReceiptLevel::name)
.orElse("n/a")),
UserAgentTagUtil.getPlatformTag(userAgent)))
.increment();
throw new SubscriptionReceiptAlreadyRedeemedException();
}
return new ReceiptResult(receiptCredentialResponse, receipt, processor);
@@ -576,7 +576,7 @@ public class SubscriptionsGrpcServiceTest extends
final ReceiptCredentialResponse receiptCredentialResponse = mock(ReceiptCredentialResponse.class);
final byte[] responseBytes = TestRandomUtil.nextBytes(16);
when(receiptCredentialResponse.serialize()).thenReturn(responseBytes);
when(subscriptionManager.createReceiptCredentials(any(), any(), any()))
when(subscriptionManager.createReceiptCredentials(any(), any(), any(), any()))
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, new SubscriptionPaymentProcessor.ReceiptItem("test-item-id", null, 5), PaymentProvider.STRIPE));
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
GetReceiptCredentialsRequest.newBuilder()
@@ -602,7 +602,7 @@ public class SubscriptionsGrpcServiceTest extends
@MethodSource
void getReceiptCredentialsExceptions(final SubscriptionException exception,
final GetReceiptCredentialsResponse.ResponseCase expectedCase) throws Exception {
doThrow(exception).when(subscriptionManager).createReceiptCredentials(any(), any(), any());
doThrow(exception).when(subscriptionManager).createReceiptCredentials(any(), any(), any(), any());
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
GetReceiptCredentialsRequest.newBuilder()
.setSubscriberId(SUBSCRIBER_ID)
@@ -615,7 +615,7 @@ public class SubscriptionsGrpcServiceTest extends
void getReceiptCredentialsChargeFailure() throws Exception {
doThrow(new SubscriptionChargeFailurePaymentRequiredException(PaymentProvider.STRIPE,
new ChargeFailure("card_declined", "Insufficient funds", null, null, null)))
.when(subscriptionManager).createReceiptCredentials(any(), any(), any());
.when(subscriptionManager).createReceiptCredentials(any(), any(), any(), any());
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
GetReceiptCredentialsRequest.newBuilder()
.setSubscriberId(SUBSCRIBER_ID)