mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 08:40:16 +01:00
Add AppleAppStoreManager
This commit is contained in:
@@ -15,6 +15,7 @@ import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.attachments.TusConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ApnConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory;
|
||||
@@ -94,6 +95,11 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private GooglePlayBillingConfiguration googlePlayBilling;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
private AppleAppStoreConfiguration appleAppStore;
|
||||
|
||||
@NotNull
|
||||
@Valid
|
||||
@JsonProperty
|
||||
@@ -368,6 +374,10 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return googlePlayBilling;
|
||||
}
|
||||
|
||||
public AppleAppStoreConfiguration getAppleAppStore() {
|
||||
return appleAppStore;
|
||||
}
|
||||
|
||||
public DynamoDbClientFactory getDynamoDbClientConfiguration() {
|
||||
return dynamoDbClient;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Subscriptions;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||
@@ -570,7 +571,11 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
|
||||
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
|
||||
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));
|
||||
ExecutorService appleAppStoreExecutor = environment.lifecycle()
|
||||
.virtualExecutorService(name(getClass(), "appleAppStore-%d"));
|
||||
|
||||
ScheduledExecutorService appleAppStoreRetryExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "appleAppStoreRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService cloudflareTurnRetryExecutor = environment.lifecycle()
|
||||
@@ -738,6 +743,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
config.getGooglePlayBilling().applicationName(),
|
||||
config.getGooglePlayBilling().productIdToLevel(),
|
||||
googlePlayBillingExecutor);
|
||||
AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager(
|
||||
config.getAppleAppStore().env(), config.getAppleAppStore().bundleId(), config.getAppleAppStore().appAppleId(),
|
||||
config.getAppleAppStore().issuerId(), config.getAppleAppStore().keyId(),
|
||||
config.getAppleAppStore().encodedKey().value(), config.getAppleAppStore().subscriptionGroupId(),
|
||||
config.getAppleAppStore().productIdToLevel(),
|
||||
config.getAppleAppStore().appleRootCerts(),
|
||||
config.getAppleAppStore().retry(), appleAppStoreExecutor, appleAppStoreRetryExecutor);
|
||||
|
||||
environment.lifecycle().manage(apnSender);
|
||||
environment.lifecycle().manage(pushNotificationScheduler);
|
||||
@@ -1127,7 +1139,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
);
|
||||
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
|
||||
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
|
||||
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
|
||||
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
|
||||
zkReceiptOperations, issuedReceiptsManager);
|
||||
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
|
||||
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.apple.itunes.storekit.model.Environment;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
|
||||
/**
|
||||
* @param env The ios environment to use, typically SANDBOX or PRODUCTION
|
||||
* @param bundleId The bundleId of the app
|
||||
* @param appAppleId The integer id of the app
|
||||
* @param issuerId The issuerId for the keys:
|
||||
* https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
|
||||
* @param keyId The keyId for encodedKey
|
||||
* @param encodedKey A private key with the "In-App Purchase" key type
|
||||
* @param subscriptionGroupId The subscription group for in-app purchases
|
||||
* @param productIdToLevel A map of productIds offered in the product catalog to their corresponding numeric
|
||||
* subscription levels
|
||||
* @param appleRootCerts Apple root certificates to verify signed API responses, encoded as base64 strings:
|
||||
* https://www.apple.com/certificateauthority/
|
||||
*/
|
||||
public record AppleAppStoreConfiguration(
|
||||
@NotNull Environment env,
|
||||
@NotBlank String bundleId,
|
||||
@NotNull Long appAppleId,
|
||||
@NotBlank String issuerId,
|
||||
@NotBlank String keyId,
|
||||
@NotNull SecretString encodedKey,
|
||||
@NotBlank String subscriptionGroupId,
|
||||
@NotNull Map<String, Long> productIdToLevel,
|
||||
@NotNull List<@NotBlank String> appleRootCerts,
|
||||
@NotNull @Valid RetryConfiguration retry) {
|
||||
|
||||
public AppleAppStoreConfiguration {
|
||||
if (retry == null) {
|
||||
retry = new RetryConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ public class OneTimeDonationController {
|
||||
case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);
|
||||
case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);
|
||||
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations");
|
||||
case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations");
|
||||
};
|
||||
|
||||
return paymentDetailsFut.thenCompose(paymentDetails -> {
|
||||
|
||||
@@ -262,8 +262,8 @@ public class SubscriptionController {
|
||||
// Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process
|
||||
// other types (like CARD) in the future.
|
||||
case CARD, SEPA_DEBIT, IDEAL -> stripeManager;
|
||||
case GOOGLE_PLAY_BILLING ->
|
||||
throw new BadRequestException("cannot create payment methods with payment type GOOGLE_PLAY_BILLING");
|
||||
case GOOGLE_PLAY_BILLING, APPLE_APP_STORE ->
|
||||
throw new BadRequestException("cannot create payment methods with payment type " + paymentMethodType);
|
||||
case PAYPAL -> throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal");
|
||||
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
|
||||
};
|
||||
@@ -316,7 +316,7 @@ public class SubscriptionController {
|
||||
return switch (processor) {
|
||||
case STRIPE -> stripeManager;
|
||||
case BRAINTREE -> braintreeManager;
|
||||
case GOOGLE_PLAY_BILLING -> throw new BadRequestException("Operation cannot be performed with the GOOGLE_PLAY_BILLING payment provider");
|
||||
case GOOGLE_PLAY_BILLING, APPLE_APP_STORE -> throw new BadRequestException("Operation cannot be performed with the " + processor + " payment provider");
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ public class SubscriptionException extends Exception {
|
||||
public InvalidArguments(final String message, final Exception cause) {
|
||||
super(cause, message);
|
||||
}
|
||||
|
||||
public InvalidArguments(final String message) {
|
||||
this(message, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidLevel extends InvalidArguments {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.apple.itunes.storekit.client.APIException;
|
||||
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
|
||||
import com.apple.itunes.storekit.model.AutoRenewStatus;
|
||||
import com.apple.itunes.storekit.model.Environment;
|
||||
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
|
||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||
import com.apple.itunes.storekit.model.LastTransactionsItem;
|
||||
import com.apple.itunes.storekit.model.Status;
|
||||
import com.apple.itunes.storekit.model.StatusResponse;
|
||||
import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;
|
||||
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
||||
import com.apple.itunes.storekit.verification.VerificationException;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.github.resilience4j.retry.Retry;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
|
||||
/**
|
||||
* Manages subscriptions made with the Apple App Store
|
||||
* <p>
|
||||
* Clients create a subscription using storekit directly, and then notify us about their subscription with their
|
||||
* subscription's <a
|
||||
* href="https://developer.apple.com/documentation/appstoreserverapi/originaltransactionid">originalTransactionId</a>.
|
||||
*/
|
||||
public class AppleAppStoreManager implements SubscriptionPaymentProcessor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AppleAppStoreManager.class);
|
||||
|
||||
private final AppStoreServerAPIClient apiClient;
|
||||
private final SignedDataVerifier signedDataVerifier;
|
||||
private final ExecutorService executor;
|
||||
private final ScheduledExecutorService retryExecutor;
|
||||
private final Map<String, Long> productIdToLevel;
|
||||
|
||||
private static final Status[] EMPTY_STATUSES = new Status[0];
|
||||
|
||||
private static final String GET_SUBSCRIPTION_ERROR_COUNTER_NAME =
|
||||
MetricsUtil.name(AppleAppStoreManager.class, "getSubscriptionsError");
|
||||
|
||||
private final String subscriptionGroupId;
|
||||
private final Retry retry;
|
||||
|
||||
|
||||
public AppleAppStoreManager(
|
||||
final Environment env,
|
||||
final String bundleId,
|
||||
final long appAppleId,
|
||||
final String issuerId,
|
||||
final String keyId,
|
||||
final String encodedKey,
|
||||
final String subscriptionGroupId,
|
||||
final Map<String, Long> productIdToLevel,
|
||||
final List<String> base64AppleRootCerts,
|
||||
final RetryConfiguration retryConfiguration,
|
||||
final ExecutorService executor,
|
||||
final ScheduledExecutorService retryExecutor) {
|
||||
this(new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, env),
|
||||
new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, env, true),
|
||||
subscriptionGroupId, productIdToLevel, retryConfiguration, executor, retryExecutor);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
AppleAppStoreManager(
|
||||
final AppStoreServerAPIClient apiClient,
|
||||
final SignedDataVerifier signedDataVerifier,
|
||||
final String subscriptionGroupId,
|
||||
final Map<String, Long> productIdToLevel,
|
||||
final RetryConfiguration retryConfiguration,
|
||||
final ExecutorService executor,
|
||||
final ScheduledExecutorService retryExecutor) {
|
||||
this.apiClient = apiClient;
|
||||
this.signedDataVerifier = signedDataVerifier;
|
||||
this.subscriptionGroupId = subscriptionGroupId;
|
||||
this.productIdToLevel = productIdToLevel;
|
||||
this.retry = Retry.of("appstore-retry", retryConfiguration
|
||||
.toRetryConfigBuilder()
|
||||
.retryOnException(AppleAppStoreManager::shouldRetry).build());
|
||||
this.executor = Objects.requireNonNull(executor);
|
||||
this.retryExecutor = Objects.requireNonNull(retryExecutor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentProvider getProvider() {
|
||||
return PaymentProvider.APPLE_APP_STORE;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the subscription with the provided originalTransactionId is valid.
|
||||
*
|
||||
* @param originalTransactionId The originalTransactionId associated with the subscription
|
||||
* @return A stage that completes successfully when the transaction has been validated, or fails if the token does not
|
||||
* represent an active subscription.
|
||||
*/
|
||||
public CompletableFuture<Long> validateTransaction(final String originalTransactionId) {
|
||||
return lookup(originalTransactionId).thenApplyAsync(tx -> {
|
||||
if (!isSubscriptionActive(tx)) {
|
||||
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
|
||||
}
|
||||
return getLevel(tx);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cancel the subscription
|
||||
* <p>
|
||||
* The App Store does not support backend cancellation, so this does not actually cancel, but it does verify that the
|
||||
* user has no active subscriptions. End-users must cancel their subscription directly through storekit before calling
|
||||
* this method.
|
||||
*
|
||||
* @param originalTransactionId The originalTransactionId associated with the subscription
|
||||
* @return A stage that completes when the subscription has successfully been cancelled
|
||||
*/
|
||||
@Override
|
||||
public CompletableFuture<Void> cancelAllActiveSubscriptions(String originalTransactionId) {
|
||||
return lookup(originalTransactionId).thenApplyAsync(tx -> {
|
||||
if (tx.signedTransaction.getStatus() != Status.EXPIRED &&
|
||||
tx.signedTransaction.getStatus() != Status.REVOKED &&
|
||||
tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) {
|
||||
throw ExceptionUtils.wrap(
|
||||
new SubscriptionException.InvalidArguments("must cancel subscription with storekit before deleting"));
|
||||
}
|
||||
// The subscription will not auto-renew, so we can stop tracking it
|
||||
return null;
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<SubscriptionInformation> getSubscriptionInformation(final String originalTransactionId) {
|
||||
return lookup(originalTransactionId).thenApplyAsync(tx -> {
|
||||
|
||||
final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) {
|
||||
case ACTIVE -> SubscriptionStatus.ACTIVE;
|
||||
case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;
|
||||
case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;
|
||||
case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;
|
||||
};
|
||||
|
||||
return new SubscriptionInformation(
|
||||
getSubscriptionPrice(tx),
|
||||
getLevel(tx),
|
||||
Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()),
|
||||
Instant.ofEpochMilli(tx.transaction.getExpiresDate()),
|
||||
isSubscriptionActive(tx),
|
||||
tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF,
|
||||
status,
|
||||
PaymentProvider.APPLE_APP_STORE,
|
||||
PaymentMethod.APPLE_APP_STORE,
|
||||
false,
|
||||
null);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ReceiptItem> getReceiptItem(String originalTransactionId) {
|
||||
return lookup(originalTransactionId).thenApplyAsync(tx -> {
|
||||
if (!isSubscriptionActive(tx)) {
|
||||
throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired());
|
||||
}
|
||||
|
||||
// A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is
|
||||
// guaranteed not to change for a specific renewal purchase.
|
||||
// See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid
|
||||
final String itemId = tx.transaction.getWebOrderLineItemId();
|
||||
final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate()));
|
||||
|
||||
return new ReceiptItem(itemId, paymentTime, getLevel(tx));
|
||||
|
||||
}, executor);
|
||||
}
|
||||
|
||||
private CompletableFuture<DecodedTransaction> lookup(final String originalTransactionId) {
|
||||
return getAllSubscriptions(originalTransactionId).thenApplyAsync(statuses -> {
|
||||
|
||||
final SubscriptionGroupIdentifierItem item = statuses.getData().stream()
|
||||
.filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()
|
||||
.orElseThrow(() -> ExceptionUtils.wrap(
|
||||
new SubscriptionException.InvalidArguments("transaction did not contain a backup subscription", null)));
|
||||
|
||||
final List<DecodedTransaction> txs = item.getLastTransactions().stream()
|
||||
.map(this::decode)
|
||||
.filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId()))
|
||||
.toList();
|
||||
|
||||
if (txs.isEmpty()) {
|
||||
throw ExceptionUtils.wrap(
|
||||
new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null));
|
||||
}
|
||||
|
||||
if (txs.size() > 1) {
|
||||
logger.warn("Multiple matching product transactions found for transactionId {}, only considering first",
|
||||
originalTransactionId);
|
||||
}
|
||||
|
||||
if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) {
|
||||
// Get All Subscriptions only requires that the transaction be some transaction associated with the
|
||||
// subscription. This is too flexible, since we'd like to key on the originalTransactionId in the
|
||||
// SubscriptionManager.
|
||||
throw ExceptionUtils.wrap(
|
||||
new SubscriptionException.InvalidArguments(
|
||||
"transactionId was not the transaction's originalTransactionId", null));
|
||||
}
|
||||
|
||||
return txs.getFirst();
|
||||
}, executor).toCompletableFuture();
|
||||
}
|
||||
|
||||
private CompletionStage<StatusResponse> getAllSubscriptions(final String originalTransactionId) {
|
||||
Supplier<CompletionStage<StatusResponse>> supplier = () -> CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES);
|
||||
} catch (final APIException e) {
|
||||
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment();
|
||||
throw ExceptionUtils.wrap(switch (e.getApiError()) {
|
||||
case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionException.NotFound();
|
||||
case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);
|
||||
case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage());
|
||||
default -> e;
|
||||
});
|
||||
} catch (final IOException e) {
|
||||
Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment();
|
||||
throw ExceptionUtils.wrap(e);
|
||||
}
|
||||
}, executor);
|
||||
return retry.executeCompletionStage(retryExecutor, supplier);
|
||||
}
|
||||
|
||||
private static boolean shouldRetry(Throwable e) {
|
||||
return ExceptionUtils.unwrap(e) instanceof APIException apiException && switch (apiException.getApiError()) {
|
||||
case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private record DecodedTransaction(
|
||||
LastTransactionsItem signedTransaction,
|
||||
JWSTransactionDecodedPayload transaction,
|
||||
JWSRenewalInfoDecodedPayload renewalInfo) {}
|
||||
|
||||
/**
|
||||
* Verify signature and decode transaction payloads
|
||||
*/
|
||||
private DecodedTransaction decode(final LastTransactionsItem tx) {
|
||||
try {
|
||||
return new DecodedTransaction(
|
||||
tx,
|
||||
signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()),
|
||||
signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo()));
|
||||
} catch (VerificationException e) {
|
||||
throw ExceptionUtils.wrap(new IOException("Failed to verify payload from App Store Server", e));
|
||||
}
|
||||
}
|
||||
|
||||
private SubscriptionPrice getSubscriptionPrice(final DecodedTransaction tx) {
|
||||
final BigDecimal amount = new BigDecimal(tx.transaction.getPrice()).scaleByPowerOfTen(-3);
|
||||
return new SubscriptionPrice(
|
||||
tx.transaction.getCurrency().toUpperCase(Locale.ROOT),
|
||||
SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount));
|
||||
}
|
||||
|
||||
private long getLevel(final DecodedTransaction tx) {
|
||||
final Long level = productIdToLevel.get(tx.transaction.getProductId());
|
||||
if (level == null) {
|
||||
throw ExceptionUtils.wrap(
|
||||
new SubscriptionException.InvalidArguments(
|
||||
"Transaction for unknown productId " + tx.transaction.getProductId()));
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the subscription's entitlement can currently be granted
|
||||
*/
|
||||
private boolean isSubscriptionActive(final DecodedTransaction tx) {
|
||||
return tx.signedTransaction.getStatus() == Status.ACTIVE
|
||||
|| tx.signedTransaction.getStatus() == Status.BILLING_GRACE_PERIOD;
|
||||
}
|
||||
|
||||
private static Set<InputStream> decodeRootCerts(final List<String> rootCerts) {
|
||||
return rootCerts.stream()
|
||||
.map(Base64.getDecoder()::decode)
|
||||
.map(ByteArrayInputStream::new)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -412,7 +412,6 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState
|
||||
@VisibleForTesting
|
||||
enum SubscriptionState {
|
||||
|
||||
@@ -23,5 +23,6 @@ public enum PaymentMethod {
|
||||
* An iDEAL account
|
||||
*/
|
||||
IDEAL,
|
||||
GOOGLE_PLAY_BILLING
|
||||
GOOGLE_PLAY_BILLING,
|
||||
APPLE_APP_STORE
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public enum PaymentProvider {
|
||||
STRIPE(1),
|
||||
BRAINTREE(2),
|
||||
GOOGLE_PLAY_BILLING(3),
|
||||
;
|
||||
APPLE_APP_STORE(4);
|
||||
|
||||
private static final Map<Integer, PaymentProvider> IDS_TO_PROCESSORS = new HashMap<>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user