mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 09:57:59 +01:00
Add support for one-time PayPal donations
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.apollographql.apollo3.api.ApolloResponse;
|
||||
import com.apollographql.apollo3.api.Operation;
|
||||
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.CreatePayPalOneTimePaymentInput;
|
||||
import com.braintree.graphql.client.type.CustomFieldInput;
|
||||
import com.braintree.graphql.client.type.MonetaryAmountInput;
|
||||
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.TokenizePayPalOneTimePaymentInput;
|
||||
import com.braintree.graphql.client.type.TransactionDescriptorInput;
|
||||
import com.braintree.graphql.client.type.TransactionInput;
|
||||
import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation;
|
||||
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
|
||||
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import javax.ws.rs.ServiceUnavailableException;
|
||||
import okio.Buffer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
|
||||
class BraintreeGraphqlClient {
|
||||
|
||||
// required header value, recommended to be the date the integration began
|
||||
// https://graphql.braintreepayments.com/guides/making_api_calls/#the-braintree-version-header
|
||||
private static final String BRAINTREE_VERSION = "2022-10-01";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BraintreeGraphqlClient.class);
|
||||
|
||||
private final FaultTolerantHttpClient httpClient;
|
||||
private final URI graphqlUri;
|
||||
private final String authorizationHeader;
|
||||
|
||||
BraintreeGraphqlClient(final FaultTolerantHttpClient httpClient,
|
||||
final String graphqlUri,
|
||||
final String publicKey,
|
||||
final String privateKey) {
|
||||
this.httpClient = httpClient;
|
||||
try {
|
||||
this.graphqlUri = new URI(graphqlUri);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("Invalid URI", e);
|
||||
}
|
||||
// “public”/“private” key is a bit of a misnomer, but we follow the upstream nomenclature
|
||||
// they are used for Basic auth similar to “client key”/“client secret” credentials
|
||||
this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((publicKey + ":" + privateKey).getBytes());
|
||||
}
|
||||
|
||||
CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> createPayPalOneTimePayment(
|
||||
final BigDecimal amount, final String currency, final String returnUrl,
|
||||
final String cancelUrl, final String locale) {
|
||||
|
||||
final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl,
|
||||
cancelUrl, locale);
|
||||
final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(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 CreatePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
|
||||
return data.createPayPalOneTimePayment;
|
||||
});
|
||||
}
|
||||
|
||||
private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount,
|
||||
String currency, String returnUrl, String cancelUrl, String locale) {
|
||||
|
||||
return new CreatePayPalOneTimePaymentInput(
|
||||
Optional.absent(),
|
||||
Optional.absent(), // merchant account ID will be specified when charging
|
||||
new MonetaryAmountInput(amount.toString(), currency), // this could potentially use a CustomScalarAdapter
|
||||
cancelUrl,
|
||||
Optional.absent(),
|
||||
PayPalIntent.SALE,
|
||||
Optional.absent(),
|
||||
Optional.present(false), // offerPayLater,
|
||||
Optional.absent(),
|
||||
Optional.present(
|
||||
new PayPalExperienceProfileInput(Optional.present("Signal"),
|
||||
Optional.present(false),
|
||||
Optional.present(PayPalLandingPageType.LOGIN),
|
||||
Optional.present(locale),
|
||||
Optional.absent())),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
returnUrl,
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
);
|
||||
}
|
||||
|
||||
CompletableFuture<TokenizePayPalOneTimePaymentMutation.TokenizePayPalOneTimePayment> tokenizePayPalOneTimePayment(
|
||||
final String payerId, final String paymentId, final String paymentToken) {
|
||||
|
||||
final TokenizePayPalOneTimePaymentInput input = new TokenizePayPalOneTimePaymentInput(
|
||||
Optional.absent(),
|
||||
Optional.absent(), // merchant account ID will be specified when charging
|
||||
new PayPalOneTimePaymentInput(payerId, paymentId, paymentToken)
|
||||
);
|
||||
|
||||
final TokenizePayPalOneTimePaymentMutation mutation = new TokenizePayPalOneTimePaymentMutation(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 TokenizePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);
|
||||
return data.tokenizePayPalOneTimePayment;
|
||||
});
|
||||
}
|
||||
|
||||
CompletableFuture<ChargePayPalOneTimePaymentMutation.ChargePaymentMethod> chargeOneTimePayment(
|
||||
final String paymentMethodId, final BigDecimal amount, final String merchantAccount, final long level) {
|
||||
|
||||
final List<CustomFieldInput> customFields = List.of(
|
||||
new CustomFieldInput("level", Optional.present(Long.toString(level))));
|
||||
|
||||
final ChargePaymentMethodInput input = buildChargePaymentMethodInput(paymentMethodId, amount, merchantAccount,
|
||||
customFields);
|
||||
final ChargePayPalOneTimePaymentMutation mutation = new ChargePayPalOneTimePaymentMutation(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 ChargePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse,
|
||||
mutation);
|
||||
return data.chargePaymentMethod;
|
||||
});
|
||||
}
|
||||
|
||||
private static ChargePaymentMethodInput buildChargePaymentMethodInput(String paymentMethodId, BigDecimal amount,
|
||||
String merchantAccount, List<CustomFieldInput> customFields) {
|
||||
|
||||
return new ChargePaymentMethodInput(
|
||||
Optional.absent(),
|
||||
paymentMethodId,
|
||||
new TransactionInput(
|
||||
// documented as “amount: whole number, or exactly two or three decimal places”
|
||||
amount.toString(), // this could potentially use a CustomScalarAdapter
|
||||
Optional.present(merchantAccount),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.present(customFields),
|
||||
Optional.present(new TransactionDescriptorInput(
|
||||
Optional.present("Signal Technology Foundation"),
|
||||
Optional.absent(),
|
||||
Optional.absent()
|
||||
)),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
Optional.absent(),
|
||||
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}.
|
||||
*/
|
||||
private <T extends Operation<U>, U extends Operation.Data> U assertSuccessAndExtractData(
|
||||
HttpResponse<String> httpResponse, T operation) {
|
||||
|
||||
if (httpResponse.statusCode() != 200) {
|
||||
logger.warn("Received HTTP response status {} ({})", httpResponse.statusCode(),
|
||||
httpResponse.headers().firstValue("paypal-debug-id").orElse("<debug id absent>"));
|
||||
throw new ServiceUnavailableException();
|
||||
}
|
||||
|
||||
ApolloResponse<U> response = Operations.parseJsonResponse(operation, httpResponse.body());
|
||||
|
||||
if (response.hasErrors() || response.data == null) {
|
||||
//noinspection ConstantConditions
|
||||
response.errors.forEach(
|
||||
error -> {
|
||||
final Object legacyCode = java.util.Optional.ofNullable(error.getExtensions())
|
||||
.map(extensions -> extensions.get("legacyCode"))
|
||||
.orElse("<none>");
|
||||
logger.warn("Received GraphQL error for {}: \"{}\" (legacyCode: {})",
|
||||
response.operation.name(), error.getMessage(), legacyCode);
|
||||
});
|
||||
|
||||
throw new ServiceUnavailableException();
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private HttpRequest buildRequest(final Operation<?> operation) {
|
||||
|
||||
final Buffer buffer = new Buffer();
|
||||
Operations.composeJsonRequest(operation, new BufferedSinkJsonWriter(buffer));
|
||||
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(graphqlUri)
|
||||
.method("POST", HttpRequest.BodyPublishers.ofString(buffer.readUtf8()))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", authorizationHeader)
|
||||
.header("Braintree-Version", BRAINTREE_VERSION)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.braintreegateway.BraintreeGateway;
|
||||
import com.braintreegateway.ResourceCollection;
|
||||
import com.braintreegateway.Transaction;
|
||||
import com.braintreegateway.TransactionSearchRequest;
|
||||
import com.braintreegateway.exceptions.NotFoundException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.core.Response;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
|
||||
public class BraintreeManager implements SubscriptionProcessorManager {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);
|
||||
|
||||
private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094";
|
||||
private final BraintreeGateway braintreeGateway;
|
||||
private final BraintreeGraphqlClient braintreeGraphqlClient;
|
||||
private final Executor executor;
|
||||
private final Set<String> supportedCurrencies;
|
||||
private final Map<String, String> currenciesToMerchantAccounts;
|
||||
|
||||
public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey,
|
||||
final String braintreePrivateKey,
|
||||
final String braintreeEnvironment,
|
||||
final Set<String> supportedCurrencies,
|
||||
final Map<String, String> currenciesToMerchantAccounts,
|
||||
final String graphqlUri,
|
||||
final CircuitBreakerConfiguration circuitBreakerConfiguration,
|
||||
final Executor executor) {
|
||||
|
||||
this.braintreeGateway = new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey,
|
||||
braintreePrivateKey);
|
||||
this.supportedCurrencies = supportedCurrencies;
|
||||
this.currenciesToMerchantAccounts = currenciesToMerchantAccounts;
|
||||
|
||||
final FaultTolerantHttpClient httpClient = FaultTolerantHttpClient.newBuilder()
|
||||
.withName("braintree-graphql")
|
||||
.withCircuitBreaker(circuitBreakerConfiguration)
|
||||
.withExecutor(executor)
|
||||
.build();
|
||||
this.braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, graphqlUri, braintreePublicKey,
|
||||
braintreePrivateKey);
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getSupportedCurrencies() {
|
||||
return supportedCurrencies;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SubscriptionProcessor getProcessor() {
|
||||
return SubscriptionProcessor.BRAINTREE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPaymentMethod(final PaymentMethod paymentMethod) {
|
||||
return paymentMethod == PaymentMethod.PAYPAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsCurrency(final String currency) {
|
||||
return supportedCurrencies.contains(currency.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
final Transaction transaction = braintreeGateway.transaction().find(paymentId);
|
||||
|
||||
return new PaymentDetails(transaction.getGraphQLId(),
|
||||
transaction.getCustomFields(),
|
||||
getPaymentStatus(transaction.getStatus()),
|
||||
transaction.getCreatedAt().toInstant());
|
||||
|
||||
} catch (final NotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
}, 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),
|
||||
currency.toUpperCase(Locale.ROOT), returnUrl,
|
||||
cancelUrl, locale)
|
||||
.thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId));
|
||||
}
|
||||
|
||||
public CompletableFuture<PayPalChargeSuccessDetails> captureOneTimePayment(String payerId, String paymentId,
|
||||
String paymentToken, String currency, long amount, long level) {
|
||||
return braintreeGraphqlClient.tokenizePayPalOneTimePayment(payerId, paymentId, paymentToken)
|
||||
.thenCompose(response -> braintreeGraphqlClient.chargeOneTimePayment(
|
||||
response.paymentMethod.id,
|
||||
convertApiAmountToBraintreeAmount(currency, amount),
|
||||
currenciesToMerchantAccounts.get(currency.toLowerCase(Locale.ROOT)),
|
||||
level)
|
||||
.thenComposeAsync(chargeResponse -> {
|
||||
|
||||
final PaymentStatus paymentStatus = getPaymentStatus(chargeResponse.transaction.status);
|
||||
if (paymentStatus == PaymentStatus.SUCCEEDED || paymentStatus == PaymentStatus.PROCESSING) {
|
||||
return CompletableFuture.completedFuture(new PayPalChargeSuccessDetails(chargeResponse.transaction.id));
|
||||
}
|
||||
|
||||
// the GraphQL/Apollo interfaces are a tad unwieldy for this type of status checking
|
||||
final Transaction unsuccessfulTx = braintreeGateway.transaction().find(chargeResponse.transaction.id);
|
||||
|
||||
if (PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE.equals(unsuccessfulTx.getProcessorResponseCode())
|
||||
|| Transaction.GatewayRejectionReason.DUPLICATE.equals(
|
||||
unsuccessfulTx.getGatewayRejectionReason())) {
|
||||
// the payment has already been charged - maybe a previous call timed out or was interrupted -
|
||||
// in any case, check for a successful transaction with the paymentId
|
||||
final ResourceCollection<Transaction> search = braintreeGateway.transaction()
|
||||
.search(new TransactionSearchRequest()
|
||||
.paypalPaymentId().is(paymentId)
|
||||
.status().in(
|
||||
Transaction.Status.SETTLED,
|
||||
Transaction.Status.SETTLING,
|
||||
Transaction.Status.SUBMITTED_FOR_SETTLEMENT,
|
||||
Transaction.Status.SETTLEMENT_PENDING
|
||||
)
|
||||
);
|
||||
|
||||
if (search.getMaximumSize() == 0) {
|
||||
return CompletableFuture.failedFuture(
|
||||
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
|
||||
final Transaction successfulTx = search.getFirst();
|
||||
|
||||
return CompletableFuture.completedFuture(
|
||||
new PayPalChargeSuccessDetails(successfulTx.getGraphQLId()));
|
||||
}
|
||||
|
||||
logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode());
|
||||
|
||||
return CompletableFuture.failedFuture(
|
||||
new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR));
|
||||
|
||||
}, executor));
|
||||
}
|
||||
|
||||
private static PaymentStatus getPaymentStatus(Transaction.Status status) {
|
||||
return switch (status) {
|
||||
case SETTLEMENT_CONFIRMED, SETTLING, SUBMITTED_FOR_SETTLEMENT, SETTLED -> PaymentStatus.SUCCEEDED;
|
||||
case AUTHORIZATION_EXPIRED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLEMENT_DECLINED, VOIDED, FAILED ->
|
||||
PaymentStatus.FAILED;
|
||||
default -> PaymentStatus.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
private static PaymentStatus getPaymentStatus(com.braintree.graphql.client.type.PaymentStatus status) {
|
||||
try {
|
||||
Transaction.Status transactionStatus = Transaction.Status.valueOf(status.rawValue);
|
||||
|
||||
return getPaymentStatus(transactionStatus);
|
||||
} catch (final Exception e) {
|
||||
return PaymentStatus.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {
|
||||
return switch (currency.toLowerCase(Locale.ROOT)) {
|
||||
// JPY is the only supported zero-decimal currency
|
||||
case "jpy" -> BigDecimal.valueOf(amount);
|
||||
default -> BigDecimal.valueOf(amount).scaleByPowerOfTen(-2);
|
||||
};
|
||||
}
|
||||
|
||||
public record PayPalOneTimePaymentApprovalDetails(String approvalUrl, String paymentId) {
|
||||
|
||||
}
|
||||
|
||||
public record PayPalChargeSuccessDetails(String paymentId) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,4 +10,8 @@ public enum PaymentMethod {
|
||||
* A credit card or debit card, including those from Apple Pay and Google Pay
|
||||
*/
|
||||
CARD,
|
||||
/**
|
||||
* A PayPal account
|
||||
*/
|
||||
PAYPAL,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -66,28 +67,18 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
|
||||
private static final String METADATA_KEY_LEVEL = "level";
|
||||
|
||||
// https://stripe.com/docs/currencies?presentment-currency=US
|
||||
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
|
||||
"aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", "bif", "bmd",
|
||||
"bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", "cve", "czk", "djf",
|
||||
"dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", "gnf", "gtq", "gyd", "hkd",
|
||||
"hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", "kgs", "khr", "kmf", "krw", "kyd",
|
||||
"kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", "mmk", "mnt", "mop", "mro", "mur", "mvr",
|
||||
"mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", "nzd", "pab", "pen", "pgk", "php", "pkr", "pln",
|
||||
"pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std",
|
||||
"szl", "thb", "tjs", "top", "try", "ttd", "twd", "tzs", "uah", "ugx", "usd", "uyu", "uzs", "vnd", "vuv", "wst",
|
||||
"xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw");
|
||||
|
||||
private final String apiKey;
|
||||
private final Executor executor;
|
||||
private final byte[] idempotencyKeyGenerator;
|
||||
private final String boostDescription;
|
||||
private final Set<String> supportedCurrencies;
|
||||
|
||||
public StripeManager(
|
||||
@Nonnull String apiKey,
|
||||
@Nonnull Executor executor,
|
||||
@Nonnull byte[] idempotencyKeyGenerator,
|
||||
@Nonnull String boostDescription) {
|
||||
@Nonnull String boostDescription,
|
||||
@Nonnull Set<String> supportedCurrencies) {
|
||||
this.apiKey = Objects.requireNonNull(apiKey);
|
||||
if (Strings.isNullOrEmpty(apiKey)) {
|
||||
throw new IllegalArgumentException("apiKey cannot be empty");
|
||||
@@ -98,6 +89,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty");
|
||||
}
|
||||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
||||
this.supportedCurrencies = supportedCurrencies;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,6 +102,11 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
return paymentMethod == PaymentMethod.CARD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsCurrency(final String currency) {
|
||||
return supportedCurrencies.contains(currency);
|
||||
}
|
||||
|
||||
private RequestOptions commonOptions() {
|
||||
return commonOptions(null);
|
||||
}
|
||||
@@ -181,7 +178,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
|
||||
@Override
|
||||
public Set<String> getSupportedCurrencies() {
|
||||
return SUPPORTED_CURRENCIES;
|
||||
return supportedCurrencies;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,10 +207,15 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<PaymentIntent> getPaymentIntent(String paymentIntentId) {
|
||||
public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return PaymentIntent.retrieve(paymentIntentId, commonOptions());
|
||||
final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, commonOptions());
|
||||
|
||||
return new PaymentDetails(paymentIntent.getId(),
|
||||
paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),
|
||||
getPaymentStatusForStatus(paymentIntent.getStatus()),
|
||||
Instant.ofEpochSecond(paymentIntent.getCreated()));
|
||||
} catch (StripeException e) {
|
||||
if (e.getStatusCode() == 404) {
|
||||
return null;
|
||||
@@ -224,7 +226,16 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level, long lastSubscriptionCreatedAt) {
|
||||
private static PaymentStatus getPaymentStatusForStatus(String status) {
|
||||
return switch (status.toLowerCase(Locale.ROOT)) {
|
||||
case "processing" -> PaymentStatus.PROCESSING;
|
||||
case "succeeded" -> PaymentStatus.SUCCEEDED;
|
||||
default -> PaymentStatus.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
public CompletableFuture<Subscription> createSubscription(String customerId, String priceId, long level,
|
||||
long lastSubscriptionCreatedAt) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
|
||||
@@ -16,6 +16,7 @@ public enum SubscriptionProcessor {
|
||||
// because provider IDs are stored, they should not be reused, and great care
|
||||
// must be used if a provider is removed from the list
|
||||
STRIPE(1),
|
||||
BRAINTREE(2),
|
||||
;
|
||||
|
||||
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@@ -14,9 +16,27 @@ public interface SubscriptionProcessorManager {
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
boolean supportsCurrency(String currency);
|
||||
|
||||
Set<String> getSupportedCurrencies();
|
||||
|
||||
CompletableFuture<PaymentDetails> getPaymentDetails(String paymentId);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
|
||||
Set<String> getSupportedCurrencies();
|
||||
record PaymentDetails(String id,
|
||||
Map<String, String> customMetadata,
|
||||
PaymentStatus status,
|
||||
Instant created) {
|
||||
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
SUCCEEDED,
|
||||
PROCESSING,
|
||||
FAILED,
|
||||
UNKNOWN,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user