Skip to content

Commit

Permalink
Add GooglePlayBillingManager
Browse files Browse the repository at this point in the history
  • Loading branch information
ravi-signal authored Aug 28, 2024
1 parent 9249cf2 commit 176a15d
Show file tree
Hide file tree
Showing 24 changed files with 999 additions and 39 deletions.
3 changes: 3 additions & 0 deletions service/config/sample-secrets-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ stripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request i

braintree.privateKey: unset

googlePlayBilling.credentialsJson: |
{ "json": true }
directoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users
directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users

Expand Down
7 changes: 7 additions & 0 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ braintree:
"credential": "configuration"
}
googlePlayBilling:
credentialsJson: secret://googlePlayBilling.credentialsJson
packageName: package.name
applicationName: test
productIdToLevel: {}

dynamoDbClient:
region: us-west-2 # AWS Region

Expand Down Expand Up @@ -364,6 +370,7 @@ subscription: # configuration for Stripe subscriptions
badgeExpiration: P30D
badgeGracePeriod: P15D
backupExpiration: P30D
backupGracePeriod: P15D
backupFreeTierMediaDuration: P30D
levels:
500:
Expand Down
7 changes: 7 additions & 0 deletions service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@
<firebase-admin.version>9.2.0</firebase-admin.version>
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
<sqlite4java.version>1.0.392</sqlite4java.version>
<google-androidpublisher.version>v3-rev20240820-2.0.0</google-androidpublisher.version>
</properties>

<dependencies>
<dependency>
<groupId>com.google.apis</groupId>
<artifactId>google-api-services-androidpublisher</artifactId>
<version>${google-androidpublisher.version}</version>
</dependency>

<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
Expand Down Expand Up @@ -88,6 +89,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private BraintreeConfiguration braintree;

@NotNull
@Valid
@JsonProperty
private GooglePlayBillingConfiguration googlePlayBilling;

@NotNull
@Valid
@JsonProperty
Expand Down Expand Up @@ -358,6 +364,10 @@ public BraintreeConfiguration getBraintree() {
return braintree;
}

public GooglePlayBillingConfiguration getGooglePlayBilling() {
return googlePlayBilling;
}

public DynamoDbClientFactory getDynamoDbClientConfiguration() {
return dynamoDbClient;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
import io.netty.resolver.ResolvedAddressTypes;
import io.netty.resolver.dns.DnsNameResolver;
import io.netty.resolver.dns.DnsNameResolverBuilder;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
Expand Down Expand Up @@ -241,6 +243,7 @@
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.util.BufferingInterceptor;
import org.whispersystems.textsecuregcm.util.ManagedAwsCrt;
Expand Down Expand Up @@ -578,6 +581,8 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
.build();
ExecutorService keyTransparencyCallbackExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));

ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
Expand Down Expand Up @@ -738,6 +743,12 @@ public void run(WhisperServerConfiguration config, Environment environment) thro
config.getBraintree().graphqlUrl(), currencyManager, config.getBraintree().pubSubPublisher().build(),
config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor,
subscriptionProcessorRetryExecutor);
GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager(
new ByteArrayInputStream(config.getGooglePlayBilling().credentialsJson().value().getBytes(StandardCharsets.UTF_8)),
config.getGooglePlayBilling().packageName(),
config.getGooglePlayBilling().applicationName(),
config.getGooglePlayBilling().productIdToLevel(),
googlePlayBillingExecutor);

environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(pushNotificationScheduler);
Expand Down Expand Up @@ -1128,7 +1139,8 @@ protected void configureServer(final ServerBuilder<?> serverBuilder) {
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
List.of(stripeManager, braintreeManager), zkReceiptOperations, issuedReceiptsManager);
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, profileBadgeConverter, resourceBundleLevelTranslator,
bankMandateTranslator));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.whispersystems.textsecuregcm.configuration;

import java.util.Map;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;

