Support PayPal for recurring donations
This commit is contained in:
parent
a34b5a6122
commit
f361f436d8
2
pom.xml
2
pom.xml
|
@ -56,7 +56,7 @@
|
||||||
<jackson.version>2.13.4</jackson.version>
|
<jackson.version>2.13.4</jackson.version>
|
||||||
<jaxb.version>2.3.1</jaxb.version>
|
<jaxb.version>2.3.1</jaxb.version>
|
||||||
<jedis.version>2.9.0</jedis.version>
|
<jedis.version>2.9.0</jedis.version>
|
||||||
<kotlin.version>1.7.21</kotlin.version>
|
<kotlin.version>1.8.0</kotlin.version>
|
||||||
<kotlinx-serialization.version>1.4.1</kotlinx-serialization.version>
|
<kotlinx-serialization.version>1.4.1</kotlinx-serialization.version>
|
||||||
<lettuce.version>6.2.1.RELEASE</lettuce.version>
|
<lettuce.version>6.2.1.RELEASE</lettuce.version>
|
||||||
<libphonenumber.version>8.12.54</libphonenumber.version>
|
<libphonenumber.version>8.12.54</libphonenumber.version>
|
||||||
|
|
|
@ -352,7 +352,9 @@ subscription: # configuration for Stripe subscriptions
|
||||||
# list of ISO 4217 currency codes and amounts for the given badge level
|
# list of ISO 4217 currency codes and amounts for the given badge level
|
||||||
xts:
|
xts:
|
||||||
amount: '10'
|
amount: '10'
|
||||||
id: price_example # stripe ID
|
processorIds:
|
||||||
|
STRIPE: price_example # stripe Price ID
|
||||||
|
BRAINTREE: plan_example # braintree Plan ID
|
||||||
|
|
||||||
oneTimeDonations:
|
oneTimeDonations:
|
||||||
boost:
|
boost:
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
mutation CreatePayPalBillingAgreement($input: CreatePayPalBillingAgreementInput!) {
|
||||||
|
createPayPalBillingAgreement(input: $input) {
|
||||||
|
approvalUrl,
|
||||||
|
billingAgreementToken
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
mutation TokenizePayPalBillingAgreement($input: TokenizePayPalBillingAgreementInput!) {
|
||||||
|
tokenizePayPalBillingAgreement(input: $input) {
|
||||||
|
paymentMethod {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
mutation VaultPaymentMethod($input: VaultPaymentMethodInput!) {
|
||||||
|
vaultPaymentMethod(input: $input) {
|
||||||
|
paymentMethod {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,31 +5,16 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.DecimalMin;
|
import javax.validation.constraints.DecimalMin;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||||
|
|
||||||
public class SubscriptionPriceConfiguration {
|
public record SubscriptionPriceConfiguration(@Valid @NotEmpty Map<SubscriptionProcessor, @NotBlank String> processorIds,
|
||||||
|
@NotNull @DecimalMin("0.01") BigDecimal amount) {
|
||||||
|
|
||||||
private final String id;
|
|
||||||
private final BigDecimal amount;
|
|
||||||
|
|
||||||
@JsonCreator
|
|
||||||
public SubscriptionPriceConfiguration(
|
|
||||||
@JsonProperty("id") @NotEmpty String id,
|
|
||||||
@JsonProperty("amount") @NotNull @DecimalMin("0.01") BigDecimal amount) {
|
|
||||||
this.id = id;
|
|
||||||
this.amount = amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getAmount() {
|
|
||||||
return amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,11 +14,6 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.stripe.exception.StripeException;
|
import com.stripe.exception.StripeException;
|
||||||
import com.stripe.model.Charge;
|
|
||||||
import com.stripe.model.Charge.Outcome;
|
|
||||||
import com.stripe.model.Invoice;
|
|
||||||
import com.stripe.model.InvoiceLineItem;
|
|
||||||
import com.stripe.model.Subscription;
|
|
||||||
import io.dropwizard.auth.Auth;
|
import io.dropwizard.auth.Auth;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
import io.micrometer.core.instrument.Metrics;
|
||||||
import io.micrometer.core.instrument.Tags;
|
import io.micrometer.core.instrument.Tags;
|
||||||
|
@ -31,7 +26,6 @@ import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
@ -47,6 +41,7 @@ import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import javax.validation.constraints.Min;
|
import javax.validation.constraints.Min;
|
||||||
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotEmpty;
|
import javax.validation.constraints.NotEmpty;
|
||||||
import javax.validation.constraints.NotNull;
|
import javax.validation.constraints.NotNull;
|
||||||
import javax.ws.rs.BadRequestException;
|
import javax.ws.rs.BadRequestException;
|
||||||
|
@ -72,7 +67,6 @@ import javax.ws.rs.core.HttpHeaders;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
import javax.ws.rs.core.Response.Status;
|
import javax.ws.rs.core.Response.Status;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
|
||||||
|
@ -87,7 +81,6 @@ import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfigurati
|
||||||
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;
|
||||||
import org.whispersystems.textsecuregcm.configuration.SubscriptionPriceConfiguration;
|
|
||||||
import org.whispersystems.textsecuregcm.entities.Badge;
|
import org.whispersystems.textsecuregcm.entities.Badge;
|
||||||
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
|
||||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||||
|
@ -98,6 +91,7 @@ import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
||||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||||
|
@ -168,7 +162,7 @@ public class SubscriptionController {
|
||||||
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency))
|
.filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency))
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
|
levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),
|
||||||
levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).getAmount()));
|
levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount()));
|
||||||
|
|
||||||
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
|
final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())
|
||||||
.filter(paymentMethod -> subscriptionProcessorManagers.stream()
|
.filter(paymentMethod -> subscriptionProcessorManagers.stream()
|
||||||
|
@ -233,19 +227,7 @@ public class SubscriptionController {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
return getResult.record.getProcessorCustomer()
|
return getResult.record.getProcessorCustomer()
|
||||||
.map(processorCustomer -> stripeManager.getCustomer(processorCustomer.customerId())
|
.map(processorCustomer -> getManagerForProcessor(processorCustomer.processor()).cancelAllActiveSubscriptions(processorCustomer.customerId()))
|
||||||
.thenCompose(customer -> {
|
|
||||||
if (customer == null) {
|
|
||||||
throw new InternalServerErrorException(
|
|
||||||
"no customer record found for id " + processorCustomer.customerId());
|
|
||||||
}
|
|
||||||
return stripeManager.listNonCanceledSubscriptions(customer);
|
|
||||||
}).thenCompose(subscriptions -> {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
|
|
||||||
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
|
|
||||||
return CompletableFuture.allOf(futures);
|
|
||||||
}))
|
|
||||||
// a missing customer ID is OK; it means the subscriber never started to add a payment method
|
// a missing customer ID is OK; it means the subscriber never started to add a payment method
|
||||||
.orElseGet(() -> CompletableFuture.completedFuture(null));
|
.orElseGet(() -> CompletableFuture.completedFuture(null));
|
||||||
})
|
})
|
||||||
|
@ -307,7 +289,14 @@ public class SubscriptionController {
|
||||||
.thenCompose(record -> {
|
.thenCompose(record -> {
|
||||||
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture =
|
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture =
|
||||||
record.getProcessorCustomer()
|
record.getProcessorCustomer()
|
||||||
.map(ignored -> CompletableFuture.completedFuture(record))
|
.map(ProcessorCustomer::processor)
|
||||||
|
.map(processor -> {
|
||||||
|
if (processor != subscriptionProcessorManager.getProcessor()) {
|
||||||
|
throw new ClientErrorException("existing processor does not match", Status.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompletableFuture.completedFuture(record);
|
||||||
|
})
|
||||||
.orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
|
.orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
|
||||||
.thenApply(ProcessorCustomer::customerId)
|
.thenApply(ProcessorCustomer::customerId)
|
||||||
.thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record,
|
.thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record,
|
||||||
|
@ -317,6 +306,7 @@ public class SubscriptionController {
|
||||||
return updatedRecordFuture.thenCompose(
|
return updatedRecordFuture.thenCompose(
|
||||||
updatedRecord -> {
|
updatedRecord -> {
|
||||||
final String customerId = updatedRecord.getProcessorCustomer()
|
final String customerId = updatedRecord.getProcessorCustomer()
|
||||||
|
.filter(pc -> pc.processor().equals(subscriptionProcessorManager.getProcessor()))
|
||||||
.orElseThrow(() -> new InternalServerErrorException("record should not be missing customer"))
|
.orElseThrow(() -> new InternalServerErrorException("record should not be missing customer"))
|
||||||
.customerId();
|
.customerId();
|
||||||
return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId);
|
return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId);
|
||||||
|
@ -327,6 +317,64 @@ public class SubscriptionController {
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreatePayPalBillingAgreementResponse(@NotBlank String approvalUrl, @NotBlank String token) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@POST
|
||||||
|
@Path("/{subscriberId}/create_payment_method/paypal")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public CompletableFuture<Response> createPayPalPaymentMethod(
|
||||||
|
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||||
|
@PathParam("subscriberId") String subscriberId,
|
||||||
|
@NotNull @Valid CreatePayPalBillingAgreementRequest request,
|
||||||
|
@Context ContainerRequestContext containerRequestContext) {
|
||||||
|
|
||||||
|
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||||
|
|
||||||
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
|
.thenCompose(record -> {
|
||||||
|
|
||||||
|
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture =
|
||||||
|
record.getProcessorCustomer()
|
||||||
|
.map(ProcessorCustomer::processor)
|
||||||
|
.map(processor -> {
|
||||||
|
if (processor != braintreeManager.getProcessor()) {
|
||||||
|
throw new ClientErrorException("existing processor does not match", Status.CONFLICT);
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(record);
|
||||||
|
})
|
||||||
|
.orElseGet(() -> braintreeManager.createCustomer(requestData.subscriberUser)
|
||||||
|
.thenApply(ProcessorCustomer::customerId)
|
||||||
|
.thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record,
|
||||||
|
new ProcessorCustomer(customerId, braintreeManager.getProcessor()),
|
||||||
|
Instant.now())));
|
||||||
|
|
||||||
|
return updatedRecordFuture.thenCompose(
|
||||||
|
updatedRecord -> {
|
||||||
|
final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream()
|
||||||
|
.filter(l -> !"*".equals(l.getLanguage()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(Locale.US);
|
||||||
|
|
||||||
|
return braintreeManager.createPayPalBillingAgreement(request.returnUrl, request.cancelUrl,
|
||||||
|
locale.toLanguageTag());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.thenApply(
|
||||||
|
billingAgreementApprovalDetails -> Response.ok(
|
||||||
|
new CreatePayPalBillingAgreementResponse(billingAgreementApprovalDetails.approvalUrl(),
|
||||||
|
billingAgreementApprovalDetails.billingAgreementToken()))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
||||||
return switch (paymentMethod) {
|
return switch (paymentMethod) {
|
||||||
case CARD -> stripeManager;
|
case CARD -> stripeManager;
|
||||||
|
@ -346,16 +394,38 @@ public class SubscriptionController {
|
||||||
@Path("/{subscriberId}/default_payment_method/{paymentMethodId}")
|
@Path("/{subscriberId}/default_payment_method/{paymentMethodId}")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Deprecated // use /{subscriberId}/default_payment_method/{processor}/{paymentMethodId}
|
||||||
public CompletableFuture<Response> setDefaultPaymentMethod(
|
public CompletableFuture<Response> setDefaultPaymentMethod(
|
||||||
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||||
@PathParam("subscriberId") String subscriberId,
|
@PathParam("subscriberId") String subscriberId,
|
||||||
@PathParam("paymentMethodId") @NotEmpty String paymentMethodId) {
|
@PathParam("paymentMethodId") @NotEmpty String paymentMethodId) {
|
||||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||||
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
|
.thenCompose(record -> stripeManager.setDefaultPaymentMethodForCustomer(
|
||||||
|
record.getProcessorCustomer().orElseThrow().customerId(), paymentMethodId, record.subscriptionId))
|
||||||
|
.thenApply(customer -> Response.ok().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Timed
|
||||||
|
@POST
|
||||||
|
@Path("/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public CompletableFuture<Response> setDefaultPaymentMethodWithProcessor(
|
||||||
|
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||||
|
@PathParam("subscriberId") String subscriberId,
|
||||||
|
@PathParam("processor") SubscriptionProcessor processor,
|
||||||
|
@PathParam("paymentMethodToken") @NotEmpty String paymentMethodToken) {
|
||||||
|
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||||
|
|
||||||
|
final SubscriptionProcessorManager manager = getManagerForProcessor(processor);
|
||||||
|
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
.thenCompose(record -> record.getProcessorCustomer()
|
.thenCompose(record -> record.getProcessorCustomer()
|
||||||
.map(processorCustomer -> stripeManager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(),
|
.map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(),
|
||||||
paymentMethodId))
|
paymentMethodToken, record.subscriptionId))
|
||||||
.orElseThrow(() ->
|
.orElseThrow(() ->
|
||||||
// a missing customer ID indicates the client made requests out of order,
|
// a missing customer ID indicates the client made requests out of order,
|
||||||
// and needs to call create_payment_method to create a customer for the given payment method
|
// and needs to call create_payment_method to create a customer for the given payment method
|
||||||
|
@ -435,76 +505,67 @@ public class SubscriptionController {
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
.thenCompose(record -> {
|
.thenCompose(record -> {
|
||||||
SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level);
|
|
||||||
if (levelConfiguration == null) {
|
|
||||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
|
||||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
|
||||||
new SetSubscriptionLevelErrorResponse.Error(
|
|
||||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
SubscriptionPriceConfiguration priceConfiguration = levelConfiguration.getPrices()
|
|
||||||
.get(currency.toLowerCase(Locale.ROOT));
|
|
||||||
if (priceConfiguration == null) {
|
|
||||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
|
||||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
|
||||||
new SetSubscriptionLevelErrorResponse.Error(
|
|
||||||
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (record.subscriptionId == null) {
|
final ProcessorCustomer processorCustomer = record.getProcessorCustomer()
|
||||||
long lastSubscriptionCreatedAt =
|
.orElseThrow(() ->
|
||||||
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
|
// a missing customer ID indicates the client made requests out of order,
|
||||||
|
// and needs to call create_payment_method to create a customer for the given payment method
|
||||||
|
new ClientErrorException(Status.CONFLICT));
|
||||||
|
|
||||||
return record.getProcessorCustomer()
|
final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, processorCustomer.processor());
|
||||||
.map(processorCustomer ->
|
|
||||||
// we don't have a subscription yet so create it and then record the subscription id
|
final SubscriptionProcessorManager manager = getManagerForProcessor(processorCustomer.processor());
|
||||||
//
|
|
||||||
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
return Optional.ofNullable(record.subscriptionId)
|
||||||
// retries this request
|
.map(subId -> {
|
||||||
stripeManager.createSubscription(processorCustomer.customerId(), priceConfiguration.getId(), level,
|
// we already have a subscription in our records so let's check the level and change it if needed
|
||||||
lastSubscriptionCreatedAt)
|
return manager.getSubscription(subId).thenCompose(
|
||||||
.exceptionally(e -> {
|
subscription -> manager.getLevelForSubscription(subscription).thenCompose(existingLevel -> {
|
||||||
if (e.getCause() instanceof StripeException stripeException
|
if (level == existingLevel) {
|
||||||
&& stripeException.getCode().equals("subscription_payment_intent_requires_action")) {
|
return CompletableFuture.completedFuture(subscription);
|
||||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
}
|
||||||
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
return manager.updateSubscription(
|
||||||
new SetSubscriptionLevelErrorResponse.Error(
|
subscription, subscriptionTemplateId, level, idempotencyKey)
|
||||||
SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null
|
.thenCompose(updatedSubscription ->
|
||||||
)
|
subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser,
|
||||||
))).build());
|
requestData.now,
|
||||||
}
|
level, updatedSubscription.id())
|
||||||
if (e instanceof RuntimeException re) {
|
.thenApply(unused -> updatedSubscription));
|
||||||
|
}));
|
||||||
|
}).orElseGet(() -> {
|
||||||
|
long lastSubscriptionCreatedAt =
|
||||||
|
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
|
||||||
|
|
||||||
|
// we don't have a subscription yet so create it and then record the subscription id
|
||||||
|
//
|
||||||
|
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
|
||||||
|
// retries this request
|
||||||
|
return manager.createSubscription(processorCustomer.customerId(),
|
||||||
|
subscriptionTemplateId,
|
||||||
|
level,
|
||||||
|
lastSubscriptionCreatedAt)
|
||||||
|
.exceptionally(e -> {
|
||||||
|
if (e.getCause() instanceof StripeException stripeException
|
||||||
|
&& stripeException.getCode().equals("subscription_payment_intent_requires_action")) {
|
||||||
|
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
|
SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null
|
||||||
|
)
|
||||||
|
))).build());
|
||||||
|
}
|
||||||
|
if (e instanceof RuntimeException re) {
|
||||||
throw re;
|
throw re;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CompletionException(e);
|
throw new CompletionException(e);
|
||||||
})
|
})
|
||||||
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
|
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
|
||||||
requestData.subscriberUser, subscription.getId(), requestData.now, level)
|
requestData.subscriberUser, subscription.id(), requestData.now, level)
|
||||||
.thenApply(unused -> subscription)))
|
.thenApply(unused -> subscription));
|
||||||
.orElseThrow(() ->
|
});
|
||||||
// a missing customer ID indicates the client made requests out of order,
|
|
||||||
// and needs to call create_payment_method to create a customer for the given payment method
|
|
||||||
new ClientErrorException(Status.CONFLICT));
|
|
||||||
} else {
|
|
||||||
// we already have a subscription in our records so let's check the level and change it if needed
|
|
||||||
return stripeManager.getSubscription(record.subscriptionId).thenCompose(
|
|
||||||
subscription -> stripeManager.getLevelForSubscription(subscription).thenCompose(existingLevel -> {
|
|
||||||
if (level == existingLevel) {
|
|
||||||
return CompletableFuture.completedFuture(subscription);
|
|
||||||
}
|
|
||||||
return stripeManager.updateSubscription(
|
|
||||||
subscription, priceConfiguration.getId(), level, idempotencyKey)
|
|
||||||
.thenCompose(updatedSubscription ->
|
|
||||||
subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, requestData.now,
|
|
||||||
level)
|
|
||||||
.thenApply(unused -> updatedSubscription));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.thenApply(subscription -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build());
|
.thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class GetLevelsResponse {
|
public static class GetLevelsResponse {
|
||||||
|
@ -613,7 +674,7 @@ public class SubscriptionController {
|
||||||
badgeTranslator.translate(acceptableLanguages, entry.getValue().getBadge()),
|
badgeTranslator.translate(acceptableLanguages, entry.getValue().getBadge()),
|
||||||
entry.getValue().getPrices().entrySet().stream().collect(
|
entry.getValue().getPrices().entrySet().stream().collect(
|
||||||
Collectors.toMap(levelEntry -> levelEntry.getKey().toUpperCase(Locale.ROOT),
|
Collectors.toMap(levelEntry -> levelEntry.getKey().toUpperCase(Locale.ROOT),
|
||||||
levelEntry -> levelEntry.getValue().getAmount()))))));
|
levelEntry -> levelEntry.getValue().amount()))))));
|
||||||
return Response.ok(getLevelsResponse).build();
|
return Response.ok(getLevelsResponse).build();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -730,6 +791,9 @@ public class SubscriptionController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Stripe PaymentIntent with the requested amount and currency
|
||||||
|
*/
|
||||||
@Timed
|
@Timed
|
||||||
@POST
|
@POST
|
||||||
@Path("/boost/create")
|
@Path("/boost/create")
|
||||||
|
@ -745,7 +809,7 @@ public class SubscriptionController {
|
||||||
BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies()
|
BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies()
|
||||||
.get(request.currency.toLowerCase(Locale.ROOT)).gift();
|
.get(request.currency.toLowerCase(Locale.ROOT)).gift();
|
||||||
if (amountConfigured == null ||
|
if (amountConfigured == null ||
|
||||||
stripeManager.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured)
|
SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured)
|
||||||
.compareTo(amount) != 0) {
|
.compareTo(amount) != 0) {
|
||||||
throw new WebApplicationException(
|
throw new WebApplicationException(
|
||||||
Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build());
|
Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build());
|
||||||
|
@ -773,7 +837,8 @@ public class SubscriptionController {
|
||||||
|
|
||||||
BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
|
BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()
|
||||||
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
|
.get(request.currency.toLowerCase(Locale.ROOT)).minimum();
|
||||||
BigDecimal minCurrencyAmountMinorUnits = stripeManager.convertConfiguredAmountToStripeAmount(request.currency,
|
BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(
|
||||||
|
request.currency,
|
||||||
minCurrencyAmountMajorUnits);
|
minCurrencyAmountMajorUnits);
|
||||||
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
|
if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {
|
||||||
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
|
@ -954,6 +1019,7 @@ public class SubscriptionController {
|
||||||
private final String currency;
|
private final String currency;
|
||||||
private final BigDecimal amount;
|
private final BigDecimal amount;
|
||||||
private final String status;
|
private final String status;
|
||||||
|
private final SubscriptionProcessor processor;
|
||||||
|
|
||||||
@JsonCreator
|
@JsonCreator
|
||||||
public Subscription(
|
public Subscription(
|
||||||
|
@ -964,7 +1030,8 @@ public class SubscriptionController {
|
||||||
@JsonProperty("cancelAtPeriodEnd") boolean cancelAtPeriodEnd,
|
@JsonProperty("cancelAtPeriodEnd") boolean cancelAtPeriodEnd,
|
||||||
@JsonProperty("currency") String currency,
|
@JsonProperty("currency") String currency,
|
||||||
@JsonProperty("amount") BigDecimal amount,
|
@JsonProperty("amount") BigDecimal amount,
|
||||||
@JsonProperty("status") String status) {
|
@JsonProperty("status") String status,
|
||||||
|
@JsonProperty("processor") SubscriptionProcessor processor) {
|
||||||
this.level = level;
|
this.level = level;
|
||||||
this.billingCycleAnchor = billingCycleAnchor;
|
this.billingCycleAnchor = billingCycleAnchor;
|
||||||
this.endOfCurrentPeriod = endOfCurrentPeriod;
|
this.endOfCurrentPeriod = endOfCurrentPeriod;
|
||||||
|
@ -973,6 +1040,7 @@ public class SubscriptionController {
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.processor = processor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getLevel() {
|
public long getLevel() {
|
||||||
|
@ -1006,6 +1074,10 @@ public class SubscriptionController {
|
||||||
public String getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SubscriptionProcessor getProcessor() {
|
||||||
|
return processor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ChargeFailure {
|
public static class ChargeFailure {
|
||||||
|
@ -1082,38 +1154,38 @@ public class SubscriptionController {
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
.thenCompose(record -> {
|
.thenCompose(record -> {
|
||||||
if (record.subscriptionId == null) {
|
if (record.subscriptionId == null) {
|
||||||
return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build());
|
return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build());
|
||||||
}
|
}
|
||||||
return stripeManager.getSubscription(record.subscriptionId).thenCompose(subscription ->
|
|
||||||
stripeManager.getPriceForSubscription(subscription).thenCompose(price ->
|
final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor());
|
||||||
stripeManager.getLevelForPrice(price).thenApply(level -> {
|
|
||||||
GetSubscriptionInformationResponse.ChargeFailure chargeFailure = null;
|
return manager.getSubscription(record.subscriptionId).thenCompose(subscription ->
|
||||||
if (subscription.getLatestInvoiceObject() != null && subscription.getLatestInvoiceObject().getChargeObject() != null &&
|
manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> {
|
||||||
(subscription.getLatestInvoiceObject().getChargeObject().getFailureCode() != null || subscription.getLatestInvoiceObject().getChargeObject().getFailureMessage() != null)) {
|
final GetSubscriptionInformationResponse.ChargeFailure chargeFailure = Optional.ofNullable(subscriptionInformation.chargeFailure())
|
||||||
Charge charge = subscription.getLatestInvoiceObject().getChargeObject();
|
.map(chargeFailure1 -> new GetSubscriptionInformationResponse.ChargeFailure(
|
||||||
Outcome outcome = charge.getOutcome();
|
subscriptionInformation.chargeFailure().code(),
|
||||||
chargeFailure = new GetSubscriptionInformationResponse.ChargeFailure(
|
subscriptionInformation.chargeFailure().message(),
|
||||||
charge.getFailureCode(),
|
subscriptionInformation.chargeFailure().outcomeNetworkStatus(),
|
||||||
charge.getFailureMessage(),
|
subscriptionInformation.chargeFailure().outcomeReason(),
|
||||||
outcome != null ? outcome.getNetworkStatus() : null,
|
subscriptionInformation.chargeFailure().outcomeType()
|
||||||
outcome != null ? outcome.getReason() : null,
|
))
|
||||||
outcome != null ? outcome.getType() : null);
|
.orElse(null);
|
||||||
}
|
return Response.ok(
|
||||||
return Response.ok(
|
new GetSubscriptionInformationResponse(
|
||||||
new GetSubscriptionInformationResponse(
|
new GetSubscriptionInformationResponse.Subscription(
|
||||||
new GetSubscriptionInformationResponse.Subscription(
|
subscriptionInformation.level(),
|
||||||
level,
|
subscriptionInformation.billingCycleAnchor(),
|
||||||
Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),
|
subscriptionInformation.endOfCurrentPeriod(),
|
||||||
Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()),
|
subscriptionInformation.active(),
|
||||||
Objects.equals(subscription.getStatus(), "active"),
|
subscriptionInformation.cancelAtPeriodEnd(),
|
||||||
subscription.getCancelAtPeriodEnd(),
|
subscriptionInformation.price().currency(),
|
||||||
price.getCurrency().toUpperCase(Locale.ROOT),
|
subscriptionInformation.price().amount(),
|
||||||
price.getUnitAmountDecimal(),
|
subscriptionInformation.status().getApiValue(),
|
||||||
subscription.getStatus()),
|
manager.getProcessor()),
|
||||||
chargeFailure
|
chargeFailure
|
||||||
)).build();
|
)).build();
|
||||||
})));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1162,98 +1234,60 @@ public class SubscriptionController {
|
||||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||||
.thenApply(this::requireRecordFromGetResult)
|
.thenApply(this::requireRecordFromGetResult)
|
||||||
.thenCompose(record -> {
|
.thenCompose(record -> {
|
||||||
if (record.subscriptionId == null) {
|
if (record.subscriptionId == null) {
|
||||||
return CompletableFuture.completedFuture(Response.status(Status.NOT_FOUND).build());
|
return CompletableFuture.completedFuture(Response.status(Status.NOT_FOUND).build());
|
||||||
}
|
}
|
||||||
ReceiptCredentialRequest receiptCredentialRequest;
|
ReceiptCredentialRequest receiptCredentialRequest;
|
||||||
try {
|
try {
|
||||||
receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest());
|
receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest());
|
||||||
} catch (InvalidInputException e) {
|
} catch (InvalidInputException e) {
|
||||||
throw new BadRequestException("invalid receipt credential request", e);
|
throw new BadRequestException("invalid receipt credential request", e);
|
||||||
}
|
}
|
||||||
return stripeManager.getLatestInvoiceForSubscription(record.subscriptionId)
|
|
||||||
.thenCompose(invoice -> convertInvoiceToReceipt(invoice, record.subscriptionId))
|
final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor());
|
||||||
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
|
return manager.getReceiptItem(record.subscriptionId)
|
||||||
receipt.getInvoiceLineItemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest,
|
.thenCompose(receipt -> issuedReceiptsManager.recordIssuance(
|
||||||
requestData.now)
|
receipt.itemId(), SubscriptionProcessor.STRIPE, receiptCredentialRequest,
|
||||||
.thenApply(unused -> receipt))
|
requestData.now)
|
||||||
.thenApply(receipt -> {
|
.thenApply(unused -> receipt))
|
||||||
ReceiptCredentialResponse receiptCredentialResponse;
|
.thenApply(receipt -> {
|
||||||
try {
|
ReceiptCredentialResponse receiptCredentialResponse;
|
||||||
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
try {
|
||||||
receiptCredentialRequest, receipt.getExpiration().getEpochSecond(), receipt.getLevel());
|
receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(
|
||||||
} catch (VerificationFailedException e) {
|
receiptCredentialRequest, receiptExpirationWithGracePeriod(receipt.expiration()).getEpochSecond(), receipt.level());
|
||||||
throw new BadRequestException("receipt credential request failed verification", e);
|
} catch (VerificationFailedException e) {
|
||||||
}
|
throw new BadRequestException("receipt credential request failed verification", e);
|
||||||
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build();
|
}
|
||||||
});
|
return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Receipt {
|
private Instant receiptExpirationWithGracePeriod(Instant itemExpiration) {
|
||||||
|
return itemExpiration.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
||||||
private final Instant expiration;
|
.truncatedTo(ChronoUnit.DAYS)
|
||||||
private final long level;
|
.plus(1, ChronoUnit.DAYS);
|
||||||
private final String invoiceLineItemId;
|
|
||||||
|
|
||||||
public Receipt(Instant expiration, long level, String invoiceLineItemId) {
|
|
||||||
this.expiration = expiration;
|
|
||||||
this.level = level;
|
|
||||||
this.invoiceLineItemId = invoiceLineItemId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Instant getExpiration() {
|
private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) {
|
||||||
return expiration;
|
SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level);
|
||||||
}
|
if (levelConfiguration == null) {
|
||||||
|
throw new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
public long getLevel() {
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
return level;
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
}
|
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))
|
||||||
|
.build());
|
||||||
public String getInvoiceLineItemId() {
|
|
||||||
return invoiceLineItemId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private CompletableFuture<Receipt> 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 stripeManager.getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> {
|
|
||||||
Collection<InvoiceLineItem> subscriptionLineItems = invoiceLineItems.stream()
|
|
||||||
.filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType()))
|
|
||||||
.collect(Collectors.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 Optional.ofNullable(levelConfiguration.getPrices()
|
||||||
return getReceiptForSubscriptionInvoiceLineItem(subscriptionLineItem);
|
.get(currency.toLowerCase(Locale.ROOT)))
|
||||||
});
|
.map(priceConfiguration -> priceConfiguration.processorIds().get(processor))
|
||||||
}
|
.orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST)
|
||||||
|
.entity(new SetSubscriptionLevelErrorResponse(List.of(
|
||||||
private CompletableFuture<Receipt> getReceiptForSubscriptionInvoiceLineItem(InvoiceLineItem subscriptionLineItem) {
|
new SetSubscriptionLevelErrorResponse.Error(
|
||||||
return stripeManager.getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new Receipt(
|
SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))
|
||||||
Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getEnd())
|
.build()));
|
||||||
.plus(subscriptionConfiguration.getBadgeGracePeriod())
|
}
|
||||||
.truncatedTo(ChronoUnit.DAYS)
|
|
||||||
.plus(1, ChronoUnit.DAYS),
|
|
||||||
stripeManager.getLevelForProduct(product),
|
|
||||||
subscriptionLineItem.getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) {
|
private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) {
|
||||||
if (getResult == GetResult.PASSWORD_MISMATCH) {
|
if (getResult == GetResult.PASSWORD_MISMATCH) {
|
||||||
|
|
|
@ -65,6 +65,7 @@ public class SubscriptionManager {
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
@Nullable
|
@Nullable
|
||||||
ProcessorCustomer processorCustomer;
|
ProcessorCustomer processorCustomer;
|
||||||
|
@Nullable
|
||||||
public String subscriptionId;
|
public String subscriptionId;
|
||||||
public Instant subscriptionCreatedAt;
|
public Instant subscriptionCreatedAt;
|
||||||
public Long subscriptionLevel;
|
public Long subscriptionLevel;
|
||||||
|
@ -187,7 +188,8 @@ public class SubscriptionManager {
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
return null;
|
return null;
|
||||||
} else if (count > 1) {
|
} else if (count > 1) {
|
||||||
logger.error("expected invariant of 1-1 subscriber-customer violated for customer {}", processorCustomer);
|
logger.error("expected invariant of 1-1 subscriber-customer violated for customer {} ({})",
|
||||||
|
processorCustomer.customerId(), processorCustomer.processor());
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"expected invariant of 1-1 subscriber-customer violated for customer " + processorCustomer);
|
"expected invariant of 1-1 subscriber-customer violated for customer " + processorCustomer);
|
||||||
} else {
|
} else {
|
||||||
|
@ -392,7 +394,7 @@ public class SubscriptionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Void> subscriptionLevelChanged(
|
public CompletableFuture<Void> subscriptionLevelChanged(
|
||||||
byte[] user, Instant subscriptionLevelChangedAt, long level) {
|
byte[] user, Instant subscriptionLevelChangedAt, long level, String subscriptionId) {
|
||||||
checkUserLength(user);
|
checkUserLength(user);
|
||||||
|
|
||||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||||
|
@ -401,14 +403,17 @@ public class SubscriptionManager {
|
||||||
.returnValues(ReturnValue.NONE)
|
.returnValues(ReturnValue.NONE)
|
||||||
.updateExpression("SET "
|
.updateExpression("SET "
|
||||||
+ "#accessed_at = :accessed_at, "
|
+ "#accessed_at = :accessed_at, "
|
||||||
|
+ "#subscription_id = :subscription_id, "
|
||||||
+ "#subscription_level = :subscription_level, "
|
+ "#subscription_level = :subscription_level, "
|
||||||
+ "#subscription_level_changed_at = :subscription_level_changed_at")
|
+ "#subscription_level_changed_at = :subscription_level_changed_at")
|
||||||
.expressionAttributeNames(Map.of(
|
.expressionAttributeNames(Map.of(
|
||||||
"#accessed_at", KEY_ACCESSED_AT,
|
"#accessed_at", KEY_ACCESSED_AT,
|
||||||
|
"#subscription_id", KEY_SUBSCRIPTION_ID,
|
||||||
"#subscription_level", KEY_SUBSCRIPTION_LEVEL,
|
"#subscription_level", KEY_SUBSCRIPTION_LEVEL,
|
||||||
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
|
"#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT))
|
||||||
.expressionAttributeValues(Map.of(
|
.expressionAttributeValues(Map.of(
|
||||||
":accessed_at", n(subscriptionLevelChangedAt.getEpochSecond()),
|
":accessed_at", n(subscriptionLevelChangedAt.getEpochSecond()),
|
||||||
|
":subscription_id", s(subscriptionId),
|
||||||
":subscription_level", n(level),
|
":subscription_level", n(level),
|
||||||
":subscription_level_changed_at", n(subscriptionLevelChangedAt.getEpochSecond())))
|
":subscription_level_changed_at", n(subscriptionLevelChangedAt.getEpochSecond())))
|
||||||
.build();
|
.build();
|
||||||
|
|
|
@ -11,19 +11,29 @@ import com.apollographql.apollo3.api.Operations;
|
||||||
import com.apollographql.apollo3.api.Optional;
|
import com.apollographql.apollo3.api.Optional;
|
||||||
import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;
|
import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;
|
||||||
import com.braintree.graphql.client.type.ChargePaymentMethodInput;
|
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.CreatePayPalOneTimePaymentInput;
|
||||||
import com.braintree.graphql.client.type.CustomFieldInput;
|
import com.braintree.graphql.client.type.CustomFieldInput;
|
||||||
import com.braintree.graphql.client.type.MonetaryAmountInput;
|
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.PayPalExperienceProfileInput;
|
||||||
import com.braintree.graphql.client.type.PayPalIntent;
|
import com.braintree.graphql.client.type.PayPalIntent;
|
||||||
import com.braintree.graphql.client.type.PayPalLandingPageType;
|
import com.braintree.graphql.client.type.PayPalLandingPageType;
|
||||||
import com.braintree.graphql.client.type.PayPalOneTimePaymentInput;
|
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.PayPalUserAction;
|
||||||
|
import com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput;
|
||||||
import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;
|
import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;
|
||||||
import com.braintree.graphql.client.type.TransactionInput;
|
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.ChargePayPalOneTimePaymentMutation;
|
||||||
|
import com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation;
|
||||||
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
|
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
|
||||||
|
import com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation;
|
||||||
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
|
import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;
|
||||||
|
import com.braintree.graphql.clientoperation.VaultPaymentMethodMutation;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
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
|
* Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise
|
||||||
* throws a {@link ServiceUnavailableException}.
|
* throws a {@link ServiceUnavailableException}.
|
||||||
|
|
|
@ -6,19 +6,36 @@
|
||||||
package org.whispersystems.textsecuregcm.subscriptions;
|
package org.whispersystems.textsecuregcm.subscriptions;
|
||||||
|
|
||||||
import com.braintreegateway.BraintreeGateway;
|
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.ResourceCollection;
|
||||||
|
import com.braintreegateway.Result;
|
||||||
|
import com.braintreegateway.Subscription;
|
||||||
|
import com.braintreegateway.SubscriptionRequest;
|
||||||
import com.braintreegateway.Transaction;
|
import com.braintreegateway.Transaction;
|
||||||
import com.braintreegateway.TransactionSearchRequest;
|
import com.braintreegateway.TransactionSearchRequest;
|
||||||
|
import com.braintreegateway.exceptions.BraintreeException;
|
||||||
import com.braintreegateway.exceptions.NotFoundException;
|
import com.braintreegateway.exceptions.NotFoundException;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
import java.util.concurrent.Executor;
|
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.WebApplicationException;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
|
import org.apache.commons.codec.binary.Hex;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||||
|
@ -97,16 +114,6 @@ public class BraintreeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, 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,
|
public CompletableFuture<PayPalOneTimePaymentApprovalDetails> createOneTimePayment(String currency, long amount,
|
||||||
String locale, String returnUrl, String cancelUrl) {
|
String locale, String returnUrl, String cancelUrl) {
|
||||||
return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount),
|
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) {
|
private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {
|
||||||
return switch (currency.toLowerCase(Locale.ROOT)) {
|
return switch (currency.toLowerCase(Locale.ROOT)) {
|
||||||
// JPY is the only supported zero-decimal currency
|
// 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.base.Strings;
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import com.stripe.exception.StripeException;
|
import com.stripe.exception.StripeException;
|
||||||
|
import com.stripe.model.Charge;
|
||||||
import com.stripe.model.Customer;
|
import com.stripe.model.Customer;
|
||||||
import com.stripe.model.Invoice;
|
import com.stripe.model.Invoice;
|
||||||
import com.stripe.model.InvoiceLineItem;
|
import com.stripe.model.InvoiceLineItem;
|
||||||
|
@ -33,7 +34,6 @@ import com.stripe.param.SubscriptionRetrieveParams;
|
||||||
import com.stripe.param.SubscriptionUpdateParams;
|
import com.stripe.param.SubscriptionUpdateParams;
|
||||||
import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;
|
import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;
|
||||||
import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;
|
import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.InvalidKeyException;
|
import java.security.InvalidKeyException;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
@ -57,10 +57,12 @@ import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import javax.ws.rs.InternalServerErrorException;
|
||||||
import javax.ws.rs.WebApplicationException;
|
import javax.ws.rs.WebApplicationException;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
import javax.ws.rs.core.Response.Status;
|
import javax.ws.rs.core.Response.Status;
|
||||||
import org.apache.commons.codec.binary.Hex;
|
import org.apache.commons.codec.binary.Hex;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||||
|
|
||||||
public class StripeManager implements SubscriptionProcessorManager {
|
public class StripeManager implements SubscriptionProcessorManager {
|
||||||
|
@ -144,7 +146,9 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, executor);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Customer> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId) {
|
@Override
|
||||||
|
public CompletableFuture<Void> setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId,
|
||||||
|
@Nullable String currentSubscriptionId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
Customer customer = new Customer();
|
Customer customer = new Customer();
|
||||||
customer.setId(customerId);
|
customer.setId(customerId);
|
||||||
|
@ -154,7 +158,8 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
return customer.update(params, commonOptions());
|
customer.update(params, commonOptions());
|
||||||
|
return null;
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
throw new CompletionException(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) {
|
long lastSubscriptionCreatedAt) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
SubscriptionCreateParams params = SubscriptionCreateParams.builder()
|
||||||
.setCustomer(customerId)
|
.setCustomer(customerId)
|
||||||
.setOffSession(true)
|
.setOffSession(true)
|
||||||
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||||
.addItem(SubscriptionCreateParams.Item.builder()
|
.addItem(SubscriptionCreateParams.Item.builder()
|
||||||
.setPrice(priceId)
|
.setPrice(priceId)
|
||||||
.build())
|
.build())
|
||||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
// the idempotency key intentionally excludes priceId
|
// 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
|
// 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.
|
// create a subscription, we want to ensure only one gets created.
|
||||||
return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription(
|
return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription(
|
||||||
customerId, lastSubscriptionCreatedAt)));
|
customerId, lastSubscriptionCreatedAt)));
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
throw new CompletionException(e);
|
throw new CompletionException(e);
|
||||||
}
|
}
|
||||||
}, executor);
|
}, executor)
|
||||||
|
.thenApply(subscription -> new SubscriptionId(subscription.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Subscription> updateSubscription(
|
@Override
|
||||||
Subscription subscription, String priceId, long level, String idempotencyKey) {
|
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(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
|
List<SubscriptionUpdateParams.Item> items = new ArrayList<>();
|
||||||
for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) {
|
for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) {
|
||||||
items.add(SubscriptionUpdateParams.Item.builder()
|
items.add(SubscriptionUpdateParams.Item.builder()
|
||||||
.setId(item.getId())
|
.setId(item.getId())
|
||||||
.setDeleted(true)
|
.setDeleted(true)
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
items.add(SubscriptionUpdateParams.Item.builder()
|
items.add(SubscriptionUpdateParams.Item.builder()
|
||||||
.setPrice(priceId)
|
.setPrice(priceId)
|
||||||
.build());
|
.build());
|
||||||
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
|
SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
|
||||||
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
.putMetadata(METADATA_KEY_LEVEL, Long.toString(level))
|
||||||
|
|
||||||
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
|
// since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and
|
||||||
// not prorated
|
// not prorated
|
||||||
.setProrationBehavior(ProrationBehavior.NONE)
|
.setProrationBehavior(ProrationBehavior.NONE)
|
||||||
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
.setBillingCycleAnchor(BillingCycleAnchor.NOW)
|
||||||
.setOffSession(true)
|
.setOffSession(true)
|
||||||
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
.setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)
|
||||||
.addAllItem(items)
|
.addAllItem(items)
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate(
|
return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate(
|
||||||
subscription.getCustomer(), idempotencyKey)));
|
subscription.getCustomer(), idempotencyKey)));
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
throw new CompletionException(e);
|
throw new CompletionException(e);
|
||||||
}
|
}
|
||||||
}, executor);
|
}, executor)
|
||||||
|
.thenApply(subscription1 -> new SubscriptionId(subscription1.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Subscription> getSubscription(String subscriptionId) {
|
public CompletableFuture<Object> getSubscription(String subscriptionId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
||||||
.addExpand("latest_invoice")
|
.addExpand("latest_invoice")
|
||||||
|
@ -306,6 +324,21 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, 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) {
|
public CompletableFuture<Collection<Subscription>> listNonCanceledSubscriptions(Customer customer) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionListParams params = SubscriptionListParams.builder()
|
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()));
|
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);
|
return getProductForSubscription(subscription).thenApply(this::getLevelForProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -404,7 +442,7 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
.build();
|
.build();
|
||||||
try {
|
try {
|
||||||
ArrayList<Invoice> invoices = Lists.newArrayList(Invoice.list(params, commonOptions())
|
ArrayList<Invoice> invoices = Lists.newArrayList(Invoice.list(params, commonOptions())
|
||||||
.autoPagingIterable(null, commonOptions()));
|
.autoPagingIterable(null, commonOptions()));
|
||||||
invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed());
|
invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed());
|
||||||
return invoices;
|
return invoices;
|
||||||
} catch (StripeException e) {
|
} catch (StripeException e) {
|
||||||
|
@ -413,6 +451,54 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, 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) {
|
public CompletableFuture<Invoice> getLatestInvoiceForSubscription(String subscriptionId) {
|
||||||
return CompletableFuture.supplyAsync(() -> {
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
|
||||||
|
@ -426,24 +512,48 @@ public class StripeManager implements SubscriptionProcessorManager {
|
||||||
}, executor);
|
}, 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) {
|
public CompletableFuture<Collection<InvoiceLineItem>> getInvoiceLineItemsForInvoice(Invoice invoice) {
|
||||||
return CompletableFuture.supplyAsync(
|
return CompletableFuture.supplyAsync(
|
||||||
() -> Lists.newArrayList(invoice.getLines().autoPagingIterable(null, commonOptions())), executor);
|
() -> 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
|
* 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
|
* 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;
|
private final byte id;
|
||||||
|
|
||||||
SubscriptionProcessor(int id) {
|
SubscriptionProcessor(int id) {
|
||||||
if (id > 256) {
|
if (id > 255) {
|
||||||
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,16 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.subscriptions;
|
package org.whispersystems.textsecuregcm.subscriptions;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public interface SubscriptionProcessorManager {
|
public interface SubscriptionProcessorManager {
|
||||||
|
|
||||||
SubscriptionProcessor getProcessor();
|
SubscriptionProcessor getProcessor();
|
||||||
|
|
||||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||||
|
@ -26,6 +29,32 @@ public interface SubscriptionProcessorManager {
|
||||||
|
|
||||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
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,
|
record PaymentDetails(String id,
|
||||||
Map<String, String> customMetadata,
|
Map<String, String> customMetadata,
|
||||||
PaymentStatus status,
|
PaymentStatus status,
|
||||||
|
@ -39,4 +68,96 @@ public interface SubscriptionProcessorManager {
|
||||||
FAILED,
|
FAILED,
|
||||||
UNKNOWN,
|
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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
import com.stripe.exception.ApiException;
|
import com.stripe.exception.ApiException;
|
||||||
import com.stripe.model.PaymentIntent;
|
import com.stripe.model.PaymentIntent;
|
||||||
import com.stripe.model.Subscription;
|
|
||||||
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
|
||||||
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
|
||||||
import io.dropwizard.testing.junit5.ResourceExtension;
|
import io.dropwizard.testing.junit5.ResourceExtension;
|
||||||
|
@ -65,6 +64,7 @@ import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||||
|
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
||||||
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
|
||||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||||
|
@ -127,7 +127,6 @@ class SubscriptionControllerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() {
|
void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() {
|
||||||
when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(250));
|
|
||||||
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
|
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
|
||||||
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
|
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
|
||||||
.request()
|
.request()
|
||||||
|
@ -150,24 +149,21 @@ class SubscriptionControllerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCreateBoostPaymentIntentLevelAmountMismatch() {
|
void testCreateBoostPaymentIntentLevelAmountMismatch() {
|
||||||
when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(20));
|
|
||||||
|
|
||||||
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
|
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create")
|
||||||
.request()
|
.request()
|
||||||
.post(Entity.json("""
|
.post(Entity.json("""
|
||||||
{
|
{
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"amount": 25,
|
"amount": 25,
|
||||||
"level": 100
|
"level": 100
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
));
|
));
|
||||||
assertThat(response.getStatus()).isEqualTo(409);
|
assertThat(response.getStatus()).isEqualTo(409);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCreateBoostPaymentIntent() {
|
void testCreateBoostPaymentIntent() {
|
||||||
when(STRIPE_MANAGER.convertConfiguredAmountToStripeAmount(any(), any())).thenReturn(new BigDecimal(300));
|
|
||||||
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong()))
|
when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong()))
|
||||||
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
|
.thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));
|
||||||
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
|
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
|
||||||
|
@ -233,7 +229,7 @@ class SubscriptionControllerTest {
|
||||||
@Test
|
@Test
|
||||||
void success() {
|
void success() {
|
||||||
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
|
when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))
|
||||||
.thenReturn(CompletableFuture.completedFuture(mock(Subscription.class)));
|
.thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class)));
|
||||||
|
|
||||||
final String level = String.valueOf(levelId);
|
final String level = String.valueOf(levelId);
|
||||||
final String idempotencyKey = UUID.randomUUID().toString();
|
final String idempotencyKey = UUID.randomUUID().toString();
|
||||||
|
@ -669,37 +665,55 @@ class SubscriptionControllerTest {
|
||||||
prices:
|
prices:
|
||||||
usd:
|
usd:
|
||||||
amount: '5'
|
amount: '5'
|
||||||
id: R1
|
processorIds:
|
||||||
|
STRIPE: R1
|
||||||
|
BRAINTREE: M1
|
||||||
jpy:
|
jpy:
|
||||||
amount: '500'
|
amount: '500'
|
||||||
id: Q1
|
processorIds:
|
||||||
|
STRIPE: Q1
|
||||||
|
BRAINTREE: N1
|
||||||
bif:
|
bif:
|
||||||
amount: '5000'
|
amount: '5000'
|
||||||
id: S1
|
processorIds:
|
||||||
|
STRIPE: S1
|
||||||
|
BRAINTREE: O1
|
||||||
15:
|
15:
|
||||||
badge: B2
|
badge: B2
|
||||||
prices:
|
prices:
|
||||||
usd:
|
usd:
|
||||||
amount: '15'
|
amount: '15'
|
||||||
id: R2
|
processorIds:
|
||||||
|
STRIPE: R2
|
||||||
|
BRAINTREE: M2
|
||||||
jpy:
|
jpy:
|
||||||
amount: '1500'
|
amount: '1500'
|
||||||
id: Q2
|
processorIds:
|
||||||
|
STRIPE: Q2
|
||||||
|
BRAINTREE: N2
|
||||||
bif:
|
bif:
|
||||||
amount: '15000'
|
amount: '15000'
|
||||||
id: S2
|
processorIds:
|
||||||
|
STRIPE: S2
|
||||||
|
BRAINTREE: O2
|
||||||
35:
|
35:
|
||||||
badge: B3
|
badge: B3
|
||||||
prices:
|
prices:
|
||||||
usd:
|
usd:
|
||||||
amount: '35'
|
amount: '35'
|
||||||
id: R3
|
processorIds:
|
||||||
|
STRIPE: R3
|
||||||
|
BRAINTREE: M3
|
||||||
jpy:
|
jpy:
|
||||||
amount: '3500'
|
amount: '3500'
|
||||||
id: Q3
|
processorIds:
|
||||||
|
STRIPE: Q3
|
||||||
|
BRAINTREE: N3
|
||||||
bif:
|
bif:
|
||||||
amount: '35000'
|
amount: '35000'
|
||||||
id: S3
|
processorIds:
|
||||||
|
STRIPE: S3
|
||||||
|
BRAINTREE: O3
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String ONETIME_CONFIG_YAML = """
|
private static final String ONETIME_CONFIG_YAML = """
|
||||||
|
|
|
@ -40,6 +40,7 @@ import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||||
class SubscriptionManagerTest {
|
class SubscriptionManagerTest {
|
||||||
|
|
||||||
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
|
private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;
|
||||||
|
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);
|
||||||
private static final String SUBSCRIPTIONS_TABLE_NAME = "subscriptions";
|
private static final String SUBSCRIPTIONS_TABLE_NAME = "subscriptions";
|
||||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
@ -95,13 +96,13 @@ class SubscriptionManagerTest {
|
||||||
Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||||
|
|
||||||
CompletableFuture<GetResult> getFuture = subscriptionManager.get(user, password1);
|
CompletableFuture<GetResult> getFuture = subscriptionManager.get(user, password1);
|
||||||
assertThat(getFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
||||||
assertThat(getResult.record).isNull();
|
assertThat(getResult.record).isNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
getFuture = subscriptionManager.get(user, password2);
|
getFuture = subscriptionManager.get(user, password2);
|
||||||
assertThat(getFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
||||||
assertThat(getResult.record).isNull();
|
assertThat(getResult.record).isNull();
|
||||||
});
|
});
|
||||||
|
@ -109,35 +110,35 @@ class SubscriptionManagerTest {
|
||||||
CompletableFuture<SubscriptionManager.Record> createFuture =
|
CompletableFuture<SubscriptionManager.Record> createFuture =
|
||||||
subscriptionManager.create(user, password1, created1);
|
subscriptionManager.create(user, password1, created1);
|
||||||
Consumer<Record> recordRequirements = checkFreshlyCreatedRecord(user, password1, created1);
|
Consumer<Record> recordRequirements = checkFreshlyCreatedRecord(user, password1, created1);
|
||||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements);
|
assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements);
|
||||||
|
|
||||||
// password check fails so this should return null
|
// password check fails so this should return null
|
||||||
createFuture = subscriptionManager.create(user, password2, created2);
|
createFuture = subscriptionManager.create(user, password2, created2);
|
||||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).isNull();
|
assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).isNull();
|
||||||
|
|
||||||
// password check matches, but the record already exists so nothing should get updated
|
// password check matches, but the record already exists so nothing should get updated
|
||||||
createFuture = subscriptionManager.create(user, password1, created2);
|
createFuture = subscriptionManager.create(user, password1, created2);
|
||||||
assertThat(createFuture).succeedsWithin(Duration.ofSeconds(3)).satisfies(recordRequirements);
|
assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGet() {
|
void testGet() {
|
||||||
byte[] wrongUser = getRandomBytes(16);
|
byte[] wrongUser = getRandomBytes(16);
|
||||||
byte[] wrongPassword = getRandomBytes(16);
|
byte[] wrongPassword = getRandomBytes(16);
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult.type).isEqualTo(FOUND);
|
assertThat(getResult.type).isEqualTo(FOUND);
|
||||||
assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created));
|
assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created));
|
||||||
});
|
});
|
||||||
|
|
||||||
assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(Duration.ofSeconds(3))
|
assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(DEFAULT_TIMEOUT)
|
||||||
.satisfies(getResult -> {
|
.satisfies(getResult -> {
|
||||||
assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH);
|
assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH);
|
||||||
assertThat(getResult.record).isNull();
|
assertThat(getResult.record).isNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(Duration.ofSeconds(3))
|
assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(DEFAULT_TIMEOUT)
|
||||||
.satisfies(getResult -> {
|
.satisfies(getResult -> {
|
||||||
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
assertThat(getResult.type).isEqualTo(NOT_STORED);
|
||||||
assertThat(getResult.record).isNull();
|
assertThat(getResult.record).isNull();
|
||||||
|
@ -147,15 +148,15 @@ class SubscriptionManagerTest {
|
||||||
@Test
|
@Test
|
||||||
void testSetCustomerIdAndProcessor() throws Exception {
|
void testSetCustomerIdAndProcessor() throws Exception {
|
||||||
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
||||||
assertThat(getUser).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
final Record userRecord = getUser.get().record;
|
final Record userRecord = getUser.get().record;
|
||||||
|
|
||||||
assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord,
|
assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord,
|
||||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
||||||
subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3))
|
subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT)
|
||||||
.hasFieldOrPropertyWithValue("processorCustomer",
|
.hasFieldOrPropertyWithValue("processorCustomer",
|
||||||
Optional.of(new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE)));
|
Optional.of(new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE)));
|
||||||
|
|
||||||
|
@ -166,7 +167,7 @@ class SubscriptionManagerTest {
|
||||||
assertThat(
|
assertThat(
|
||||||
subscriptionManager.setProcessorAndCustomerId(userRecord,
|
subscriptionManager.setProcessorAndCustomerId(userRecord,
|
||||||
new ProcessorCustomer(customer + "1", SubscriptionProcessor.STRIPE),
|
new ProcessorCustomer(customer + "1", SubscriptionProcessor.STRIPE),
|
||||||
subscriptionUpdated)).failsWithin(Duration.ofSeconds(3))
|
subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT)
|
||||||
.withThrowableOfType(ExecutionException.class)
|
.withThrowableOfType(ExecutionException.class)
|
||||||
.withCauseInstanceOf(ClientErrorException.class)
|
.withCauseInstanceOf(ClientErrorException.class)
|
||||||
.extracting(Throwable::getCause)
|
.extracting(Throwable::getCause)
|
||||||
|
@ -176,7 +177,7 @@ class SubscriptionManagerTest {
|
||||||
assertThat(
|
assertThat(
|
||||||
subscriptionManager.setProcessorAndCustomerId(userRecord,
|
subscriptionManager.setProcessorAndCustomerId(userRecord,
|
||||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
||||||
subscriptionUpdated)).failsWithin(Duration.ofSeconds(3))
|
subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT)
|
||||||
.withThrowableOfType(ExecutionException.class)
|
.withThrowableOfType(ExecutionException.class)
|
||||||
.withCauseInstanceOf(ClientErrorException.class)
|
.withCauseInstanceOf(ClientErrorException.class)
|
||||||
.extracting(Throwable::getCause)
|
.extracting(Throwable::getCause)
|
||||||
|
@ -184,34 +185,34 @@ class SubscriptionManagerTest {
|
||||||
|
|
||||||
assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer(
|
assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer(
|
||||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE)))
|
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE)))
|
||||||
.succeedsWithin(Duration.ofSeconds(3)).
|
.succeedsWithin(DEFAULT_TIMEOUT).
|
||||||
isEqualTo(user);
|
isEqualTo(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testLookupByCustomerId() throws Exception {
|
void testLookupByCustomerId() throws Exception {
|
||||||
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
|
|
||||||
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
final CompletableFuture<GetResult> getUser = subscriptionManager.get(user, password);
|
||||||
assertThat(getUser).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
final Record userRecord = getUser.get().record;
|
final Record userRecord = getUser.get().record;
|
||||||
|
|
||||||
assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord,
|
assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord,
|
||||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE),
|
||||||
subscriptionUpdated)).succeedsWithin(Duration.ofSeconds(3));
|
subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer(
|
assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer(
|
||||||
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))).
|
new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))).
|
||||||
succeedsWithin(Duration.ofSeconds(3)).
|
succeedsWithin(DEFAULT_TIMEOUT).
|
||||||
isEqualTo(user);
|
isEqualTo(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCanceledAt() {
|
void testCanceledAt() {
|
||||||
Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42);
|
Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42);
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult).isNotNull();
|
assertThat(getResult).isNotNull();
|
||||||
assertThat(getResult.type).isEqualTo(FOUND);
|
assertThat(getResult.type).isEqualTo(FOUND);
|
||||||
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
||||||
|
@ -227,10 +228,10 @@ class SubscriptionManagerTest {
|
||||||
String subscriptionId = Base64.getEncoder().encodeToString(getRandomBytes(16));
|
String subscriptionId = Base64.getEncoder().encodeToString(getRandomBytes(16));
|
||||||
Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);
|
||||||
long level = 42;
|
long level = 42;
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)).
|
assertThat(subscriptionManager.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)).
|
||||||
succeedsWithin(Duration.ofSeconds(3));
|
succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult).isNotNull();
|
assertThat(getResult).isNotNull();
|
||||||
assertThat(getResult.type).isEqualTo(FOUND);
|
assertThat(getResult.type).isEqualTo(FOUND);
|
||||||
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
||||||
|
@ -247,15 +248,20 @@ class SubscriptionManagerTest {
|
||||||
void testSubscriptionLevelChanged() {
|
void testSubscriptionLevelChanged() {
|
||||||
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
|
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
|
||||||
long level = 1776;
|
long level = 1776;
|
||||||
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(Duration.ofSeconds(3));
|
String updatedSubscriptionId = "new";
|
||||||
assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level)).succeedsWithin(Duration.ofSeconds(3));
|
assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);
|
||||||
assertThat(subscriptionManager.get(user, password)).succeedsWithin(Duration.ofSeconds(3)).satisfies(getResult -> {
|
assertThat(subscriptionManager.subscriptionCreated(user, "original", created, level - 1)).succeedsWithin(
|
||||||
|
DEFAULT_TIMEOUT);
|
||||||
|
assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level, updatedSubscriptionId)).succeedsWithin(
|
||||||
|
DEFAULT_TIMEOUT);
|
||||||
|
assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {
|
||||||
assertThat(getResult).isNotNull();
|
assertThat(getResult).isNotNull();
|
||||||
assertThat(getResult.type).isEqualTo(FOUND);
|
assertThat(getResult.type).isEqualTo(FOUND);
|
||||||
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
assertThat(getResult.record).isNotNull().satisfies(record -> {
|
||||||
assertThat(record.accessedAt).isEqualTo(at);
|
assertThat(record.accessedAt).isEqualTo(at);
|
||||||
assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);
|
assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);
|
||||||
assertThat(record.subscriptionLevel).isEqualTo(level);
|
assertThat(record.subscriptionLevel).isEqualTo(level);
|
||||||
|
assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue