Add `playbilling` endpoint to `/v1/subscriptions`
This commit is contained in:
parent
3b4d445ca8
commit
564dba3053
|
@ -1142,8 +1142,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
|||
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
|
||||
zkReceiptOperations, issuedReceiptsManager);
|
||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
|
||||
bankMandateTranslator));
|
||||
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
|
||||
profileBadgeConverter, resourceBundleLevelTranslator, bankMandateTranslator));
|
||||
commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,
|
||||
zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));
|
||||
}
|
||||
|
|
|
@ -47,7 +47,6 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
||||
|
@ -58,7 +57,7 @@ import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
|||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
@ -166,7 +165,7 @@ public class OneTimeDonationController {
|
|||
* @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details
|
||||
*/
|
||||
private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount,
|
||||
SubscriptionPaymentProcessor manager) {
|
||||
CustomerAwareSubscriptionPaymentProcessor manager) {
|
||||
if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)
|
||||
.contains(request.currency.toLowerCase(Locale.ROOT))) {
|
||||
throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)
|
||||
|
|
|
@ -13,6 +13,7 @@ import io.dropwizard.auth.Auth;
|
|||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.ExternalDocumentation;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
@ -78,11 +79,12 @@ 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.CustomerAwareSubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
@ -102,6 +104,7 @@ public class SubscriptionController {
|
|||
private final SubscriptionManager subscriptionManager;
|
||||
private final StripeManager stripeManager;
|
||||
private final BraintreeManager braintreeManager;
|
||||
private final GooglePlayBillingManager googlePlayBillingManager;
|
||||
private final BadgeTranslator badgeTranslator;
|
||||
private final LevelTranslator levelTranslator;
|
||||
private final BankMandateTranslator bankMandateTranslator;
|
||||
|
@ -117,6 +120,7 @@ public class SubscriptionController {
|
|||
@Nonnull SubscriptionManager subscriptionManager,
|
||||
@Nonnull StripeManager stripeManager,
|
||||
@Nonnull BraintreeManager braintreeManager,
|
||||
@Nonnull GooglePlayBillingManager googlePlayBillingManager,
|
||||
@Nonnull BadgeTranslator badgeTranslator,
|
||||
@Nonnull LevelTranslator levelTranslator,
|
||||
@Nonnull BankMandateTranslator bankMandateTranslator) {
|
||||
|
@ -126,13 +130,14 @@ public class SubscriptionController {
|
|||
this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);
|
||||
this.stripeManager = Objects.requireNonNull(stripeManager);
|
||||
this.braintreeManager = Objects.requireNonNull(braintreeManager);
|
||||
this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);
|
||||
this.badgeTranslator = Objects.requireNonNull(badgeTranslator);
|
||||
this.levelTranslator = Objects.requireNonNull(levelTranslator);
|
||||
this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);
|
||||
}
|
||||
|
||||
private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {
|
||||
final List<SubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
|
||||
final List<CustomerAwareSubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);
|
||||
return oneTimeDonationConfiguration.currencies()
|
||||
.entrySet().stream()
|
||||
.collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {
|
||||
|
@ -252,7 +257,7 @@ public class SubscriptionController {
|
|||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
final SubscriptionPaymentProcessor subscriptionPaymentProcessor = switch (paymentMethodType) {
|
||||
final CustomerAwareSubscriptionPaymentProcessor customerAwareSubscriptionPaymentProcessor = switch (paymentMethodType) {
|
||||
// Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process
|
||||
// other types (like CARD) in the future.
|
||||
case CARD, SEPA_DEBIT, IDEAL -> stripeManager;
|
||||
|
@ -264,11 +269,11 @@ public class SubscriptionController {
|
|||
|
||||
return subscriptionManager.addPaymentMethodToCustomer(
|
||||
subscriberCredentials,
|
||||
subscriptionPaymentProcessor,
|
||||
customerAwareSubscriptionPaymentProcessor,
|
||||
getClientPlatform(userAgentString),
|
||||
SubscriptionPaymentProcessor::createPaymentMethodSetupToken)
|
||||
CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken)
|
||||
.thenApply(token ->
|
||||
Response.ok(new CreatePaymentMethodResponse(token, subscriptionPaymentProcessor.getProvider())).build());
|
||||
Response.ok(new CreatePaymentMethodResponse(token, customerAwareSubscriptionPaymentProcessor.getProvider())).build());
|
||||
}
|
||||
|
||||
public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {}
|
||||
|
@ -306,7 +311,7 @@ public class SubscriptionController {
|
|||
.build());
|
||||
}
|
||||
|
||||
private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) {
|
||||
private CustomerAwareSubscriptionPaymentProcessor getCustomerAwareProcessor(PaymentProvider processor) {
|
||||
return switch (processor) {
|
||||
case STRIPE -> stripeManager;
|
||||
case BRAINTREE -> braintreeManager;
|
||||
|
@ -326,7 +331,7 @@ public class SubscriptionController {
|
|||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processor);
|
||||
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processor);
|
||||
|
||||
return setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);
|
||||
}
|
||||
|
@ -369,7 +374,7 @@ public class SubscriptionController {
|
|||
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,
|
||||
processorCustomer.processor());
|
||||
|
||||
final SubscriptionPaymentProcessor manager = getManagerForProcessor(processorCustomer.processor());
|
||||
final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processorCustomer.processor());
|
||||
return subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,
|
||||
currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);
|
||||
})
|
||||
|
@ -395,6 +400,43 @@ public class SubscriptionController {
|
|||
== subscriptionConfiguration.getSubscriptionLevel(level2).type();
|
||||
}
|
||||
|
||||
|
||||
@POST
|
||||
@Path("/{subscriberId}/playbilling/{purchaseToken}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Set a google play billing purchase token", description = """
|
||||
Set a purchaseToken that represents an IAP subscription made with Google Play Billing.
|
||||
|
||||
To set up a subscription with Google Play Billing:
|
||||
1. Create a subscriber with `PUT subscriptions/{subscriberId}` (you must regularly refresh this subscriber)
|
||||
2. [Create a subscription](https://developer.android.com/google/play/billing/integrate) with Google Play Billing
|
||||
directly and obtain a purchaseToken. Do not [acknowledge](https://developer.android.com/google/play/billing/integrate#subscriptions)
|
||||
the purchaseToken.
|
||||
3. `POST` the purchaseToken here
|
||||
4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the
|
||||
entitlement
|
||||
|
||||
After calling this method, the payment is confirmed. Callers must durably store their subscriberId before calling
|
||||
this method to ensure their payment is tracked.
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "The purchaseToken was validated and acknowledged")
|
||||
@ApiResponse(responseCode = "402", description = "The purchaseToken payment is incomplete or invalid")
|
||||
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist")
|
||||
@ApiResponse(responseCode = "409", description = "subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.")
|
||||
public CompletableFuture<SetSubscriptionLevelSuccessResponse> setPlayStoreSubscription(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@PathParam("purchaseToken") String purchaseToken) throws SubscriptionException {
|
||||
final SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
return subscriptionManager
|
||||
.updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager, purchaseToken)
|
||||
.thenApply(SetSubscriptionLevelSuccessResponse::new);
|
||||
}
|
||||
|
||||
@Schema(description = """
|
||||
Comprehensive configuration for donation subscriptions, backup subscriptions, gift subscriptions, and one-time
|
||||
donations pricing information for all levels are included in currencies. All levels that have an associated
|
||||
|
@ -472,49 +514,89 @@ public class SubscriptionController {
|
|||
public record GetBankMandateResponse(String mandate) {}
|
||||
|
||||
public record GetSubscriptionInformationResponse(
|
||||
@Schema(description = "Information about the subscription, or null if no subscription is present")
|
||||
SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,
|
||||
@Schema(description = "May be omitted entirely if no charge failure is detected")
|
||||
@JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {
|
||||
|
||||
public record Subscription(long level, Instant billingCycleAnchor, Instant endOfCurrentPeriod, boolean active,
|
||||
boolean cancelAtPeriodEnd, String currency, BigDecimal amount, String status,
|
||||
PaymentProvider processor, PaymentMethod paymentMethod, boolean paymentProcessing) {
|
||||
public record Subscription(
|
||||
@Schema(description = "The subscription level")
|
||||
long level,
|
||||
|
||||
}
|
||||
}
|
||||
@Schema(
|
||||
description = "If present, UNIX Epoch Timestamp in seconds, can be used to calculate next billing date. May be absent for IAP subscriptions",
|
||||
externalDocs = @ExternalDocumentation(description = "Calculate next billing date", url = "https://stripe.com/docs/billing/subscriptions/billing-cycle"))
|
||||
Instant billingCycleAnchor,
|
||||
|
||||
@Schema(description = "UNIX Epoch Timestamp in seconds, when the current subscription period ends")
|
||||
Instant endOfCurrentPeriod,
|
||||
|
||||
@Schema(description = "Whether there is a currently active subscription")
|
||||
boolean active,
|
||||
|
||||
@Schema(description = "If true, an active subscription will not auto-renew at the end of the current period")
|
||||
boolean cancelAtPeriodEnd,
|
||||
|
||||
@Schema(description = "A three-letter ISO 4217 currency code for currency used in the subscription")
|
||||
String currency,
|
||||
|
||||
@Schema(
|
||||
description = "The amount paid for the subscription in the currency's smallest unit",
|
||||
externalDocs = @ExternalDocumentation(description = "Stripe Currencies", url = "https://docs.stripe.com/currencies"))
|
||||
BigDecimal amount,
|
||||
|
||||
@Schema(
|
||||
description = "The subscription's status, mapped to Stripe's statuses. trialing will never be returned",
|
||||
externalDocs = @ExternalDocumentation(description = "Stripe subscription statuses", url = "https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses"))
|
||||
String status,
|
||||
|
||||
@Schema(description = "The payment provider associated with the subscription")
|
||||
PaymentProvider processor,
|
||||
|
||||
@Schema(description = "The payment method associated with the subscription")
|
||||
PaymentMethod paymentMethod,
|
||||
|
||||
@Schema(description = "Whether the latest invoice for the subscription is in a non-terminal state")
|
||||
boolean paymentProcessing) {}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{subscriberId}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Subscription information", description = """
|
||||
Returns information about the current subscription associated with the provided subscriberId if one exists.
|
||||
|
||||
Although it uses [Stripe’s values](https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses),
|
||||
the status field in the response is generic, with [Braintree-specific values](https://developer.paypal.com/braintree/docs/guides/recurring-billing/overview#subscription-statuses) mapped
|
||||
to Stripe's. Since we don’t support trials or unpaid subscriptions, the associated statuses will never be returned
|
||||
by the API.
|
||||
""")
|
||||
@ApiResponse(responseCode = "200", description = "The subscriberId exists", content = @Content(schema = @Schema(implementation = GetSubscriptionInformationResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "subscriberId authentication failure OR account authentication is present")
|
||||
@ApiResponse(responseCode = "404", description = "No such subscriberId exists or subscriberId is malformed")
|
||||
public CompletableFuture<Response> getSubscriptionInformation(
|
||||
@ReadOnly @Auth Optional<AuthenticatedDevice> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) throws SubscriptionException {
|
||||
SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
return subscriptionManager.getSubscriber(subscriberCredentials)
|
||||
.thenCompose(record -> {
|
||||
if (record.subscriptionId == null) {
|
||||
return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build());
|
||||
}
|
||||
|
||||
final SubscriptionPaymentProcessor manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor());
|
||||
|
||||
return manager.getSubscription(record.subscriptionId).thenCompose(subscription ->
|
||||
manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> Response.ok(
|
||||
new GetSubscriptionInformationResponse(
|
||||
new GetSubscriptionInformationResponse.Subscription(
|
||||
subscriptionInformation.level(),
|
||||
subscriptionInformation.billingCycleAnchor(),
|
||||
subscriptionInformation.endOfCurrentPeriod(),
|
||||
subscriptionInformation.active(),
|
||||
subscriptionInformation.cancelAtPeriodEnd(),
|
||||
subscriptionInformation.price().currency(),
|
||||
subscriptionInformation.price().amount(),
|
||||
subscriptionInformation.status().getApiValue(),
|
||||
manager.getProvider(),
|
||||
subscriptionInformation.paymentMethod(),
|
||||
subscriptionInformation.paymentProcessing()),
|
||||
subscriptionInformation.chargeFailure()
|
||||
)).build()));
|
||||
});
|
||||
SubscriberCredentials subscriberCredentials =
|
||||
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);
|
||||
return subscriptionManager.getSubscriptionInformation(subscriberCredentials).thenApply(maybeInfo -> maybeInfo
|
||||
.map(subscriptionInformation -> Response.ok(
|
||||
new GetSubscriptionInformationResponse(
|
||||
new GetSubscriptionInformationResponse.Subscription(
|
||||
subscriptionInformation.level(),
|
||||
subscriptionInformation.billingCycleAnchor(),
|
||||
subscriptionInformation.endOfCurrentPeriod(),
|
||||
subscriptionInformation.active(),
|
||||
subscriptionInformation.cancelAtPeriodEnd(),
|
||||
subscriptionInformation.price().currency(),
|
||||
subscriptionInformation.price().amount(),
|
||||
subscriptionInformation.status().getApiValue(),
|
||||
subscriptionInformation.paymentProvider(),
|
||||
subscriptionInformation.paymentMethod(),
|
||||
subscriptionInformation.paymentProcessing()),
|
||||
subscriptionInformation.chargeFailure()
|
||||
)).build())
|
||||
.orElseGet(() -> Response.ok(new GetSubscriptionInformationResponse(null, null)).build()));
|
||||
}
|
||||
|
||||
public record GetReceiptCredentialsRequest(@NotEmpty byte[] receiptCredentialRequest) {
|
||||
|
@ -536,7 +618,7 @@ public class SubscriptionController {
|
|||
return subscriptionManager.createReceiptCredentials(subscriberCredentials, request, this::receiptExpirationWithGracePeriod)
|
||||
.thenApply(receiptCredential -> {
|
||||
final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();
|
||||
final SubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
|
||||
final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();
|
||||
Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,
|
||||
Tags.of(
|
||||
Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),
|
||||
|
@ -564,7 +646,7 @@ public class SubscriptionController {
|
|||
.thenCompose(generatedSepaId -> setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials));
|
||||
}
|
||||
|
||||
private CompletableFuture<Response> setDefaultPaymentMethod(final SubscriptionPaymentProcessor manager,
|
||||
private CompletableFuture<Response> setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,
|
||||
final String paymentMethodId,
|
||||
final SubscriberCredentials requestData) {
|
||||
return subscriptionManager.getSubscriber(requestData)
|
||||
|
@ -578,7 +660,7 @@ public class SubscriptionController {
|
|||
.thenApply(customer -> Response.ok().build());
|
||||
}
|
||||
|
||||
private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) {
|
||||
private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {
|
||||
final PaymentTime paymentTime = receiptItem.paymentTime();
|
||||
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
|
||||
case DONATION -> paymentTime.receiptExpiration(
|
||||
|
|
|
@ -22,9 +22,11 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
|||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;
|
||||
import org.whispersystems.textsecuregcm.controllers.SubscriptionController;
|
||||
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.SubscriptionInformation;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Util;
|
||||
|
@ -41,54 +43,22 @@ import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
|||
public class SubscriptionManager {
|
||||
|
||||
private final Subscriptions subscriptions;
|
||||
private final EnumMap<PaymentProvider, Processor> processors;
|
||||
private final EnumMap<PaymentProvider, SubscriptionPaymentProcessor> processors;
|
||||
private final ServerZkReceiptOperations zkReceiptOperations;
|
||||
private final IssuedReceiptsManager issuedReceiptsManager;
|
||||
|
||||
public SubscriptionManager(
|
||||
@Nonnull Subscriptions subscriptions,
|
||||
@Nonnull List<Processor> processors,
|
||||
@Nonnull List<SubscriptionPaymentProcessor> processors,
|
||||
@Nonnull ServerZkReceiptOperations zkReceiptOperations,
|
||||
@Nonnull IssuedReceiptsManager issuedReceiptsManager) {
|
||||
this.subscriptions = Objects.requireNonNull(subscriptions);
|
||||
this.processors = new EnumMap<>(processors.stream()
|
||||
.collect(Collectors.toMap(Processor::getProvider, Function.identity())));
|
||||
.collect(Collectors.toMap(SubscriptionPaymentProcessor::getProvider, Function.identity())));
|
||||
this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);
|
||||
this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);
|
||||
}
|
||||
|
||||
public interface Processor {
|
||||
|
||||
PaymentProvider getProvider();
|
||||
|
||||
/**
|
||||
* A receipt of payment from a payment provider
|
||||
*
|
||||
* @param itemId An identifier for the payment that should be unique within the payment provider. Note that
|
||||
* this must identify an actual individual charge, not the subscription as a whole.
|
||||
* @param paymentTime The time this payment was for
|
||||
* @param level The level which this payment corresponds to
|
||||
*/
|
||||
record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
|
||||
|
||||
/**
|
||||
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
|
||||
*
|
||||
* @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription
|
||||
* @return A {@link ReceiptItem} if the subscription is valid
|
||||
*/
|
||||
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
|
||||
|
||||
/**
|
||||
* Cancel all active subscriptions for this key within the payment provider.
|
||||
*
|
||||
* @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in
|
||||
* the subscriptions table
|
||||
* @return A stage that completes when all subscriptions associated with the key are cancelled
|
||||
*/
|
||||
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription with the upstream payment provider and remove the subscription from the table
|
||||
*
|
||||
|
@ -146,6 +116,16 @@ public class SubscriptionManager {
|
|||
.thenRun(Util.NOOP);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<SubscriptionInformation>> getSubscriptionInformation(final SubscriberCredentials subscriberCredentials) {
|
||||
return getSubscriber(subscriberCredentials).thenCompose(record -> {
|
||||
if (record.subscriptionId == null) {
|
||||
return CompletableFuture.completedFuture(Optional.empty());
|
||||
}
|
||||
final SubscriptionPaymentProcessor manager = getProcessor(record.processorCustomer.processor());
|
||||
return manager.getSubscriptionInformation(record.subscriptionId).thenApply(Optional::of);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subscriber record
|
||||
*
|
||||
|
@ -167,7 +147,7 @@ public class SubscriptionManager {
|
|||
|
||||
public record ReceiptResult(
|
||||
ReceiptCredentialResponse receiptCredentialResponse,
|
||||
SubscriptionPaymentProcessor.ReceiptItem receiptItem,
|
||||
CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem,
|
||||
PaymentProvider paymentProvider) {}
|
||||
|
||||
/**
|
||||
|
@ -175,14 +155,14 @@ public class SubscriptionManager {
|
|||
*
|
||||
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
|
||||
* @param request The ZK Receipt credential request
|
||||
* @param expiration A function that takes a {@link SubscriptionPaymentProcessor.ReceiptItem} and returns
|
||||
* @param expiration A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem} and returns
|
||||
* the expiration time of the receipt
|
||||
* @return If the subscription had a valid payment, the requested ZK receipt credential
|
||||
*/
|
||||
public CompletableFuture<ReceiptResult> createReceiptCredentials(
|
||||
final SubscriberCredentials subscriberCredentials,
|
||||
final SubscriptionController.GetReceiptCredentialsRequest request,
|
||||
final Function<SubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
|
||||
final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration) {
|
||||
return getSubscriber(subscriberCredentials).thenCompose(record -> {
|
||||
if (record.subscriptionId == null) {
|
||||
return CompletableFuture.failedFuture(new SubscriptionException.NotFound());
|
||||
|
@ -197,7 +177,7 @@ public class SubscriptionManager {
|
|||
}
|
||||
|
||||
final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();
|
||||
final Processor manager = getProcessor(processor);
|
||||
final SubscriptionPaymentProcessor manager = getProcessor(processor);
|
||||
return manager.getReceiptItem(record.subscriptionId)
|
||||
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
|
||||
receipt.itemId(), manager.getProvider(), receiptCredentialRequest,
|
||||
|
@ -224,7 +204,7 @@ public class SubscriptionManager {
|
|||
* <p>
|
||||
* If the customer does not exist in the table, a customer is created via the subscriptionPaymentProcessor and added
|
||||
* to the table. Not all payment processors support server-managed customers, so a payment processor that implements
|
||||
* {@link SubscriptionPaymentProcessor} must be passed in.
|
||||
* {@link CustomerAwareSubscriptionPaymentProcessor} must be passed in.
|
||||
*
|
||||
* @param subscriberCredentials Subscriber credentials derived from the subscriberId
|
||||
* @param subscriptionPaymentProcessor A customer-aware payment processor to use. If the subscriber already has a
|
||||
|
@ -240,7 +220,7 @@ public class SubscriptionManager {
|
|||
* @return A stage that completes when the payment method has been created in the payment processor and the table has
|
||||
* been updated
|
||||
*/
|
||||
public <T extends SubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
|
||||
public <T extends CustomerAwareSubscriptionPaymentProcessor, R> CompletableFuture<R> addPaymentMethodToCustomer(
|
||||
final SubscriberCredentials subscriberCredentials,
|
||||
final T subscriptionPaymentProcessor,
|
||||
final ClientPlatform clientPlatform,
|
||||
|
@ -305,7 +285,7 @@ public class SubscriptionManager {
|
|||
public CompletableFuture<Void> updateSubscriptionLevelForCustomer(
|
||||
final SubscriberCredentials subscriberCredentials,
|
||||
final Subscriptions.Record record,
|
||||
final SubscriptionPaymentProcessor processor,
|
||||
final CustomerAwareSubscriptionPaymentProcessor processor,
|
||||
final long level,
|
||||
final String currency,
|
||||
final String idempotencyKey,
|
||||
|
@ -320,7 +300,7 @@ public class SubscriptionManager {
|
|||
.getSubscription(subId)
|
||||
.thenCompose(subscription -> processor.getLevelAndCurrencyForSubscription(subscription)
|
||||
.thenCompose(existingLevelAndCurrency -> {
|
||||
if (existingLevelAndCurrency.equals(new SubscriptionPaymentProcessor.LevelAndCurrency(level,
|
||||
if (existingLevelAndCurrency.equals(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level,
|
||||
currency.toLowerCase(Locale.ROOT)))) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
@ -383,7 +363,7 @@ public class SubscriptionManager {
|
|||
return googlePlayBillingManager
|
||||
// Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager,
|
||||
// but we don't want to acknowledge it until it's successfully persisted.
|
||||
.validateToken(record.subscriptionId)
|
||||
.validateToken(purchaseToken)
|
||||
// Store the purchaseToken with the subscriber
|
||||
.thenCompose(validatedToken -> subscriptions.setIapPurchase(
|
||||
record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now())
|
||||
|
@ -394,7 +374,7 @@ public class SubscriptionManager {
|
|||
|
||||
}
|
||||
|
||||
private Processor getProcessor(PaymentProvider provider) {
|
||||
private SubscriptionPaymentProcessor getProcessor(PaymentProvider provider) {
|
||||
return processors.get(provider);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ import org.whispersystems.textsecuregcm.util.GoogleApiUtil;
|
|||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
public class BraintreeManager implements SubscriptionPaymentProcessor {
|
||||
public class BraintreeManager implements CustomerAwareSubscriptionPaymentProcessor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
|
||||
|
||||
|
@ -496,10 +496,9 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
|
|||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
|
||||
return getSubscription(subscriptionId).thenApplyAsync(subscriptionObj -> {
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
|
||||
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
|
||||
|
||||
|
@ -531,10 +530,12 @@ public class BraintreeManager implements SubscriptionPaymentProcessor {
|
|||
Subscription.Status.ACTIVE == subscription.getStatus(),
|
||||
!subscription.neverExpires(),
|
||||
getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed),
|
||||
PaymentProvider.BRAINTREE,
|
||||
latestTransaction.map(this::getPaymentMethodFromTransaction).orElse(PaymentMethod.PAYPAL),
|
||||
paymentProcessing,
|
||||
chargeFailure
|
||||
);
|
||||
|
||||
}, executor);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,9 +5,51 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import io.swagger.v3.oas.annotations.ExternalDocumentation;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus,
|
||||
@Nullable String outcomeReason, @Nullable String outcomeType) {
|
||||
/**
|
||||
* Information about a charge failure.
|
||||
* <p>
|
||||
* This is returned directly from {@link org.whispersystems.textsecuregcm.controllers.SubscriptionController}, so modify
|
||||
* with care.
|
||||
*/
|
||||
@Schema(description = """
|
||||
Meaningfully interpreting chargeFailure response fields requires inspecting the processor field first.
|
||||
|
||||
}
|
||||
For Stripe, code will be one of the [codes defined here](https://stripe.com/docs/api/charges/object#charge_object-failure_code),
|
||||
while message [may contain a further textual description](https://stripe.com/docs/api/charges/object#charge_object-failure_message).
|
||||
The outcome fields are nullable, but present values will directly map to Stripe [response properties](https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status)
|
||||
|
||||
For Braintree, the outcome fields will be null. The code and message will contain one of
|
||||
- a processor decline code (as a string) in code, and associated text in message, as defined this [table](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses)
|
||||
- `gateway` in code, with a [reason](https://developer.paypal.com/braintree/articles/control-panel/transactions/gateway-rejections) in message
|
||||
- `code` = "unknown", message = "unknown"
|
||||
|
||||
IAP payment processors will never include charge failure information, and detailed order information should be
|
||||
retrieved from the payment processor directly
|
||||
""")
|
||||
public record ChargeFailure(
|
||||
@Schema(description = """
|
||||
See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or
|
||||
[Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)
|
||||
depending on which processor was used
|
||||
""")
|
||||
String code,
|
||||
|
||||
@Schema(description = """
|
||||
See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or
|
||||
[Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)
|
||||
depending on which processor was used
|
||||
""")
|
||||
String message,
|
||||
|
||||
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Network Status", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status"))
|
||||
@Nullable String outcomeNetworkStatus,
|
||||
|
||||
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Reason", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-reason"))
|
||||
@Nullable String outcomeReason,
|
||||
|
||||
@Schema(externalDocs = @ExternalDocumentation(description = "Outcome Type", url = "https://stripe.com/docs/api/charges/object#charge_object-outcome-type"))
|
||||
@Nullable String outcomeType) {}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
/**
|
||||
* Interface for an external payment provider that has an API-accessible notion of customer that implementations can
|
||||
* manage. Payment providers that let you add and remove payment methods to an existing customer should implement this
|
||||
* interface. Contrast this with the super interface {@link SubscriptionPaymentProcessor}, which allows for a payment
|
||||
* provider with an API that only operations on subscriptions.
|
||||
*/
|
||||
public interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionPaymentProcessor {
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
|
||||
|
||||
/**
|
||||
* @param customerId
|
||||
* @param paymentMethodToken a processor-specific token necessary
|
||||
* @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update
|
||||
* @return
|
||||
*/
|
||||
CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
|
||||
@Nullable String currentSubscriptionId);
|
||||
|
||||
CompletableFuture<Object> getSubscription(String subscriptionId);
|
||||
|
||||
CompletableFuture<SubscriptionId> createSubscription(String customerId, String templateId, long level,
|
||||
long lastSubscriptionCreatedAt);
|
||||
|
||||
CompletableFuture<SubscriptionId> updateSubscription(
|
||||
Object subscription, String templateId, long level, String idempotencyKey);
|
||||
|
||||
/**
|
||||
* @param subscription
|
||||
* @return the subscription’s current level and lower-case currency code
|
||||
*/
|
||||
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
|
||||
|
||||
record SubscriptionId(String id) {
|
||||
|
||||
}
|
||||
|
||||
record LevelAndCurrency(long level, String currency) {
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -12,6 +12,9 @@ import com.google.api.client.json.gson.GsonFactory;
|
|||
import com.google.api.services.androidpublisher.AndroidPublisher;
|
||||
import com.google.api.services.androidpublisher.AndroidPublisherRequest;
|
||||
import com.google.api.services.androidpublisher.AndroidPublisherScopes;
|
||||
import com.google.api.services.androidpublisher.model.BasePlan;
|
||||
import com.google.api.services.androidpublisher.model.OfferDetails;
|
||||
import com.google.api.services.androidpublisher.model.RegionalBasePlanConfig;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;
|
||||
|
@ -28,6 +31,7 @@ import java.time.Instant;
|
|||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
@ -41,7 +45,6 @@ import org.slf4j.LoggerFactory;
|
|||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
|
@ -56,7 +59,7 @@ import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
|||
* <li> querying the current status of a token's underlying subscription </li>
|
||||
* </ul>
|
||||
*/
|
||||
public class GooglePlayBillingManager implements SubscriptionManager.Processor {
|
||||
public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);
|
||||
|
||||
|
@ -218,6 +221,67 @@ public class GooglePlayBillingManager implements SubscriptionManager.Processor {
|
|||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String purchaseToken) {
|
||||
|
||||
final CompletableFuture<SubscriptionPurchaseV2> subscriptionFuture = lookupSubscription(purchaseToken);
|
||||
final CompletableFuture<SubscriptionPrice> priceFuture = subscriptionFuture.thenCompose(this::getSubscriptionPrice);
|
||||
|
||||
return subscriptionFuture.thenCombineAsync(priceFuture, (subscription, price) -> {
|
||||
|
||||
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription);
|
||||
final Optional<Instant> expiration = getExpiration(lineItem);
|
||||
|
||||
final SubscriptionStatus status = switch (SubscriptionState
|
||||
.fromString(subscription.getSubscriptionState())
|
||||
.orElse(SubscriptionState.UNSPECIFIED)) {
|
||||
case ACTIVE -> SubscriptionStatus.ACTIVE;
|
||||
case PENDING -> SubscriptionStatus.INCOMPLETE;
|
||||
case EXPIRED, ON_HOLD, PAUSED -> SubscriptionStatus.PAST_DUE;
|
||||
case IN_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
|
||||
case CANCELED, PENDING_PURCHASE_CANCELED -> SubscriptionStatus.CANCELED;
|
||||
case UNSPECIFIED -> SubscriptionStatus.UNKNOWN;
|
||||
};
|
||||
|
||||
return new SubscriptionInformation(
|
||||
price,
|
||||
productIdToLevel(lineItem.getProductId()),
|
||||
null, expiration.orElse(null),
|
||||
expiration.map(clock.instant()::isBefore).orElse(false),
|
||||
lineItem.getAutoRenewingPlan() != null && lineItem.getAutoRenewingPlan().getAutoRenewEnabled(),
|
||||
status,
|
||||
PaymentProvider.GOOGLE_PLAY_BILLING,
|
||||
PaymentMethod.GOOGLE_PLAY_BILLING,
|
||||
false,
|
||||
null);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
private CompletableFuture<SubscriptionPrice> getSubscriptionPrice(final SubscriptionPurchaseV2 subscriptionPurchase) {
|
||||
|
||||
final SubscriptionPurchaseLineItem lineItem = getLineItem(subscriptionPurchase);
|
||||
final OfferDetails offerDetails = lineItem.getOfferDetails();
|
||||
final String basePlanId = offerDetails.getBasePlanId();
|
||||
|
||||
return this.executeAsync(pub -> pub.monetization().subscriptions().get(packageName, lineItem.getProductId()))
|
||||
.thenApplyAsync(subscription -> {
|
||||
|
||||
final BasePlan basePlan = subscription.getBasePlans().stream()
|
||||
.filter(bp -> bp.getBasePlanId().equals(basePlanId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown basePlanId " + basePlanId)));
|
||||
final String region = subscriptionPurchase.getRegionCode();
|
||||
final RegionalBasePlanConfig basePlanConfig = basePlan.getRegionalConfigs()
|
||||
.stream()
|
||||
.filter(rbpc -> Objects.equals(region, rbpc.getRegionCode()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> ExceptionUtils.wrap(new IOException("unknown subscription region " + region)));
|
||||
|
||||
return new SubscriptionPrice(
|
||||
basePlanConfig.getPrice().getCurrencyCode().toUpperCase(Locale.ROOT),
|
||||
SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(basePlanConfig.getPrice()));
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ReceiptItem> getReceiptItem(String purchaseToken) {
|
||||
|
|
|
@ -77,7 +77,7 @@ import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
|||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
public class StripeManager implements SubscriptionPaymentProcessor {
|
||||
public class StripeManager implements CustomerAwareSubscriptionPaymentProcessor {
|
||||
private static final Logger logger = LoggerFactory.getLogger(StripeManager.class);
|
||||
private static final String METADATA_KEY_LEVEL = "level";
|
||||
private static final String METADATA_KEY_CLIENT_PLATFORM = "clientPlatform";
|
||||
|
@ -364,6 +364,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
|
|||
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Object> getSubscription(String subscriptionId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
||||
|
@ -378,6 +379,7 @@ public class StripeManager implements SubscriptionPaymentProcessor {
|
|||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
|
||||
return getCustomer(customerId).thenCompose(customer -> {
|
||||
if (customer == null) {
|
||||
|
@ -536,46 +538,45 @@ public class StripeManager implements SubscriptionPaymentProcessor {
|
|||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId) {
|
||||
return getSubscription(subscriptionId).thenApply(this::getSubscription).thenCompose(subscription ->
|
||||
getPriceForSubscription(subscription).thenCompose(price ->
|
||||
getLevelForPrice(price).thenApply(level -> {
|
||||
ChargeFailure chargeFailure = null;
|
||||
boolean paymentProcessing = false;
|
||||
PaymentMethod paymentMethod = null;
|
||||
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
if (subscription.getLatestInvoiceObject() != null) {
|
||||
final Invoice invoice = subscription.getLatestInvoiceObject();
|
||||
paymentProcessing = "open".equals(invoice.getStatus());
|
||||
|
||||
return getPriceForSubscription(subscription).thenCompose(price ->
|
||||
getLevelForPrice(price).thenApply(level -> {
|
||||
ChargeFailure chargeFailure = null;
|
||||
boolean paymentProcessing = false;
|
||||
PaymentMethod paymentMethod = null;
|
||||
if (invoice.getChargeObject() != null) {
|
||||
final Charge charge = invoice.getChargeObject();
|
||||
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
|
||||
chargeFailure = createChargeFailure(charge);
|
||||
}
|
||||
|
||||
if (subscription.getLatestInvoiceObject() != null) {
|
||||
final Invoice invoice = subscription.getLatestInvoiceObject();
|
||||
paymentProcessing = "open".equals(invoice.getStatus());
|
||||
|
||||
if (invoice.getChargeObject() != null) {
|
||||
final Charge charge = invoice.getChargeObject();
|
||||
if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {
|
||||
chargeFailure = createChargeFailure(charge);
|
||||
}
|
||||
|
||||
if (charge.getPaymentMethodDetails() != null
|
||||
&& charge.getPaymentMethodDetails().getType() != null) {
|
||||
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
|
||||
}
|
||||
if (charge.getPaymentMethodDetails() != null
|
||||
&& charge.getPaymentMethodDetails().getType() != null) {
|
||||
paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SubscriptionInformation(
|
||||
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
|
||||
level,
|
||||
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
|
||||
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
|
||||
Objects.equals(subscription.getStatus(), "active"),
|
||||
subscription.getCancelAtPeriodEnd(),
|
||||
getSubscriptionStatus(subscription.getStatus()),
|
||||
paymentMethod,
|
||||
paymentProcessing,
|
||||
chargeFailure
|
||||
);
|
||||
}));
|
||||
return new SubscriptionInformation(
|
||||
new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),
|
||||
level,
|
||||
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
|
||||
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
|
||||
Objects.equals(subscription.getStatus(), "active"),
|
||||
subscription.getCancelAtPeriodEnd(),
|
||||
getSubscriptionStatus(subscription.getStatus()),
|
||||
PaymentProvider.STRIPE,
|
||||
paymentMethod,
|
||||
paymentProcessing,
|
||||
chargeFailure
|
||||
);
|
||||
})));
|
||||
}
|
||||
|
||||
private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.google.api.services.androidpublisher.model.Money;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
@ -71,4 +72,16 @@ public class SubscriptionCurrencyUtil {
|
|||
static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) {
|
||||
return convertConfiguredAmountToApiAmount(currency, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Play Billing's representation of currency amounts to a Stripe-style amount
|
||||
*
|
||||
* @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,
|
||||
* BigDecimal)
|
||||
*/
|
||||
static BigDecimal convertGoogleMoneyToApiAmount(final Money money) {
|
||||
final BigDecimal fractionalComponent = BigDecimal.valueOf(money.getNanos()).scaleByPowerOfTen(-9);
|
||||
final BigDecimal amount = BigDecimal.valueOf(money.getUnits()).add(fractionalComponent);
|
||||
return convertConfiguredAmountToApiAmount(money.getCurrencyCode(), amount);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.time.Instant;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record SubscriptionInformation(
|
||||
SubscriptionPrice price,
|
||||
long level,
|
||||
Instant billingCycleAnchor,
|
||||
Instant endOfCurrentPeriod,
|
||||
boolean active,
|
||||
boolean cancelAtPeriodEnd,
|
||||
SubscriptionStatus status,
|
||||
PaymentProvider paymentProvider,
|
||||
PaymentMethod paymentMethod,
|
||||
boolean paymentProcessing,
|
||||
@Nullable ChargeFailure chargeFailure) {}
|
|
@ -2,139 +2,42 @@
|
|||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.util.ua.ClientPlatform;
|
||||
|
||||
public interface SubscriptionPaymentProcessor extends SubscriptionManager.Processor {
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
public interface SubscriptionPaymentProcessor {
|
||||
|
||||
PaymentProvider getProvider();
|
||||
|
||||
/**
|
||||
* @param customerId
|
||||
* @param paymentMethodToken a processor-specific token necessary
|
||||
* @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update
|
||||
* @return
|
||||
* A receipt of payment from a payment provider
|
||||
*
|
||||
* @param itemId An identifier for the payment that should be unique within the payment provider. Note that this
|
||||
* must identify an actual individual charge, not the subscription as a whole.
|
||||
* @param paymentTime The time this payment was for
|
||||
* @param level The level which this payment corresponds to
|
||||
*/
|
||||
CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,
|
||||
@Nullable String currentSubscriptionId);
|
||||
|
||||
CompletableFuture<Object> getSubscription(String subscriptionId);
|
||||
|
||||
CompletableFuture<SubscriptionId> createSubscription(String customerId, String templateId, long level,
|
||||
long lastSubscriptionCreatedAt);
|
||||
|
||||
CompletableFuture<SubscriptionId> updateSubscription(
|
||||
Object subscription, String templateId, long level, String idempotencyKey);
|
||||
record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}
|
||||
|
||||
/**
|
||||
* @param subscription
|
||||
* @return the subscription’s current level and lower-case currency code
|
||||
* Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table
|
||||
*
|
||||
* @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription
|
||||
* @return A {@link ReceiptItem} if the subscription is valid
|
||||
*/
|
||||
CompletableFuture<LevelAndCurrency> getLevelAndCurrencyForSubscription(Object subscription);
|
||||
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
|
||||
|
||||
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscription);
|
||||
|
||||
enum SubscriptionStatus {
|
||||
/**
|
||||
* The subscription is in good standing and the most recent payment was successful.
|
||||
*/
|
||||
ACTIVE("active"),
|
||||
|
||||
/**
|
||||
* Payment failed when creating the subscription, or the subscription’s start date is in the future.
|
||||
*/
|
||||
INCOMPLETE("incomplete"),
|
||||
|
||||
/**
|
||||
* Payment on the latest renewal failed but there are processor retries left, or payment wasn't attempted.
|
||||
*/
|
||||
PAST_DUE("past_due"),
|
||||
|
||||
/**
|
||||
* The subscription has been canceled.
|
||||
*/
|
||||
CANCELED("canceled"),
|
||||
|
||||
/**
|
||||
* The latest renewal hasn't been paid but the subscription remains in place.
|
||||
*/
|
||||
UNPAID("unpaid"),
|
||||
|
||||
/**
|
||||
* The status from the downstream processor is unknown.
|
||||
*/
|
||||
UNKNOWN("unknown");
|
||||
|
||||
|
||||
private final String apiValue;
|
||||
|
||||
SubscriptionStatus(String apiValue) {
|
||||
this.apiValue = apiValue;
|
||||
}
|
||||
|
||||
public static SubscriptionStatus forApiValue(String status) {
|
||||
return switch (status) {
|
||||
case "active" -> ACTIVE;
|
||||
case "canceled", "incomplete_expired" -> CANCELED;
|
||||
case "unpaid" -> UNPAID;
|
||||
case "past_due" -> PAST_DUE;
|
||||
case "incomplete" -> INCOMPLETE;
|
||||
|
||||
case "trialing" -> {
|
||||
final Logger logger = LoggerFactory.getLogger(SubscriptionPaymentProcessor.class);
|
||||
logger.error("Subscription has status that should never happen: {}", status);
|
||||
|
||||
yield UNKNOWN;
|
||||
}
|
||||
default -> {
|
||||
final Logger logger = LoggerFactory.getLogger(SubscriptionPaymentProcessor.class);
|
||||
logger.error("Subscription has unknown status: {}", status);
|
||||
|
||||
yield UNKNOWN;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public String getApiValue() {
|
||||
return apiValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
record SubscriptionId(String id) {
|
||||
|
||||
}
|
||||
|
||||
record SubscriptionInformation(SubscriptionPrice price, long level, Instant billingCycleAnchor,
|
||||
Instant endOfCurrentPeriod, boolean active, boolean cancelAtPeriodEnd,
|
||||
SubscriptionStatus status, PaymentMethod paymentMethod, boolean paymentProcessing,
|
||||
@Nullable ChargeFailure chargeFailure) {
|
||||
|
||||
}
|
||||
|
||||
record SubscriptionPrice(String currency, BigDecimal amount) {
|
||||
|
||||
}
|
||||
|
||||
record LevelAndCurrency(long level, String currency) {
|
||||
|
||||
}
|
||||
/**
|
||||
* Cancel all active subscriptions for this key within the payment provider.
|
||||
*
|
||||
* @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the
|
||||
* subscriptions table
|
||||
* @return A stage that completes when all subscriptions associated with the key are cancelled
|
||||
*/
|
||||
CompletableFuture<Void> cancelAllActiveSubscriptions(String key);
|
||||
|
||||
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String subscriptionId);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record SubscriptionPrice(String currency, BigDecimal amount) {}
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public enum SubscriptionStatus {
|
||||
/**
|
||||
* The subscription is in good standing and the most recent payment was successful.
|
||||
*/
|
||||
ACTIVE("active"),
|
||||
|
||||
/**
|
||||
* Payment failed when creating the subscription, or the subscription’s start date is in the future.
|
||||
*/
|
||||
INCOMPLETE("incomplete"),
|
||||
|
||||
/**
|
||||
* Payment on the latest renewal failed but there are processor retries left, or payment wasn't attempted.
|
||||
*/
|
||||
PAST_DUE("past_due"),
|
||||
|
||||
/**
|
||||
* The subscription has been canceled.
|
||||
*/
|
||||
CANCELED("canceled"),
|
||||
|
||||
/**
|
||||
* The latest renewal hasn't been paid but the subscription remains in place.
|
||||
*/
|
||||
UNPAID("unpaid"),
|
||||
|
||||
/**
|
||||
* The status from the downstream processor is unknown.
|
||||
*/
|
||||
UNKNOWN("unknown");
|
||||
|
||||
|
||||
private final String apiValue;
|
||||
|
||||
SubscriptionStatus(String apiValue) {
|
||||
this.apiValue = apiValue;
|
||||
}
|
||||
|
||||
public static SubscriptionStatus forApiValue(String status) {
|
||||
return switch (status) {
|
||||
case "active" -> ACTIVE;
|
||||
case "canceled", "incomplete_expired" -> CANCELED;
|
||||
case "unpaid" -> UNPAID;
|
||||
case "past_due" -> PAST_DUE;
|
||||
case "incomplete" -> INCOMPLETE;
|
||||
|
||||
case "trialing" -> {
|
||||
final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);
|
||||
logger.error("Subscription has status that should never happen: {}", status);
|
||||
|
||||
yield UNKNOWN;
|
||||
}
|
||||
default -> {
|
||||
final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);
|
||||
logger.error("Subscription has unknown status: {}", status);
|
||||
|
||||
yield UNKNOWN;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public String getApiValue() {
|
||||
return apiValue;
|
||||
}
|
||||
}
|
|
@ -33,4 +33,5 @@ public class CompletableFutureUtil {
|
|||
|
||||
return completableFuture;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
@ -93,7 +94,7 @@ import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
|||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;
|
||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
@ -124,7 +125,7 @@ class SubscriptionControllerTest {
|
|||
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
||||
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG,
|
||||
ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS,
|
||||
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
|
||||
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
|
||||
BANK_MANDATE_TRANSLATOR);
|
||||
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
|
||||
ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);
|
||||
|
@ -403,7 +404,7 @@ class SubscriptionControllerTest {
|
|||
@Test
|
||||
void createSubscriptionSuccess() {
|
||||
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
|
||||
.thenReturn(CompletableFuture.completedFuture(mock(SubscriptionPaymentProcessor.SubscriptionId.class)));
|
||||
.thenReturn(CompletableFuture.completedFuture(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class)));
|
||||
|
||||
final String level = String.valueOf(levelId);
|
||||
final String idempotencyKey = UUID.randomUUID().toString();
|
||||
|
@ -715,7 +716,7 @@ class SubscriptionControllerTest {
|
|||
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
|
||||
|
||||
when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
|
||||
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId(
|
||||
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
|
||||
"subscription")));
|
||||
when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
@ -769,12 +770,12 @@ class SubscriptionControllerTest {
|
|||
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
|
||||
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
|
||||
.thenReturn(CompletableFuture.completedFuture(
|
||||
new SubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
|
||||
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency)));
|
||||
final String updatedSubscriptionId = "updatedSubscriptionId";
|
||||
|
||||
if (expectUpdate) {
|
||||
when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString()))
|
||||
.thenReturn(CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.SubscriptionId(
|
||||
.thenReturn(CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(
|
||||
updatedSubscriptionId)));
|
||||
when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString()))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
@ -844,7 +845,7 @@ class SubscriptionControllerTest {
|
|||
.thenReturn(CompletableFuture.completedFuture(subscriptionObj));
|
||||
when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))
|
||||
.thenReturn(CompletableFuture.completedFuture(
|
||||
new SubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")));
|
||||
new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, "usd")));
|
||||
|
||||
// Try to change from a backup subscription (201) to a donation subscription (5)
|
||||
final Response response = RESOURCE_EXTENSION
|
||||
|
@ -860,6 +861,50 @@ class SubscriptionControllerTest {
|
|||
.isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void setPlayPurchaseToken() {
|
||||
final String purchaseToken = "aPurchaseToken";
|
||||
final byte[] subscriberUserAndKey = new byte[32];
|
||||
Arrays.fill(subscriberUserAndKey, (byte) 1);
|
||||
final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16);
|
||||
final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);
|
||||
|
||||
final Instant now = Instant.now();
|
||||
when(CLOCK.instant()).thenReturn(now);
|
||||
|
||||
final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),
|
||||
Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),
|
||||
Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())
|
||||
);
|
||||
final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem);
|
||||
when(SUBSCRIPTIONS.get(any(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
|
||||
|
||||
final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);
|
||||
when(validatedToken.getLevel()).thenReturn(99L);
|
||||
when(validatedToken.acknowledgePurchase()).thenReturn(CompletableFuture.completedFuture(null));
|
||||
when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(CompletableFuture.completedFuture(validatedToken));
|
||||
|
||||
when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(null));
|
||||
|
||||
final Response response = RESOURCE_EXTENSION
|
||||
.target(String.format("/v1/subscription/%s/playbilling/%s", subscriberId, purchaseToken))
|
||||
.request()
|
||||
.post(Entity.json(""));
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level())
|
||||
.isEqualTo(99L);
|
||||
|
||||
verify(SUBSCRIPTIONS, times(1)).setIapPurchase(
|
||||
any(),
|
||||
eq(new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING)),
|
||||
eq(purchaseToken),
|
||||
eq(99L),
|
||||
eq(now));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"5, P45D", "201, P13D"})
|
||||
public void createReceiptCredential(long level, Duration expectedExpirationWindow)
|
||||
|
@ -887,7 +932,7 @@ class SubscriptionControllerTest {
|
|||
when(SUBSCRIPTIONS.get(any(), any()))
|
||||
.thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));
|
||||
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
|
||||
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
|
||||
CompletableFuture.completedFuture(new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(
|
||||
"itemId",
|
||||
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
|
||||
level
|
||||
|
|
|
@ -16,9 +16,15 @@ import static org.mockito.Mockito.verifyNoInteractions;
|
|||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.api.services.androidpublisher.AndroidPublisher;
|
||||
import com.google.api.services.androidpublisher.model.BasePlan;
|
||||
import com.google.api.services.androidpublisher.model.Money;
|
||||
import com.google.api.services.androidpublisher.model.OfferDetails;
|
||||
import com.google.api.services.androidpublisher.model.RegionalBasePlanConfig;
|
||||
import com.google.api.services.androidpublisher.model.Subscription;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
@ -32,7 +38,6 @@ import org.junit.jupiter.api.Test;
|
|||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||
|
@ -56,6 +61,10 @@ class GooglePlayBillingManagerTest {
|
|||
private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =
|
||||
mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class);
|
||||
|
||||
// Returned in response to a monetization.subscriptions.get
|
||||
private final AndroidPublisher.Monetization.Subscriptions.Get subscriptionConfig =
|
||||
mock(AndroidPublisher.Monetization.Subscriptions.Get.class);
|
||||
|
||||
private final MutableClock clock = MockUtils.mutableClock(0L);
|
||||
|
||||
private ExecutorService executor;
|
||||
|
@ -68,9 +77,12 @@ class GooglePlayBillingManagerTest {
|
|||
|
||||
AndroidPublisher androidPublisher = mock(AndroidPublisher.class);
|
||||
AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.class);
|
||||
AndroidPublisher.Monetization monetization = mock(AndroidPublisher.Monetization.class);
|
||||
|
||||
when(androidPublisher.purchases()).thenReturn(purchases);
|
||||
when(androidPublisher.monetization()).thenReturn(monetization);
|
||||
|
||||
AndroidPublisher.Purchases.Subscriptionsv2 subscriptionsv2 = mock(AndroidPublisher.Purchases.Subscriptionsv2.class);
|
||||
when(androidPublisher.purchases()).thenReturn(purchases);
|
||||
when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2);
|
||||
when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get);
|
||||
|
||||
|
@ -81,6 +93,11 @@ class GooglePlayBillingManagerTest {
|
|||
when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))
|
||||
.thenReturn(cancel);
|
||||
|
||||
AndroidPublisher.Monetization.Subscriptions msubscriptions = mock(
|
||||
AndroidPublisher.Monetization.Subscriptions.class);
|
||||
when(monetization.subscriptions()).thenReturn(msubscriptions);
|
||||
when(msubscriptions.get(PACKAGE_NAME, PRODUCT_ID)).thenReturn(subscriptionConfig);
|
||||
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
googlePlayBillingManager = new GooglePlayBillingManager(
|
||||
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
|
||||
|
@ -186,7 +203,7 @@ class GooglePlayBillingManagerTest {
|
|||
.setProductId(PRODUCT_ID))));
|
||||
|
||||
clock.setTimeInstant(day9);
|
||||
SubscriptionManager.Processor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
|
||||
SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
|
||||
assertThat(item.itemId()).isEqualTo(ORDER_ID);
|
||||
assertThat(item.level()).isEqualTo(201L);
|
||||
|
||||
|
@ -207,4 +224,33 @@ class GooglePlayBillingManagerTest {
|
|||
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getSubscriptionInfo() throws IOException {
|
||||
final String basePlanId = "basePlanId";
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
|
||||
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
|
||||
.setLatestOrderId(ORDER_ID)
|
||||
.setRegionCode("US")
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID)
|
||||
.setOfferDetails(new OfferDetails().setBasePlanId(basePlanId)))));
|
||||
|
||||
final BasePlan basePlan = new BasePlan()
|
||||
.setBasePlanId(basePlanId)
|
||||
.setRegionalConfigs(List.of(
|
||||
new RegionalBasePlanConfig()
|
||||
.setRegionCode("US")
|
||||
.setPrice(new Money().setCurrencyCode("USD").setUnits(1L).setNanos(750_000_000))));
|
||||
when(subscriptionConfig.execute()).thenReturn(new Subscription().setBasePlans(List.of(basePlan)));
|
||||
|
||||
final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN).join();
|
||||
assertThat(info.active()).isTrue();
|
||||
assertThat(info.paymentProcessing()).isFalse();
|
||||
assertThat(info.price().currency()).isEqualTo("USD");
|
||||
assertThat(info.price().amount().compareTo(new BigDecimal("175"))).isEqualTo(0); // 175 cents
|
||||
assertThat(info.level()).isEqualTo(201L);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue