mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 22:38:05 +01:00
Add playbilling endpoint to /v1/subscriptions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user