mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 03:28:04 +01:00
Support PayPal for recurring donations
This commit is contained in:
@@ -11,19 +11,29 @@ import com.apollographql.apollo3.api.Operations;
|
||||
import com.apollographql.apollo3.api.Optional;
|
||||
import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;
|
||||
import com.braintree.graphql.client.type.ChargePaymentMethodInput;
|
||||
import com.braintree.graphql.client.type.CreatePayPalBillingAgreementInput;
|
||||
import com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput;
|
||||
import com.braintree.graphql.client.type.CustomFieldInput;
|
||||
import com.braintree.graphql.client.type.MonetaryAmountInput;
|
||||
import com.braintree.graphql.client.type.PayPalBillingAgreementChargePattern;
|
||||
import com.braintree.graphql.client.type.PayPalBillingAgreementExperienceProfileInput;
|
||||
import com.braintree.graphql.client.type.PayPalBillingAgreementInput;
|
||||
import com.braintree.graphql.client.type.PayPalExperienceProfileInput;
|
||||
import com.braintree.graphql.client.type.PayPalIntent;
|
||||
import com.braintree.graphql.client.type.PayPalLandingPageType;
|
||||
import com.braintree.graphql.client.type.PayPalOneTimePaymentInput;
|
||||
import com.braintree.graphql.client.type.PayPalProductAttributesInput;
|
||||
import com.braintree.graphql.client.type.PayPalUserAction;
|
||||
import com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput;
|
||||
import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;
|
||||
import com.braintree.graphql.client.type.TransactionInput;
|
||||
import com.braintree.graphql.client.type.VaultPaymentMethodInput;
|
||||
import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation;
|
||||
import com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation;
|
||||
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
|
||||
import com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation;
|
||||
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
|
||||
import com.braintree.graphql.clientoperation.VaultPaymentMethodMutation;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
@@ -185,6 +195,94 @@ class BraintreeGraphqlClient {
|
||||
);
|
||||
}
|
||||
|
||||
public CompletableFuture<CreatePayPalBillingAgreementMutation.CreatePayPalBillingAgreement> createPayPalBillingAgreement(
|
||||
final String returnUrl, final String cancelUrl, final String locale) {
|
||||
|
||||
final CreatePayPalBillingAgreementInput input = buildCreatePayPalBillingAgreementInput(returnUrl, cancelUrl,
|
||||
locale);
|
||||
final CreatePayPalBillingAgreementMutation mutation = new CreatePayPalBillingAgreementMutation(input);
|
||||
final HttpRequest request = buildRequest(mutation);
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(httpResponse -> {
|
||||
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
|
||||
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
|
||||
final CreatePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
|
||||
return data.createPayPalBillingAgreement;
|
||||
});
|
||||
}
|
||||
|
||||
private static CreatePayPalBillingAgreementInput buildCreatePayPalBillingAgreementInput(String returnUrl,
|
||||
String cancelUrl, String locale) {
|
||||
|
||||
return new CreatePayPalBillingAgreementInput(
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
returnUrl,
|
||||
cancelUrl,
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.present(false), // offerPayPalCredit
|
||||
Optional.absent(),
|
||||
Optional.present(
|
||||
new PayPalBillingAgreementExperienceProfileInput(Optional.present("Signal"),
|
||||
Optional.present(false), // collectShippingAddress
|
||||
Optional.present(PayPalLandingPageType.LOGIN),
|
||||
Optional.present(locale),
|
||||
Optional.absent())),
|
||||
Optional.absent(),
|
||||
Optional.present(new PayPalProductAttributesInput(
|
||||
Optional.present(PayPalBillingAgreementChargePattern.RECURRING_PREPAID)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
public CompletableFuture<TokenizePayPalBillingAgreementMutation.TokenizePayPalBillingAgreement> tokenizePayPalBillingAgreement(
|
||||
final String billingAgreementToken) {
|
||||
|
||||
final TokenizePayPalBillingAgreementInput input = new TokenizePayPalBillingAgreementInput(
|
||||
Optional.absent(),
|
||||
new PayPalBillingAgreementInput(billingAgreementToken));
|
||||
final TokenizePayPalBillingAgreementMutation mutation = new TokenizePayPalBillingAgreementMutation(input);
|
||||
final HttpRequest request = buildRequest(mutation);
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(httpResponse -> {
|
||||
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
|
||||
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
|
||||
final TokenizePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
|
||||
return data.tokenizePayPalBillingAgreement;
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<VaultPaymentMethodMutation.VaultPaymentMethod> vaultPaymentMethod(final String customerId,
|
||||
final String paymentMethodId) {
|
||||
|
||||
final VaultPaymentMethodInput input = buildVaultPaymentMethodInput(customerId, paymentMethodId);
|
||||
final VaultPaymentMethodMutation mutation = new VaultPaymentMethodMutation(input);
|
||||
final HttpRequest request = buildRequest(mutation);
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenApply(httpResponse -> {
|
||||
// IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”
|
||||
// is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/
|
||||
final VaultPaymentMethodMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
|
||||
return data.vaultPaymentMethod;
|
||||
});
|
||||
}
|
||||
|
||||
private static VaultPaymentMethodInput buildVaultPaymentMethodInput(String customerId, String paymentMethodId) {
|
||||
return new VaultPaymentMethodInput(
|
||||
Optional.absent(),
|
||||
paymentMethodId,
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.present(customerId),
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise
|
||||
* throws a {@link ServiceUnavailableException}.
|
||||
|
||||
@@ -6,19 +6,36 @@
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.braintreegateway.BraintreeGateway;
|
||||
import com.braintreegateway.ClientTokenRequest;
|
||||
import com.braintreegateway.Customer;
|
||||
import com.braintreegateway.CustomerRequest;
|
||||
import com.braintreegateway.Plan;
|
||||
import com.braintreegateway.ResourceCollection;
|
||||
import com.braintreegateway.Result;
|
||||
import com.braintreegateway.Subscription;
|
||||
import com.braintreegateway.SubscriptionRequest;
|
||||
import com.braintreegateway.Transaction;
|
||||
import com.braintreegateway.TransactionSearchRequest;
|
||||
import com.braintreegateway.exceptions.BraintreeException;
|
||||
import com.braintreegateway.exceptions.NotFoundException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.ws.rs.ClientErrorException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
@@ -97,16 +114,6 @@ public class BraintreeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) {
|
||||
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<String> createPaymentMethodSetupToken(final String customerId) {
|
||||
return CompletableFuture.failedFuture(new BadRequestException("Unsupported"));
|
||||
}
|
||||
|
||||
public CompletableFuture<PayPalOneTimePaymentApprovalDetails> createOneTimePayment(String currency, long amount,
|
||||
String locale, String returnUrl, String cancelUrl) {
|
||||
return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount),
|
||||
@@ -187,6 +194,19 @@ public class BraintreeManager implements SubscriptionProcessorManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static SubscriptionStatus getSubscriptionStatus(final Subscription.Status status) {
|
||||
return switch (status) {
|
||||
case ACTIVE -> SubscriptionStatus.ACTIVE;
|
||||
case CANCELED, EXPIRED -> SubscriptionStatus.CANCELED;
|
||||
case PAST_DUE -> SubscriptionStatus.PAST_DUE;
|
||||
case PENDING -> SubscriptionStatus.INCOMPLETE;
|
||||
case UNRECOGNIZED -> {
|
||||
logger.error("Subscription has unrecognized status; library may need to be updated: {}", status);
|
||||
yield SubscriptionStatus.UNKNOWN;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {
|
||||
return switch (currency.toLowerCase(Locale.ROOT)) {
|
||||
// JPY is the only supported zero-decimal currency
|
||||
@@ -203,4 +223,311 @@ public class BraintreeManager implements SubscriptionProcessorManager {
|
||||
|
||||
}
|
||||
|
||||
private void assertResultSuccess(Result<?> result) throws CompletionException {
|
||||
if (!result.isSuccess()) {
|
||||
throw new CompletionException(new BraintreeException(result.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ProcessorCustomer> createCustomer(final byte[] subscriberUser) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final CustomerRequest request = new CustomerRequest()
|
||||
.customField("subscriber_user", Hex.encodeHexString(subscriberUser));
|
||||
try {
|
||||
return braintreeGateway.customer().create(request);
|
||||
} catch (BraintreeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(result -> {
|
||||
assertResultSuccess(result);
|
||||
|
||||
return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<String> createPaymentMethodSetupToken(final String customerId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
ClientTokenRequest request = new ClientTokenRequest()
|
||||
.customerId(customerId);
|
||||
|
||||
return braintreeGateway.clientToken().generate(request);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken,
|
||||
@Nullable String currentSubscriptionId) {
|
||||
final Optional<String> maybeSubscriptionId = Optional.ofNullable(currentSubscriptionId);
|
||||
return braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken)
|
||||
.thenCompose(tokenizePayPalBillingAgreement ->
|
||||
braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id))
|
||||
.thenApplyAsync(vaultPaymentMethod -> braintreeGateway.customer()
|
||||
.update(customerId, new CustomerRequest()
|
||||
.defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id)),
|
||||
executor)
|
||||
.thenAcceptAsync(result -> {
|
||||
maybeSubscriptionId.ifPresent(
|
||||
subscriptionId -> braintreeGateway.subscription()
|
||||
.update(subscriptionId, new SubscriptionRequest()
|
||||
.paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken())));
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Object> getSubscription(String subscriptionId) {
|
||||
return CompletableFuture.supplyAsync(() -> braintreeGateway.subscription().find(subscriptionId), executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionId> createSubscription(String customerId, String planId, long level,
|
||||
long lastSubscriptionCreatedAt) {
|
||||
|
||||
return getDefaultPaymentMethod(customerId)
|
||||
.thenCompose(paymentMethod -> {
|
||||
if (paymentMethod == null) {
|
||||
throw new ClientErrorException(Response.Status.CONFLICT);
|
||||
}
|
||||
|
||||
final Optional<Subscription> maybeExistingSubscription = paymentMethod.getSubscriptions().stream()
|
||||
.filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE))
|
||||
.filter(Subscription::neverExpires)
|
||||
.findAny();
|
||||
|
||||
final CompletableFuture<Plan> planFuture = maybeExistingSubscription.map(sub ->
|
||||
findPlan(sub.getPlanId()).thenApply(plan -> {
|
||||
if (getLevelForPlan(plan) != level) {
|
||||
// if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption)
|
||||
// with a different level.
|
||||
// In this case, it’s safer and easier to recover by returning this subscription, rather than
|
||||
// returning an error
|
||||
logger.warn("existing subscription had unexpected level");
|
||||
}
|
||||
return plan;
|
||||
})).orElseGet(() -> findPlan(planId));
|
||||
|
||||
return maybeExistingSubscription
|
||||
.map(subscription -> {
|
||||
return findPlan(subscription.getPlanId())
|
||||
.thenApply(plan -> {
|
||||
if (getLevelForPlan(plan) != level) {
|
||||
// if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption)
|
||||
// with a different level.
|
||||
// In this case, it’s safer and easier to recover by returning this subscription, rather than
|
||||
// returning an error
|
||||
logger.warn("existing subscription had unexpected level");
|
||||
}
|
||||
return subscription;
|
||||
});
|
||||
})
|
||||
.orElseGet(() -> findPlan(planId).thenApplyAsync(plan -> {
|
||||
final Result<Subscription> result = braintreeGateway.subscription().create(new SubscriptionRequest()
|
||||
.planId(planId)
|
||||
.paymentMethodToken(paymentMethod.getToken())
|
||||
.merchantAccountId(
|
||||
currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT)))
|
||||
.options()
|
||||
.startImmediately(true)
|
||||
.done()
|
||||
);
|
||||
|
||||
assertResultSuccess(result);
|
||||
|
||||
return result.getTarget();
|
||||
}));
|
||||
}).thenApply(subscription -> new SubscriptionId(subscription.getId()));
|
||||
}
|
||||
|
||||
private CompletableFuture<com.braintreegateway.PaymentMethod> getDefaultPaymentMethod(String customerId) {
|
||||
return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId).getDefaultPaymentMethod(),
|
||||
executor);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionId> updateSubscription(Object subscriptionObj, String planId, long level,
|
||||
String idempotencyKey) {
|
||||
|
||||
if (!(subscriptionObj instanceof final Subscription subscription)) {
|
||||
throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName());
|
||||
}
|
||||
|
||||
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
|
||||
// and not prorated. Braintree subscriptions cannot change their next billing date,
|
||||
// so we must end the existing one and create a new one
|
||||
return cancelSubscriptionAtEndOfCurrentPeriod(subscription)
|
||||
.thenCompose(ignored -> {
|
||||
|
||||
final Transaction transaction = getLatestTransactionForSubscription(subscription).orElseThrow(
|
||||
() -> new ClientErrorException(
|
||||
Response.Status.CONFLICT));
|
||||
|
||||
final Customer customer = transaction.getCustomer();
|
||||
|
||||
return createSubscription(customer.getId(), planId, level,
|
||||
subscription.getCreatedAt().toInstant().getEpochSecond());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Long> getLevelForSubscription(Object subscriptionObj) {
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
|
||||
return findPlan(subscription.getPlanId())
|
||||
.thenApply(this::getLevelForPlan);
|
||||
}
|
||||
|
||||
private CompletableFuture<Plan> findPlan(String planId) {
|
||||
return CompletableFuture.supplyAsync(() -> braintreeGateway.plan().find(planId), executor);
|
||||
}
|
||||
|
||||
private long getLevelForPlan(final Plan plan) {
|
||||
final BraintreePlanMetadata metadata;
|
||||
try {
|
||||
metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return metadata.level();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
|
||||
final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());
|
||||
|
||||
final long level = getLevelForPlan(plan);
|
||||
|
||||
final Instant anchor = subscription.getFirstBillingDate().toInstant();
|
||||
final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant();
|
||||
|
||||
final Optional<Transaction> maybeTransaction = getLatestTransactionForSubscription(subscription);
|
||||
|
||||
final ChargeFailure chargeFailure = maybeTransaction.map(transaction -> {
|
||||
|
||||
if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
if (transaction.getProcessorResponseCode() != null) {
|
||||
code = transaction.getProcessorResponseCode();
|
||||
message = transaction.getProcessorResponseText();
|
||||
} else if (transaction.getGatewayRejectionReason() != null) {
|
||||
code = "gateway";
|
||||
message = transaction.getGatewayRejectionReason().toString();
|
||||
} else {
|
||||
code = "unknown";
|
||||
message = "unknown";
|
||||
}
|
||||
|
||||
return new ChargeFailure(
|
||||
code,
|
||||
message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
}).orElse(null);
|
||||
|
||||
|
||||
return new SubscriptionInformation(
|
||||
new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT),
|
||||
SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())),
|
||||
level,
|
||||
anchor,
|
||||
endOfCurrentPeriod,
|
||||
Subscription.Status.ACTIVE == subscription.getStatus(),
|
||||
!subscription.neverExpires(),
|
||||
getSubscriptionStatus(subscription.getStatus()),
|
||||
chargeFailure
|
||||
);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId), executor).thenCompose(customer -> {
|
||||
|
||||
final List<CompletableFuture<Void>> subscriptionCancelFutures = customer.getDefaultPaymentMethod().getSubscriptions().stream()
|
||||
.map(this::cancelSubscriptionAtEndOfCurrentPeriod)
|
||||
.toList();
|
||||
|
||||
return CompletableFuture.allOf(subscriptionCancelFutures.toArray(new CompletableFuture[0]));
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
braintreeGateway.subscription().update(subscription.getId(),
|
||||
new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle()));
|
||||
return null;
|
||||
}, executor);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId) {
|
||||
|
||||
return getLatestTransactionForSubscription(subscriptionId).thenApply(maybeTransaction -> maybeTransaction.map(transaction -> {
|
||||
|
||||
if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {
|
||||
throw new WebApplicationException(Response.Status.PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
final Instant expiration = transaction.getSubscriptionDetails().getBillingPeriodEndDate().toInstant();
|
||||
final Plan plan = braintreeGateway.plan().find(transaction.getPlanId());
|
||||
|
||||
final BraintreePlanMetadata metadata;
|
||||
try {
|
||||
metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
return new ReceiptItem(transaction.getId(), expiration, metadata.level());
|
||||
|
||||
}).orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT)));
|
||||
}
|
||||
|
||||
private static Subscription getSubscription(Object subscriptionObj) {
|
||||
if (!(subscriptionObj instanceof final Subscription subscription)) {
|
||||
throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName());
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Transaction>> getLatestTransactionForSubscription(String subscriptionId) {
|
||||
return getSubscription(subscriptionId)
|
||||
.thenApply(BraintreeManager::getSubscription)
|
||||
.thenApply(this::getLatestTransactionForSubscription);
|
||||
}
|
||||
|
||||
private Optional<Transaction> getLatestTransactionForSubscription(Subscription subscription) {
|
||||
return subscription.getTransactions().stream()
|
||||
.max(Comparator.comparing(Transaction::getCreatedAt));
|
||||
}
|
||||
|
||||
public CompletableFuture<PayPalBillingAgreementApprovalDetails> createPayPalBillingAgreement(final String returnUrl,
|
||||
final String cancelUrl, final String locale) {
|
||||
return braintreeGraphqlClient.createPayPalBillingAgreement(returnUrl, cancelUrl, locale)
|
||||
.thenApply(response ->
|
||||
new PayPalBillingAgreementApprovalDetails((String) response.approvalUrl, response.billingAgreementToken)
|
||||
);
|
||||
}
|
||||
|
||||
public record PayPalBillingAgreementApprovalDetails(String approvalUrl, String billingAgreementToken) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
public record BraintreePlanMetadata(long level) {
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package org.whispersystems.textsecuregcm.subscriptions;
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.stripe.exception.StripeException;
|
||||
import com.stripe.model.Charge;
|
||||
import com.stripe.model.Customer;
|
||||
import com.stripe.model.Invoice;
|
||||
import com.stripe.model.InvoiceLineItem;
|
||||
@@ -33,7 +34,6 @@ import com.stripe.param.SubscriptionRetrieveParams;
|
||||
import com.stripe.param.SubscriptionUpdateParams;
|
||||
import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;
|
||||
import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -57,10 +57,12 @@ import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.ws.rs.InternalServerErrorException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
|
||||
public class StripeManager implements SubscriptionProcessorManager {
|
||||
@@ -144,7 +146,9 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId) {
|
||||
@Override
|
||||
public CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId,
|
||||
@Nullable String currentSubscriptionId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
Customer customer = new Customer();
|
||||
customer.setId(customerId);
|
||||
@@ -154,7 +158,8 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
.build())
|
||||
.build();
|
||||
try {
|
||||
return customer.update(params, commonOptions());
|
||||
customer.update(params, commonOptions());
|
||||
return null;
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
@@ -234,65 +239,78 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
};
|
||||
}
|
||||
|
||||
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level,
|
||||
private static SubscriptionStatus getSubscriptionStatus(final String status) {
|
||||
return SubscriptionStatus.forApiValue(status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionId> createSubscription(String customerId, String priceId, long level,
|
||||
long lastSubscriptionCreatedAt) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.setOffSession(true)
|
||||
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||
.addItem(SubscriptionCreateParams.Item.builder()
|
||||
.setPrice(priceId)
|
||||
.build())
|
||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||
.build();
|
||||
try {
|
||||
// the idempotency key intentionally excludes priceId
|
||||
//
|
||||
// If the client tells the server several times in a row before the initial creation of a subscription to
|
||||
// create a subscription, we want to ensure only one gets created.
|
||||
return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription(
|
||||
customerId, lastSubscriptionCreatedAt)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.setOffSession(true)
|
||||
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||
.addItem(SubscriptionCreateParams.Item.builder()
|
||||
.setPrice(priceId)
|
||||
.build())
|
||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||
.build();
|
||||
try {
|
||||
// the idempotency key intentionally excludes priceId
|
||||
//
|
||||
// If the client tells the server several times in a row before the initial creation of a subscription to
|
||||
// create a subscription, we want to ensure only one gets created.
|
||||
return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription(
|
||||
customerId, lastSubscriptionCreatedAt)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(subscription -> new SubscriptionId(subscription.getId()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Subscription> updateSubscription(
|
||||
Subscription subscription, String priceId, long level, String idempotencyKey) {
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionId> updateSubscription(
|
||||
Object subscriptionObj, String priceId, long level, String idempotencyKey) {
|
||||
|
||||
if (!(subscriptionObj instanceof final Subscription subscription)) {
|
||||
throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName());
|
||||
}
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
|
||||
for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) {
|
||||
items.add(SubscriptionUpdateParams.Item.builder()
|
||||
.setId(item.getId())
|
||||
.setDeleted(true)
|
||||
.build());
|
||||
}
|
||||
items.add(SubscriptionUpdateParams.Item.builder()
|
||||
.setPrice(priceId)
|
||||
.build());
|
||||
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
|
||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
|
||||
for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) {
|
||||
items.add(SubscriptionUpdateParams.Item.builder()
|
||||
.setId(item.getId())
|
||||
.setDeleted(true)
|
||||
.build());
|
||||
}
|
||||
items.add(SubscriptionUpdateParams.Item.builder()
|
||||
.setPrice(priceId)
|
||||
.build());
|
||||
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
|
||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||
|
||||
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
|
||||
// not prorated
|
||||
.setProrationBehavior(ProrationBehavior.NONE)
|
||||
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
||||
.setOffSession(true)
|
||||
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||
.addAllItem(items)
|
||||
.build();
|
||||
try {
|
||||
return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate(
|
||||
subscription.getCustomer(), idempotencyKey)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
|
||||
// not prorated
|
||||
.setProrationBehavior(ProrationBehavior.NONE)
|
||||
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
||||
.setOffSession(true)
|
||||
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||
.addAllItem(items)
|
||||
.build();
|
||||
try {
|
||||
return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate(
|
||||
subscription.getCustomer(), idempotencyKey)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Subscription> getSubscription(String subscriptionId) {
|
||||
public CompletableFuture<Object> getSubscription(String subscriptionId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
||||
.addExpand("latest_invoice")
|
||||
@@ -306,6 +324,21 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId) {
|
||||
return getCustomer(customerId).thenCompose(customer -> {
|
||||
if (customer == null) {
|
||||
throw new InternalServerErrorException(
|
||||
"no customer record found for id " + customerId);
|
||||
}
|
||||
return listNonCanceledSubscriptions(customer);
|
||||
}).thenCompose(subscriptions -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
||||
.map(this::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
||||
return CompletableFuture.allOf(futures);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Collection<Subscription>> listNonCanceledSubscriptions(Customer customer) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionListParams params = SubscriptionListParams.builder()
|
||||
@@ -362,11 +395,16 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Product> getProductForSubscription(Subscription subscription) {
|
||||
private CompletableFuture<Product> getProductForSubscription(Subscription subscription) {
|
||||
return getPriceForSubscription(subscription).thenCompose(price -> getProductForPrice(price.getId()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Long> getLevelForSubscription(Subscription subscription) {
|
||||
@Override
|
||||
public CompletableFuture<Long> getLevelForSubscription(Object subscriptionObj) {
|
||||
if (!(subscriptionObj instanceof final Subscription subscription)) {
|
||||
|
||||
throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName());
|
||||
}
|
||||
return getProductForSubscription(subscription).thenApply(this::getLevelForProduct);
|
||||
}
|
||||
|
||||
@@ -404,7 +442,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
.build();
|
||||
try {
|
||||
ArrayList<Invoice> invoices = Lists.newArrayList(Invoice.list(params, commonOptions())
|
||||
.autoPagingIterable(null, commonOptions()));
|
||||
.autoPagingIterable(null, commonOptions()));
|
||||
invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed());
|
||||
return invoices;
|
||||
} catch (StripeException e) {
|
||||
@@ -413,6 +451,54 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscriptionObj) {
|
||||
|
||||
final Subscription subscription = getSubscription(subscriptionObj);
|
||||
|
||||
return getPriceForSubscription(subscription).thenCompose(price ->
|
||||
getLevelForPrice(price).thenApply(level -> {
|
||||
ChargeFailure chargeFailure = null;
|
||||
|
||||
if (subscription.getLatestInvoiceObject() != null && subscription.getLatestInvoiceObject().getChargeObject() != null &&
|
||||
(subscription.getLatestInvoiceObject().getChargeObject().getFailureCode() != null || subscription.getLatestInvoiceObject().getChargeObject().getFailureMessage() != null)) {
|
||||
Charge charge = subscription.getLatestInvoiceObject().getChargeObject();
|
||||
Charge.Outcome outcome = charge.getOutcome();
|
||||
chargeFailure = new ChargeFailure(
|
||||
charge.getFailureCode(),
|
||||
charge.getFailureMessage(),
|
||||
outcome != null ? outcome.getNetworkStatus() : null,
|
||||
outcome != null ? outcome.getReason() : null,
|
||||
outcome != null ? outcome.getType() : null);
|
||||
}
|
||||
|
||||
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()),
|
||||
chargeFailure
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
private Subscription getSubscription(Object subscriptionObj) {
|
||||
if (!(subscriptionObj instanceof final Subscription subscription)) {
|
||||
throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName());
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId) {
|
||||
return getLatestInvoiceForSubscription(subscriptionId)
|
||||
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, subscriptionId));
|
||||
}
|
||||
|
||||
public CompletableFuture<Invoice> getLatestInvoiceForSubscription(String subscriptionId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
||||
@@ -426,24 +512,48 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
private CompletableFuture<ReceiptItem> convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) {
|
||||
if (latestSubscriptionInvoice == null) {
|
||||
throw new WebApplicationException(Status.NO_CONTENT);
|
||||
}
|
||||
if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) {
|
||||
throw new WebApplicationException(Status.NO_CONTENT);
|
||||
}
|
||||
if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) {
|
||||
throw new WebApplicationException(Status.PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
return getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> {
|
||||
Collection<InvoiceLineItem> subscriptionLineItems = invoiceLineItems.stream()
|
||||
.filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType()))
|
||||
.toList();
|
||||
if (subscriptionLineItems.isEmpty()) {
|
||||
throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId="
|
||||
+ subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId());
|
||||
}
|
||||
if (subscriptionLineItems.size() > 1) {
|
||||
throw new IllegalStateException(
|
||||
"latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId
|
||||
+ "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size());
|
||||
}
|
||||
|
||||
InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get();
|
||||
return getReceiptForSubscriptionInvoiceLineItem(subscriptionLineItem);
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<ReceiptItem> getReceiptForSubscriptionInvoiceLineItem(InvoiceLineItem subscriptionLineItem) {
|
||||
return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem(
|
||||
subscriptionLineItem.getId(),
|
||||
Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getEnd()),
|
||||
getLevelForProduct(product)));
|
||||
}
|
||||
|
||||
public CompletableFuture<Collection<InvoiceLineItem>> getInvoiceLineItemsForInvoice(Invoice invoice) {
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> Lists.newArrayList(invoice.getLines().autoPagingIterable(null, commonOptions())), executor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an amount as configured; for instance USD 4.99 and turns it into an amount as Stripe expects to see it.
|
||||
* Stripe appears to only support 0 and 2 decimal currencies, but also has some backwards compatibility issues with 0
|
||||
* decimal currencies so this is not to any ISO standard but rather directly from Stripe's API doc page.
|
||||
*/
|
||||
public BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) {
|
||||
return switch (currency.toLowerCase(Locale.ROOT)) {
|
||||
// Yuck, but this list was taken from https://stripe.com/docs/currencies?presentment-currency=US
|
||||
case "bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf" -> configuredAmount;
|
||||
default -> configuredAmount.scaleByPowerOfTen(2);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* We use a client generated idempotency key for subscription updates due to not being able to distinguish between a
|
||||
* call to update to level 2, then back to level 1, then back to level 2. If this all happens within Stripe's
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Utility for scaling amounts among Stripe, Braintree, configuration, and API responses.
|
||||
* <p>
|
||||
* In general, the API input and output follow’s Stripe’s <a href= >specification</a> to use amounts in a currency’s
|
||||
* smallest unit. The exception is configuration APIs, which return values in the currency’s primary unit. Braintree
|
||||
* uses the currency’s primary unit for its input and output.
|
||||
* <h2>Examples</h2>
|
||||
* <table>
|
||||
* <thead>
|
||||
* <td>Currency, Amount</td>API</td><td>Stripe</td><td>Braintree</td>
|
||||
* </thead>
|
||||
* <tbody>
|
||||
* <tr>
|
||||
* <td>USD 4.99</td><td>499</td><td>499</td><td>4.99</td>
|
||||
* </tr>
|
||||
* <tr>
|
||||
* <td>JPY 501</td><td>501</td><td>501</td><td>501</td>
|
||||
* </tr>
|
||||
* </tbody>
|
||||
* </table>
|
||||
*/
|
||||
public class SubscriptionCurrencyUtil {
|
||||
|
||||
// This list was taken from https://stripe.com/docs/currencies?presentment-currency=US
|
||||
// Braintree
|
||||
private static final Set<String> stripeZeroDecimalCurrencies = Set.of("bif", "clp", "djf", "gnf", "jpy", "kmf", "krw",
|
||||
"mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf");
|
||||
|
||||
|
||||
/**
|
||||
* Takes an amount as configured and turns it into an amount as API clients (and Stripe) expect to see it. For
|
||||
* instance, {@code USD 4.99} return {@code 499}, while {@code JPY 500} returns {@code 500}.
|
||||
*
|
||||
* <p>
|
||||
* Stripe appears to only support zero- and two-decimal currencies, but also has some backwards compatibility issues
|
||||
* with 0 decimal currencies, so this is not to any ISO standard but rather directly from Stripe's API doc page.
|
||||
*/
|
||||
public static BigDecimal convertConfiguredAmountToApiAmount(String currency, BigDecimal configuredAmount) {
|
||||
if (stripeZeroDecimalCurrencies.contains(currency.toLowerCase(Locale.ROOT))) {
|
||||
return configuredAmount;
|
||||
}
|
||||
|
||||
return configuredAmount.scaleByPowerOfTen(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,
|
||||
* BigDecimal)
|
||||
*/
|
||||
public static BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) {
|
||||
return convertConfiguredAmountToApiAmount(currency, configuredAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Braintree’s API expects amounts in a currency’s primary unit (e.g. USD 4.99)
|
||||
*
|
||||
* @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,
|
||||
* BigDecimal)
|
||||
*/
|
||||
static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) {
|
||||
return convertConfiguredAmountToApiAmount(currency, amount);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public enum SubscriptionProcessor {
|
||||
private final byte id;
|
||||
|
||||
SubscriptionProcessor(int id) {
|
||||
if (id > 256) {
|
||||
if (id > 255) {
|
||||
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public interface SubscriptionProcessorManager {
|
||||
|
||||
SubscriptionProcessor getProcessor();
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
@@ -26,6 +29,32 @@ public interface SubscriptionProcessorManager {
|
||||
|
||||
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);
|
||||
|
||||
CompletableFuture<Long> getLevelForSubscription(Object subscription);
|
||||
|
||||
CompletableFuture<Void> cancelAllActiveSubscriptions(String customerId);
|
||||
|
||||
CompletableFuture<ReceiptItem> getReceiptItem(String subscriptionId);
|
||||
|
||||
CompletableFuture<SubscriptionInformation> getSubscriptionInformation(Object subscription);
|
||||
|
||||
record PaymentDetails(String id,
|
||||
Map<String, String> customMetadata,
|
||||
PaymentStatus status,
|
||||
@@ -39,4 +68,96 @@ public interface SubscriptionProcessorManager {
|
||||
FAILED,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
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 either failed or 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(SubscriptionProcessorManager.class);
|
||||
logger.error("Subscription has status that should never happen: {}", status);
|
||||
|
||||
yield UNKNOWN;
|
||||
}
|
||||
default -> {
|
||||
final Logger logger = LoggerFactory.getLogger(SubscriptionProcessorManager.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,
|
||||
ChargeFailure chargeFailure) {
|
||||
|
||||
}
|
||||
|
||||
record SubscriptionPrice(String currency, BigDecimal amount) {
|
||||
|
||||
}
|
||||
|
||||
record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus,
|
||||
@Nullable String outcomeReason, @Nullable String outcomeType) {
|
||||
|
||||
}
|
||||
|
||||
record ReceiptItem(String itemId, Instant expiration, long level) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user