diff --git a/README.md b/README.md index 65a5749..c5a1b8c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ your tests "directly" inside the mock's memory, this would be a bad idea, becaus could not possibly do if you were testing using a real Stripe connection. There are some exceptions, however. These exceptions are mostly to smooth the transition from using real Stripe testing to using a mock, and they are related -to the boostrapping done in `StripeMock.reset()`. +to the bootstrapping done in `StripeMock.reset()`. 1. You can change the time using `StripeMock.adjustTime()` to let you set up things like subscriptions in the past. This is useful when migrating from using Stripe testing, where you might rely on things you created in the past in Stripe. 2. You can set the id for entities on creation by passing `StripeMock.OVERRIDE_ID_FOR_TESTING` in via the metadata. diff --git a/pom.xml b/pom.xml index 7fed392..ac7dc0b 100644 --- a/pom.xml +++ b/pom.xml @@ -44,7 +44,7 @@ org.apache.maven.plugins maven-source-plugin - 3.3.0 + 3.3.1 attach-sources diff --git a/src/main/java/com/sesame/oss/stripemock/entities/InvoiceItemManager.java b/src/main/java/com/sesame/oss/stripemock/entities/InvoiceItemManager.java new file mode 100644 index 0000000..50261d1 --- /dev/null +++ b/src/main/java/com/sesame/oss/stripemock/entities/InvoiceItemManager.java @@ -0,0 +1,60 @@ +package com.sesame.oss.stripemock.entities; + +import com.sesame.oss.stripemock.http.ResponseCodeException; +import com.sesame.oss.stripemock.util.Utilities; +import com.stripe.model.Invoice; +import com.stripe.model.InvoiceItem; +import com.stripe.model.InvoiceLineItem; +import com.stripe.net.ApiResource; + +import java.time.Clock; +import java.util.Map; +import java.util.Optional; + +class InvoiceItemManager extends AbstractEntityManager { + private final StripeEntities stripeEntities; + + protected InvoiceItemManager(Clock clock, StripeEntities stripeEntities) { + super(clock, InvoiceItem.class, "ii", 24); + this.stripeEntities = stripeEntities; + } + + @Override + protected InvoiceItem initialize(InvoiceItem invoiceItem, Map formData) throws ResponseCodeException { + String invoiceId = invoiceItem.getInvoice(); + if (invoiceId != null) { + Invoice invoice = stripeEntities.getEntityManager(Invoice.class) + .get(invoiceId) + .orElseThrow(() -> ResponseCodeException.noSuchEntity(404, "invoice", invoiceId)); + invoice.getLines() + .getData() + .add(convertToLineItem(invoiceItem)); + } + return super.initialize(invoiceItem, formData); + } + + private InvoiceLineItem convertToLineItem(InvoiceItem invoiceItem) { + String json = Utilities.PRODUCER_GSON.toJson(invoiceItem); + InvoiceLineItem invoiceLineItem = ApiResource.GSON.fromJson(json, InvoiceLineItem.class); + invoiceLineItem.setObject("line_item"); + invoiceLineItem.setId(Utilities.randomIdWithPrefix("il_tmp", 24)); + invoiceLineItem.setInvoiceItem(invoiceItem.getId()); + return invoiceLineItem; + } + + @Override + public String getNormalizedEntityName() { + return "invoiceitems"; + } + + @Override + public Optional delete(String id) throws ResponseCodeException { + // todo: sync with the behavior of stripe + InvoiceItem invoiceItem = entities.remove(id); + if (invoiceItem == null) { + return Optional.empty(); + } + invoiceItem.setDeleted(true); + return Optional.of(invoiceItem); + } +} diff --git a/src/main/java/com/sesame/oss/stripemock/entities/InvoiceManager.java b/src/main/java/com/sesame/oss/stripemock/entities/InvoiceManager.java index 51a6c53..4779a6f 100644 --- a/src/main/java/com/sesame/oss/stripemock/entities/InvoiceManager.java +++ b/src/main/java/com/sesame/oss/stripemock/entities/InvoiceManager.java @@ -3,11 +3,13 @@ import com.sesame.oss.stripemock.http.ResponseCodeException; import com.stripe.model.Customer; import com.stripe.model.Invoice; +import com.stripe.model.InvoiceLineItem; import com.stripe.model.InvoiceLineItemCollection; import java.time.Clock; import java.time.Instant; import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -76,26 +78,25 @@ protected Invoice initialize(Invoice invoice, Map formData) thro @Override protected Invoice perform(Invoice existingInvoice, Invoice updatedInvoice, String operation, Map formData) throws ResponseCodeException { + // https://docs.stripe.com/invoicing/integration/workflow-transitions if (operation.equals("finalize")) { - updatedInvoice.setStatus("paid"); - updatedInvoice.setPaid(true); + long nowInEpochSecond = Instant.now(clock) + .getEpochSecond(); + updatedInvoice.getStatusTransitions() + .setFinalizedAt(nowInEpochSecond); - updatedInvoice.setAttempted(true); - updatedInvoice.setEffectiveAt(Instant.now(clock) - .getEpochSecond()); + updatedInvoice.setStatus("open"); + updatedInvoice.setPaid(false); updatedInvoice.setEndingBalance(0L); + updatedInvoice.setSubtotal(0L); + updatedInvoice.setSubtotalExcludingTax(0L); + + attemptToPayIfNecessary(updatedInvoice, nowInEpochSecond); + // todo: hosted_invoice_url, invoice_pdf // "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1D4ByEF2zu6DinIq/test_YWNjdF8xRDRCeUVGMnp1NkRpbklxLF9PeE1JeHNFSHVZTGd0MnNhcDk2akdFYUNlOFA5UUo5LDg5ODEzMjY40200Ghh1Nmex?s\u003dap", // "invoice_pdf": "https://pay.stripe.com/invoice/acct_1D4ByEF2zu6DinIq/test_YWNjdF8xRDRCeUVGMnp1NkRpbklxLF9PeE1JeHNFSHVZTGd0MnNhcDk2akdFYUNlOFA5UUo5LDg5ODEzMjY40200Ghh1Nmex/pdf?s\u003dap", updatedInvoice.setNumber("todo: what should this be? 666970EC-0001"); - updatedInvoice.getStatusTransitions() - .setFinalizedAt(Instant.now(clock) - .getEpochSecond()); - updatedInvoice.getStatusTransitions() - .setPaidAt(Instant.now(clock) - .getEpochSecond()); - updatedInvoice.setSubtotal(0L); - updatedInvoice.setSubtotalExcludingTax(0L); } else if (operation.equals(MAGIC_UPDATE_OPERATION)) { if (!updatedInvoice.getStatus() .equals("draft")) { @@ -106,6 +107,43 @@ protected Invoice perform(Invoice existingInvoice, Invoice updatedInvoice, Strin return updatedInvoice; } + private static void attemptToPayIfNecessary(Invoice updatedInvoice, long nowInEpochSecond) { + if (canAndShouldTransitionToPaid(updatedInvoice)) { + updatedInvoice.setStatus("paid"); + updatedInvoice.setPaid(true); + updatedInvoice.setAttempted(true); + updatedInvoice.setEffectiveAt(nowInEpochSecond); + updatedInvoice.getStatusTransitions() + .setPaidAt(nowInEpochSecond); + // todo: change these amounts when we actually support charging for invoices + updatedInvoice.setEndingBalance(0L); + updatedInvoice.setSubtotal(0L); + updatedInvoice.setSubtotalExcludingTax(0L); + } + } + + private static boolean canAndShouldTransitionToPaid(Invoice updatedInvoice) { + List lines = updatedInvoice.getLines() + .getData(); + if (lines.isEmpty()) { + // Nothing to pay for, transition immediately to "paid" + return true; + } else { + long totalAmount = lines.stream() + .mapToLong(InvoiceLineItem::getAmount) + .sum(); + if (totalAmount == 0) { + // There were things to pay for, but they sum up to 0, so transition immediately to "paid" + return true; + } else { + // There were things to pay for, so we need to actually try to charge for them. + // todo: when we can extract payment for invoice items automatically, this should change. + // But for now, let's behave as if it was not possible to get payment, even though we wanted to. + return false; + } + } + } + @Override public boolean canPerformOperation(String operation) { return operation.equals("finalize"); diff --git a/src/main/java/com/sesame/oss/stripemock/entities/StripeEntities.java b/src/main/java/com/sesame/oss/stripemock/entities/StripeEntities.java index d98f725..ffd7fb6 100644 --- a/src/main/java/com/sesame/oss/stripemock/entities/StripeEntities.java +++ b/src/main/java/com/sesame/oss/stripemock/entities/StripeEntities.java @@ -28,6 +28,7 @@ public StripeEntities(Clock clock) { add(new TransferManager(clock, this)); add(new CustomerManager(clock, this)); add(new InvoiceManager(clock, this)); + add(new InvoiceItemManager(clock, this)); add(new ProductManager(clock)); add(new AccountManager(clock)); } @@ -47,7 +48,11 @@ public EntityManager getEntityManager(Class getEntityManager(String normalizedEntityName) { - return entityManagersByNormalizedEntityName.get(normalizedEntityName); + EntityManager entityManager = entityManagersByNormalizedEntityName.get(normalizedEntityName); + if (entityManager == null) { + throw new IllegalStateException("Unable to find entity manager for normalized entity name: " + normalizedEntityName); + } + return entityManager; } public void clear() { diff --git a/src/main/java/com/sesame/oss/stripemock/entities/SubscriptionManager.java b/src/main/java/com/sesame/oss/stripemock/entities/SubscriptionManager.java index 5288ccb..f485ec9 100644 --- a/src/main/java/com/sesame/oss/stripemock/entities/SubscriptionManager.java +++ b/src/main/java/com/sesame/oss/stripemock/entities/SubscriptionManager.java @@ -1,10 +1,9 @@ package com.sesame.oss.stripemock.entities; import com.sesame.oss.stripemock.http.ResponseCodeException; -import com.stripe.model.Customer; -import com.stripe.model.Invoice; -import com.stripe.model.PaymentIntent; -import com.stripe.model.Subscription; +import com.sesame.oss.stripemock.util.Utilities; +import com.stripe.model.*; +import com.stripe.net.ApiResource; import java.time.Clock; import java.time.Instant; @@ -35,6 +34,14 @@ protected Subscription initialize(Subscription subscription, Map invoiceParameters.put("customer", subscription.getCustomer()); EntityManager invoiceEntityManager = stripeEntities.getEntityManager(Invoice.class); Invoice firstInvoice = invoiceEntityManager.add(invoiceParameters); + for (SubscriptionItem subscriptionItem : subscription.getItems() + .getData()) { + subscriptionItem.setId(Utilities.randomIdWithPrefix("si", 24)); + subscriptionItem.setSubscription(subscription.getId()); + firstInvoice.getLines() + .getData() + .add(toInvoiceLineItem(subscriptionItem)); + } // invoices that are part of a subscription are automatically finalized, meaning that they can't change. // This moves them from 'draft' to 'open' firstInvoice = invoiceEntityManager.perform(firstInvoice.getId(), "finalize", new HashMap<>()) @@ -71,6 +78,22 @@ protected Subscription initialize(Subscription subscription, Map return super.initialize(subscription, formData); } + private InvoiceLineItem toInvoiceLineItem(SubscriptionItem subscriptionItem) { + String json = Utilities.PRODUCER_GSON.toJson(subscriptionItem); + InvoiceLineItem invoiceLineItem = ApiResource.GSON.fromJson(json, InvoiceLineItem.class); + invoiceLineItem.setObject("line_item"); + invoiceLineItem.setId(Utilities.randomIdWithPrefix("il_tmp", 24)); + + // todo: should we set anything else? + Price price = subscriptionItem.getPrice(); + invoiceLineItem.setCurrency(price.getCurrency()); + invoiceLineItem.setAmount(price.getUnitAmount()); + invoiceLineItem.setLivemode(false); + invoiceLineItem.setSubscriptionItem(subscriptionItem.getId()); + invoiceLineItem.setSubscription(subscriptionItem.getSubscription()); + return invoiceLineItem; + } + @Override public Optional delete(String id) throws ResponseCodeException { Subscription subscription = entities.get(id); diff --git a/src/test/java/com/sesame/oss/stripemock/AbstractStripeMockTest.java b/src/test/java/com/sesame/oss/stripemock/AbstractStripeMockTest.java index 4489c41..e940485 100644 --- a/src/test/java/com/sesame/oss/stripemock/AbstractStripeMockTest.java +++ b/src/test/java/com/sesame/oss/stripemock/AbstractStripeMockTest.java @@ -1,9 +1,12 @@ package com.sesame.oss.stripemock; +import com.stripe.model.Invoice; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import static org.junit.jupiter.api.Assertions.assertEquals; + public class AbstractStripeMockTest { @BeforeAll static void setUp() { @@ -20,4 +23,19 @@ void reset() { StripeMock.reset(); } + /** + * For unknown reasons, the invoice urls for an invoice keep changing. As a result, we'll need to compare the two WITHOUT looking at the invoice urls. + */ + protected void assertInvoiceEquals(Invoice i1, Invoice i2) { + i1.setFromInvoice(null); + i2.setFromInvoice(null); + + i1.setHostedInvoiceUrl(null); + i2.setHostedInvoiceUrl(null); + + i1.setInvoicePdf(null); + i2.setInvoicePdf(null); + + assertEquals(i1, i2); + } } diff --git a/src/test/java/com/sesame/oss/stripemock/InvoiceTest.java b/src/test/java/com/sesame/oss/stripemock/InvoiceTest.java index 106e20f..d15f279 100644 --- a/src/test/java/com/sesame/oss/stripemock/InvoiceTest.java +++ b/src/test/java/com/sesame/oss/stripemock/InvoiceTest.java @@ -5,10 +5,13 @@ import com.stripe.exception.StripeException; import com.stripe.model.Customer; import com.stripe.model.Invoice; +import com.stripe.model.InvoiceItem; import com.stripe.net.RequestOptions; import com.stripe.param.CustomerCreateParams; import com.stripe.param.InvoiceCreateParams; +import com.stripe.param.InvoiceItemCreateParams; import com.stripe.param.InvoiceUpdateParams; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -118,8 +121,41 @@ void shouldNotBeAbleToFetchUnknownInvoice() { .getType()); } + @Disabled("This test accurately represents the stripe behavior, but it's not supported in the mock yet.") @Test - void shouldFinalizeInvoice() throws StripeException { + void shouldFinalizeInvoiceToDraftWithItems() throws StripeException { + Customer customer = Customer.create(CustomerCreateParams.builder() + .setName("stripe-mock test") + .build()); + Invoice createdInvoice = // + Invoice.create(InvoiceCreateParams.builder() + .setCustomer(customer.getId()) + .setDescription("this is a stripe-mock test invoice") + .putMetadata("integration_test", "true") + .build()); + assertEquals("draft", createdInvoice.getStatus()); + Invoice retrievedDraftInvoice = Invoice.retrieve(createdInvoice.getId()); + assertEquals(createdInvoice, retrievedDraftInvoice); + // Setting the 'invoice' field on an invoice item adds it to the invoice. + InvoiceItem.create(InvoiceItemCreateParams.builder() + .setCustomer(customer.getId()) + .setInvoice(createdInvoice.getId()) + .setAmount(10_00L) + .setCurrency("usd") + .build()); + + // Because there's an invoice item with a non-0 cost, finalizing the invoice won't also immediately transition it to "paid". + // If the invoice was automatically payable, as it would have been with a total cost of 0, then the status would immediately + // have been "paid". + Invoice finalizedInvoice = createdInvoice.finalizeInvoice(); + assertEquals("open", finalizedInvoice.getStatus()); + + Invoice retrievedfinalizedInvoice = Invoice.retrieve(createdInvoice.getId()); + assertInvoiceEquals(finalizedInvoice, retrievedfinalizedInvoice); + } + + @Test + void shouldFinalizeInvoiceWith0CostToPaid() throws StripeException { Customer customer = Customer.create(CustomerCreateParams.builder() .setName("stripe-mock test") .build()); @@ -136,6 +172,6 @@ void shouldFinalizeInvoice() throws StripeException { assertEquals("paid", finalizedInvoice.getStatus()); Invoice retrievedfinalizedInvoice = Invoice.retrieve(createdInvoice.getId()); - assertEquals(finalizedInvoice, retrievedfinalizedInvoice); + assertInvoiceEquals(finalizedInvoice, retrievedfinalizedInvoice); } } diff --git a/src/test/java/com/sesame/oss/stripemock/SubscriptionTest.java b/src/test/java/com/sesame/oss/stripemock/SubscriptionTest.java index 52a7280..2786867 100644 --- a/src/test/java/com/sesame/oss/stripemock/SubscriptionTest.java +++ b/src/test/java/com/sesame/oss/stripemock/SubscriptionTest.java @@ -196,6 +196,65 @@ void shouldNotCreateSubscriptionWithoutCustomer() throws StripeException { .getMessage()); } + @Test + void shouldKeepInvoiceOpenIfSubscriptionCannotBeImmediatelyPaidFor() throws StripeException { + Product product = Product.create(ProductCreateParams.builder() + .setName("Stripe-mock test product") + .putMetadata("integration_test", "true") + .build()); + Customer customer = Customer.create(CustomerCreateParams.builder() + .setName("stripe-mock test") + .build()); + + Recurring recurring = Recurring.builder() + .setInterval(Recurring.Interval.MONTH) + .setIntervalCount(1L) + .build(); + PriceData priceData = PriceData.builder() + .setCurrency("USD") + .setProduct(product.getId()) + .setRecurring(recurring) + .setUnitAmount(10_00L) + .build(); + + Subscription createdSubscription = // + Subscription.create(SubscriptionCreateParams.builder() + .putMetadata("integration_test", "true") + .addItem(Item.builder() + .setPriceData(priceData) + .build()) + .setCustomer(customer.getId()) + .setPaymentBehavior(PaymentBehavior.DEFAULT_INCOMPLETE) + .addExpand("latest_invoice.payment_intent") + .build()); + + assertEquals("open", + createdSubscription.getLatestInvoiceObject() + .getStatus()); + assertEquals("incomplete", createdSubscription.getStatus()); + + + Subscription retrievedSubscription = Subscription.retrieve(createdSubscription.getId()); + assertEquals(createdSubscription, retrievedSubscription); + + assertNotNull(createdSubscription.getLatestInvoice()); + assertNotNull(createdSubscription.getLatestInvoiceObject() + .getPaymentIntentObject()); + + Invoice subscriptionInvoice = Invoice.retrieve(createdSubscription.getLatestInvoice(), + InvoiceRetrieveParams.builder() + .addExpand("payment_intent") + .build(), + RequestOptions.builder() + .build()); + assertNotNull(subscriptionInvoice.getPaymentIntent()); + assertNotNull(subscriptionInvoice.getPaymentIntentObject()); + assertInvoiceEquals(createdSubscription.getLatestInvoiceObject(), subscriptionInvoice); + + customer.delete(); + product.delete(); + } + @Test void testSubscription() throws Exception { Product product = Product.create(ProductCreateParams.builder()