Add additional handling for nullable field in recurring donation record

This commit is contained in:
Chris Eager
2022-10-21 12:56:39 -05:00
committed by GitHub
parent 70a6c3e8e5
commit 8ea794baef
4 changed files with 173 additions and 106 deletions

View File

@@ -12,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.Strings;
import com.stripe.model.Charge;
import com.stripe.model.Charge.Outcome;
import com.stripe.model.Invoice;
@@ -46,6 +45,7 @@ import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
@@ -150,21 +150,22 @@ public class SubscriptionController {
if (getResult == GetResult.NOT_STORED || getResult == GetResult.PASSWORD_MISMATCH) {
throw new NotFoundException();
}
String customerId = getResult.record.customerId;
if (Strings.isNullOrEmpty(customerId)) {
throw new InternalServerErrorException("no customer id found");
}
return stripeManager.getCustomer(customerId).thenCompose(customer -> {
if (customer == null) {
throw new InternalServerErrorException("no customer record found for id " + customerId);
}
return stripeManager.listNonCanceledSubscriptions(customer);
}).thenCompose(subscriptions -> {
@SuppressWarnings("unchecked")
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(futures);
});
return getResult.record.getProcessorCustomer()
.map(processorCustomer -> stripeManager.getCustomer(processorCustomer.customerId())
.thenCompose(customer -> {
if (customer == null) {
throw new InternalServerErrorException(
"no customer record found for id " + processorCustomer.customerId());
}
return stripeManager.listNonCanceledSubscriptions(customer);
}).thenCompose(subscriptions -> {
@SuppressWarnings("unchecked")
CompletableFuture<Subscription>[] futures = (CompletableFuture<Subscription>[]) subscriptions.stream()
.map(stripeManager::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(futures);
}))
// a missing customer ID is OK; it means the subscriber never started to add a payment method
.orElseGet(() -> CompletableFuture.completedFuture(null));
})
.thenCompose(unused -> subscriptionManager.canceledAt(requestData.subscriberUser, requestData.now))
.thenApply(unused -> Response.ok().build());
@@ -222,19 +223,22 @@ public class SubscriptionController {
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
.thenApply(this::requireRecordFromGetResult)
.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);
}
final CompletableFuture<SubscriptionManager.Record> updatedRecordFuture =
record.getProcessorCustomer()
.map(ignored -> CompletableFuture.completedFuture(record))
.orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser)
.thenApply(ProcessorCustomer::customerId)
.thenCompose(customerId -> subscriptionManager.updateProcessorAndCustomerId(record,
new ProcessorCustomer(customerId, subscriptionProcessorManager.getProcessor()),
Instant.now())));
return updatedRecordFuture.thenCompose(
updatedRecord -> subscriptionProcessorManager.createPaymentMethodSetupToken(updatedRecord.customerId));
updatedRecord -> {
final String customerId = updatedRecord.getProcessorCustomer()
.orElseThrow(() -> new InternalServerErrorException("record should not be missing customer"))
.customerId();
return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId);
});
})
.thenApply(
token -> Response.ok(new CreatePaymentMethodResponse(token, subscriptionProcessorManager.getProcessor()))
@@ -259,10 +263,15 @@ public class SubscriptionController {
RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock);
return subscriptionManager.get(requestData.subscriberUser, requestData.hmac)
.thenApply(this::requireRecordFromGetResult)
.thenCompose(record -> stripeManager.setDefaultPaymentMethodForCustomer(record.customerId, paymentMethodId))
.thenCompose(record -> record.getProcessorCustomer()
.map(processorCustomer -> stripeManager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(),
paymentMethodId))
.orElseThrow(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
new ClientErrorException(Status.CONFLICT)))
.thenApply(customer -> Response.ok().build());
}
public static class SetSubscriptionLevelSuccessResponse {
private final long level;
@@ -356,15 +365,22 @@ public class SubscriptionController {
if (record.subscriptionId == null) {
long lastSubscriptionCreatedAt =
record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0;
// we don't have one yet so create it and then record the subscription id
//
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
// retries this request
return stripeManager.createSubscription(record.customerId, priceConfiguration.getId(), level,
lastSubscriptionCreatedAt)
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
requestData.subscriberUser, subscription.getId(), requestData.now, level)
.thenApply(unused -> subscription));
return record.getProcessorCustomer()
.map(processorCustomer ->
// we don't have a subscription yet so create it and then record the subscription id
//
// this relies on stripe's idempotency key to avoid creating more than one subscription if the client
// retries this request
stripeManager.createSubscription(processorCustomer.customerId(), priceConfiguration.getId(), level,
lastSubscriptionCreatedAt)
.thenCompose(subscription -> subscriptionManager.subscriptionCreated(
requestData.subscriberUser, subscription.getId(), requestData.now, level)
.thenApply(unused -> subscription)))
.orElseThrow(() ->
// a missing customer ID indicates the client made requests out of order,
// and needs to call create_payment_method to create a customer for the given payment method
new ClientErrorException(Status.CONFLICT));
} else {
// we already have a subscription in our records so let's check the level and change it if needed
return stripeManager.getSubscription(record.subscriptionId).thenCompose(

View File

@@ -10,6 +10,7 @@ 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.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -18,6 +19,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import javax.annotation.Nonnull;
@@ -64,8 +66,9 @@ public class SubscriptionManager {
public final byte[] user;
public final byte[] password;
public final Instant createdAt;
public @Nullable String customerId;
public @Nullable SubscriptionProcessor processor;
@VisibleForTesting
@Nullable
ProcessorCustomer processorCustomer;
public Map<SubscriptionProcessor, String> processorsToCustomerIds;
public String subscriptionId;
public Instant subscriptionCreatedAt;
@@ -82,28 +85,28 @@ public class SubscriptionManager {
}
public static Record from(byte[] user, Map<String, AttributeValue> item) {
Record self = new Record(
Record record = new Record(
user,
item.get(KEY_PASSWORD).b().asByteArray(),
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);
record.processorCustomer = new ProcessorCustomer(processorCustomerId.second(), processorCustomerId.first());
}
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);
self.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);
self.accessedAt = getInstant(item, KEY_ACCESSED_AT);
self.canceledAt = getInstant(item, KEY_CANCELED_AT);
self.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);
return self;
record.processorsToCustomerIds = getProcessorsToCustomerIds(item);
record.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);
record.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);
record.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);
record.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);
record.accessedAt = getInstant(item, KEY_ACCESSED_AT);
record.canceledAt = getInstant(item, KEY_CANCELED_AT);
record.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);
return record;
}
public Optional<ProcessorCustomer> getProcessorCustomer() {
return Optional.ofNullable(processorCustomer);
}
private static Map<SubscriptionProcessor, String> getProcessorsToCustomerIds(Map<String, AttributeValue> item) {
@@ -314,45 +317,33 @@ public class SubscriptionManager {
public CompletableFuture<Record> updateProcessorAndCustomerId(Record userRecord,
ProcessorCustomer activeProcessorCustomer, Instant updatedAt) {
// Dont attempt to modify the existing map, since it may be immutable, and we also dont 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))"
// there is no active processor+customer attribute
"attribute_not_exists(#processor_customer_id) " +
// or an attribute in the map with an inactive processor+customer
"AND attribute_not_exists(#processors_to_customer_ids.#processor_name)"
)
.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, "
+ "#processors_to_customer_ids.#processor_name = :customer_id, "
+ "#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(),
"#processor_name", activeProcessorCustomer.processor().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))
":processor_customer_id", b(activeProcessorCustomer.toDynamoBytes())
)).build();
return client.updateItem(request)
@@ -367,15 +358,6 @@ public class SubscriptionManager {
});
}
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);