Skip to content

Commit

Permalink
Support unsetting the default payment method, and tracking the last p…
Browse files Browse the repository at this point in the history
…ayment error for payment intents
  • Loading branch information
markusjevringsesame committed Nov 3, 2023
1 parent a359c85 commit 4386237
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 107 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.sesame.oss.stripemock.entities;

import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.sesame.oss.stripemock.StripeMock;
import com.sesame.oss.stripemock.http.QueryParameters;
Expand Down Expand Up @@ -194,6 +195,7 @@ public void merge(JsonObject parent, Map<String, Object> formData) {
merge(child, m);
} else {
switch (value) {
case null -> parent.add(name, JsonNull.INSTANCE);
case Number n -> parent.addProperty(name, n);
case String s -> parent.addProperty(name, s);
case Boolean b -> parent.addProperty(name, b);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,74 @@

import com.sesame.oss.stripemock.http.ResponseCodeException;
import com.stripe.model.Customer;
import com.stripe.model.PaymentMethod;

import java.time.Clock;
import java.util.Map;
import java.util.Optional;

class CustomerManager extends AbstractEntityManager<Customer> {
protected CustomerManager(Clock clock) {
private final StripeEntities stripeEntities;

protected CustomerManager(Clock clock, StripeEntities stripeEntities) {
super(clock, Customer.class, "cus");
this.stripeEntities = stripeEntities;
}

@Override
protected Customer initialize(Customer customer, Map<String, Object> formData) throws ResponseCodeException {
setDefaultSourceIfNecessary(customer, formData);
return super.initialize(customer, formData);
}

@Override
protected void validate(Customer customer) throws ResponseCodeException {
super.validate(customer);
Customer.InvoiceSettings invoiceSettings = customer.getInvoiceSettings();
if (invoiceSettings != null && invoiceSettings.getDefaultPaymentMethod() != null) {
stripeEntities.getEntityManager(PaymentMethod.class)
.get(invoiceSettings.getDefaultPaymentMethod())
.filter(pm -> pm.getCustomer() != null &&
pm.getCustomer()
.equals(customer.getId()))
.orElseThrow(() -> {
String entityId = invoiceSettings.getDefaultPaymentMethod();
return new ResponseCodeException(400,
String.format(
"No such PaymentMethod: '%s'; It's possible this PaymentMethod exists on one of your connected accounts, in which case you should retry this request on that connected account. Learn more at https://stripe.com/docs/connect/authentication",
entityId),
"resource_missing",
"invalid_request_error",
null);
});
}

}

@Override
protected Customer perform(Customer existingEntity, Customer updatedEntity, String operation, Map<String, Object> formData) throws ResponseCodeException {
protected Customer perform(Customer existingCustomer, Customer updatedCustomer, String operation, Map<String, Object> formData)
throws ResponseCodeException {
if (formData.containsKey("default_source") && formData.get("default_source") == null) {
// We tried to unset this, but it's not allowed
throw new ResponseCodeException(400,
"You passed an empty string for 'default_source'. We assume empty values are an attempt to unset a parameter; however 'default_source' cannot be unset. You should remove 'default_source' from your request or supply a non-empty value.",
"parameter_invalid_empty",
null,
null);
}

if (operation.equals(MAGIC_UPDATE_OPERATION)) {
Object source = formData.get("source");
if (source instanceof String defaultSource) {
// This is actually a null check AND a cast.
updatedEntity.setDefaultSource(defaultSource);
}
setDefaultSourceIfNecessary(updatedCustomer, formData);
}
return updatedCustomer;
}

private static void setDefaultSourceIfNecessary(Customer updatedEntity, Map<String, Object> formData) {
Object source = formData.get("source");
if (source instanceof String defaultSource) {
// This is actually a null check AND a cast.
updatedEntity.setDefaultSource(defaultSource);
}
return updatedEntity;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,57 +85,69 @@ private PaymentMethod getPaymentMethodForCustomerOrThrow(String paymentMethod, S
@Override
protected PaymentIntent perform(PaymentIntent existingPaymentIntent, PaymentIntent updatedPaymentIntent, String operation, Map<String, Object> formData)
throws ResponseCodeException {
updatedPaymentIntent.setLastPaymentError(null);
// We should make sure that we don't perform any changes to updatedPaymentIntent until we are sure that they will all succeed.
// The code we have here today is a bit sloppy, and doesn't use transactions etc, but it's only a test mock, and not
// actually a real payment platform
String updatedPaymentIntentStatus = updatedPaymentIntent.getStatus();
return switch (operation) {
case "confirm" -> {
// todo: do pre-check state transitions as well, just like for the __update branch
String paymentMethodId = null;
if (updatedPaymentIntent.getPaymentMethod() == null) {
if (updatedPaymentIntent.getCustomer() != null) {
// todo: test that we throw if the customer is wrong
String id = updatedPaymentIntent.getCustomer();
Customer customer = stripeEntities.getEntityManager(Customer.class)
.get(id)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400,
"customer",
updatedPaymentIntent.getCustomer()));
Customer.InvoiceSettings invoiceSettings = customer.getInvoiceSettings();
if (invoiceSettings != null && invoiceSettings.getDefaultPaymentMethod() != null) {
paymentMethodId = invoiceSettings.getDefaultPaymentMethod();
} else if (customer.getDefaultSource() != null) {
// todo: tests that use default source to pay
paymentMethodId = customer.getDefaultSource();
try {
// todo: do pre-check state transitions as well, just like for the __update branch
String paymentMethodId = null;
if (updatedPaymentIntent.getPaymentMethod() == null) {
if (updatedPaymentIntent.getCustomer() != null) {
// todo: test that we throw if the customer is wrong
String id = updatedPaymentIntent.getCustomer();
Customer customer = stripeEntities.getEntityManager(Customer.class)
.get(id)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400,
"customer",
updatedPaymentIntent.getCustomer()));
Customer.InvoiceSettings invoiceSettings = customer.getInvoiceSettings();
if (invoiceSettings != null && invoiceSettings.getDefaultPaymentMethod() != null) {
paymentMethodId = invoiceSettings.getDefaultPaymentMethod();
} else if (customer.getDefaultSource() != null) {
// todo: tests that use default source to pay
paymentMethodId = customer.getDefaultSource();
}
}
} else {
paymentMethodId = updatedPaymentIntent.getPaymentMethod();
}
} else {
paymentMethodId = updatedPaymentIntent.getPaymentMethod();
}
if (paymentMethodId == null) {
throw new ResponseCodeException(400,
"You cannot confirm this PaymentIntent because it's missing a payment method. You can either update the PaymentIntent with a payment method and then confirm it again, or confirm it again directly with a payment method.");
}

PaymentMethodManager.throwIfPaymentMethodIsNotValid(getPaymentMethodForCustomerOrThrow(paymentMethodId, updatedPaymentIntent.getCustomer()));
// In reality this would progress to "processing" first, and then to "succeeded" when it was actually successful,
// we're not going to bother with that here, since it will be immediately successful or failed
updatedPaymentIntent.setStatus("succeeded");
updatedPaymentIntent.setAmountReceived(updatedPaymentIntent.getAmount());
if (updatedPaymentIntent.getInvoice() != null) {
String invoiceId = updatedPaymentIntent.getInvoice();
Invoice invoice = stripeEntities.getEntityManager(Invoice.class)
.get(invoiceId)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400, "invoice", invoiceId));
invoice.setStatus("paid");
if (invoice.getSubscription() != null) {
String subscriptionId = invoice.getSubscription();
stripeEntities.getEntityManager(Subscription.class)
.get(subscriptionId)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400, "subscription", subscriptionId))
.setStatus("active");
if (paymentMethodId == null) {
throw new ResponseCodeException(400,
"You cannot confirm this PaymentIntent because it's missing a payment method. You can either update the PaymentIntent with a payment method and then confirm it again, or confirm it again directly with a payment method.");
}
PaymentMethodManager.throwIfPaymentMethodIsNotValid(getPaymentMethodForCustomerOrThrow(paymentMethodId,
updatedPaymentIntent.getCustomer()));
// In reality this would progress to "processing" first, and then to "succeeded" when it was actually successful,
// we're not going to bother with that here, since it will be immediately successful or failed
updatedPaymentIntent.setStatus("succeeded");
updatedPaymentIntent.setAmountReceived(updatedPaymentIntent.getAmount());
if (updatedPaymentIntent.getInvoice() != null) {
String invoiceId = updatedPaymentIntent.getInvoice();
Invoice invoice = stripeEntities.getEntityManager(Invoice.class)
.get(invoiceId)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400, "invoice", invoiceId));
invoice.setStatus("paid");
if (invoice.getSubscription() != null) {
String subscriptionId = invoice.getSubscription();
stripeEntities.getEntityManager(Subscription.class)
.get(subscriptionId)
.orElseThrow(() -> ResponseCodeException.noSuchEntity(400, "subscription", subscriptionId))
.setStatus("active");
}
}
} catch (ResponseCodeException e) {
StripeError lastPaymentError = new StripeError();
lastPaymentError.setCode(e.getCode());
lastPaymentError.setDeclineCode(e.getDeclineCode());
lastPaymentError.setType(e.getErrorType());
lastPaymentError.setMessage(e.getMessage());
// We have to set this on the *existing* payment intent, as the *updated* payment intent is discarded when we throw this exception
existingPaymentIntent.setLastPaymentError(lastPaymentError);
throw e;
}
yield updatedPaymentIntent;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public StripeEntities(Clock clock) {
add(new RefundManager(clock, this));
add(new SetupIntentManager(clock));
add(new TransferManager(clock));
add(new CustomerManager(clock));
add(new CustomerManager(clock, this));
add(new InvoiceManager(clock));
add(new ProductManager(clock));
add(new AccountManager(clock));
Expand Down
Loading

0 comments on commit 4386237

Please sign in to comment.