Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

- `POST /v1/invoiceitems` can omit `invoice`. The item is stored as a pending
customer item (`invoice` is null) and `subscription` is accepted and echoed.
- `POST /v1/invoices` `pending_invoice_items_behavior=include` attaches that
customer's pending items of the same currency and adds them to
`subtotal`/`total` once at create. `exclude` (and omit) leaves them
unattached.
- `POST /v1/invoices` accepts `subscription` (stored and echoed) and
`days_until_due` (response `due_date` is created plus that many days).
- Invoice responses populate `lines.data` from the invoice's items using the
same serialization as `GET /v1/invoices/{id}/lines`, including Wave 0
`pricing` and `quantity`.
- Paid invoice responses set `post_payment_credit_notes_amount` to the sum of
issued (non-void) credit notes' `credit_amount` for that invoice.
- Invoice preview accepts `subscription_details[trial_end]` (`now` or a unix
timestamp). For a `trialing` subscription, `trial_end=now` (or a timestamp
that is not in the future) previews the first paid cycle instead of a
Expand Down
136 changes: 95 additions & 41 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3236,11 +3236,12 @@ func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) {
}
metadata := invoiceMetadataFromParams(p)
invoice, err := h.billing.CreateInvoice(r.Context(), billing.Invoice{
ID: p.string("id"),
CustomerID: p.string("customer"),
Currency: p.stringDefault("currency", "usd"),
Status: "draft",
Metadata: metadata,
ID: p.string("id"),
CustomerID: p.string("customer"),
Currency: p.stringDefault("currency", "usd"),
Status: "draft",
SubscriptionID: p.string("subscription"),
Metadata: metadata,
})
if err == nil {
h.emitGenericWebhook(r, "invoice.created", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI)
Expand Down Expand Up @@ -3307,13 +3308,14 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) {
return
}
item := billing.InvoiceItem{
ID: p.string("id"),
CustomerID: p.string("customer"),
InvoiceID: p.string("invoice"),
Amount: p.int64("amount"),
Currency: p.string("currency"),
Description: p.string("description"),
Metadata: p.metadata(),
ID: p.string("id"),
CustomerID: p.string("customer"),
InvoiceID: p.string("invoice"),
SubscriptionID: p.string("subscription"),
Amount: p.int64("amount"),
Currency: p.string("currency"),
Description: p.string("description"),
Metadata: p.metadata(),
}
if p.has("pricing[price]") {
price, err := h.billing.GetPrice(r.Context(), p.string("pricing[price]"))
Expand All @@ -3338,11 +3340,7 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) {
CustomerID: r.URL.Query().Get("customer"),
InvoiceID: r.URL.Query().Get("invoice"),
})
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, stripeInvoiceItem(item))
}
writeResult(w, stripeList(r.URL.Path, data), err)
writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err)
default:
h.methodNotAllowed(w, r, "GET, POST")
}
Expand Down Expand Up @@ -3396,7 +3394,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) {
}
result, err := h.billing.FinalizeInvoice(r.Context(), id)
if err == nil {
h.emitGenericWebhook(r, "invoice.finalized", result.Invoice.ID, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), webhooks.SourceAPI)
h.emitGenericWebhook(r, "invoice.finalized", result.Invoice.ID, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), webhooks.SourceAPI)
if result.PaymentIntent.ID != "" {
h.emitPaymentIntentWebhook(r, "payment_intent.created", result.PaymentIntent)
}
Expand All @@ -3408,7 +3406,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) {
}
}
}
writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err)
writeResult(w, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), err)
return
}
if len(parts) == 2 && parts[1] == "send" {
Expand Down Expand Up @@ -3459,7 +3457,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) {
if err == nil {
h.emitInvoicePaymentWebhooks(r, result, webhooks.SourceAPI)
}
writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err)
writeResult(w, h.stripeInvoiceWithPaymentIntent(r.Context(), result.Invoice, result.PaymentIntent), err)
return
}
if len(parts) == 2 && parts[1] == "lines" {
Expand All @@ -3468,11 +3466,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) {
return
}
items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{InvoiceID: id})
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, stripeInvoiceItem(item))
}
writeResult(w, stripeList(r.URL.Path, data), err)
writeResult(w, stripeList(r.URL.Path, stripeInvoiceItemMaps(items)), err)
return
}
if len(parts) == 2 && parts[1] == "payments" {
Expand Down Expand Up @@ -5994,6 +5988,7 @@ func invoiceMetadataFromParams(p params) map[string]string {
{param: "collection_method", key: "collection_method"},
{param: "auto_advance", key: "auto_advance"},
{param: "pending_invoice_items_behavior", key: "pending_invoice_items_behavior"},
{param: "days_until_due", key: "days_until_due"},
{param: "payment_settings[payment_method_types][]", key: "payment_method_types"},
{param: "payment_settings[payment_method_types][0]", key: "payment_method_types"},
} {
Expand Down Expand Up @@ -7254,15 +7249,49 @@ func (h *Handler) stripeInvoice(ctx context.Context, invoice billing.Invoice) ma
intent = &pi
}
}
return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, intent, h.stripeTaxRateObjects(invoice.DefaultTaxRates))
return h.enrichStripeInvoice(ctx, invoice, stripeInvoiceWithPaymentIntentAndTaxRates(invoice, intent, h.stripeTaxRateObjects(invoice.DefaultTaxRates)))
}

