Skip to content

Commit

Permalink
Better support for invoice items
Browse files Browse the repository at this point in the history
  • Loading branch information
markusjevringsesame committed Apr 15, 2024
1 parent 8314b5d commit 823c112
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 22 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.3.0</version>
<version>3.3.1</version>
<executions>
<execution>
<id>attach-sources</id>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InvoiceItem> {
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<String, Object> 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<InvoiceItem> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -76,26 +78,25 @@ protected Invoice initialize(Invoice invoice, Map<String, Object> formData) thro

@Override
protected Invoice perform(Invoice existingInvoice, Invoice updatedInvoice, String operation, Map<String, Object> 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")) {
Expand All @@ -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<InvoiceLineItem> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -47,7 +48,11 @@ public <T extends ApiResource & HasId> EntityManager<T> getEntityManager(Class<T
* @see EntityManager#getNormalizedEntityName()
*/
public EntityManager<?> getEntityManager(String normalizedEntityName) {
return entityManagersByNormalizedEntityName.get(normalizedEntityName);
EntityManager<? extends ApiResource> 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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,6 +34,14 @@ protected Subscription initialize(Subscription subscription, Map<String, Object>
invoiceParameters.put("customer", subscription.getCustomer());
EntityManager<Invoice> 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<>())
Expand Down Expand Up @@ -71,6 +78,22 @@ protected Subscription initialize(Subscription subscription, Map<String, Object>
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<Subscription> delete(String id) throws ResponseCodeException {
Subscription subscription = entities.get(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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);
}
}
40 changes: 38 additions & 2 deletions src/test/java/com/sesame/oss/stripemock/InvoiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -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());
Expand All @@ -136,6 +172,6 @@ void shouldFinalizeInvoice() throws StripeException {
assertEquals("paid", finalizedInvoice.getStatus());

Invoice retrievedfinalizedInvoice = Invoice.retrieve(createdInvoice.getId());
assertEquals(finalizedInvoice, retrievedfinalizedInvoice);
assertInvoiceEquals(finalizedInvoice, retrievedfinalizedInvoice);
}
}
Loading

0 comments on commit 823c112

Please sign in to comment.