Compare commits
6 Commits
v20260624.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5ac70ad3 | ||
|
|
2abf55e395 | ||
|
|
808bb16103 | ||
|
|
4743abcfbd | ||
|
|
1b09529ece | ||
|
|
0c3c390a0b |
@ -176,6 +176,7 @@ import org.whispersystems.textsecuregcm.grpc.MessageDispatcher;
|
||||
import org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.MessagesGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor;
|
||||
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationsGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileAnonymousGrpcService;
|
||||
import org.whispersystems.textsecuregcm.grpc.ProfileGrpcService;
|
||||
@ -1095,7 +1096,10 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
new CredentialsAnonymousGrpcService(accountsManager, ExternalServiceDefinitions.SVR.generatorFactory().apply(config, Clock.systemUTC())),
|
||||
new SubscriptionsGrpcService(clock, config.getSubscription(), config.getOneTimeDonations(), subscriptionManager,
|
||||
donationPermitsManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,
|
||||
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager))
|
||||
profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager),
|
||||
new OneTimeDonationsGrpcService(config.getOneTimeDonations(), stripeManager, braintreeManager,
|
||||
payPalDonationsTranslator, oneTimeDonationsManager, issuedReceiptsManager,
|
||||
zkReceiptOperations, clock, rateLimiters, donationPermitsManager))
|
||||
.map(bindableService -> ServerInterceptors.intercept(bindableService,
|
||||
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
|
||||
// depends on the user-agent context so it has to come first here!
|
||||
|
||||
@ -33,6 +33,8 @@ import org.glassfish.jersey.server.ManagedAsync;
|
||||
import org.signal.keytransparency.client.AciMonitorRequest;
|
||||
import org.signal.keytransparency.client.E164MonitorRequest;
|
||||
import org.signal.keytransparency.client.E164SearchRequest;
|
||||
import org.signal.keytransparency.client.MonitorResponseV2;
|
||||
import org.signal.keytransparency.client.SearchResponseV2;
|
||||
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -101,15 +103,23 @@ public class KeyTransparencyController {
|
||||
.build()
|
||||
));
|
||||
|
||||
return new KeyTransparencySearchResponse(
|
||||
keyTransparencyServiceClient.search(
|
||||
ByteString.copyFrom(request.aci().toCompactByteArray()),
|
||||
ByteString.copyFrom(request.aciIdentityKey().serialize()),
|
||||
request.usernameHash().map(ByteString::copyFrom),
|
||||
maybeE164SearchRequest,
|
||||
request.lastTreeHeadSize(),
|
||||
request.distinguishedTreeHeadSize())
|
||||
.toByteArray());
|
||||
final SearchResponseV2 searchResponse = keyTransparencyServiceClient.search(
|
||||
ByteString.copyFrom(request.aci().toCompactByteArray()),
|
||||
ByteString.copyFrom(request.aciIdentityKey().serialize()),
|
||||
request.usernameHash().map(ByteString::copyFrom),
|
||||
maybeE164SearchRequest,
|
||||
request.lastTreeHeadSize(),
|
||||
request.distinguishedTreeHeadSize());
|
||||
|
||||
if (searchResponse.hasPermissionDenied()) {
|
||||
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
if (!searchResponse.hasSearchResponse()) {
|
||||
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing search response"));
|
||||
}
|
||||
|
||||
return new KeyTransparencySearchResponse(searchResponse.getSearchResponse().toByteArray());
|
||||
} catch (final StatusRuntimeException exception) {
|
||||
handleKeyTransparencyServiceError(exception);
|
||||
}
|
||||
@ -163,13 +173,22 @@ public class KeyTransparencyController {
|
||||
.setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))
|
||||
.build());
|
||||
|
||||
return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(
|
||||
final MonitorResponseV2 monitorResponse = keyTransparencyServiceClient.monitor(
|
||||
aciMonitorRequest,
|
||||
usernameHashMonitorRequest,
|
||||
e164MonitorRequest,
|
||||
request.lastNonDistinguishedTreeHeadSize(),
|
||||
request.lastDistinguishedTreeHeadSize())
|
||||
.toByteArray());
|
||||
request.lastDistinguishedTreeHeadSize());
|
||||
|
||||
if (monitorResponse.hasPermissionDenied()) {
|
||||
throw new StatusRuntimeException(Status.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
if (!monitorResponse.hasMonitorResponse()) {
|
||||
throw new StatusRuntimeException(Status.UNAVAILABLE.withDescription("Missing monitor response"));
|
||||
}
|
||||
|
||||
return new KeyTransparencyMonitorResponse(monitorResponse.getMonitorResponse().toByteArray());
|
||||
} catch (final StatusRuntimeException exception) {
|
||||
handleKeyTransparencyServiceError(exception);
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@ -55,6 +56,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.DonationPermitHeader;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.grpc.OneTimeDonationUtil;
|
||||
import org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
@ -72,11 +74,9 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
|
||||
|
||||
/**
|
||||
* Endpoints for making one-time donation payments (boost and gift)
|
||||
* <p>
|
||||
@ -90,8 +90,6 @@ public class OneTimeDonationController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class);
|
||||
|
||||
private static final String EURO_CURRENCY_CODE = "EUR";
|
||||
|
||||
private final Clock clock;
|
||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
|
||||
private final StripeManager stripeManager;
|
||||
@ -103,15 +101,15 @@ public class OneTimeDonationController {
|
||||
private final DonationPermitsManager donationPermitsManager;
|
||||
|
||||
public OneTimeDonationController(
|
||||
Clock clock,
|
||||
OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
StripeManager stripeManager,
|
||||
BraintreeManager braintreeManager,
|
||||
PayPalDonationsTranslator payPalDonationsTranslator,
|
||||
ServerZkReceiptOperations zkReceiptOperations,
|
||||
IssuedReceiptsManager issuedReceiptsManager,
|
||||
OneTimeDonationsManager oneTimeDonationsManager,
|
||||
DonationPermitsManager donationPermitsManager) {
|
||||
final Clock clock,
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
final StripeManager stripeManager,
|
||||
final BraintreeManager braintreeManager,
|
||||
final PayPalDonationsTranslator payPalDonationsTranslator,
|
||||
final ServerZkReceiptOperations zkReceiptOperations,
|
||||
final IssuedReceiptsManager issuedReceiptsManager,
|
||||
final OneTimeDonationsManager oneTimeDonationsManager,
|
||||
final DonationPermitsManager donationPermitsManager) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
|
||||
this.stripeManager = Objects.requireNonNull(stripeManager);
|
||||
@ -151,10 +149,10 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a Stripe payment intent", description = """
|
||||
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
|
||||
|
||||
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
|
||||
""")
|
||||
Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.
|
||||
|
||||
Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "Payment Intent created", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel")
|
||||
@ApiResponse(responseCode = "400", description = """
|
||||
@ -171,13 +169,12 @@ public class OneTimeDonationController {
|
||||
@ApiResponse(responseCode = "401", description = "Donation permit was invalid or already spent")
|
||||
@RateLimitedByIp(RateLimiters.For.ONE_TIME_DONATION)
|
||||
public CompletableFuture<Response> createBoostPaymentIntent(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
|
||||
@HeaderParam(HeaderUtils.DONATION_PERMIT)
|
||||
final Optional<DonationPermitHeader> donationPermitHeader,
|
||||
@Parameter(description = "A base64-encoded donation permit retrieved from POST /v1/donation/permit")
|
||||
@HeaderParam(HeaderUtils.DONATION_PERMIT) final Optional<DonationPermitHeader> donationPermitHeader,
|
||||
|
||||
@NotNull @Valid CreateBoostRequest request,
|
||||
@NotNull @Valid final CreateBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
if (authenticatedAccount.isPresent()) {
|
||||
@ -189,7 +186,7 @@ public class OneTimeDonationController {
|
||||
permitHeader -> {
|
||||
try {
|
||||
return SubscriptionsUtil.verifyAndSpendDonationPermit(permitHeader.permit(), donationPermitsManager, clock);
|
||||
} catch (VerificationFailedException e) {
|
||||
} catch (final VerificationFailedException e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
@ -206,47 +203,34 @@ public class OneTimeDonationController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the request level is valid, the currency is supported by the {@code manager} and {@code request.paymentMethod},
|
||||
* and that the amount meets minimum and maximum constraints.
|
||||
* Validates that the request level is valid, the currency is supported by the {@code manager} and
|
||||
* {@code request.paymentMethod}, and that the amount meets minimum and maximum constraints.
|
||||
*
|
||||
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
|
||||
*/
|
||||
private void validateRequestCurrencyAmount(final CreateBoostRequest request, final BigDecimal amount,
|
||||
final CustomerAwareSubscriptionPaymentProcessor manager) {
|
||||
|
||||
if (!(request.level == oneTimeDonationConfiguration.gift().level()
|
||||
|| request.level == oneTimeDonationConfiguration.boost().level())) {
|
||||
final Map<String, String> errorBody = switch (OneTimeDonationUtil.validateOneTimeDonationRequest(request.currency,
|
||||
amount, request.level, request.paymentMethod, oneTimeDonationConfiguration, manager)) {
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
|
||||
Map.of("error", "invalid_level");
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
|
||||
Map.of("error", "unsupported_currency");
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum(final BigDecimal min) ->
|
||||
Map.of("error", "amount_below_currency_minimum",
|
||||
"minimum", min.toString());
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(final BigDecimal max) ->
|
||||
Map.of("error", "amount_above_sepa_limit",
|
||||
"maximum", max.toString());
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> Collections.emptyMap();
|
||||
};
|
||||
|
||||
if (!errorBody.isEmpty()) {
|
||||
throw new BadRequestException(
|
||||
Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid_level")).build());
|
||||
Response.status(Response.Status.BAD_REQUEST).entity(errorBody).build());
|
||||
}
|
||||
|
||||
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
|
||||
.contains(request.currency.toLowerCase(Locale.ROOT))) {
|
||||
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "unsupported_currency")).build());
|
||||
}
|
||||
|
||||
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
|
||||
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
|
||||
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
|
||||
request.currency,
|
||||
minCurrencyAmountMajorUnits);
|
||||
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
|
||||
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of(
|
||||
"error", "amount_below_currency_minimum",
|
||||
"minimum", minCurrencyAmountMajorUnits.toString())).build());
|
||||
}
|
||||
|
||||
if (request.paymentMethod == PaymentMethod.SEPA_DEBIT &&
|
||||
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
|
||||
EURO_CURRENCY_CODE,
|
||||
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
|
||||
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of(
|
||||
"error", "amount_above_sepa_limit",
|
||||
"maximum", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build());
|
||||
}
|
||||
}
|
||||
|
||||
public static class CreatePayPalBoostRequest extends CreateBoostRequest {
|
||||
@ -268,9 +252,9 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPayPalBoost(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid CreatePayPalBoostRequest request,
|
||||
@Context ContainerRequestContext containerRequestContext) {
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final CreatePayPalBoostRequest request,
|
||||
@Context final ContainerRequestContext containerRequestContext) {
|
||||
|
||||
if (authenticatedAccount.isPresent()) {
|
||||
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
|
||||
@ -281,21 +265,11 @@ public class OneTimeDonationController {
|
||||
.thenCompose(_ -> {
|
||||
final List<Locale> acceptableLanguages =
|
||||
HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);
|
||||
|
||||
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
|
||||
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
|
||||
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
|
||||
// independently.
|
||||
final Locale locale = acceptableLanguages.stream()
|
||||
.filter(l -> !"*".equals(l.getLanguage()))
|
||||
.findFirst()
|
||||
.orElse(Locale.US);
|
||||
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages,
|
||||
PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
|
||||
|
||||
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
|
||||
payPalDonationsTranslator, acceptableLanguages);
|
||||
return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,
|
||||
locale.toLanguageTag(),
|
||||
request.returnUrl, request.cancelUrl, localizedLineItemName);
|
||||
localizedLineItem.locale().toLanguageTag(),
|
||||
request.returnUrl, request.cancelUrl, localizedLineItem.itemName());
|
||||
})
|
||||
.thenApply(approvalDetails -> Response.ok(
|
||||
new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());
|
||||
@ -322,8 +296,8 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> confirmPayPalBoost(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid ConfirmPayPalBoostRequest request,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final ConfirmPayPalBoostRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
if (authenticatedAccount.isPresent()) {
|
||||
@ -366,7 +340,7 @@ public class OneTimeDonationController {
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createBoostReceiptCredentials(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@Auth final Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@NotNull @Valid final CreateBoostReceiptCredentialsRequest request,
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {
|
||||
|
||||
@ -374,68 +348,56 @@ public class OneTimeDonationController {
|
||||
throw new ForbiddenException("must not use authenticated connection for one-time donation operations");
|
||||
}
|
||||
|
||||
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
|
||||
final CompletableFuture<Optional<PaymentDetails>> paymentDetailsFut = switch (request.processor) {
|
||||
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
|
||||
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
|
||||
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
|
||||
case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
|
||||
};
|
||||
|
||||
return paymentDetailsFut.thenApply(paymentDetails -> {
|
||||
if (paymentDetails == null) {
|
||||
return paymentDetailsFut.thenApply(maybePaymentDetails -> {
|
||||
if (maybePaymentDetails.isEmpty()) {
|
||||
throw new WebApplicationException(Response.Status.NOT_FOUND);
|
||||
} else if (paymentDetails.status() == PaymentStatus.PROCESSING) {
|
||||
}
|
||||
final PaymentDetails paymentDetails = maybePaymentDetails.get();
|
||||
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
|
||||
return Response.noContent().build();
|
||||
} else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
|
||||
}
|
||||
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
|
||||
throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)
|
||||
.entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());
|
||||
}
|
||||
|
||||
// The payment was successful, try to issue the receipt credential
|
||||
|
||||
long level = oneTimeDonationConfiguration.boost().level();
|
||||
if (paymentDetails.customMetadata() != null) {
|
||||
String levelMetadata = paymentDetails.customMetadata()
|
||||
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
|
||||
try {
|
||||
level = Long.parseLong(levelMetadata);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
|
||||
paymentDetails.id(), e);
|
||||
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
Duration levelExpiration;
|
||||
if (oneTimeDonationConfiguration.boost().level() == level) {
|
||||
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
|
||||
} else if (oneTimeDonationConfiguration.gift().level() == level) {
|
||||
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
|
||||
} else {
|
||||
logger.error("level ({}) returned from payment intent that is unknown to the server", level);
|
||||
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
|
||||
try {
|
||||
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
|
||||
} catch (OneTimeDonationUtil.InvalidLevelException _) {
|
||||
throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
ReceiptCredentialRequest receiptCredentialRequest;
|
||||
|
||||
final ReceiptCredentialRequest receiptCredentialRequest;
|
||||
try {
|
||||
receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);
|
||||
} catch (InvalidInputException e) {
|
||||
} catch (final InvalidInputException e) {
|
||||
throw new BadRequestException("invalid receipt credential request", e);
|
||||
}
|
||||
final long finalLevel = level;
|
||||
try {
|
||||
issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,
|
||||
receiptCredentialRequest, clock.instant());
|
||||
} catch (final WriteConflictException _) {
|
||||
} catch (WriteConflictException _) {
|
||||
throw new WebApplicationException(Response.Status.CONFLICT);
|
||||
}
|
||||
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
|
||||
final Instant expiration = paidAt
|
||||
.plus(levelExpiration)
|
||||
.plus(levelDetails.levelExpiration())
|
||||
.truncatedTo(ChronoUnit.DAYS)
|
||||
.plus(1, ChronoUnit.DAYS);
|
||||
final ReceiptCredentialResponse receiptCredentialResponse;
|
||||
try {
|
||||
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
||||
receiptCredentialRequest, expiration.getEpochSecond(), finalLevel);
|
||||
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
|
||||
} catch (final VerificationFailedException e) {
|
||||
throw new BadRequestException("receipt credential request failed verification", e);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildCurrencyConfiguration;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.buildDonationLevelsConfiguration;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||
@ -216,7 +217,7 @@ public class SubscriptionController {
|
||||
public Response updateSubscriber(
|
||||
@Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
|
||||
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit")
|
||||
@Parameter(description="A base64-encoded donation permit retrieved from POST /v1/donation/permit. Not required if the subscriber already exists.")
|
||||
@HeaderParam(HeaderUtils.DONATION_PERMIT)
|
||||
final Optional<DonationPermitHeader> donationPermitHeader,
|
||||
|
||||
@ -318,10 +319,7 @@ public class SubscriptionController {
|
||||
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()
|
||||
.filter(l -> !"*".equals(l.getLanguage()))
|
||||
.findFirst()
|
||||
.orElse(Locale.US);
|
||||
final Locale locale = getPayPalLocale(HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext));
|
||||
|
||||
final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(
|
||||
subscriberCredentials,
|
||||
|
||||
@ -15,8 +15,10 @@ import org.signal.keytransparency.client.E164MonitorRequest;
|
||||
import org.signal.keytransparency.client.E164SearchRequest;
|
||||
import org.signal.keytransparency.client.MonitorRequest;
|
||||
import org.signal.keytransparency.client.MonitorResponse;
|
||||
import org.signal.keytransparency.client.MonitorResponseV2;
|
||||
import org.signal.keytransparency.client.SearchRequest;
|
||||
import org.signal.keytransparency.client.SearchResponse;
|
||||
import org.signal.keytransparency.client.SearchResponseV2;
|
||||
import org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;
|
||||
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
|
||||
import org.whispersystems.textsecuregcm.controllers.AccountController;
|
||||
@ -39,102 +41,51 @@ public class KeyTransparencyGrpcService extends
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {
|
||||
public SearchResponseV2 searchV2(final SearchRequest request) throws RateLimitExceededException {
|
||||
rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
|
||||
return client.search(validateSearchRequest(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {
|
||||
public MonitorResponseV2 monitorV2(final MonitorRequest request) throws RateLimitExceededException {
|
||||
rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
|
||||
return client.monitor(validateMonitorRequest(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {
|
||||
public DistinguishedResponse distinguishedV2(final DistinguishedRequest request) throws RateLimitExceededException {
|
||||
rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());
|
||||
// A client's very first distinguished request will not have a "last" parameter
|
||||
if (request.hasLast() && request.getLast() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
|
||||
}
|
||||
return client.distinguished(request);
|
||||
}
|
||||
|
||||
private SearchRequest validateSearchRequest(final SearchRequest request) {
|
||||
validateAci(request.getAci().toByteArray());
|
||||
|
||||
if (request.hasE164SearchRequest()) {
|
||||
final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();
|
||||
if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Unidentified access key and E164 must be provided together or not at all").asRuntimeException();
|
||||
throw GrpcExceptions.fieldViolation("e164_search_request", "Unidentified access key and E164 must be provided together or not at all");
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.getConsistency().hasDistinguished()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished tree head size").asRuntimeException();
|
||||
}
|
||||
|
||||
validateConsistencyParameters(request.getConsistency());
|
||||
return request;
|
||||
}
|
||||
|
||||
private void validateAci(final byte[] aci) {
|
||||
try {
|
||||
AciServiceIdentifier.fromBytes(aci);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw GrpcExceptions.fieldViolation("aci", "Invalid ACI");
|
||||
}
|
||||
}
|
||||
|
||||
private MonitorRequest validateMonitorRequest(final MonitorRequest request) {
|
||||
final AciMonitorRequest aciMonitorRequest = request.getAci();
|
||||
validateAci(request.getAci().getAci().toByteArray());
|
||||
|
||||
try {
|
||||
AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Invalid ACI").asRuntimeException();
|
||||
}
|
||||
if (aciMonitorRequest.getEntryPosition() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Aci entry position must be positive").asRuntimeException();
|
||||
}
|
||||
if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Aci commitment index must be 32 bytes").asRuntimeException();
|
||||
if (!request.getConsistency().hasLast()) {
|
||||
throw GrpcExceptions.fieldViolation("consistency_last", "Must provide distinguished and last tree head sizes");
|
||||
}
|
||||
|
||||
if (request.hasUsernameHash()) {
|
||||
final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();
|
||||
if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Username hash cannot be empty").asRuntimeException();
|
||||
}
|
||||
if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Invalid username hash length").asRuntimeException();
|
||||
}
|
||||
if (usernameHashMonitorRequest.getEntryPosition() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Username hash entry position must be positive").asRuntimeException();
|
||||
}
|
||||
if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Username hash commitment index must be 32 bytes").asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
if (request.hasE164()) {
|
||||
final E164MonitorRequest e164MonitorRequest = request.getE164();
|
||||
if (e164MonitorRequest.getE164().isEmpty()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("E164 cannot be empty").asRuntimeException();
|
||||
}
|
||||
if (e164MonitorRequest.getEntryPosition() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("E164 entry position must be positive").asRuntimeException();
|
||||
}
|
||||
if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("E164 commitment index must be 32 bytes").asRuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Must provide distinguished and last tree head sizes").asRuntimeException();
|
||||
}
|
||||
|
||||
validateConsistencyParameters(request.getConsistency());
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void validateConsistencyParameters(final ConsistencyParameters consistency) {
|
||||
if (consistency.getDistinguished() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Distinguished tree head size must be positive").asRuntimeException();
|
||||
}
|
||||
|
||||
if (consistency.hasLast() && consistency.getLast() <= 0) {
|
||||
throw Status.INVALID_ARGUMENT.withDescription("Last tree head size must be positive").asRuntimeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
|
||||
|
||||
public class OneTimeDonationUtil {
|
||||
|
||||
private static final String EURO_CURRENCY_CODE = "EUR";
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(OneTimeDonationUtil.class);
|
||||
|
||||
/// Thrown if a one time donation level cannot be parsed or if it is not found in configuration
|
||||
public static class InvalidLevelException extends Exception {
|
||||
|
||||
public InvalidLevelException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public record LocalizedPayPalDonationLineItem(Locale locale, String itemName){}
|
||||
public record DonationLevelDetails(long level, Duration levelExpiration){}
|
||||
|
||||
public sealed interface OneTimeDonationRequestValidationResult permits OneTimeDonationRequestValidationResult.Success,
|
||||
OneTimeDonationRequestValidationResult.UnsupportedCurrency,
|
||||
OneTimeDonationRequestValidationResult.UnsupportedLevel,
|
||||
OneTimeDonationRequestValidationResult.AmountBelowMinimum,
|
||||
OneTimeDonationRequestValidationResult.AmountAboveSepaLimit {
|
||||
|
||||
record Success() implements OneTimeDonationRequestValidationResult {}
|
||||
|
||||
record UnsupportedCurrency() implements OneTimeDonationRequestValidationResult {}
|
||||
|
||||
record UnsupportedLevel() implements OneTimeDonationRequestValidationResult {}
|
||||
|
||||
record AmountBelowMinimum(BigDecimal minimum) implements OneTimeDonationRequestValidationResult {}
|
||||
|
||||
record AmountAboveSepaLimit(BigDecimal maximum) implements OneTimeDonationRequestValidationResult {}
|
||||
|
||||
}
|
||||
|
||||
public static OneTimeDonationRequestValidationResult validateOneTimeDonationRequest(
|
||||
final String currency,
|
||||
final BigDecimal amount,
|
||||
final long level,
|
||||
final PaymentMethod paymentMethod,
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
final CustomerAwareSubscriptionPaymentProcessor manager
|
||||
) {
|
||||
|
||||
if (!(level == oneTimeDonationConfiguration.gift().level()
|
||||
|| level == oneTimeDonationConfiguration.boost().level())) {
|
||||
return new OneTimeDonationRequestValidationResult.UnsupportedLevel();
|
||||
}
|
||||
|
||||
if (!manager.getSupportedCurrenciesForPaymentMethod(paymentMethod)
|
||||
.contains(currency.toLowerCase(Locale.ROOT))) {
|
||||
return new OneTimeDonationRequestValidationResult.UnsupportedCurrency();
|
||||
}
|
||||
|
||||
final BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
|
||||
.get(currency.toLowerCase(Locale.ROOT)).minimum();
|
||||
final BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
|
||||
currency,
|
||||
minCurrencyAmountMajorUnits);
|
||||
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
|
||||
return new OneTimeDonationRequestValidationResult.AmountBelowMinimum(minCurrencyAmountMajorUnits);
|
||||
}
|
||||
|
||||
if (paymentMethod == PaymentMethod.SEPA_DEBIT &&
|
||||
amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
|
||||
EURO_CURRENCY_CODE,
|
||||
oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {
|
||||
return new OneTimeDonationRequestValidationResult.AmountAboveSepaLimit(
|
||||
oneTimeDonationConfiguration.sepaMaximumEuros());
|
||||
}
|
||||
return new OneTimeDonationRequestValidationResult.Success();
|
||||
}
|
||||
|
||||
public static LocalizedPayPalDonationLineItem localizePayPalDonationLineItem(
|
||||
final PayPalDonationsTranslator payPalDonationsTranslator, final List<Locale> acceptableLocales) {
|
||||
// These two localizations are a best-effort, and it's possible that the first `locale` and the localized line
|
||||
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
|
||||
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
|
||||
// independently.
|
||||
final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales);
|
||||
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales,
|
||||
org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
|
||||
return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName);
|
||||
}
|
||||
|
||||
public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails,
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration)
|
||||
throws InvalidLevelException {
|
||||
|
||||
long level = oneTimeDonationConfiguration.boost().level();
|
||||
if (paymentDetails.customMetadata() != null) {
|
||||
final String levelMetadata = paymentDetails.customMetadata()
|
||||
.getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level()));
|
||||
try {
|
||||
level = Long.parseLong(levelMetadata);
|
||||
} catch (final NumberFormatException e) {
|
||||
LOGGER.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata,
|
||||
paymentDetails.id(), e);
|
||||
throw new InvalidLevelException("failed to parse level metadata");
|
||||
}
|
||||
}
|
||||
|
||||
final Duration levelExpiration;
|
||||
if (level == oneTimeDonationConfiguration.boost().level()) {
|
||||
levelExpiration = oneTimeDonationConfiguration.boost().expiration();
|
||||
} else if (level == oneTimeDonationConfiguration.gift().level()) {
|
||||
levelExpiration = oneTimeDonationConfiguration.gift().expiration();
|
||||
} else {
|
||||
LOGGER.error("level ({}) returned from payment intent that is unknown to the server", level);
|
||||
throw new InvalidLevelException("unrecognized level");
|
||||
}
|
||||
return new DonationLevelDetails(level, levelExpiration);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.toChargeFailure;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.signal.chat.errors.FailedPrecondition;
|
||||
import org.signal.chat.errors.FailedZkAuthentication;
|
||||
import org.signal.chat.errors.NotFound;
|
||||
import org.signal.chat.one_time_donations.AmountAboveSepaLimitError;
|
||||
import org.signal.chat.one_time_donations.AmountBelowMinimumError;
|
||||
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
|
||||
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
|
||||
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
|
||||
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
|
||||
import org.signal.chat.one_time_donations.CreateBoostRequest;
|
||||
import org.signal.chat.one_time_donations.CreateBoostResponse;
|
||||
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
|
||||
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
|
||||
import org.signal.chat.one_time_donations.SimpleOneTimeDonationsGrpc;
|
||||
import org.signal.chat.subscriptions.PaymentRequired;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermit;
|
||||
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.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
|
||||
public class OneTimeDonationsGrpcService extends SimpleOneTimeDonationsGrpc.OneTimeDonationsImplBase {
|
||||
|
||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration;
|
||||
private final StripeManager stripeManager;
|
||||
private final BraintreeManager braintreeManager;
|
||||
private final PayPalDonationsTranslator payPalDonationsTranslator;
|
||||
private final OneTimeDonationsManager oneTimeDonationsManager;
|
||||
private final IssuedReceiptsManager issuedReceiptsManager;
|
||||
private final ServerZkReceiptOperations zkReceiptOperations;
|
||||
private final Clock clock;
|
||||
private final RateLimiters rateLimiters;
|
||||
private final DonationPermitsManager donationPermitsManager;
|
||||
|
||||
public OneTimeDonationsGrpcService(
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration,
|
||||
final StripeManager stripeManager,
|
||||
final BraintreeManager braintreeManager,
|
||||
final PayPalDonationsTranslator payPalDonationsTranslator,
|
||||
final OneTimeDonationsManager oneTimeDonationsManager,
|
||||
final IssuedReceiptsManager issuedReceiptsManager,
|
||||
final ServerZkReceiptOperations zkReceiptOperations,
|
||||
final Clock clock,
|
||||
final RateLimiters rateLimiters,
|
||||
final DonationPermitsManager donationPermitsManager) {
|
||||
this.oneTimeDonationConfiguration = oneTimeDonationConfiguration;
|
||||
this.stripeManager = stripeManager;
|
||||
this.braintreeManager = braintreeManager;
|
||||
this.payPalDonationsTranslator = payPalDonationsTranslator;
|
||||
this.oneTimeDonationsManager = oneTimeDonationsManager;
|
||||
this.issuedReceiptsManager = issuedReceiptsManager;
|
||||
this.zkReceiptOperations = zkReceiptOperations;
|
||||
this.clock = clock;
|
||||
this.rateLimiters = rateLimiters;
|
||||
this.donationPermitsManager = donationPermitsManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateBoostResponse createBoost(final CreateBoostRequest request) throws RateLimitExceededException {
|
||||
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
|
||||
|
||||
try {
|
||||
final DonationPermit donationPermit = new DonationPermit(request.getDonationPermit().toByteArray());
|
||||
if (!donationPermitsManager.spend(donationPermit)) {
|
||||
return CreateBoostResponse.newBuilder()
|
||||
.setPermitRejected(FailedZkAuthentication.newBuilder()
|
||||
.setDescription("donation permit rejected")
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
} catch (InvalidInputException | VerificationFailedException _) {
|
||||
return CreateBoostResponse.newBuilder()
|
||||
.setPermitRejected(FailedZkAuthentication.newBuilder()
|
||||
.setDescription("donation permit rejected")
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
final org.whispersystems.textsecuregcm.subscriptions.PaymentMethod paymentMethod =
|
||||
switch (request.getPaymentMethod()) {
|
||||
case PAYMENT_METHOD_CARD -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD;
|
||||
case PAYMENT_METHOD_SEPA_DEBIT -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT;
|
||||
case PAYMENT_METHOD_IDEAL -> org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.IDEAL;
|
||||
default -> throw GrpcExceptions.fieldViolation("payment_method", "Unsupported payment method");
|
||||
};
|
||||
|
||||
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
|
||||
OneTimeDonationUtil.validateOneTimeDonationRequest(
|
||||
request.getCurrency(),
|
||||
BigDecimal.valueOf(request.getAmount()),
|
||||
request.getLevel(),
|
||||
paymentMethod,
|
||||
oneTimeDonationConfiguration,
|
||||
stripeManager);
|
||||
|
||||
return switch (validationResult) {
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
|
||||
CreateBoostResponse.newBuilder()
|
||||
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
|
||||
CreateBoostResponse.newBuilder()
|
||||
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
|
||||
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
|
||||
CreateBoostResponse.newBuilder()
|
||||
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
|
||||
.setMinimum(r.minimum().toString()).build()).build();
|
||||
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit r ->
|
||||
CreateBoostResponse.newBuilder()
|
||||
.setAmountAboveSepaLimit(AmountAboveSepaLimitError.newBuilder()
|
||||
.setMaximum(r.maximum().toString()).build()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
|
||||
final com.stripe.model.PaymentIntent paymentIntent = stripeManager.createPaymentIntent(
|
||||
request.getCurrency(), request.getAmount(), request.getLevel(),
|
||||
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
|
||||
yield CreateBoostResponse.newBuilder().setClientSecret(paymentIntent.getClientSecret()).build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreatePayPalBoostResponse createPayPalBoost(final CreatePayPalBoostRequest request) {
|
||||
|
||||
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
|
||||
OneTimeDonationUtil.validateOneTimeDonationRequest(
|
||||
request.getCurrency(),
|
||||
BigDecimal.valueOf(request.getAmount()),
|
||||
request.getLevel(),
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
|
||||
oneTimeDonationConfiguration,
|
||||
braintreeManager);
|
||||
|
||||
return switch (validationResult) {
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
|
||||
CreatePayPalBoostResponse.newBuilder()
|
||||
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
|
||||
CreatePayPalBoostResponse.newBuilder()
|
||||
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
|
||||
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
|
||||
CreatePayPalBoostResponse.newBuilder()
|
||||
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
|
||||
.setMinimum(r.minimum().toString()).build()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
|
||||
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
|
||||
final List<Locale> acceptableLocales = RequestAttributesUtil.getAvailableAcceptedLocales();
|
||||
final OneTimeDonationUtil.LocalizedPayPalDonationLineItem localizedLineItem = OneTimeDonationUtil.localizePayPalDonationLineItem(
|
||||
payPalDonationsTranslator, acceptableLocales);
|
||||
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
|
||||
braintreeManager.createOneTimePayment(
|
||||
request.getCurrency().toUpperCase(Locale.ROOT), request.getAmount(),
|
||||
localizedLineItem.locale().toLanguageTag(), request.getReturnUrl(), request.getCancelUrl(),
|
||||
localizedLineItem.itemName()).join();
|
||||
yield CreatePayPalBoostResponse.newBuilder()
|
||||
.setResult(CreatePayPalBoostResponse.CreatePayPalBoostResult.newBuilder()
|
||||
.setApprovalUrl(approvalDetails.approvalUrl())
|
||||
.setPaymentId(approvalDetails.paymentId()).build()).build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConfirmPayPalBoostResponse confirmPayPalBoost(final ConfirmPayPalBoostRequest request)
|
||||
throws RateLimitExceededException {
|
||||
RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION));
|
||||
|
||||
final OneTimeDonationUtil.OneTimeDonationRequestValidationResult validationResult =
|
||||
OneTimeDonationUtil.validateOneTimeDonationRequest(
|
||||
request.getCurrency(),
|
||||
BigDecimal.valueOf(request.getAmount()),
|
||||
request.getLevel(),
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL,
|
||||
oneTimeDonationConfiguration,
|
||||
braintreeManager);
|
||||
|
||||
return switch (validationResult) {
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedLevel _ ->
|
||||
ConfirmPayPalBoostResponse.newBuilder()
|
||||
.setUnsupportedLevel(FailedPrecondition.getDefaultInstance()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.UnsupportedCurrency _ ->
|
||||
ConfirmPayPalBoostResponse.newBuilder()
|
||||
.setUnsupportedCurrency(FailedPrecondition.getDefaultInstance()).build();
|
||||
case final OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountBelowMinimum r ->
|
||||
ConfirmPayPalBoostResponse.newBuilder()
|
||||
.setAmountBelowMinimum(AmountBelowMinimumError.newBuilder()
|
||||
.setMinimum(r.minimum().toString()).build()).build();
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.AmountAboveSepaLimit _ ->
|
||||
throw new IllegalStateException("SEPA limit should not trigger for PayPal");
|
||||
case OneTimeDonationUtil.OneTimeDonationRequestValidationResult.Success _ -> {
|
||||
final BraintreeManager.PayPalChargeSuccessDetails chargeSuccessDetails =
|
||||
braintreeManager.captureOneTimePayment(
|
||||
request.getPayerId(), request.getPaymentId(), request.getPaymentToken(),
|
||||
request.getCurrency(), request.getAmount(), request.getLevel(),
|
||||
getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null))).join();
|
||||
oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), clock.instant());
|
||||
yield ConfirmPayPalBoostResponse.newBuilder()
|
||||
.setResult(ConfirmPayPalBoostResponse.ConfirmPayPalBoostResult.newBuilder()
|
||||
.setPaymentId(chargeSuccessDetails.paymentId()).build()).build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateBoostReceiptCredentialsResponse createBoostReceiptCredentials(
|
||||
final CreateBoostReceiptCredentialsRequest request) {
|
||||
|
||||
final PaymentProvider processor;
|
||||
final Optional<PaymentDetails> maybePaymentDetails;
|
||||
switch (request.getProcessor()) {
|
||||
case PAYMENT_PROVIDER_STRIPE -> {
|
||||
processor = PaymentProvider.STRIPE;
|
||||
maybePaymentDetails = stripeManager.getPaymentDetails(request.getPaymentIntentId()).join();
|
||||
}
|
||||
case PAYMENT_PROVIDER_BRAINTREE -> {
|
||||
processor = PaymentProvider.BRAINTREE;
|
||||
maybePaymentDetails = braintreeManager.getPaymentDetails(request.getPaymentIntentId()).join();
|
||||
}
|
||||
default -> throw GrpcExceptions.fieldViolation("processor", "Unsupported payment processor");
|
||||
}
|
||||
|
||||
if (maybePaymentDetails.isEmpty()) {
|
||||
return CreateBoostReceiptCredentialsResponse.newBuilder()
|
||||
.setPaymentNotFound(NotFound.getDefaultInstance()).build();
|
||||
}
|
||||
final PaymentDetails paymentDetails = maybePaymentDetails.get();
|
||||
if (paymentDetails.status() == PaymentStatus.PROCESSING) {
|
||||
return CreateBoostReceiptCredentialsResponse.newBuilder()
|
||||
.setPaymentStillProcessing(FailedPrecondition.getDefaultInstance()).build();
|
||||
}
|
||||
if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {
|
||||
final PaymentRequired.Builder paymentRequiredBuilder = PaymentRequired.newBuilder();
|
||||
if (paymentDetails.chargeFailure() != null) {
|
||||
paymentRequiredBuilder.setChargeFailure(toChargeFailure(processor, paymentDetails.chargeFailure()));
|
||||
}
|
||||
return CreateBoostReceiptCredentialsResponse.newBuilder()
|
||||
.setPaymentRequired(paymentRequiredBuilder).build();
|
||||
}
|
||||
|
||||
final OneTimeDonationUtil.DonationLevelDetails levelDetails;
|
||||
try {
|
||||
levelDetails = OneTimeDonationUtil.getLevelDetails(paymentDetails, oneTimeDonationConfiguration);
|
||||
} catch (final OneTimeDonationUtil.InvalidLevelException e) {
|
||||
throw GrpcExceptions.unavailable(e.getMessage());
|
||||
}
|
||||
|
||||
final ReceiptCredentialRequest receiptCredentialRequest;
|
||||
try {
|
||||
receiptCredentialRequest = new ReceiptCredentialRequest(
|
||||
request.getReceiptCredentialRequest().toByteArray());
|
||||
} catch (final InvalidInputException e) {
|
||||
throw GrpcExceptions.fieldViolation("receipt_credential_request", "invalid receipt credential request");
|
||||
}
|
||||
|
||||
try {
|
||||
issuedReceiptsManager.recordIssuance(
|
||||
paymentDetails.id(), processor, receiptCredentialRequest, clock.instant());
|
||||
} catch (final WriteConflictException e) {
|
||||
return CreateBoostReceiptCredentialsResponse.newBuilder()
|
||||
.setReceiptAlreadyIssued(FailedPrecondition.getDefaultInstance()).build();
|
||||
}
|
||||
|
||||
final Instant paidAt = oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created());
|
||||
final Instant expiration = paidAt
|
||||
.plus(levelDetails.levelExpiration())
|
||||
.truncatedTo(ChronoUnit.DAYS)
|
||||
.plus(1, ChronoUnit.DAYS);
|
||||
|
||||
final ReceiptCredentialResponse receiptCredentialResponse;
|
||||
try {
|
||||
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
||||
receiptCredentialRequest, expiration.getEpochSecond(), levelDetails.level());
|
||||
} catch (final VerificationFailedException e) {
|
||||
throw GrpcExceptions.fieldViolation("receipt_credential_request",
|
||||
"receipt credential request failed verification");
|
||||
}
|
||||
|
||||
Metrics.counter(SubscriptionsGrpcService.RECEIPT_ISSUED_COUNTER_NAME,
|
||||
Tags.of(
|
||||
Tag.of(SubscriptionsGrpcService.PROCESSOR_TAG_NAME, processor.toString()),
|
||||
Tag.of(SubscriptionsGrpcService.TYPE_TAG_NAME, "boost"),
|
||||
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
|
||||
.increment();
|
||||
|
||||
return CreateBoostReceiptCredentialsResponse.newBuilder()
|
||||
.setResult(CreateBoostReceiptCredentialsResponse.CreateBoostReceiptCredentialsResult.newBuilder()
|
||||
.setReceiptCredentialResponse(ByteString.copyFrom(receiptCredentialResponse.serialize()))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getClientPlatform;
|
||||
import static org.whispersystems.textsecuregcm.grpc.SubscriptionsUtil.getPayPalLocale;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.google.protobuf.Empty;
|
||||
@ -11,6 +12,9 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.signal.chat.errors.FailedPrecondition;
|
||||
import org.signal.chat.errors.FailedUnidentifiedAuthorization;
|
||||
import org.signal.chat.errors.FailedZkAuthentication;
|
||||
@ -30,6 +34,7 @@ import org.signal.chat.subscriptions.GetReceiptCredentialsResponse;
|
||||
import org.signal.chat.subscriptions.GetSubscriptionInformationRequest;
|
||||
import org.signal.chat.subscriptions.GetSubscriptionInformationResponse;
|
||||
import org.signal.chat.subscriptions.PaymentMethod;
|
||||
import org.signal.chat.subscriptions.PaymentRequired;
|
||||
import org.signal.chat.subscriptions.SetDefaultPaymentMethodRequest;
|
||||
import org.signal.chat.subscriptions.SetDefaultPaymentMethodResponse;
|
||||
import org.signal.chat.subscriptions.SetIapSubscriptionRequest;
|
||||
@ -50,6 +55,8 @@ import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfigurati
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.entities.Badge;
|
||||
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
|
||||
@ -59,11 +66,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
|
||||
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.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriberIdCreationNotPermittedException;
|
||||
@ -97,6 +102,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
private final BankMandateTranslator bankMandateTranslator;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionsGrpcService.class, "receiptIssued");
|
||||
static final String PROCESSOR_TAG_NAME = "processor";
|
||||
static final String TYPE_TAG_NAME = "type";
|
||||
private static final String SUBSCRIPTION_TYPE_TAG_NAME = "subscriptionType";
|
||||
|
||||
public SubscriptionsGrpcService(final Clock clock, final SubscriptionConfiguration subscriptionConfiguration,
|
||||
final OneTimeDonationConfiguration oneTimeDonationConfiguration, final SubscriptionManager subscriptionManager,
|
||||
final DonationPermitsManager donationPermitsManager, final StripeManager stripeManager,
|
||||
@ -136,7 +146,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
}
|
||||
} catch (final InvalidInputException _) {
|
||||
throw GrpcExceptions.invalidArguments("invalid donation permit");
|
||||
} catch (final VerificationFailedException _ ) {
|
||||
} catch (final VerificationFailedException _) {
|
||||
return UpdateSubscriberResponse.newBuilder()
|
||||
.setPermitRejected(FailedZkAuthentication.newBuilder()
|
||||
.setDescription("donation permit failed verification")
|
||||
@ -198,7 +208,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
}
|
||||
} catch (final InvalidInputException _) {
|
||||
throw GrpcExceptions.invalidArguments("invalid donation permit");
|
||||
} catch (final VerificationFailedException _ ) {
|
||||
} catch (final VerificationFailedException _) {
|
||||
return CreatePaymentMethodResponse.newBuilder()
|
||||
.setPermitRejected(FailedZkAuthentication.getDefaultInstance())
|
||||
.build();
|
||||
@ -227,8 +237,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
public CreatePayPalPaymentMethodResponse createPayPalPaymentMethod(final CreatePayPalPaymentMethodRequest request) {
|
||||
final SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(
|
||||
request.getSubscriberId().toByteArray(), clock);
|
||||
final Locale locale = RequestAttributesUtil.getAcceptableLanguages().stream().filter(r -> !"*".equals(r.getRange()))
|
||||
.findFirst().map(r -> Locale.forLanguageTag(r.getRange())).orElse(Locale.US);
|
||||
final Locale locale = getPayPalLocale(RequestAttributesUtil.getAvailableAcceptedLocales());
|
||||
try {
|
||||
final BraintreeManager.PayPalBillingAgreementApprovalDetails details = subscriptionManager.addPaymentMethodToCustomer(
|
||||
subscriberCredentials, braintreeManager, getClientPlatform(RequestAttributesUtil.getUserAgent().orElse(null)),
|
||||
@ -363,7 +372,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
.build();
|
||||
} catch (final SubscriptionProcessorException e) {
|
||||
return SetSubscriptionLevelResponse.newBuilder()
|
||||
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
|
||||
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build();
|
||||
} catch (final SubscriptionPaymentRequiresActionException e) {
|
||||
return SetSubscriptionLevelResponse.newBuilder().setPaymentRequiresAction(FailedPrecondition.newBuilder().build())
|
||||
.build();
|
||||
@ -443,7 +452,7 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
subscription.setBillingCycleAnchor(info.billingCycleAnchor().getEpochSecond());
|
||||
}
|
||||
if (info.chargeFailure() != null) {
|
||||
subscription.setChargeFailure(toChargeFailure(info.paymentProvider(), info.chargeFailure()));
|
||||
subscription.setChargeFailure(SubscriptionsUtil.toChargeFailure(info.paymentProvider(), info.chargeFailure()));
|
||||
}
|
||||
return GetSubscriptionInformationResponse.newBuilder().setSuccess(subscription.build()).build();
|
||||
|
||||
@ -458,6 +467,15 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
final SubscriptionManager.ReceiptResult result = subscriptionManager.createReceiptCredentials(
|
||||
subscriberCredentials, request.getReceiptCredentialRequest().toByteArray(),
|
||||
r -> SubscriptionsUtil.receiptExpirationWithGracePeriod(subscriptionConfiguration, r));
|
||||
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
|
||||
Tags.of(
|
||||
Tag.of(PROCESSOR_TAG_NAME, result.paymentProvider().toString()),
|
||||
Tag.of(TYPE_TAG_NAME, "subscription"),
|
||||
Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,
|
||||
subscriptionConfiguration.getSubscriptionLevel(result.receiptItem().level()).type().name()
|
||||
.toLowerCase(Locale.ROOT)),
|
||||
UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))
|
||||
.increment();
|
||||
return GetReceiptCredentialsResponse.newBuilder().setSuccess(
|
||||
GetReceiptCredentialsResponse.GetReceiptCredentialsResult.newBuilder()
|
||||
.setReceiptCredentialResponse(ByteString.copyFrom(result.receiptCredentialResponse().serialize()))
|
||||
@ -467,11 +485,11 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
.build();
|
||||
} catch (final SubscriptionChargeFailurePaymentRequiredException e) {
|
||||
return GetReceiptCredentialsResponse.newBuilder().setPaymentRequired(
|
||||
GetReceiptCredentialsResponse.PaymentRequired.newBuilder()
|
||||
.setChargeFailure(toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
|
||||
PaymentRequired.newBuilder()
|
||||
.setChargeFailure(SubscriptionsUtil.toChargeFailure(e.getProcessor(), e.getChargeFailure())).build()).build();
|
||||
} catch (final SubscriptionPaymentRequiredException e) {
|
||||
return GetReceiptCredentialsResponse.newBuilder()
|
||||
.setPaymentRequired(GetReceiptCredentialsResponse.PaymentRequired.newBuilder().build()).build();
|
||||
.setPaymentRequired(PaymentRequired.newBuilder().build()).build();
|
||||
} catch (final SubscriptionInvalidArgumentsException e) {
|
||||
throw GrpcExceptions.invalidArguments(e.errorDetail().orElse(""));
|
||||
} catch (final SubscriptionReceiptAlreadyRedeemedException e) {
|
||||
@ -589,20 +607,4 @@ public class SubscriptionsGrpcService extends SimpleSubscriptionsGrpc.Subscripti
|
||||
return GetBankMandateResponse.newBuilder().setMandate(mandate).build();
|
||||
}
|
||||
|
||||
private static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
|
||||
final ChargeFailure chargeFailure) {
|
||||
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
|
||||
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
|
||||
if (chargeFailure.outcomeNetworkStatus() != null) {
|
||||
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
|
||||
}
|
||||
if (chargeFailure.outcomeReason() != null) {
|
||||
builder.setOutcomeReason(chargeFailure.outcomeReason());
|
||||
}
|
||||
if (chargeFailure.outcomeType() != null) {
|
||||
builder.setOutcomeType(chargeFailure.outcomeType());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -25,10 +25,12 @@ import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.LevelConfiguration;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;
|
||||
import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||
@ -160,4 +162,26 @@ public class SubscriptionsUtil {
|
||||
.increment();
|
||||
}
|
||||
|
||||
public static org.signal.chat.subscriptions.ChargeFailure toChargeFailure(final PaymentProvider processor,
|
||||
final ChargeFailure chargeFailure) {
|
||||
final org.signal.chat.subscriptions.ChargeFailure.Builder builder = org.signal.chat.subscriptions.ChargeFailure.newBuilder()
|
||||
.setProcessor(processor.toProto()).setCode(chargeFailure.code()).setMessage(chargeFailure.message());
|
||||
if (chargeFailure.outcomeNetworkStatus() != null) {
|
||||
builder.setOutcomeNetworkStatus(chargeFailure.outcomeNetworkStatus());
|
||||
}
|
||||
if (chargeFailure.outcomeReason() != null) {
|
||||
builder.setOutcomeReason(chargeFailure.outcomeReason());
|
||||
}
|
||||
if (chargeFailure.outcomeType() != null) {
|
||||
builder.setOutcomeType(chargeFailure.outcomeType());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static Locale getPayPalLocale(final List<Locale> acceptableLocales) {
|
||||
return acceptableLocales.stream()
|
||||
.filter(l -> !"*".equals(l.getLanguage()))
|
||||
.findFirst()
|
||||
.orElse(Locale.US);
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,8 +29,10 @@ import org.signal.keytransparency.client.E164SearchRequest;
|
||||
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
|
||||
import org.signal.keytransparency.client.MonitorRequest;
|
||||
import org.signal.keytransparency.client.MonitorResponse;
|
||||
import org.signal.keytransparency.client.MonitorResponseV2;
|
||||
import org.signal.keytransparency.client.SearchRequest;
|
||||
import org.signal.keytransparency.client.SearchResponse;
|
||||
import org.signal.keytransparency.client.SearchResponseV2;
|
||||
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -110,7 +112,7 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public SearchResponse search(
|
||||
public SearchResponseV2 search(
|
||||
final ByteString aci,
|
||||
final ByteString aciIdentityKey,
|
||||
final Optional<ByteString> usernameHash,
|
||||
@ -132,13 +134,13 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||
return search(searchRequestBuilder.build());
|
||||
}
|
||||
|
||||
public SearchResponse search(final SearchRequest request) {
|
||||
public SearchResponseV2 search(final SearchRequest request) {
|
||||
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
|
||||
.search(request);
|
||||
.searchV2(request);
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public MonitorResponse monitor(final AciMonitorRequest aciMonitorRequest,
|
||||
public MonitorResponseV2 monitor(final AciMonitorRequest aciMonitorRequest,
|
||||
final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,
|
||||
final Optional<E164MonitorRequest> e164MonitorRequest,
|
||||
final long lastTreeHeadSize,
|
||||
@ -155,12 +157,11 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||
return monitor(monitorRequestBuilder.build());
|
||||
}
|
||||
|
||||
public MonitorResponse monitor(final MonitorRequest request) {
|
||||
public MonitorResponseV2 monitor(final MonitorRequest request) {
|
||||
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
|
||||
.monitor(request);
|
||||
.monitorV2(request);
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
public DistinguishedResponse getDistinguishedKey(final Optional<Long> lastTreeHeadSize) {
|
||||
final DistinguishedRequest request = lastTreeHeadSize.map(
|
||||
@ -171,7 +172,7 @@ public class KeyTransparencyServiceClient implements Managed {
|
||||
|
||||
public DistinguishedResponse distinguished(final DistinguishedRequest request) {
|
||||
return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))
|
||||
.distinguished(request);
|
||||
.distinguishedV2(request);
|
||||
}
|
||||
|
||||
private static Deadline toDeadline(final Duration timeout) {
|
||||
|
||||
@ -28,6 +28,7 @@ public class DonationPermitsManager {
|
||||
|
||||
/// Verifies and then spends the given {@link DonationPermit}
|
||||
///
|
||||
/// @return whether the spend was successful
|
||||
/// @throws VerificationFailedException if the permit was not valid (expired or otherwise)
|
||||
public boolean spend(final DonationPermit donationPermit) throws VerificationFailedException {
|
||||
// Permits must be verified with the key pair that issued them, which can be re-derived from the embedded expiration
|
||||
|
||||
@ -9,10 +9,14 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Flow;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.whispersystems.textsecuregcm.entities.MessageProtos;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;
|
||||
import org.whispersystems.textsecuregcm.util.UUIDUtil;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
import reactor.adapter.JdkFlowAdapter;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
/// A [MessageStream] implementation that produces message from a joint DynamoDB/Redis message store.
|
||||
public class RedisDynamoDbMessageStream implements MessageStream {
|
||||
@ -25,6 +29,14 @@ public class RedisDynamoDbMessageStream implements MessageStream {
|
||||
|
||||
private final RedisDynamoDbMessagePublisher messagePublisher;
|
||||
|
||||
private static final String MESSAGE_READ_COUNTER_NAME =
|
||||
name(RedisDynamoDbMessageStream.class, "messagesRead");
|
||||
|
||||
private static final String MESSAGE_ACKNOWLEDGED_COUNTER_NAME =
|
||||
name(RedisDynamoDbMessageStream.class, "messagesAcknowledged");
|
||||
|
||||
private static final String UUID_VERSION_TAG = "uuidVersion";
|
||||
|
||||
public RedisDynamoDbMessageStream(final MessagesDynamoDb messagesDynamoDb,
|
||||
final MessagesCache messagesCache,
|
||||
final RedisMessageAvailabilityManager redisMessageAvailabilityManager,
|
||||
@ -54,11 +66,22 @@ public class RedisDynamoDbMessageStream implements MessageStream {
|
||||
|
||||
@Override
|
||||
public Flow.Publisher<MessageStreamEntry> getMessages() {
|
||||
return messagePublisher;
|
||||
return JdkFlowAdapter.publisherToFlowPublisher(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)
|
||||
.doOnNext(messageStreamEntry -> {
|
||||
if (messageStreamEntry instanceof MessageStreamEntry.Envelope(org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope message)) {
|
||||
final UUID messageGuid = UUIDUtil.fromByteString(message.getServerGuid());
|
||||
|
||||
Metrics.counter(MESSAGE_READ_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
|
||||
.increment();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> acknowledgeMessage(final UUID messageGuid, final long serverTimestamp) {
|
||||
Metrics.counter(MESSAGE_ACKNOWLEDGED_COUNTER_NAME, UUID_VERSION_TAG, String.valueOf(messageGuid.version()))
|
||||
.increment();
|
||||
|
||||
return messagesCache.remove(accountIdentifier, device.getId(), messageGuid)
|
||||
.thenCompose(removed -> removed.map(_ -> CompletableFuture.<Void>completedFuture(null))
|
||||
.orElseGet(() ->
|
||||
|
||||
@ -85,7 +85,7 @@ class FoundationDbMessagePublisher {
|
||||
|
||||
/// Tracks the current state of the publisher state machine. Initial state presumes that messages are available in the queue.
|
||||
private State state = State.MESSAGES_AVAILABLE;
|
||||
/// Reference to the sink we publishes messages to.
|
||||
/// Reference to the sink we publish messages to.
|
||||
private volatile FluxSink<FoundationDbMessageStreamEntry.Message> emitter;
|
||||
/// Future that completes when the watch for {@link #messagesAvailableWatchKey} triggers.
|
||||
private CompletableFuture<Void> watchFuture;
|
||||
@ -192,6 +192,7 @@ class FoundationDbMessagePublisher {
|
||||
setState(State.MESSAGES_AVAILABLE, event);
|
||||
transitionStateOnEvent(Event.INTERNAL_TRIGGER);
|
||||
}
|
||||
case MESSAGE_AVAILABLE_WATCH_TRIGGERED -> setState(State.MESSAGE_AVAILABLE_SIGNAL_BUFFERED, event);
|
||||
case FETCH_OR_PUBLISH_ERROR_OCCURRED -> setState(State.ERROR, event);
|
||||
default -> knownTransition = false;
|
||||
}
|
||||
@ -247,27 +248,33 @@ class FoundationDbMessagePublisher {
|
||||
///
|
||||
/// @return a future of a list of [FoundationDbMessageStreamEntry.Message] with a max size of [#maxMessagesPerScan]
|
||||
private CompletableFuture<List<FoundationDbMessageStreamEntry.Message>> getMessagesBatch() {
|
||||
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
|
||||
final Consumer<Transaction> doBeforePageFetch = beforePageFetch.get();
|
||||
|
||||
return database.runAsync(transaction -> {
|
||||
doBeforePageFetch.accept(transaction);
|
||||
|
||||
return getItemsInRange(transaction, beginKeyCursor, endKeyExclusive, maxMessagesPerScan)
|
||||
.thenApply(lastKeyReadAndItems -> {
|
||||
// Set our beginning key to just past the last key read so that we're ready for our next fetch
|
||||
lastKeyReadAndItems.first()
|
||||
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
|
||||
|
||||
final List<FoundationDbMessageStreamEntry.Message> items = lastKeyReadAndItems.second();
|
||||
if (items.size() < maxMessagesPerScan) {
|
||||
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
|
||||
if (!terminateOnQueueEmpty) {
|
||||
setWatch(transaction);
|
||||
}
|
||||
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan && !terminateOnQueueEmpty) {
|
||||
setWatch(transaction);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
}
|
||||
);
|
||||
return lastKeyReadAndItems;
|
||||
});
|
||||
})
|
||||
// Defer any state mutations until after the transaction has been committed. The transaction block can
|
||||
// fail/retry, and we don't want to trigger spurious state transitions when that happens.
|
||||
.thenApply(lastKeyReadAndItems -> {
|
||||
// Set our beginning key to just past the last key read so that we're ready for our next fetch
|
||||
lastKeyReadAndItems.first()
|
||||
.ifPresent(lastKeyRead -> beginKeyCursor = KeySelector.firstGreaterThan(lastKeyRead));
|
||||
|
||||
if (lastKeyReadAndItems.second().size() < maxMessagesPerScan) {
|
||||
transitionStateOnEvent(Event.FETCHED_ALL_AVAILABLE_MESSAGES);
|
||||
}
|
||||
|
||||
return lastKeyReadAndItems.second();
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch messages in the range between `begin` and `end` limited to a batch size of `maxMessagesPerSccan`
|
||||
|
||||
@ -128,7 +128,7 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
|
||||
return paymentMethod == PaymentMethod.PAYPAL;
|
||||
}
|
||||
|
||||
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
|
||||
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
|
||||
@ -136,14 +136,14 @@ public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcess
|
||||
if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {
|
||||
chargeFailure = createChargeFailure(transaction);
|
||||
}
|
||||
return new PaymentDetails(transaction.getGraphQLId(),
|
||||
return Optional.of(new PaymentDetails(transaction.getGraphQLId(),
|
||||
transaction.getCustomFields(),
|
||||
getPaymentStatus(transaction.getStatus()),
|
||||
transaction.getCreatedAt().toInstant(),
|
||||
chargeFailure);
|
||||
chargeFailure));
|
||||
|
||||
} catch (final NotFoundException e) {
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@ -242,7 +242,7 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
|
||||
public CompletableFuture<Optional<PaymentDetails>> getPaymentDetails(final String paymentIntentId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
final PaymentIntent paymentIntent = getPaymentIntent(paymentIntentId);
|
||||
@ -255,14 +255,14 @@ public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor
|
||||
}
|
||||
}
|
||||
|
||||
return new PaymentDetails(paymentIntent.getId(),
|
||||
return Optional.of(new PaymentDetails(paymentIntent.getId(),
|
||||
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
|
||||
getPaymentStatusForStatus(paymentIntent.getStatus()),
|
||||
Instant.ofEpochSecond(paymentIntent.getCreated()),
|
||||
chargeFailure);
|
||||
chargeFailure));
|
||||
} catch (StripeException e) {
|
||||
if (e.getStatusCode() == 404) {
|
||||
return null;
|
||||
return Optional.empty();
|
||||
} else {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
|
||||
@ -34,17 +34,17 @@ service KeyTransparencyQueryService {
|
||||
* subsequent Search and Monitor requests. It should be the first key
|
||||
* transparency RPC a client calls.
|
||||
*/
|
||||
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
|
||||
rpc DistinguishedV2(DistinguishedRequest) returns (DistinguishedResponse) {}
|
||||
/**
|
||||
* An endpoint used by clients to search for one or more identifiers in the transparency log.
|
||||
* The server returns proof that the identifier(s) exist in the log.
|
||||
*/
|
||||
rpc Search(SearchRequest) returns (SearchResponse) {}
|
||||
rpc SearchV2(SearchRequest) returns (SearchResponseV2) {}
|
||||
/**
|
||||
* An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
|
||||
* constructed correctly in later entries for those identifiers.
|
||||
*/
|
||||
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
|
||||
rpc MonitorV2(MonitorRequest) returns (MonitorResponseV2) {}
|
||||
}
|
||||
|
||||
message SearchRequest {
|
||||
@ -112,6 +112,19 @@ message SearchResponse {
|
||||
optional CondensedTreeSearchResponse username_hash = 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that the client is not authorized to make the request because
|
||||
* it did not provide the correct set of data.
|
||||
*/
|
||||
message PermissionDenied {}
|
||||
|
||||
message SearchResponseV2 {
|
||||
oneof response {
|
||||
SearchResponse search_response = 1;
|
||||
PermissionDenied permission_denied = 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree head size(s) to prove consistency against. A client's very first
|
||||
* key transparency request should be looking up the "distinguished" key;
|
||||
@ -124,13 +137,11 @@ message ConsistencyParameters {
|
||||
* This field may be omitted if the client is looking up an identifier
|
||||
* for the first time.
|
||||
*/
|
||||
optional uint64 last = 1;
|
||||
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
|
||||
/**
|
||||
* The distinguished tree head size to prove consistency against.
|
||||
* This field may be omitted when the client is looking up the
|
||||
* "distinguished" key for the very first time.
|
||||
*/
|
||||
optional uint64 distinguished = 2;
|
||||
uint64 distinguished = 2 [(org.signal.chat.require.range) = {min: 1}];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +154,7 @@ message DistinguishedRequest {
|
||||
* exception of a client's very first request, this field should always be
|
||||
* set.
|
||||
*/
|
||||
optional uint64 last = 1;
|
||||
optional uint64 last = 1 [(org.signal.chat.require.range) = {min: 1}];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -343,20 +354,20 @@ message MonitorRequest {
|
||||
|
||||
message AciMonitorRequest {
|
||||
bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];
|
||||
uint64 entry_position = 2;
|
||||
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
|
||||
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
|
||||
}
|
||||
|
||||
message UsernameHashMonitorRequest {
|
||||
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
|
||||
uint64 entry_position = 2;
|
||||
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
|
||||
bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 32];
|
||||
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
|
||||
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
|
||||
}
|
||||
|
||||
message E164MonitorRequest {
|
||||
optional string e164 = 1 [(org.signal.chat.require.e164) = true];
|
||||
uint64 entry_position = 2;
|
||||
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];
|
||||
string e164 = 1 [(org.signal.chat.require.e164) = true];
|
||||
uint64 entry_position = 2 [(org.signal.chat.require.range) = {min: 1}];
|
||||
bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];
|
||||
}
|
||||
|
||||
message MonitorProof {
|
||||
@ -397,3 +408,10 @@ message MonitorResponse {
|
||||
*/
|
||||
repeated bytes inclusion = 5;
|
||||
}
|
||||
|
||||
message MonitorResponseV2 {
|
||||
oneof response {
|
||||
MonitorResponse monitor_response = 1;
|
||||
PermissionDenied permission_denied = 2;
|
||||
}
|
||||
}
|
||||
|
||||
167
service/src/main/proto/org/signal/chat/one_time_donations.proto
Normal file
167
service/src/main/proto/org/signal/chat/one_time_donations.proto
Normal file
@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.chat.one_time_donations;
|
||||
|
||||
import "org/signal/chat/require.proto";
|
||||
import "org/signal/chat/errors.proto";
|
||||
import "org/signal/chat/tag.proto";
|
||||
import "org/signal/chat/subscriptions.proto";
|
||||
|
||||
// Service for making one-time donation payments (boost and gift)
|
||||
//
|
||||
// Note that these are siblings of the RPCs in the Subscriptions service. One-time payments do
|
||||
// not require the subscription management methods in that service, though the configuration at
|
||||
// Subscriptions.GetConfiguration is shared between subscription and one-time payments.
|
||||
service OneTimeDonations {
|
||||
option (require.auth) = AUTH_ONLY_ANONYMOUS;
|
||||
|
||||
// Create a Stripe payment intent and return a client secret that can be used to complete the payment.
|
||||
// Once the payment is complete, the paymentIntentId can be used with CreateBoostReceiptCredentials
|
||||
rpc CreateBoost(CreateBoostRequest) returns (CreateBoostResponse) {}
|
||||
|
||||
// Create a PayPal one-time payment.
|
||||
// Once the payment is complete, call ConfirmPayPalBoost with the payment ID and token
|
||||
rpc CreatePayPalBoost(CreatePayPalBoostRequest) returns (CreatePayPalBoostResponse) {}
|
||||
|
||||
// Confirm a PayPal one-time payment
|
||||
rpc ConfirmPayPalBoost(ConfirmPayPalBoostRequest) returns (ConfirmPayPalBoostResponse) {}
|
||||
|
||||
// Obtain a ZK receipt credential for a completed one-time donation payment.
|
||||
// The receipt credential can then be used to redeem the one-time donation entitlement
|
||||
// via Donations.RedeemReceipt
|
||||
rpc CreateBoostReceiptCredentials(CreateBoostReceiptCredentialsRequest) returns (CreateBoostReceiptCredentialsResponse) {}
|
||||
}
|
||||
|
||||
// The amount is below the minimum for the currency.
|
||||
message AmountBelowMinimumError {
|
||||
// The minimum amount for the currency
|
||||
string minimum = 1;
|
||||
}
|
||||
|
||||
// The SEPA Direct Debit amount exceeds the allowed maximum.
|
||||
message AmountAboveSepaLimitError {
|
||||
// The maximum amount for a SEPA transaction
|
||||
string maximum = 1;
|
||||
}
|
||||
|
||||
message CreateBoostRequest {
|
||||
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
|
||||
string currency = 1 [(require.exactlySize) = 3];
|
||||
// The amount to pay in the [currency's minor unit](https://docs.stripe.com/currencies#minor-units)
|
||||
uint64 amount = 2 [(require.range).min = 1];
|
||||
// The level for the boost payment
|
||||
uint64 level = 3 [(require.range).min = 1];
|
||||
// The payment method
|
||||
subscriptions.PaymentMethod payment_method = 4 [(require.specified) = true];
|
||||
// A donation permit retrieved from Donations.createDonationPermit
|
||||
bytes donation_permit = 5 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message CreateBoostResponse {
|
||||
oneof response {
|
||||
// A client secret that can be used to complete a stripe PaymentIntent
|
||||
string client_secret = 1;
|
||||
// The amount is below the minimum for the currency
|
||||
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
|
||||
// The amount exceeds the maximum for SEPA Direct Debit
|
||||
AmountAboveSepaLimitError amount_above_sepa_limit = 3 [(tag.reason) = "amount_above_sepa_limit"];
|
||||
// The requested currency is not supported for the given payment method
|
||||
errors.FailedPrecondition unsupported_currency = 4 [(tag.reason) = "unsupported_currency"];
|
||||
// The requested level is not a valid one-time donation level
|
||||
errors.FailedPrecondition unsupported_level = 5 [(tag.reason) = "unsupported_level"];
|
||||
// Donation permit was invalid or already spent
|
||||
errors.FailedZkAuthentication permit_rejected = 6 [(tag.reason) = "permit_rejected"];
|
||||
}
|
||||
}
|
||||
|
||||
message CreatePayPalBoostRequest {
|
||||
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
|
||||
string currency = 1 [(require.exactlySize) = 3];
|
||||
// Amount in the currency's minor unit (e.g. cents for USD), must be >= 1
|
||||
uint64 amount = 2 [(require.range).min = 1];
|
||||
// Donation level.
|
||||
uint64 level = 3 [(require.range).min = 1];
|
||||
// URL to redirect the user to after PayPal approval
|
||||
string return_url = 4 [(require.nonEmpty) = true];
|
||||
// URL to redirect the user to if they cancel
|
||||
string cancel_url = 5 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message CreatePayPalBoostResponse {
|
||||
message CreatePayPalBoostResult {
|
||||
string approval_url = 1;
|
||||
string payment_id = 2;
|
||||
}
|
||||
oneof response {
|
||||
CreatePayPalBoostResult result = 1;
|
||||
// The amount is below the minimum for the currency
|
||||
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
|
||||
// The requested currency is not supported for PayPal
|
||||
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
|
||||
// The requested level is not a valid one-time donation level
|
||||
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
|
||||
}
|
||||
}
|
||||
|
||||
message ConfirmPayPalBoostRequest {
|
||||
// ISO 4217 currency code, case-insensitive (e.g. "usd", "EUR")
|
||||
string currency = 1 [(require.exactlySize) = 3];
|
||||
// Amount in the currency's minor unit, must be >= 1
|
||||
uint64 amount = 2 [(require.range).min = 1];
|
||||
// Donation level.
|
||||
uint64 level = 3 [(require.range).min = 1];
|
||||
// PayPal payer ID from the approval redirect
|
||||
string payer_id = 4 [(require.nonEmpty) = true];
|
||||
// PayPal payment ID (PAYID-…) from CreatePayPalBoost
|
||||
string payment_id = 5 [(require.nonEmpty) = true];
|
||||
// PayPal payment token (EC-…) from the approval redirect
|
||||
string payment_token = 6 [(require.nonEmpty) = true];
|
||||
}
|
||||
|
||||
message ConfirmPayPalBoostResponse {
|
||||
message ConfirmPayPalBoostResult {
|
||||
string payment_id = 1;
|
||||
}
|
||||
oneof response {
|
||||
ConfirmPayPalBoostResult result = 1;
|
||||
// The amount is below the minimum for the currency
|
||||
AmountBelowMinimumError amount_below_minimum = 2 [(tag.reason) = "amount_below_minimum"];
|
||||
// The requested currency is not supported for PayPal
|
||||
errors.FailedPrecondition unsupported_currency = 3 [(tag.reason) = "unsupported_currency"];
|
||||
// The requested level is not a valid one-time donation level
|
||||
errors.FailedPrecondition unsupported_level = 4 [(tag.reason) = "unsupported_level"];
|
||||
}
|
||||
}
|
||||
|
||||
message CreateBoostReceiptCredentialsRequest {
|
||||
// a payment ID from the processor
|
||||
string payment_intent_id = 1 [(require.nonEmpty) = true];
|
||||
// ZK blind-signature receipt credential request bytes
|
||||
bytes receipt_credential_request = 2 [(require.nonEmpty) = true];
|
||||
// The processor that handled the payment
|
||||
subscriptions.PaymentProvider processor = 3 [(require.specified) = true];
|
||||
}
|
||||
|
||||
message CreateBoostReceiptCredentialsResponse {
|
||||
message CreateBoostReceiptCredentialsResult {
|
||||
bytes receipt_credential_response = 1;
|
||||
}
|
||||
oneof response {
|
||||
CreateBoostReceiptCredentialsResult result = 1;
|
||||
// Payment is still processing; client should retry
|
||||
errors.FailedPrecondition payment_still_processing = 2 [(tag.reason) = "payment_still_processing"];
|
||||
// Payment failed
|
||||
subscriptions.PaymentRequired payment_required = 3 [(tag.reason) = "payment_required"];
|
||||
// Payment intent not found
|
||||
errors.NotFound payment_not_found = 4 [(tag.reason) = "payment_not_found"];
|
||||
// A receipt credential was already issued for this payment
|
||||
errors.FailedPrecondition receipt_already_issued = 5 [(tag.reason) = "receipt_already_issued"];
|
||||
}
|
||||
}
|
||||
@ -109,8 +109,8 @@ service Subscriptions {
|
||||
|
||||
message UpdateSubscriberRequest {
|
||||
bytes subscriber_id = 1 [(require.exactlySize) = 32];
|
||||
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit,
|
||||
// required if the subscriber ID is new
|
||||
// A libsignal DonationPermit from rpc Donations.CreateDonationPermit.
|
||||
// Not required if the subscriber already exists.
|
||||
bytes donation_permit = 2;
|
||||
}
|
||||
|
||||
@ -295,6 +295,10 @@ message ChargeFailure {
|
||||
optional string outcome_type = 6;
|
||||
}
|
||||
|
||||
message PaymentRequired {
|
||||
optional ChargeFailure charge_failure = 1;
|
||||
}
|
||||
|
||||
message SetSubscriptionLevelResponse {
|
||||
message SetSubscriptionLevelResult {
|
||||
uint64 level = 1;
|
||||
@ -367,10 +371,6 @@ message GetReceiptCredentialsResponse {
|
||||
bytes receiptCredentialResponse = 1;
|
||||
}
|
||||
|
||||
message PaymentRequired {
|
||||
optional ChargeFailure charge_failure = 1;
|
||||
}
|
||||
|
||||
oneof response {
|
||||
GetReceiptCredentialsResult success = 1;
|
||||
errors.NotFound subscriber_not_found = 2 [(tag.reason) = "subscriber_not_found"];
|
||||
|
||||
@ -10,27 +10,31 @@ import com.apple.foundationdb.FDB;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import java.io.IOException;
|
||||
import org.whispersystems.textsecuregcm.storage.FoundationDbDatabaseLifecycleManager;
|
||||
import org.whispersystems.textsecuregcm.storage.ServiceContainerFoundationDbDatabaseLifecycleManager;
|
||||
import org.whispersystems.textsecuregcm.storage.TestcontainersFoundationDbDatabaseLifecycleManager;
|
||||
|
||||
@JsonTypeName("local")
|
||||
public class LocalFoundationDbDatabaseFactory implements FoundationDbDatabaseFactory {
|
||||
|
||||
@JsonIgnore
|
||||
private final TestcontainersFoundationDbDatabaseLifecycleManager testcontainersFoundationDbDatabaseLifecycleManager;
|
||||
|
||||
@JsonIgnore
|
||||
private Database database;
|
||||
|
||||
private LocalFoundationDbDatabaseFactory() {
|
||||
this.testcontainersFoundationDbDatabaseLifecycleManager = new TestcontainersFoundationDbDatabaseLifecycleManager();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Database build(final FDB fdb) throws IOException {
|
||||
if (database == null) {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(testcontainersFoundationDbDatabaseLifecycleManager::closeDatabase));
|
||||
testcontainersFoundationDbDatabaseLifecycleManager.initializeDatabase(fdb);
|
||||
database = testcontainersFoundationDbDatabaseLifecycleManager.getDatabase();
|
||||
final String serviceContainerNamePrefix = System.getProperty("foundationDb.serviceContainerNamePrefix");
|
||||
|
||||
final FoundationDbDatabaseLifecycleManager lifecycleManager = serviceContainerNamePrefix != null
|
||||
? new ServiceContainerFoundationDbDatabaseLifecycleManager(serviceContainerNamePrefix + "0")
|
||||
: new TestcontainersFoundationDbDatabaseLifecycleManager();
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(lifecycleManager::closeDatabase));
|
||||
lifecycleManager.initializeDatabase(fdb);
|
||||
database = lifecycleManager.getDatabase();
|
||||
}
|
||||
|
||||
return database;
|
||||
|
||||
@ -54,8 +54,10 @@ import org.signal.keytransparency.client.DistinguishedResponse;
|
||||
import org.signal.keytransparency.client.E164SearchRequest;
|
||||
import org.signal.keytransparency.client.FullTreeHead;
|
||||
import org.signal.keytransparency.client.MonitorResponse;
|
||||
import org.signal.keytransparency.client.MonitorResponseV2;
|
||||
import org.signal.keytransparency.client.SearchProof;
|
||||
import org.signal.keytransparency.client.SearchResponse;
|
||||
import org.signal.keytransparency.client.SearchResponseV2;
|
||||
import org.signal.keytransparency.client.UpdateValue;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
@ -143,7 +145,10 @@ public class KeyTransparencyControllerTest {
|
||||
usernameHash.ifPresent(ignored -> searchResponseBuilder.setUsernameHash(CondensedTreeSearchResponse.getDefaultInstance()));
|
||||
|
||||
when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), anyLong()))
|
||||
.thenReturn(searchResponseBuilder.build());
|
||||
.thenReturn(SearchResponseV2.newBuilder()
|
||||
.setSearchResponse(searchResponseBuilder.build())
|
||||
.build()
|
||||
);
|
||||
|
||||
final Invocation.Builder request = resources.getJerseyTest()
|
||||
.target("/v1/key-transparency/search")
|
||||
@ -291,7 +296,9 @@ public class KeyTransparencyControllerTest {
|
||||
@Test
|
||||
void monitorSuccess() {
|
||||
when(keyTransparencyServiceClient.monitor(any(), any(), any(), anyLong(), anyLong()))
|
||||
.thenReturn(MonitorResponse.getDefaultInstance());
|
||||
.thenReturn(MonitorResponseV2.newBuilder()
|
||||
.setMonitorResponse(MonitorResponse.getDefaultInstance())
|
||||
.build());
|
||||
|
||||
final Invocation.Builder request = resources.getJerseyTest()
|
||||
.target("/v1/key-transparency/monitor")
|
||||
@ -306,7 +313,7 @@ public class KeyTransparencyControllerTest {
|
||||
|
||||
final KeyTransparencyMonitorResponse keyTransparencyMonitorResponse = response.readEntity(
|
||||
KeyTransparencyMonitorResponse.class);
|
||||
assertNotNull(keyTransparencyMonitorResponse.serializedResponse());
|
||||
assertArrayEquals(MonitorResponse.getDefaultInstance().toByteArray(), keyTransparencyMonitorResponse.serializedResponse());
|
||||
|
||||
verify(keyTransparencyServiceClient, times(1)).monitor(
|
||||
any(), any(), any(), eq(3L), eq(4L));
|
||||
|
||||
@ -25,6 +25,7 @@ import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
@ -239,12 +240,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {
|
||||
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
|
||||
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
|
||||
"id",
|
||||
Collections.emptyMap(),
|
||||
PaymentStatus.FAILED,
|
||||
Instant.now(),
|
||||
chargeFailure)
|
||||
chargeFailure))
|
||||
));
|
||||
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
|
||||
.request()
|
||||
@ -322,12 +323,12 @@ class OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest
|
||||
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
|
||||
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
|
||||
|
||||
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(
|
||||
when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(new PaymentDetails(
|
||||
"id",
|
||||
Collections.emptyMap(),
|
||||
PaymentStatus.SUCCEEDED,
|
||||
Instant.now(),
|
||||
null)));
|
||||
null))));
|
||||
doThrow(WriteConflictException.class).when(ISSUED_RECEIPTS_MANAGER).recordIssuance(any(), any(), any(), any());
|
||||
|
||||
try (Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")
|
||||
|
||||
@ -23,9 +23,9 @@ import org.signal.keytransparency.client.E164MonitorRequest;
|
||||
import org.signal.keytransparency.client.E164SearchRequest;
|
||||
import org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;
|
||||
import org.signal.keytransparency.client.MonitorRequest;
|
||||
import org.signal.keytransparency.client.MonitorResponse;
|
||||
import org.signal.keytransparency.client.MonitorResponseV2;
|
||||
import org.signal.keytransparency.client.SearchRequest;
|
||||
import org.signal.keytransparency.client.SearchResponse;
|
||||
import org.signal.keytransparency.client.SearchResponseV2;
|
||||
import org.signal.keytransparency.client.UsernameHashMonitorRequest;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
@ -56,7 +56,7 @@ import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimi
|
||||
import static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;
|
||||
import static org.whispersystems.textsecuregcm.grpc.KeyTransparencyGrpcService.COMMITMENT_INDEX_LENGTH;
|
||||
|
||||
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
|
||||
@SuppressWarnings({"OptionalUsedAsFieldOrParameterType", "ThrowableNotThrown", "ResultOfMethodCallIgnored"})
|
||||
public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransparencyGrpcService, KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub>{
|
||||
@Mock
|
||||
private KeyTransparencyServiceClient keyTransparencyServiceClient;
|
||||
@ -80,7 +80,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
|
||||
@Test
|
||||
void searchSuccess() throws RateLimitExceededException {
|
||||
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponse.getDefaultInstance());
|
||||
when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponseV2.getDefaultInstance());
|
||||
Mockito.doNothing().when(rateLimiter).validate(any(String.class));
|
||||
final SearchRequest request = SearchRequest.newBuilder()
|
||||
.setAci(ByteString.copyFrom(ACI.toCompactByteArray()))
|
||||
@ -90,7 +90,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.build())
|
||||
.build();
|
||||
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().search(request));
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().searchV2(request));
|
||||
verify(keyTransparencyServiceClient, times(1)).search(eq(request));
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
lastTreeHeadSize.ifPresent(consistencyBuilder::setLast);
|
||||
requestBuilder.setConsistency(consistencyBuilder.build());
|
||||
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().search(requestBuilder.build()));
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().searchV2(requestBuilder.build()));
|
||||
verifyNoInteractions(keyTransparencyServiceClient);
|
||||
}
|
||||
|
||||
@ -152,13 +152,13 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.setDistinguished(10)
|
||||
.build())
|
||||
.build();
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().search(request));
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().searchV2(request));
|
||||
verifyNoInteractions(keyTransparencyServiceClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void monitorSuccess() {
|
||||
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponse.getDefaultInstance());
|
||||
when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponseV2.getDefaultInstance());
|
||||
when(rateLimiter.validateReactive(any(String.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()
|
||||
@ -175,7 +175,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.build())
|
||||
.build();
|
||||
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitor(request));
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().monitorV2(request));
|
||||
verify(keyTransparencyServiceClient, times(1)).monitor(eq(request));
|
||||
}
|
||||
|
||||
@ -199,7 +199,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
|
||||
requestBuilder.setConsistency(consistencyBuilder.build());
|
||||
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitor(requestBuilder.build()));
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitorV2(requestBuilder.build()));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> monitorInvalidRequest() {
|
||||
@ -218,9 +218,9 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
Arguments.argumentSet("Invalid username hash length", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(new byte[31], new byte[32], 10)), Optional.of(4L), Optional.of(4L)),
|
||||
Arguments.argumentSet("Invalid commitment index on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[31], 10)), Optional.of(4L), Optional.of(4L)),
|
||||
Arguments.argumentSet("Invalid entry position on username hash monitor request", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[32], 0)), Optional.of(4L), Optional.of(4L)),
|
||||
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L),
|
||||
Arguments.argumentSet("consistency.last must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),
|
||||
Arguments.argumentSet("consistency.last must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(0L), Optional.of(4L)),
|
||||
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L)), Optional.empty()),
|
||||
Arguments.argumentSet("consistency.distinguished must be provided", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.empty()),
|
||||
Arguments.argumentSet("consistency.distinguished must be positive", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(0L))
|
||||
);
|
||||
}
|
||||
@ -243,7 +243,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.setLast(10)
|
||||
.build())
|
||||
.build();
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitor(request));
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitorV2(request));
|
||||
verifyNoInteractions(keyTransparencyServiceClient);
|
||||
}
|
||||
|
||||
@ -254,7 +254,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.thenReturn(Mono.empty());
|
||||
final DistinguishedRequest request = DistinguishedRequest.newBuilder().build();
|
||||
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguished(request));
|
||||
assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguishedV2(request));
|
||||
verify(keyTransparencyServiceClient, times(1)).distinguished(eq(request));
|
||||
}
|
||||
|
||||
@ -264,7 +264,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.setLast(0)
|
||||
.build();
|
||||
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguished(request));
|
||||
assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguishedV2(request));
|
||||
verifyNoInteractions(keyTransparencyServiceClient);
|
||||
}
|
||||
|
||||
@ -277,7 +277,7 @@ public class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransp
|
||||
.setLast(10)
|
||||
.build();
|
||||
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguished(request));
|
||||
assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguishedV2(request));
|
||||
verifyNoInteractions(keyTransparencyServiceClient);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,336 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.grpc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.common.net.InetAddresses;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.stripe.model.PaymentIntent;
|
||||
import jakarta.annotation.Nullable;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.Mock;
|
||||
import org.signal.chat.one_time_donations.ConfirmPayPalBoostRequest;
|
||||
import org.signal.chat.one_time_donations.ConfirmPayPalBoostResponse;
|
||||
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsRequest;
|
||||
import org.signal.chat.one_time_donations.CreateBoostReceiptCredentialsResponse;
|
||||
import org.signal.chat.one_time_donations.CreateBoostRequest;
|
||||
import org.signal.chat.one_time_donations.CreateBoostResponse;
|
||||
import org.signal.chat.one_time_donations.CreatePayPalBoostRequest;
|
||||
import org.signal.chat.one_time_donations.CreatePayPalBoostResponse;
|
||||
import org.signal.chat.one_time_donations.OneTimeDonationsGrpc;
|
||||
import org.signal.chat.subscriptions.PaymentMethod;
|
||||
import org.signal.chat.subscriptions.PaymentProvider;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermit;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitRequest;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitRequestContext;
|
||||
import org.signal.libsignal.zkgroup.donation.DonationPermitResponse;
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiter;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.storage.DonationPermits;
|
||||
import org.whispersystems.textsecuregcm.storage.DonationPermitsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.WriteConflictException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.tests.util.SubscriptionConfigTestHelper;
|
||||
import org.whispersystems.textsecuregcm.util.TestClock;
|
||||
|
||||
public class OneTimeDonationsGrpcServiceTest extends
|
||||
SimpleBaseGrpcTest<OneTimeDonationsGrpcService, OneTimeDonationsGrpc.OneTimeDonationsBlockingStub> {
|
||||
|
||||
private final TestClock clock = TestClock.pinned(Instant.now());
|
||||
|
||||
private final OneTimeDonationConfiguration oneTimeDonationConfiguration =
|
||||
SubscriptionConfigTestHelper.getOneTimeConfig();
|
||||
|
||||
@Mock
|
||||
private DonationPermits donationPermits;
|
||||
|
||||
private static final ServerSecretParams DONATION_PERMITS_SECRET_PARAMS = ServerSecretParams.generate();
|
||||
private DonationPermitsManager donationPermitsManager;
|
||||
|
||||
@Mock
|
||||
private StripeManager stripeManager;
|
||||
|
||||
@Mock
|
||||
private BraintreeManager braintreeManager;
|
||||
|
||||
@Mock
|
||||
private PayPalDonationsTranslator payPalDonationsTranslator;
|
||||
|
||||
@Mock
|
||||
private OneTimeDonationsManager oneTimeDonationsManager;
|
||||
|
||||
@Mock
|
||||
private IssuedReceiptsManager issuedReceiptsManager;
|
||||
|
||||
@Mock
|
||||
private ServerZkReceiptOperations zkReceiptOperations;
|
||||
|
||||
@Mock
|
||||
private RateLimiters rateLimiters;
|
||||
|
||||
@Mock
|
||||
private RateLimiter rateLimiter;
|
||||
|
||||
@Override
|
||||
protected OneTimeDonationsGrpcService createServiceBeforeEachTest() {
|
||||
getMockRequestAttributesInterceptor().setRequestAttributes(
|
||||
new RequestAttributes(InetAddresses.forString("127.0.0.1"), null, "en-us"));
|
||||
|
||||
donationPermitsManager = new DonationPermitsManager(donationPermits, DONATION_PERMITS_SECRET_PARAMS, clock);
|
||||
|
||||
// spendIds are spend-once
|
||||
final Set<String> spent = new HashSet<>();
|
||||
when(donationPermits.spend(any(byte[].class), any(Instant.class)))
|
||||
.thenAnswer(answer -> spent.add(new String(answer.getArgument(0, byte[].class))));
|
||||
|
||||
when(rateLimiters.forDescriptor(RateLimiters.For.ONE_TIME_DONATION)).thenReturn(rateLimiter);
|
||||
|
||||
return new OneTimeDonationsGrpcService(
|
||||
oneTimeDonationConfiguration,
|
||||
stripeManager,
|
||||
braintreeManager,
|
||||
payPalDonationsTranslator,
|
||||
oneTimeDonationsManager,
|
||||
issuedReceiptsManager,
|
||||
zkReceiptOperations,
|
||||
clock,
|
||||
rateLimiters,
|
||||
donationPermitsManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBoost() {
|
||||
final PaymentIntent paymentIntent = mock(PaymentIntent.class);
|
||||
when(paymentIntent.getClientSecret()).thenReturn("test-client-secret");
|
||||
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
|
||||
.thenReturn(Set.of("usd"));
|
||||
when(stripeManager.createPaymentIntent(any(), anyLong(), anyLong(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(paymentIntent));
|
||||
|
||||
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
|
||||
CreateBoostRequest.newBuilder()
|
||||
.setCurrency("usd")
|
||||
.setAmount(500)
|
||||
.setLevel(1)
|
||||
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
|
||||
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
|
||||
.build());
|
||||
|
||||
assertEquals(CreateBoostResponse.ResponseCase.CLIENT_SECRET, response.getResponseCase());
|
||||
assertEquals("test-client-secret", response.getClientSecret());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBoostPermitAlreadySpent() {
|
||||
when(donationPermits.spend(any(byte[].class), any(Instant.class))).thenReturn(false);
|
||||
|
||||
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
|
||||
CreateBoostRequest.newBuilder()
|
||||
.setCurrency("usd")
|
||||
.setAmount(500)
|
||||
.setLevel(1)
|
||||
.setPaymentMethod(PaymentMethod.PAYMENT_METHOD_CARD)
|
||||
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
|
||||
.build());
|
||||
|
||||
assertEquals(CreateBoostResponse.ResponseCase.PERMIT_REJECTED, response.getResponseCase());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void createBoostValidationErrors(
|
||||
final String currency,
|
||||
final long amount,
|
||||
final PaymentMethod paymentMethod,
|
||||
final CreateBoostResponse.ResponseCase expectedResponseCase) {
|
||||
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.CARD))
|
||||
.thenReturn(Set.of("usd"));
|
||||
when(stripeManager.getSupportedCurrenciesForPaymentMethod(
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.SEPA_DEBIT))
|
||||
.thenReturn(Set.of("eur"));
|
||||
|
||||
final CreateBoostResponse response = unauthenticatedServiceStub().createBoost(
|
||||
CreateBoostRequest.newBuilder()
|
||||
.setCurrency(currency)
|
||||
.setAmount(amount)
|
||||
.setLevel(1)
|
||||
.setPaymentMethod(paymentMethod)
|
||||
.setDonationPermit(ByteString.copyFrom(getDonationPermit().serialize()))
|
||||
.build());
|
||||
|
||||
assertEquals(expectedResponseCase, response.getResponseCase());
|
||||
}
|
||||
|
||||
static Stream<Arguments> createBoostValidationErrors() {
|
||||
return Stream.of(
|
||||
Arguments.of("usd", 249L, PaymentMethod.PAYMENT_METHOD_CARD,
|
||||
CreateBoostResponse.ResponseCase.AMOUNT_BELOW_MINIMUM),
|
||||
Arguments.of("eur", 1000001L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
|
||||
CreateBoostResponse.ResponseCase.AMOUNT_ABOVE_SEPA_LIMIT),
|
||||
// USD is not supported for SEPA_DEBIT
|
||||
Arguments.of("usd", 3000L, PaymentMethod.PAYMENT_METHOD_SEPA_DEBIT,
|
||||
CreateBoostResponse.ResponseCase.UNSUPPORTED_CURRENCY)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPayPalBoost() {
|
||||
final BraintreeManager.PayPalOneTimePaymentApprovalDetails approvalDetails =
|
||||
mock(BraintreeManager.PayPalOneTimePaymentApprovalDetails.class);
|
||||
when(approvalDetails.approvalUrl()).thenReturn("test-approval-url");
|
||||
when(approvalDetails.paymentId()).thenReturn("test-id");
|
||||
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
|
||||
.thenReturn(Set.of("usd"));
|
||||
when(payPalDonationsTranslator.translate(any(), any()))
|
||||
.thenReturn("Donation to Signal Technology Foundation");
|
||||
when(braintreeManager.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(),
|
||||
anyString()))
|
||||
.thenReturn(CompletableFuture.completedFuture(approvalDetails));
|
||||
|
||||
final CreatePayPalBoostResponse response = unauthenticatedServiceStub().createPayPalBoost(
|
||||
CreatePayPalBoostRequest.newBuilder()
|
||||
.setCurrency("usd")
|
||||
.setAmount(300)
|
||||
.setLevel(1)
|
||||
.setReturnUrl("returnUrl")
|
||||
.setCancelUrl("cancelUrl")
|
||||
.build());
|
||||
|
||||
assertEquals(CreatePayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
|
||||
assertEquals("test-approval-url", response.getResult().getApprovalUrl());
|
||||
assertEquals("test-id", response.getResult().getPaymentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmPayPalBoost() {
|
||||
when(braintreeManager.getSupportedCurrenciesForPaymentMethod(
|
||||
org.whispersystems.textsecuregcm.subscriptions.PaymentMethod.PAYPAL))
|
||||
.thenReturn(Set.of("usd"));
|
||||
when(braintreeManager.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),
|
||||
anyLong(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(
|
||||
new BraintreeManager.PayPalChargeSuccessDetails("test-id")));
|
||||
|
||||
final ConfirmPayPalBoostResponse response = unauthenticatedServiceStub().confirmPayPalBoost(
|
||||
ConfirmPayPalBoostRequest.newBuilder()
|
||||
.setCurrency("usd")
|
||||
.setAmount(300)
|
||||
.setLevel(1)
|
||||
.setPayerId("test-payer-id")
|
||||
.setPaymentId("test-payment-id")
|
||||
.setPaymentToken("test-payment-token")
|
||||
.build());
|
||||
|
||||
assertEquals(ConfirmPayPalBoostResponse.ResponseCase.RESULT, response.getResponseCase());
|
||||
assertEquals("test-id", response.getResult().getPaymentId());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void createBoostReceiptCredentialsPaymentRequired(
|
||||
@Nullable final ChargeFailure chargeFailure,
|
||||
final boolean expectChargeFailure) {
|
||||
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
|
||||
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.FAILED,
|
||||
clock.instant(), chargeFailure))));
|
||||
|
||||
final CreateBoostReceiptCredentialsResponse response =
|
||||
unauthenticatedServiceStub().createBoostReceiptCredentials(
|
||||
CreateBoostReceiptCredentialsRequest.newBuilder()
|
||||
.setPaymentIntentId("test-payment-intent-id")
|
||||
.setReceiptCredentialRequest(ByteString.copyFromUtf8("abcd"))
|
||||
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
|
||||
.build());
|
||||
|
||||
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.PAYMENT_REQUIRED,
|
||||
response.getResponseCase());
|
||||
if (expectChargeFailure) {
|
||||
assertEquals("generic_decline", response.getPaymentRequired().getChargeFailure().getCode());
|
||||
} else {
|
||||
assertFalse(response.getPaymentRequired().hasChargeFailure());
|
||||
}
|
||||
}
|
||||
|
||||
static Stream<Arguments> createBoostReceiptCredentialsPaymentRequired() {
|
||||
return Stream.of(
|
||||
Arguments.of(new ChargeFailure("generic_decline", "some failure message", null, null, null), true),
|
||||
Arguments.of(null, false)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBoostReceiptCredentialsAlreadyRedeemed() throws Exception {
|
||||
final ReceiptCredentialRequest receiptCredentialRequest = new ClientZkReceiptOperations(
|
||||
ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(
|
||||
new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();
|
||||
|
||||
when(stripeManager.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(
|
||||
Optional.of(new PaymentDetails("id", Collections.emptyMap(), PaymentStatus.SUCCEEDED,
|
||||
clock.instant(), null))));
|
||||
doThrow(WriteConflictException.class).when(issuedReceiptsManager)
|
||||
.recordIssuance(any(), any(), any(), any());
|
||||
|
||||
final CreateBoostReceiptCredentialsResponse response =
|
||||
unauthenticatedServiceStub().createBoostReceiptCredentials(
|
||||
CreateBoostReceiptCredentialsRequest.newBuilder()
|
||||
.setPaymentIntentId("test-payment-intent-id")
|
||||
.setReceiptCredentialRequest(ByteString.copyFrom(receiptCredentialRequest.serialize()))
|
||||
.setProcessor(PaymentProvider.PAYMENT_PROVIDER_STRIPE)
|
||||
.build());
|
||||
|
||||
assertEquals(CreateBoostReceiptCredentialsResponse.ResponseCase.RECEIPT_ALREADY_ISSUED,
|
||||
response.getResponseCase());
|
||||
}
|
||||
|
||||
private DonationPermit getDonationPermit() {
|
||||
final DonationPermitRequestContext context = DonationPermitRequestContext.forCount(1);
|
||||
final DonationPermitRequest permitRequest = context.request();
|
||||
final DonationPermitResponse permitResponse = donationPermitsManager.issue(permitRequest);
|
||||
try {
|
||||
final List<DonationPermit> permits = context.receive(
|
||||
permitResponse, DONATION_PERMITS_SECRET_PARAMS.getPublicParams(), clock.instant());
|
||||
return permits.getFirst();
|
||||
} catch (final VerificationFailedException e) {
|
||||
throw new AssertionError("The permit was correctly requested and issued in this method", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,6 +97,7 @@ import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumen
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidIdempotencyKeyException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPrice;
|
||||
@ -594,7 +595,7 @@ public class SubscriptionsGrpcServiceTest extends
|
||||
final byte[] responseBytes = TestRandomUtil.nextBytes(16);
|
||||
when(receiptCredentialResponse.serialize()).thenReturn(responseBytes);
|
||||
when(subscriptionManager.createReceiptCredentials(any(), any(), any()))
|
||||
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, null, PaymentProvider.STRIPE));
|
||||
.thenReturn(new SubscriptionManager.ReceiptResult(receiptCredentialResponse, new SubscriptionPaymentProcessor.ReceiptItem("test-item-id", null, 5), PaymentProvider.STRIPE));
|
||||
final GetReceiptCredentialsResponse response = unauthenticatedServiceStub().getReceiptCredentials(
|
||||
GetReceiptCredentialsRequest.newBuilder()
|
||||
.setSubscriberId(SUBSCRIBER_ID)
|
||||
|
||||
@ -9,7 +9,7 @@ import com.apple.foundationdb.Database;
|
||||
import com.apple.foundationdb.FDB;
|
||||
import java.io.IOException;
|
||||
|
||||
interface FoundationDbDatabaseLifecycleManager {
|
||||
public interface FoundationDbDatabaseLifecycleManager {
|
||||
|
||||
void initializeDatabase(final FDB fdb) throws IOException;
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ import org.slf4j.LoggerFactory;
|
||||
/**
|
||||
* Manages the lifecycle of a database connected to a FoundationDB instance running as an external service container.
|
||||
*/
|
||||
class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
|
||||
public class ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {
|
||||
|
||||
private final String foundationDbServiceContainerName;
|
||||
|
||||
@ -24,7 +24,7 @@ class ServiceContainerFoundationDbDatabaseLifecycleManager implements Foundation
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ServiceContainerFoundationDbDatabaseLifecycleManager.class);
|
||||
|
||||
ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
|
||||
public ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {
|
||||
log.info("Using FoundationDB service container: {}", foundationDbServiceContainerName);
|
||||
this.foundationDbServiceContainerName = foundationDbServiceContainerName;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user