func (h *Handler) stripeInvoiceWithPaymentIntent(invoice billing.Invoice, intent billing.PaymentIntent) map[string]any {
func (h *Handler) stripeInvoiceWithPaymentIntent(ctx context.Context, invoice billing.Invoice, intent billing.PaymentIntent) map[string]any {
taxRates := h.stripeTaxRateObjects(invoice.DefaultTaxRates)
var payload map[string]any
if intent.ID == "" {
return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, nil, taxRates)
payload = stripeInvoiceWithPaymentIntentAndTaxRates(invoice, nil, taxRates)
} else {
payload = stripeInvoiceWithPaymentIntentAndTaxRates(invoice, &intent, taxRates)
}
return h.enrichStripeInvoice(ctx, invoice, payload)
}

func (h *Handler) enrichStripeInvoice(ctx context.Context, invoice billing.Invoice, payload map[string]any) map[string]any {
if invoice.ID == "" {
return payload
}
return stripeInvoiceWithPaymentIntentAndTaxRates(invoice, &intent, taxRates)
items, err := h.billing.ListInvoiceItems(ctx, billing.InvoiceItemFilter{InvoiceID: invoice.ID})
if err != nil {
items = nil
}
payload["lines"] = stripeInvoiceLines(invoice.ID, items)
payload["post_payment_credit_notes_amount"] = h.invoicePostPaymentCreditNotesAmount(ctx, invoice)
return payload
}

func (h *Handler) invoicePostPaymentCreditNotesAmount(ctx context.Context, invoice billing.Invoice) int64 {
if invoice.Status != "paid" && invoice.AmountPaid <= 0 {
return 0
}
notes, err := h.billing.ListCreditNotes(ctx, billing.CreditNoteFilter{InvoiceID: invoice.ID})
if err != nil {
return 0
}
var sum int64
for _, note := range notes {
if note.Status == "void" {
continue
}
sum += note.CreditAmount()
}
return sum
}

func (h *Handler) stripeInvoicePayments(ctx context.Context, invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any {
Expand Down Expand Up @@ -7423,7 +7452,7 @@ func stripeInvoiceWithPaymentIntentAndTaxRates(invoice billing.Invoice, intent *
"default_payment_method": emptyToNil(invoice.Metadata[billing.MetadataDefaultPaymentMethod]),
"default_source": nil,
"default_tax_rates": defaultTaxRates,
"due_date": nil,
"due_date": invoiceDueDate(invoice),
"ending_balance": 0,
"footer": nil,
"from_invoice": nil,
Expand Down Expand Up @@ -7461,17 +7490,18 @@ func stripeInvoiceItem(item billing.InvoiceItem) map[string]any {
quantity = 1
}
out := map[string]any{
"id": item.ID,
"object": billing.ObjectInvoiceItem,
"customer": item.CustomerID,
"invoice": item.InvoiceID,
"amount": item.Amount,
"currency": item.Currency,
"description": emptyToNil(item.Description),
"metadata": nonNilMap(item.Metadata),
"quantity": quantity,
"created": unix(item.CreatedAt),
"livemode": false,
"id": item.ID,
"object": billing.ObjectInvoiceItem,
"customer": item.CustomerID,
"invoice": emptyToNil(item.InvoiceID),
"subscription": emptyToNil(item.SubscriptionID),
"amount": item.Amount,
"currency": item.Currency,
"description": emptyToNil(item.Description),
"metadata": nonNilMap(item.Metadata),
"quantity": quantity,
"created": unix(item.CreatedAt),
"livemode": false,
}
if item.PriceID != "" {
unitAmount := item.Amount
Expand All @@ -7490,6 +7520,30 @@ func stripeInvoiceItem(item billing.InvoiceItem) map[string]any {
return out
}

func stripeInvoiceItemMaps(items []billing.InvoiceItem) []map[string]any {
data := make([]map[string]any, 0, len(items))
for _, item := range items {
data = append(data, stripeInvoiceItem(item))
}
return data
}

func stripeInvoiceLines(invoiceID string, items []billing.InvoiceItem) map[string]any {
return stripeList("/v1/invoices/"+invoiceID+"/lines", stripeInvoiceItemMaps(items))
}

func invoiceDueDate(invoice billing.Invoice) any {
raw := strings.TrimSpace(invoice.Metadata["days_until_due"])
if raw == "" {
return nil
}
days, err := strconv.ParseInt(raw, 10, 64)
if err != nil || days <= 0 {
return nil
}
return unix(invoice.CreatedAt.Add(time.Duration(days) * 24 * time.Hour))
}

func stripeInvoicePaymentRecords(invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any {
if invoice.PaymentIntentID == "" {
return []map[string]any{}
Expand Down
Loading
Loading