/**
* @param credentialsJson Service account credentials for Play Billing API
* @param packageName The app package name
* @param applicationName The app application name
* @param productIdToLevel A map of productIds offered in the play billing subscription catalog to their corresponding
* signal subscription level
*/
public record GooglePlayBillingConfiguration(
@NotNull SecretString credentialsJson,
@NotNull String packageName,
@NotBlank String applicationName,
@NotNull Map<String, Long> productIdToLevel) {}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class SubscriptionConfiguration {
private final Duration badgeExpiration;

private final Duration backupExpiration;
private final Duration backupGracePeriod;
private final Duration backupFreeTierMediaDuration;
private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;
private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;
Expand All @@ -38,6 +39,7 @@ public SubscriptionConfiguration(
@JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod,
@JsonProperty("badgeExpiration") @Valid Duration badgeExpiration,
@JsonProperty("backupExpiration") @Valid Duration backupExpiration,
@JsonProperty("backupGracePeriod") @Valid Duration backupGracePeriod,
@JsonProperty("backupFreeTierMediaDuration") @Valid Duration backupFreeTierMediaDuration,
@JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels,
@JsonProperty("backupLevels") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) {
Expand All @@ -46,6 +48,7 @@ public SubscriptionConfiguration(
this.backupFreeTierMediaDuration = backupFreeTierMediaDuration;
this.donationLevels = donationLevels;
this.backupExpiration = backupExpiration;
this.backupGracePeriod = backupGracePeriod;
this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;
}

Expand All @@ -62,6 +65,10 @@ public Duration getBackupExpiration() {
return backupExpiration;
}

public Duration getBackupGracePeriod() {
return backupGracePeriod;
}

public SubscriptionLevelConfiguration getSubscriptionLevel(long level) {
return Optional
.<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ public CompletableFuture<Response> createBoostReceiptCredentials(
final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {
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");
};

return paymentDetailsFut.thenCompose(paymentDetails -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -71,10 +70,10 @@
import org.whispersystems.textsecuregcm.entities.PurchasableBadge;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
import org.whispersystems.textsecuregcm.storage.PaymentTime;
import org.whispersystems.textsecuregcm.storage.SubscriberCredentials;
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.storage.Subscriptions;
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
import org.whispersystems.textsecuregcm.subscriptions.BankTransferType;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
Expand Down Expand Up @@ -253,11 +252,15 @@ public CompletableFuture<Response> createPaymentMethod(
SubscriberCredentials subscriberCredentials =
SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);

if (paymentMethodType == PaymentMethod.PAYPAL) {
throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal");
}

final SubscriptionPaymentProcessor subscriptionPaymentProcessor = getManagerForPaymentMethod(paymentMethodType);
final SubscriptionPaymentProcessor subscriptionPaymentProcessor = switch (paymentMethodType) {
// Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process
// other types (like CARD) in the future.
case CARD, SEPA_DEBIT, IDEAL -> stripeManager;
case GOOGLE_PLAY_BILLING ->
throw new BadRequestException("cannot create payment methods with payment type GOOGLE_PLAY_BILLING");
case PAYPAL -> throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal");
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
};

return subscriptionManager.addPaymentMethodToCustomer(
subscriberCredentials,
Expand Down Expand Up @@ -303,21 +306,11 @@ public CompletableFuture<Response> createPayPalPaymentMethod(
.build());
}

private SubscriptionPaymentProcessor getManagerForPaymentMethod(PaymentMethod paymentMethod) {
return switch (paymentMethod) {
// 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;
// PAYPAL payments can only be processed with braintree
case PAYPAL -> braintreeManager;
case UNKNOWN -> throw new BadRequestException("Invalid payment method");
};
}

private SubscriptionPaymentProcessor getManagerForProcessor(PaymentProvider processor) {
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");
};
}

Expand Down Expand Up @@ -586,15 +579,14 @@ private CompletableFuture<Response> setDefaultPaymentMethod(final SubscriptionPa
}

private Instant receiptExpirationWithGracePeriod(SubscriptionPaymentProcessor.ReceiptItem receiptItem) {
final Instant paidAt = receiptItem.paidAt();
final PaymentTime paymentTime = receiptItem.paymentTime();
return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {
case DONATION -> paidAt.plus(subscriptionConfiguration.getBadgeExpiration())
.plus(subscriptionConfiguration.getBadgeGracePeriod())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
case BACKUP -> paidAt.plus(subscriptionConfiguration.getBackupExpiration())
.truncatedTo(ChronoUnit.DAYS)
.plus(1, ChronoUnit.DAYS);
case DONATION -> paymentTime.receiptExpiration(
subscriptionConfiguration.getBadgeExpiration(),
subscriptionConfiguration.getBadgeGracePeriod());
case BACKUP -> paymentTime.receiptExpiration(
subscriptionConfiguration.getBackupExpiration(),
subscriptionConfiguration.getBackupGracePeriod());
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public Response toResponse(final SubscriptionException exception) {
case SubscriptionException.Forbidden e -> Response.Status.FORBIDDEN;
case SubscriptionException.InvalidArguments e -> Response.Status.BAD_REQUEST;
case SubscriptionException.ProcessorConflict e -> Response.Status.CONFLICT;
case SubscriptionException.PaymentRequired e -> Response.Status.PAYMENT_REQUIRED;
default -> Response.Status.INTERNAL_SERVER_ERROR;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;

import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import javax.annotation.Nullable;

/**
* The time at which a receipt was purchased. Some providers provide the end of the period, others the beginning. Either
* way, lets you calculate the expiration time for a product associated with the payment.
* <p>
* A subscription is typically for a fixed pay period. For example, a subscription may require renewal every 30 days.
* Until the end of a period, a subscriber may create a receipt credential that can be cashed in for access to the
* purchase. This receipt credential has an expiration that at least includes the end of the payment period but may
* additionally include allowance (gracePeriod) for missed payments. The product obtained with the receipt will be
* usable until this expiration time.
*/
public class PaymentTime {

@Nullable
Instant periodStart;
@Nullable
Instant periodEnd;

private PaymentTime(@Nullable Instant periodStart, @Nullable Instant periodEnd) {
if ((periodStart == null && periodEnd == null) || (periodStart != null && periodEnd != null)) {
throw new IllegalArgumentException("Only one of periodStart and periodEnd should be provided");
}
this.periodStart = periodStart;
this.periodEnd = periodEnd;
}

public static PaymentTime periodEnds(Instant periodEnd) {
return new PaymentTime(null, Objects.requireNonNull(periodEnd));
}

public static PaymentTime periodStart(Instant periodStart) {
return new PaymentTime(Objects.requireNonNull(periodStart), null);
}

/**
* Calculate the expiration time for this period
*
* @param periodLength How long after the time of payment should the receipt be valid
* @param gracePeriod An additional grace period after the end of the period to add to the expiration
* @return Instant when the receipt should expire
*/
public Instant receiptExpiration(final Duration periodLength, final Duration gracePeriod) {
final Instant expiration = periodStart != null
? periodStart.plus(periodLength).plus(gracePeriod)
: periodEnd.plus(gracePeriod);

return expiration.truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,23 @@ public InvalidLevel() {
}

public static class PaymentRequiresAction extends InvalidArguments {
public PaymentRequiresAction(String message) {
super(message, null);
}
public PaymentRequiresAction() {
super(null, null);
}
}

public static class PaymentRequired extends SubscriptionException {
public PaymentRequired() {
super(null, null);
}
public PaymentRequired(String message) {
super(null, message);
}
}

public static class ProcessorConflict extends SubscriptionException {
public ProcessorConflict(final String message) {
super(null, message);
Expand Down
Loading

0 comments on commit 176a15d

Please sign in to comment.