mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 00:30:21 +01:00
Update SubscriptionManager to store processor+customerId in a single attribute and a map
- add `type` query parameter to `/v1/subscription/{subscriberId}/create_payment_method`
This commit is contained in:
@@ -205,7 +205,7 @@ import org.whispersystems.textsecuregcm.storage.ReportMessageManager;
|
||||
import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationCodeStore;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.util.Constants;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import org.whispersystems.textsecuregcm.util.HostnameUtil;
|
||||
|
||||
@@ -48,6 +48,7 @@ import javax.validation.constraints.NotNull;
|
||||
import javax.ws.rs.BadRequestException;
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.InternalServerErrorException;
|
||||
@@ -58,6 +59,7 @@ import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.ProcessingException;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.WebApplicationException;
|
||||
import javax.ws.rs.container.ContainerRequestContext;
|
||||
import javax.ws.rs.core.Context;
|
||||
@@ -87,7 +89,11 @@ import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult;
|
||||
import org.whispersystems.textsecuregcm.stripe.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.StripeManager;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExactlySize;
|
||||
|
||||
@Path("/v1/subscription")
|
||||
@@ -179,15 +185,13 @@ public class SubscriptionController {
|
||||
throw new ForbiddenException("subscriberId mismatch");
|
||||
} else if (getResult == GetResult.NOT_STORED) {
|
||||
// create a customer and write it to ddb
|
||||
return stripeManager.createCustomer(requestData.subscriberUser).thenCompose(
|
||||
customer -> subscriptionManager.create(
|
||||
requestData.subscriberUser, requestData.hmac, customer.getId(), requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return updatedRecord;
|
||||
}));
|
||||
return subscriptionManager.create(requestData.subscriberUser, requestData.hmac, requestData.now)
|
||||
.thenApply(updatedRecord -> {
|
||||
if (updatedRecord == null) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return updatedRecord;
|
||||
});
|
||||
} else {
|
||||
// already exists so just touch access time and return
|
||||
return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now)
|
||||
@@ -197,20 +201,8 @@ public class SubscriptionController {
|
||||
.thenApply(record -> Response.ok().build());
|
||||
}
|
||||
|
||||
public static class CreatePaymentMethodResponse {
|
||||
record CreatePaymentMethodResponse(String clientSecret, SubscriptionProcessor processor) {
|
||||
|
||||
private final String clientSecret;
|
||||
|
||||
@JsonCreator
|
||||
public CreatePaymentMethodResponse(
|
||||
@JsonProperty("clientSecret") String clientSecret) {
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public String getClientSecret() {
|
||||
return clientSecret;
|
||||
}
|
||||
}
|
||||
|
||||
@Timed
|
||||
@@ -220,12 +212,39 @@ public class SubscriptionController {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public CompletableFuture<Response> createPaymentMethod(
|
||||
@Auth Optional<AuthenticatedAccount> authenticatedAccount,
|
||||
@PathParam("subscriberId") String subscriberId) {
|
||||
@PathParam("subscriberId") String subscriberId,
|
||||
@QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType) {
|
||||
|
||||
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
|
||||
|
||||
final SubscriptionProcessorManager subscriptionProcessorManager = getManagerForPaymentMethod(paymentMethodType);
|
||||
|
||||
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
|
||||
.thenApply(this::requireRecordFromGetResult)
|
||||
.thenCompose(record -> stripeManager.createSetupIntent(record.customerId))
|
||||
.thenApply(setupIntent -> Response.ok(new CreatePaymentMethodResponse(setupIntent.getClientSecret())).build());
|
||||
.thenCompose(record -> {
|
||||
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture;
|
||||
if (record.customerId == null) {
|
||||
updatedRecordFuture = subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
|
||||
.thenApply(ProcessorCustomer::customerId)
|
||||
.thenCompose(customerId -> subscriptionManager.updateProcessorAndCustomerId(record,
|
||||
new ProcessorCustomer(customerId,
|
||||
subscriptionProcessorManager.getProcessor()), Instant.now()));
|
||||
} else {
|
||||
updatedRecordFuture = CompletableFuture.completedFuture(record);
|
||||
}
|
||||
|
||||
return updatedRecordFuture.thenCompose(
|
||||
updatedRecord -> subscriptionProcessorManager.createPaymentMethodSetupToken(updatedRecord.customerId));
|
||||
})
|
||||
.thenApply(
|
||||
token -> Response.ok(new CreatePaymentMethodResponse(token, subscriptionProcessorManager.getProcessor()))
|
||||
.build());
|
||||
}
|
||||
|
||||
private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return switch (paymentMethod) {
|
||||
case CARD -> stripeManager;
|
||||
};
|
||||
}
|
||||
|
||||
@Timed
|
||||
|
||||
@@ -6,23 +6,32 @@
|
||||
package org.whispersystems.textsecuregcm.storage;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.b;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.m;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.n;
|
||||
import static org.whispersystems.textsecuregcm.util.AttributeValues.s;
|
||||
|
||||
import com.google.common.base.Throwables;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;
|
||||
import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor;
|
||||
import org.whispersystems.textsecuregcm.util.Pair;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
|
||||
@@ -35,8 +44,11 @@ public class SubscriptionManager {
|
||||
|
||||
public static final String KEY_USER = "U"; // B (Hash Key)
|
||||
public static final String KEY_PASSWORD = "P"; // B
|
||||
@Deprecated
|
||||
public static final String KEY_CUSTOMER_ID = "C"; // S (GSI Hash Key of `c_to_u` index)
|
||||
public static final String KEY_PROCESSOR_ID_CUSTOMER_ID = "PC"; // B (GSI Hash Key of `pc_to_u` index)
|
||||
public static final String KEY_CREATED_AT = "R"; // N
|
||||
public static final String KEY_PROCESSOR_CUSTOMER_IDS_MAP = "PCI"; // M
|
||||
public static final String KEY_SUBSCRIPTION_ID = "S"; // S
|
||||
public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N
|
||||
public static final String KEY_SUBSCRIPTION_LEVEL = "L";
|
||||
@@ -51,8 +63,10 @@ public class SubscriptionManager {
|
||||
|
||||
public final byte[] user;
|
||||
public final byte[] password;
|
||||
public final String customerId;
|
||||
public final Instant createdAt;
|
||||
public @Nullable String customerId;
|
||||
public @Nullable SubscriptionProcessor processor;
|
||||
public Map<SubscriptionProcessor, String> processorsToCustomerIds;
|
||||
public String subscriptionId;
|
||||
public Instant subscriptionCreatedAt;
|
||||
public Long subscriptionLevel;
|
||||
@@ -61,10 +75,9 @@ public class SubscriptionManager {
|
||||
public Instant canceledAt;
|
||||
public Instant currentPeriodEndsAt;
|
||||
|
||||
private Record(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private Record(byte[] user, byte[] password, Instant createdAt) {
|
||||
this.user = checkUserLength(user);
|
||||
this.password = Objects.requireNonNull(password);
|
||||
this.customerId = Objects.requireNonNull(customerId);
|
||||
this.createdAt = Objects.requireNonNull(createdAt);
|
||||
}
|
||||
|
||||
@@ -72,8 +85,17 @@ public class SubscriptionManager {
|
||||
Record self = new Record(
|
||||
user,
|
||||
item.get(KEY_PASSWORD).b().asByteArray(),
|
||||
item.get(KEY_CUSTOMER_ID).s(),
|
||||
getInstant(item, KEY_CREATED_AT));
|
||||
|
||||
final Pair<SubscriptionProcessor, String> processorCustomerId = getProcessorAndCustomer(item);
|
||||
if (processorCustomerId != null) {
|
||||
self.customerId = processorCustomerId.second();
|
||||
self.processor = processorCustomerId.first();
|
||||
} else {
|
||||
// Until all existing data is migrated to KEY_PROCESSOR_ID_CUSTOMER_ID, fall back to KEY_CUSTOMER_ID
|
||||
self.customerId = getString(item, KEY_CUSTOMER_ID);
|
||||
}
|
||||
self.processorsToCustomerIds = getProcessorsToCustomerIds(item);
|
||||
self.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
|
||||
self.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
|
||||
self.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
|
||||
@@ -84,8 +106,45 @@ public class SubscriptionManager {
|
||||
return self;
|
||||
}
|
||||
|
||||
public Map<String, AttributeValue> asKey() {
|
||||
return Map.of(KEY_USER, b(user));
|
||||
private static Map<SubscriptionProcessor, String> getProcessorsToCustomerIds(Map<String, AttributeValue> item) {
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_CUSTOMER_IDS_MAP);
|
||||
final Map<String, AttributeValue> attribute =
|
||||
attributeValue == null ? Collections.emptyMap() : attributeValue.m();
|
||||
|
||||
final Map<SubscriptionProcessor, String> processorsToCustomerIds = new HashMap<>();
|
||||
attribute.forEach((processorName, customerId) ->
|
||||
processorsToCustomerIds.put(SubscriptionProcessor.valueOf(processorName), customerId.s()));
|
||||
|
||||
return processorsToCustomerIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the active processor and customer from a single attribute value in the given item.
|
||||
* <p>
|
||||
* Until existing data is migrated, this may return {@code null}.
|
||||
*/
|
||||
@Nullable
|
||||
private static Pair<SubscriptionProcessor, String> getProcessorAndCustomer(Map<String, AttributeValue> item) {
|
||||
|
||||
final AttributeValue attributeValue = item.get(KEY_PROCESSOR_ID_CUSTOMER_ID);
|
||||
|
||||
if (attributeValue == null) {
|
||||
// temporarily allow null values
|
||||
return null;
|
||||
}
|
||||
|
||||
final byte[] processorAndCustomerId = attributeValue.b().asByteArray();
|
||||
final byte processorId = processorAndCustomerId[0];
|
||||
|
||||
final SubscriptionProcessor processor = SubscriptionProcessor.forId(processorId);
|
||||
if (processor == null) {
|
||||
throw new IllegalStateException("unknown processor id: " + processorId);
|
||||
}
|
||||
|
||||
final String customerId = new String(processorAndCustomerId, 1, processorAndCustomerId.length - 1,
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
return new Pair<>(processor, customerId);
|
||||
}
|
||||
|
||||
private static String getString(Map<String, AttributeValue> item, String key) {
|
||||
@@ -181,14 +240,7 @@ public class SubscriptionManager {
|
||||
* Looks up a record with the given {@code user} and validates the {@code hmac} before returning it.
|
||||
*/
|
||||
public CompletableFuture<GetResult> get(byte[] user, byte[] hmac) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
return client.getItem(request).thenApply(getItemResponse -> {
|
||||
return getUser(user).thenApply(getItemResponse -> {
|
||||
if (!getItemResponse.hasItem()) {
|
||||
return GetResult.NOT_STORED;
|
||||
}
|
||||
@@ -201,7 +253,19 @@ public class SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, String customerId, Instant createdAt) {
|
||||
private CompletableFuture<GetItemResponse> getUser(byte[] user) {
|
||||
checkUserLength(user);
|
||||
|
||||
GetItemRequest request = GetItemRequest.builder()
|
||||
.consistentRead(Boolean.TRUE)
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(user)))
|
||||
.build();
|
||||
|
||||
return client.getItem(request);
|
||||
}
|
||||
|
||||
public CompletableFuture<Record> create(byte[] user, byte[] password, Instant createdAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
@@ -211,20 +275,23 @@ public class SubscriptionManager {
|
||||
.conditionExpression("attribute_not_exists(#user) OR #password = :password")
|
||||
.updateExpression("SET "
|
||||
+ "#password = if_not_exists(#password, :password), "
|
||||
+ "#customer_id = if_not_exists(#customer_id, :customer_id), "
|
||||
+ "#created_at = if_not_exists(#created_at, :created_at), "
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at)")
|
||||
+ "#accessed_at = if_not_exists(#accessed_at, :accessed_at), "
|
||||
+ "#processors_to_customer_ids = if_not_exists(#processors_to_customer_ids, :initial_empty_map)"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#user", KEY_USER,
|
||||
"#password", KEY_PASSWORD,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#created_at", KEY_CREATED_AT,
|
||||
"#accessed_at", KEY_ACCESSED_AT))
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP)
|
||||
)
|
||||
.expressionAttributeValues(Map.of(
|
||||
":password", b(password),
|
||||
":customer_id", s(customerId),
|
||||
":created_at", n(createdAt.getEpochSecond()),
|
||||
":accessed_at", n(createdAt.getEpochSecond())))
|
||||
":accessed_at", n(createdAt.getEpochSecond()),
|
||||
":initial_empty_map", m(Map.of()))
|
||||
)
|
||||
.build();
|
||||
return client.updateItem(request).handle((updateItemResponse, throwable) -> {
|
||||
if (throwable != null) {
|
||||
@@ -239,6 +306,76 @@ public class SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the active processor and customer ID for the given user record.
|
||||
*
|
||||
* @return the updated user record.
|
||||
*/
|
||||
public CompletableFuture<Record> updateProcessorAndCustomerId(Record userRecord,
|
||||
ProcessorCustomer activeProcessorCustomer, Instant updatedAt) {
|
||||
|
||||
// Don’t attempt to modify the existing map, since it may be immutable, and we also don’t want to have side effects
|
||||
final Map<SubscriptionProcessor, String> allProcessorsAndCustomerIds = new HashMap<>(
|
||||
userRecord.processorsToCustomerIds);
|
||||
allProcessorsAndCustomerIds.put(activeProcessorCustomer.processor(), activeProcessorCustomer.customerId());
|
||||
|
||||
UpdateItemRequest request = UpdateItemRequest.builder()
|
||||
.tableName(table)
|
||||
.key(Map.of(KEY_USER, b(userRecord.user)))
|
||||
.returnValues(ReturnValue.ALL_NEW)
|
||||
.conditionExpression(
|
||||
// there is no customer attribute yet
|
||||
"attribute_not_exists(#customer_id) " +
|
||||
// OR this record doesn't have the new processor+customer attributes yet
|
||||
"OR (#customer_id = :customer_id " +
|
||||
"AND attribute_not_exists(#processor_customer_id) " +
|
||||
// TODO once all records are guaranteed to have the map, we can do a more targeted update
|
||||
// "AND attribute_not_exists(#processors_to_customer_ids.#processor_name) " +
|
||||
"AND attribute_not_exists(#processors_to_customer_ids))"
|
||||
)
|
||||
.updateExpression("SET "
|
||||
+ "#customer_id = :customer_id, "
|
||||
+ "#processor_customer_id = :processor_customer_id, "
|
||||
// TODO once all records are guaranteed to have the map, we can do a more targeted update
|
||||
// + "#processors_to_customer_ids.#processor_name = :customer_id, "
|
||||
+ "#processors_to_customer_ids = :processors_and_customer_ids, "
|
||||
+ "#accessed_at = :accessed_at"
|
||||
)
|
||||
.expressionAttributeNames(Map.of(
|
||||
"#accessed_at", KEY_ACCESSED_AT,
|
||||
"#customer_id", KEY_CUSTOMER_ID,
|
||||
"#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID,
|
||||
// TODO "#processor_name", activeProcessor.name(),
|
||||
"#processors_to_customer_ids", KEY_PROCESSOR_CUSTOMER_IDS_MAP
|
||||
))
|
||||
.expressionAttributeValues(Map.of(
|
||||
":accessed_at", n(updatedAt.getEpochSecond()),
|
||||
":customer_id", s(activeProcessorCustomer.customerId()),
|
||||
":processor_customer_id", b(activeProcessorCustomer.toDynamoBytes()),
|
||||
":processors_and_customer_ids", m(createProcessorsToCustomerIdsAttributeMap(allProcessorsAndCustomerIds))
|
||||
)).build();
|
||||
|
||||
return client.updateItem(request)
|
||||
.thenApply(updateItemResponse -> Record.from(userRecord.user, updateItemResponse.attributes()))
|
||||
.exceptionallyCompose(throwable -> {
|
||||
if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {
|
||||
return getUser(userRecord.user).thenApply(getItemResponse ->
|
||||
Record.from(userRecord.user, getItemResponse.item()));
|
||||
}
|
||||
Throwables.throwIfUnchecked(throwable);
|
||||
throw new CompletionException(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, AttributeValue> createProcessorsToCustomerIdsAttributeMap(
|
||||
Map<SubscriptionProcessor, String> allProcessorsAndCustomerIds) {
|
||||
final Map<String, AttributeValue> result = new HashMap<>();
|
||||
|
||||
allProcessorsAndCustomerIds.forEach((processor, customerId) -> result.put(processor.name(), s(customerId)));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {
|
||||
checkUserLength(user);
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
public enum PaymentMethod {
|
||||
/**
|
||||
* A credit card or debit card, including those from Apple Pay and Google Pay
|
||||
*/
|
||||
CARD,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.whispersystems.dispatch.util.Util;
|
||||
|
||||
public record ProcessorCustomer(String customerId, SubscriptionProcessor processor) {
|
||||
|
||||
public byte[] toDynamoBytes() {
|
||||
return Util.combine(new byte[]{processor.getId()}, customerId.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.stripe;
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import com.google.common.collect.Lists;
|
||||
@@ -61,7 +61,7 @@ import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
import org.whispersystems.textsecuregcm.util.Conversions;
|
||||
|
||||
public class StripeManager {
|
||||
public class StripeManager implements SubscriptionProcessorManager {
|
||||
|
||||
private static final String METADATA_KEY_LEVEL = "level";
|
||||
|
||||
@@ -87,6 +87,16 @@ public class StripeManager {
|
||||
this.boostDescription = Objects.requireNonNull(boostDescription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SubscriptionProcessor getProcessor() {
|
||||
return SubscriptionProcessor.STRIPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPaymentMethod(PaymentMethod paymentMethod) {
|
||||
return paymentMethod == PaymentMethod.CARD;
|
||||
}
|
||||
|
||||
private RequestOptions commonOptions() {
|
||||
return commonOptions(null);
|
||||
}
|
||||
@@ -98,17 +108,19 @@ public class StripeManager {
|
||||
.build();
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> createCustomer(byte[] subscriberUser) {
|
||||
@Override
|
||||
public CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||||
.putMetadata("subscriberUser", Hex.encodeHexString(subscriberUser))
|
||||
.build();
|
||||
try {
|
||||
return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(customer -> new ProcessorCustomer(customer.getId(), getProcessor()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Customer> getCustomer(String customerId) {
|
||||
@@ -139,17 +151,19 @@ public class StripeManager {
|
||||
}, executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<SetupIntent> createSetupIntent(String customerId) {
|
||||
@Override
|
||||
public CompletableFuture<String> createPaymentMethodSetupToken(String customerId) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor);
|
||||
SetupIntentCreateParams params = SetupIntentCreateParams.builder()
|
||||
.setCustomer(customerId)
|
||||
.build();
|
||||
try {
|
||||
return SetupIntent.create(params, commonOptions());
|
||||
} catch (StripeException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}, executor)
|
||||
.thenApply(SetupIntent::getClientSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A set of payment providers used for donations
|
||||
*/
|
||||
public enum SubscriptionProcessor {
|
||||
// because provider IDs are stored, they should not be reused, and great care
|
||||
// must be used if a provider is removed from the list
|
||||
STRIPE(1),
|
||||
;
|
||||
|
||||
private static final Map<Integer, SubscriptionProcessor> IDS_TO_PROCESSORS = new HashMap<>();
|
||||
|
||||
static {
|
||||
Arrays.stream(SubscriptionProcessor.values())
|
||||
.forEach(provider -> IDS_TO_PROCESSORS.put((int) provider.id, provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the provider associated with the given ID, or {@code null} if none exists
|
||||
*/
|
||||
public static SubscriptionProcessor forId(byte id) {
|
||||
return IDS_TO_PROCESSORS.get((int) id);
|
||||
}
|
||||
|
||||
private final byte id;
|
||||
|
||||
SubscriptionProcessor(int id) {
|
||||
if (id > 256) {
|
||||
throw new IllegalArgumentException("ID must fit in one byte: " + id);
|
||||
}
|
||||
|
||||
this.id = (byte) id;
|
||||
}
|
||||
|
||||
public byte getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.subscriptions;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public interface SubscriptionProcessorManager {
|
||||
|
||||
SubscriptionProcessor getProcessor();
|
||||
|
||||
boolean supportsPaymentMethod(PaymentMethod paymentMethod);
|
||||
|
||||
CompletableFuture<ProcessorCustomer> createCustomer(byte[] subscriberUser);
|
||||
|
||||
CompletableFuture<String> createPaymentMethodSetupToken(String customerId);
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
package org.whispersystems.textsecuregcm.util;
|
||||
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
|
||||
/** AwsAV provides static helper methods for working with AWS AttributeValues. */
|
||||
public class AttributeValues {
|
||||
@@ -37,6 +37,9 @@ public class AttributeValues {
|
||||
return AttributeValue.builder().s(value).build();
|
||||
}
|
||||
|
||||
public static AttributeValue m(Map<String, AttributeValue> value) {
|
||||
return AttributeValue.builder().m(value).build();
|
||||
}
|
||||
|
||||
// More opinionated methods
|
||||
|
||||
|
||||
Reference in New Issue
Block a user