Add support for one-time PayPal donations

This commit is contained in:
Chris Eager
2022-11-23 14:15:38 -06:00
committed by Chris Eager
parent d40d2389a9
commit 2ecbb18fe5
23 changed files with 35824 additions and 109 deletions

View File

@@ -19,8 +19,8 @@ import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.stripe.exception.ApiException;
import com.stripe.model.Subscription;
import com.stripe.model.PaymentIntent;
import com.stripe.model.Subscription;
import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
@@ -61,7 +61,7 @@ import org.whispersystems.textsecuregcm.entities.BadgeSvg;
import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
@@ -87,18 +87,21 @@ class SubscriptionControllerTest {
private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);
static {
// this behavior is required by the SubscriptionController constructor
when(STRIPE_MANAGER.getSupportedCurrencies())
.thenCallRealMethod();
when(STRIPE_MANAGER.supportsPaymentMethod(PaymentMethod.CARD))
.thenReturn(Set.of("usd", "jpy", "bif"));
when(STRIPE_MANAGER.supportsPaymentMethod(any()))
.thenCallRealMethod();
}
private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class);
private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);
private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);
private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);
private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class);
private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(
CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, ZK_OPS,
CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS,
ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR);
private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()
.addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)
@@ -157,8 +160,9 @@ class SubscriptionControllerTest {
@Test
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));
when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true);
String clientSecret = "some_client_secret";
when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);
@@ -168,7 +172,7 @@ class SubscriptionControllerTest {
.post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}"));
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void createBoostReceiptInvalid() {
final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials")

View File

@@ -19,6 +19,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
@@ -31,9 +32,9 @@ class IssuedReceiptsManagerTest {
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(ISSUED_RECEIPTS_TABLE_NAME)
.hashKey(IssuedReceiptsManager.KEY_STRIPE_ID)
.hashKey(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(IssuedReceiptsManager.KEY_STRIPE_ID)
.attributeName(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)
.attributeType(ScalarAttributeType.S)
.build())
.build();
@@ -59,18 +60,21 @@ class IssuedReceiptsManagerTest {
byte[] request1 = new byte[20];
SECURE_RANDOM.nextBytes(request1);
when(receiptCredentialRequest.serialize()).thenReturn(request1);
CompletableFuture<Void> future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
CompletableFuture<Void> future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE,
receiptCredentialRequest, now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
// same request should succeed
future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
// same item with new request should fail
byte[] request2 = new byte[20];
SECURE_RANDOM.nextBytes(request2);
when(receiptCredentialRequest.serialize()).thenReturn(request2);
future = issuedReceiptsManager.recordIssuance("item-1", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).failsWithin(Duration.ofSeconds(3)).
withThrowableOfType(Throwable.class).
havingCause().
@@ -80,7 +84,8 @@ class IssuedReceiptsManagerTest {
"status 409"));
// different item with new request should be okay though
future = issuedReceiptsManager.recordIssuance("item-2", receiptCredentialRequest, now);
future = issuedReceiptsManager.recordIssuance("item-2", SubscriptionProcessor.STRIPE, receiptCredentialRequest,
now);
assertThat(future).succeedsWithin(Duration.ofSeconds(3));
}
}

View File

@@ -0,0 +1,174 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.subscriptions;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;
import java.math.BigDecimal;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import javax.ws.rs.ServiceUnavailableException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
class BraintreeGraphqlClientTest {
private static final String CURRENCY = "xts";
private static final String RETURN_URL = "https://example.com/return";
private static final String CANCEL_URL = "https://example.com/cancel";
private static final String LOCALE = "xx";
private FaultTolerantHttpClient httpClient;
private BraintreeGraphqlClient braintreeGraphqlClient;
@BeforeEach
void setUp() {
httpClient = mock(FaultTolerantHttpClient.class);
braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, "https://example.com", "public", "super-secret");
}
@Test
void createPayPalOneTimePayment() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
final String paymentId = "PAYID-AAA1AAAA1A11111AA111111A";
when(response.body())
.thenReturn(createPayPalOneTimePaymentResponse(paymentId));
when(response.statusCode())
.thenReturn(200);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment result = future.get();
assertEquals(paymentId, result.paymentId);
assertNotNull(result.approvalUrl);
});
}
@Test
void createPayPalOneTimePaymentHttpError() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
when(response.statusCode())
.thenReturn(500);
final HttpHeaders httpheaders = mock(HttpHeaders.class);
when(httpheaders.firstValue(any())).thenReturn(Optional.empty());
when(response.headers())
.thenReturn(httpheaders);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final ExecutionException e = assertThrows(ExecutionException.class, future::get);
assertTrue(e.getCause() instanceof ServiceUnavailableException);
});
}
@Test
void createPayPalOneTimePaymentGraphQlError() {
final HttpResponse<Object> response = mock(HttpResponse.class);
when(httpClient.sendAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(response));
when(response.body())
.thenReturn(createErrorResponse("createPayPalOneTimePayment", "12345"));
when(response.statusCode())
.thenReturn(200);
final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(
BigDecimal.ONE, CURRENCY,
RETURN_URL, CANCEL_URL, LOCALE);
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {
final ExecutionException e = assertThrows(ExecutionException.class, future::get);
assertTrue(e.getCause() instanceof ServiceUnavailableException);
});
}
private String createPayPalOneTimePaymentResponse(final String paymentId) {
final String cannedToken = "EC-1AA11111AA111111A";
return String.format("""
{
"data": {
"createPayPalOneTimePayment": {
"approvalUrl": "https://www.sandbox.paypal.com/checkoutnow?nolegacy=1&token=%2$s",
"paymentId": "%1$s"
}
},
"extensions": {
"requestId": "%3$s"
}
}
""", paymentId, cannedToken, UUID.randomUUID());
}
private String createErrorResponse(final String operationName, final String legacyCode) {
return String.format("""
{
"data": {
"%1$s": null
},
"errors": [ {
"message": "This is a test error message.",
"locations": [ {
"line": 2,
"column": 7
} ],
"path": [ "%1$s" ],
"extensions": {
"errorType": "user_error",
"errorClass": "VALIDATION",
"legacyCode": "%2$s",
"inputPath": [ "input", "testField" ]
}
}],
"extensions": {
"requestId": "%3$s"
}
}
""", operationName, legacyCode, UUID.randomUUID());
}
@Test
void tokenizePayPalOneTimePayment() {
}
@Test
void chargeOneTimePayment() {
}
}