mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-21 00:48:38 +01:00
Add GooglePlayBillingManager
This commit is contained in:
@@ -78,12 +78,14 @@ import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.mappers.SubscriptionProcessorExceptionMapper;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.PaymentTime;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.Subscriptions;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager.PayPalOneTimePaymentApprovalDetails;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;
|
||||
@@ -111,6 +113,8 @@ class SubscriptionControllerTest {
|
||||
when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));
|
||||
private static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->
|
||||
when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE));
|
||||
private static final GooglePlayBillingManager PLAY_MANAGER = MockUtils.buildMock(GooglePlayBillingManager.class,
|
||||
mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.GOOGLE_PLAY_BILLING));
|
||||
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
|
||||
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
|
||||
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
|
||||
@@ -119,7 +123,7 @@ class SubscriptionControllerTest {
|
||||
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
|
||||
private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);
|
||||
private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, SUBSCRIPTION_CONFIG,
|
||||
ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER), ZK_OPS,
|
||||
ONETIME_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER), ZK_OPS,
|
||||
ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR,
|
||||
BANK_MANDATE_TRANSLATOR);
|
||||
private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,
|
||||
@@ -885,7 +889,7 @@ class SubscriptionControllerTest {
|
||||
when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(
|
||||
CompletableFuture.completedFuture(new SubscriptionPaymentProcessor.ReceiptItem(
|
||||
"itemId",
|
||||
Instant.ofEpochSecond(10).plus(Duration.ofDays(1)),
|
||||
PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),
|
||||
level
|
||||
)));
|
||||
when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq("itemId"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any()))
|
||||
@@ -1111,7 +1115,8 @@ class SubscriptionControllerTest {
|
||||
private static final String SUBSCRIPTION_CONFIG_YAML = """
|
||||
badgeExpiration: P30D
|
||||
badgeGracePeriod: P15D
|
||||
backupExpiration: P13D
|
||||
backupExpiration: P3D
|
||||
backupGracePeriod: P10D
|
||||
backupFreeTierMediaDuration: P30D
|
||||
backupLevels:
|
||||
201:
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.assertj.core.api.Fail.fail;
|
||||
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.FOUND;
|
||||
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.NOT_STORED;
|
||||
import static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.PASSWORD_MISMATCH;
|
||||
@@ -27,8 +29,9 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;
|
||||
import org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult;
|
||||
import org.whispersystems.textsecuregcm.storage.Subscriptions.Record;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
|
||||
import org.whispersystems.textsecuregcm.util.TestRandomUtil;
|
||||
|
||||
class SubscriptionsTest {
|
||||
@@ -234,6 +237,58 @@ class SubscriptionsTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetIapPurchase() {
|
||||
Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);
|
||||
long level = 100;
|
||||
|
||||
ProcessorCustomer pc = new ProcessorCustomer("customerId", PaymentProvider.GOOGLE_PLAY_BILLING);
|
||||
Record record = subscriptions.create(user, password, created).join();
|
||||
|
||||
// Should be able to set a fresh subscription
|
||||
assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", level, at))
|
||||
.succeedsWithin(DEFAULT_TIMEOUT);
|
||||
|
||||
record = subscriptions.get(user, password).join().record;
|
||||
assertThat(record.subscriptionLevel).isEqualTo(level);
|
||||
assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);
|
||||
assertThat(record.subscriptionCreatedAt).isEqualTo(at);
|
||||
assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
|
||||
|
||||
// should be able to update the level
|
||||
Instant nextAt = at.plus(Duration.ofSeconds(10));
|
||||
long nextLevel = level + 1;
|
||||
assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt))
|
||||
.succeedsWithin(DEFAULT_TIMEOUT);
|
||||
|
||||
record = subscriptions.get(user, password).join().record;
|
||||
assertThat(record.subscriptionLevel).isEqualTo(nextLevel);
|
||||
assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);
|
||||
assertThat(record.subscriptionCreatedAt).isEqualTo(at);
|
||||
assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
|
||||
|
||||
nextAt = nextAt.plus(Duration.ofSeconds(10));
|
||||
nextLevel = level + 1;
|
||||
|
||||
pc = new ProcessorCustomer("newCustomerId", PaymentProvider.STRIPE);
|
||||
try {
|
||||
subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt).join();
|
||||
fail("should not be able to change the processor for an existing subscription record");
|
||||
} catch (IllegalArgumentException e) {
|
||||
}
|
||||
|
||||
// should be able to change the customerId of an existing record if the processor matches
|
||||
pc = new ProcessorCustomer("newCustomerId", PaymentProvider.GOOGLE_PLAY_BILLING);
|
||||
assertThat(subscriptions.setIapPurchase(record, pc, "subscriptionId", nextLevel, nextAt))
|
||||
.succeedsWithin(DEFAULT_TIMEOUT);
|
||||
|
||||
record = subscriptions.get(user, password).join().record;
|
||||
assertThat(record.subscriptionLevel).isEqualTo(nextLevel);
|
||||
assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);
|
||||
assertThat(record.subscriptionCreatedAt).isEqualTo(at);
|
||||
assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testProcessorAndCustomerId() {
|
||||
final ProcessorCustomer processorCustomer =
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.api.services.androidpublisher.AndroidPublisher;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;
|
||||
import com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionException;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;
|
||||
import org.whispersystems.textsecuregcm.util.MockUtils;
|
||||
import org.whispersystems.textsecuregcm.util.MutableClock;
|
||||
|
||||
class GooglePlayBillingManagerTest {
|
||||
|
||||
private static final String PRODUCT_ID = "productId";
|
||||
private static final String PACKAGE_NAME = "package.name";
|
||||
private static final String PURCHASE_TOKEN = "purchaseToken";
|
||||
private static final String ORDER_ID = "orderId";
|
||||
|
||||
// Returned in response to a purchases.subscriptionsv2.get
|
||||
private final AndroidPublisher.Purchases.Subscriptionsv2.Get subscriptionsv2Get =
|
||||
mock(AndroidPublisher.Purchases.Subscriptionsv2.Get.class);
|
||||
|
||||
// Returned in response to a purchases.subscriptions.acknowledge
|
||||
private final AndroidPublisher.Purchases.Subscriptions.Acknowledge acknowledge =
|
||||
mock(AndroidPublisher.Purchases.Subscriptions.Acknowledge.class);
|
||||
|
||||
// Returned in response to a purchases.subscriptionscancel.
|
||||
private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =
|
||||
mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class);
|
||||
|
||||
private final MutableClock clock = MockUtils.mutableClock(0L);
|
||||
|
||||
private ExecutorService executor;
|
||||
private GooglePlayBillingManager googlePlayBillingManager;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() throws IOException {
|
||||
reset(subscriptionsv2Get);
|
||||
clock.setTimeMillis(0L);
|
||||
|
||||
AndroidPublisher androidPublisher = mock(AndroidPublisher.class);
|
||||
AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.class);
|
||||
|
||||
AndroidPublisher.Purchases.Subscriptionsv2 subscriptionsv2 = mock(AndroidPublisher.Purchases.Subscriptionsv2.class);
|
||||
when(androidPublisher.purchases()).thenReturn(purchases);
|
||||
when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2);
|
||||
when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get);
|
||||
|
||||
AndroidPublisher.Purchases.Subscriptions subscriptions = mock(AndroidPublisher.Purchases.Subscriptions.class);
|
||||
when(purchases.subscriptions()).thenReturn(subscriptions);
|
||||
when(subscriptions.acknowledge(eq(PACKAGE_NAME), eq(PRODUCT_ID), eq(PURCHASE_TOKEN), any()))
|
||||
.thenReturn(acknowledge);
|
||||
when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))
|
||||
.thenReturn(cancel);
|
||||
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
googlePlayBillingManager = new GooglePlayBillingManager(
|
||||
androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L), executor);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void teardown() throws InterruptedException {
|
||||
executor.shutdownNow();
|
||||
executor.awaitTermination(1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatePurchase() throws IOException {
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
|
||||
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
|
||||
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
|
||||
.validateToken(PURCHASE_TOKEN).join();
|
||||
|
||||
assertThat(result.getLevel()).isEqualTo(201);
|
||||
assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
|
||||
verify(acknowledge, times(1)).execute();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"ACTIVE"})
|
||||
public void rejectInactivePurchase(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
|
||||
.setSubscriptionState(subscriptionState.apiString())
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
|
||||
CompletableFutureTestUtil.assertFailsWithCause(
|
||||
SubscriptionException.PaymentRequired.class,
|
||||
googlePlayBillingManager.validateToken(PURCHASE_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void avoidDoubleAcknowledge() throws IOException {
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
|
||||
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
|
||||
final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager
|
||||
.validateToken(PURCHASE_TOKEN).join();
|
||||
|
||||
assertThat(result.getLevel()).isEqualTo(201);
|
||||
assertThatNoException().isThrownBy(() -> result.acknowledgePurchase().join());
|
||||
verifyNoInteractions(acknowledge);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource
|
||||
public void cancel(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
|
||||
.setSubscriptionState(subscriptionState.apiString())
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
assertThatNoException().isThrownBy(() ->
|
||||
googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN).join());
|
||||
final int wanted = switch (subscriptionState) {
|
||||
case CANCELED, EXPIRED -> 0;
|
||||
default -> 1;
|
||||
};
|
||||
verify(cancel, times(wanted)).execute();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getReceiptUnacknowledged() throws IOException {
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())
|
||||
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
CompletableFutureTestUtil.assertFailsWithCause(
|
||||
IllegalStateException.class,
|
||||
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getReceiptExpiring() throws IOException {
|
||||
final Instant day9 = Instant.EPOCH.plus(Duration.ofDays(9));
|
||||
final Instant day10 = Instant.EPOCH.plus(Duration.ofDays(10));
|
||||
|
||||
when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()
|
||||
.setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())
|
||||
.setSubscriptionState(GooglePlayBillingManager.SubscriptionState.CANCELED.apiString())
|
||||
.setLatestOrderId(ORDER_ID)
|
||||
.setLineItems(List.of(new SubscriptionPurchaseLineItem()
|
||||
.setExpiryTime(day10.toString().toString())
|
||||
.setProductId(PRODUCT_ID))));
|
||||
|
||||
clock.setTimeInstant(day9);
|
||||
SubscriptionManager.Processor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
|
||||
assertThat(item.itemId()).isEqualTo(ORDER_ID);
|
||||
assertThat(item.level()).isEqualTo(201L);
|
||||
|
||||
// receipt expirations rounded to nearest next day
|
||||
assertThat(item.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))
|
||||
.isEqualTo(day10.plus(Duration.ofDays(1)));
|
||||
|
||||
// should still be able to get a receipt the next day
|
||||
clock.setTimeInstant(day10);
|
||||
item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN).join();
|
||||
assertThat(item.itemId()).isEqualTo(ORDER_ID);
|
||||
|
||||
// next second should be expired
|
||||
clock.setTimeInstant(day10.plus(Duration.ofSeconds(1)));
|
||||
|
||||
CompletableFutureTestUtil.assertFailsWithCause(
|
||||
SubscriptionException.PaymentRequired.class,
|
||||
googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user