From 002197dd90b7d18404a57ae26b289465c4eaeccd Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 12 May 2026 10:00:01 +0900 Subject: [PATCH] Fix invoice preview SDK schema --- docs/COMPATIBILITY.md | 2 +- internal/api/api.go | 267 ++++++++++++++++++++++++++++++++------- internal/api/api_test.go | 43 ++++++- 3 files changed, 264 insertions(+), 48 deletions(-) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 596c5d4..d1644bc 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -103,7 +103,7 @@ Base path: `/v1` | Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, and `cancel_at_period_end`. List supports arbitrary metadata equality filters such as `metadata[billtap_fixture_ref]`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | | Subscription schedules | `POST /v1/subscription_schedules`, `GET /v1/subscription_schedules`, `GET /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/cancel`, `POST /v1/subscription_schedules/{id}/release` | Partial | Local schedule evidence for one due phase on an existing subscription. Test-clock advance applies a due phase by replacing subscription items and emits `customer.subscription.updated`; complex multi-phase billing, proration, invoices, and schedule lifecycle parity are not modeled. | | Subscription items | `POST /v1/subscription_items`, `DELETE /v1/subscription_items/{id}` | Partial | Add or remove local subscription items for integration smoke paths. Billing proration and invoice recalculation are not modeled. | -| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; taxes, pending invoice items, and full invoice collection lifecycle are outside the modeled subset. | +| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`. Invoice and preview responses include the common Stripe Invoice default fields expected by generated SDK models, including status transitions, automatic tax state, tax totals, payment settings, and array-shaped `discounts`. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; taxes, pending invoice items, and full invoice collection lifecycle are outside the modeled subset. | | Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | | Setup intents | `POST /v1/setup_intents`, `GET /v1/setup_intents`, `GET /v1/setup_intents/{id}`, `POST /v1/setup_intents/{id}/confirm`, `POST /v1/setup_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local setup intents with deterministic success, decline, and authentication-required aliases. Mandates, bank-account verification, and full SCA behavior are not modeled. | | Payment methods | `GET /v1/payment_methods?customer={id}&type=card`, `GET /v1/customers/{id}/payment_methods?type=card` | Partial | Returns deterministic sandbox card PaymentMethod projections for known customers, including SDK-friendly `billing_details`, `card.checks`, `country`, `fingerprint`, `funding`, `networks`, `three_d_secure_usage`, `metadata`, and `redaction` fields. Query validation covers `type`, `allow_redisplay`, `limit`, and unknown parameters; non-card valid types return an empty local list. Customer fixtures can opt into empty or explicit payment-method lists for no-card and multi-card local scenarios. Create, attach, detach, and general update are not supported. | diff --git a/internal/api/api.go b/internal/api/api.go index 6c913a3..b4e55b2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1960,6 +1960,13 @@ func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (ma behavior := invoicePreviewProrationBehavior(p) createdAt := invoicePreviewProrationDate(p, now) billingCycleAnchor := invoicePreviewBillingCycleAnchor(p, createdAt) + periodStart, periodEnd := invoicePreviewPeriod(subscription, createdAt) + billingReason := "upcoming" + testClock := any(nil) + if subscription.ID != "" { + billingReason = "subscription_update" + testClock = emptyToNil(firstNonEmptyString(metadataValue(subscription.Metadata, "test_clock"), metadataValue(subscription.Metadata, "testClock"))) + } amount := discountedTotal subtotal := newTotal totalDiscountAmount := discountAmount @@ -2032,24 +2039,78 @@ func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (ma lines = nil } return map[string]any{ - "id": "upcoming_in_" + strconv.FormatInt(now.Unix(), 10), - "object": "invoice", - "customer": emptyToNil(customerID), - "subscription": emptyToNil(subscriptionID), - "amount_due": amount, - "amount_paid": 0, - "amount_remaining": amount, - "subtotal": subtotal, - "total": amount, - "discount": firstDiscountObject(h, discounts, customerID, subscriptionID, ""), - "discounts": stripeList(path+"/discounts", discountObjects(h, discounts, customerID, subscriptionID, "")), - "total_discount_amounts": discountAmounts(discounts, totalDiscountAmount), - "currency": currency, - "created": now.Unix(), - "status": "draft", - "lines": stripeList(path+"/lines", lines), - "livemode": false, - "description": description, + "id": "upcoming_in_" + strconv.FormatInt(now.Unix(), 10), + "object": "invoice", + "customer": emptyToNil(customerID), + "subscription": emptyToNil(subscriptionID), + "parent": stripeInvoiceParent(subscriptionID), + "amount_due": amount, + "amount_paid": 0, + "amount_remaining": amount, + "amount_shipping": 0, + "subtotal": subtotal, + "subtotal_excluding_tax": subtotal, + "total": amount, + "total_excluding_tax": amount, + "discount": firstDiscountObject(h, discounts, customerID, subscriptionID, ""), + "discounts": discountObjects(h, discounts, customerID, subscriptionID, ""), + "total_discount_amounts": discountAmounts(discounts, totalDiscountAmount), + "currency": currency, + "created": now.Unix(), + "effective_at": nil, + "period_start": periodStart, + "period_end": periodEnd, + "status": "draft", + "lines": stripeList(path+"/lines", lines), + "payments": stripeList(path+"/payments", []map[string]any{}), + "livemode": false, + "description": description, + "account_country": nil, + "account_name": nil, + "account_tax_ids": nil, + "application": nil, + "application_fee_amount": nil, + "attempt_count": 0, + "attempted": false, + "auto_advance": false, + "automatic_tax": stripeAutomaticTax(), + "automatically_finalizes_at": nil, + "billing_reason": billingReason, + "charge": nil, + "collection_method": "charge_automatically", + "custom_fields": nil, + "default_payment_method": nil, + "default_source": nil, + "default_tax_rates": []map[string]any{}, + "due_date": nil, + "ending_balance": 0, + "footer": nil, + "from_invoice": nil, + "last_finalization_error": nil, + "metadata": map[string]string{}, + "next_payment_attempt": nil, + "number": nil, + "on_behalf_of": nil, + "paid": false, + "paid_out_of_band": false, + "payment_intent": nil, + "payment_settings": stripeInvoicePaymentSettings(), + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": nil, + "receipt_number": nil, + "rendering": nil, + "rendering_options": nil, + "shipping_cost": nil, + "shipping_details": nil, + "starting_balance": 0, + "statement_descriptor": nil, + "status_transitions": stripeInvoiceStatusTransitions(nil), + "tax": nil, + "test_clock": testClock, + "total_tax_amounts": []map[string]any{}, + "transfer_data": nil, + "webhooks_delivered_at": nil, "billtap_preview": map[string]any{ "proration_behavior": behavior, "proration_date": createdAt.Unix(), @@ -2058,6 +2119,21 @@ func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (ma }, nil } +func invoicePreviewPeriod(subscription billing.Subscription, createdAt time.Time) (int64, int64) { + if subscription.ID == "" { + return createdAt.Unix(), createdAt.Unix() + } + start := createdAt + if !subscription.CurrentPeriodStart.IsZero() { + start = subscription.CurrentPeriodStart + } + end := createdAt + if !subscription.CurrentPeriodEnd.IsZero() { + end = subscription.CurrentPeriodEnd + } + return start.Unix(), end.Unix() +} + func (h *Handler) lineItemTotal(ctx context.Context, items []billing.LineItem) (int64, string, map[string]map[string]any, error) { total := int64(0) currency := "" @@ -4864,7 +4940,7 @@ func (h *Handler) stripeSubscription(r *http.Request, sub billing.Subscription) "trial_end": metadataUnix(sub.Metadata["trial_end"]), "latest_invoice": emptyToNil(sub.LatestInvoiceID), "discount": firstDiscountObject(h, discounts, sub.CustomerID, sub.ID, ""), - "discounts": stripeList("/v1/subscriptions/"+sub.ID+"/discounts", discountObjects(h, discounts, sub.CustomerID, sub.ID, "")), + "discounts": discountObjects(h, discounts, sub.CustomerID, sub.ID, ""), "test_clock": emptyToNil(sub.Metadata["test_clock"]), "metadata": nonNilMap(sub.Metadata), "collection_method": stringDefault(sub.Metadata["collection_method"], "charge_automatically"), @@ -4934,36 +5010,139 @@ func subscriptionItemID(sub billing.Subscription, idx int) string { } func stripeInvoice(invoice billing.Invoice) map[string]any { + paidAt := optionalPaidAt(invoice) + created := unix(invoice.CreatedAt) + discounts := discountObjects(nil, invoice.Discounts, invoice.CustomerID, invoice.SubscriptionID, invoice.ID) return map[string]any{ - "id": invoice.ID, - "object": billing.ObjectInvoice, - "customer": invoice.CustomerID, - "subscription": emptyToNil(invoice.SubscriptionID), - "parent": map[string]any{"subscription_details": map[string]any{"subscription": emptyToNil(invoice.SubscriptionID)}}, - "status": invoice.Status, - "currency": invoice.Currency, - "subtotal": invoice.Subtotal, - "discount": firstDiscountObject(nil, invoice.Discounts, invoice.CustomerID, invoice.SubscriptionID, invoice.ID), - "discounts": stripeList("/v1/invoices/"+invoice.ID+"/discounts", discountObjects(nil, invoice.Discounts, invoice.CustomerID, invoice.SubscriptionID, invoice.ID)), - "total_discount_amounts": discountAmounts(invoice.Discounts, invoice.DiscountAmount), - "total": invoice.Total, - "amount_due": invoice.AmountDue, - "amount_paid": invoice.AmountPaid, - "attempt_count": invoice.AttemptCount, - "next_payment_attempt": optionalUnix(invoice.NextPaymentAttempt), - "payment_intent": emptyToNil(invoice.PaymentIntentID), - "payments": stripeList("/v1/invoices/"+invoice.ID+"/payments", []map[string]any{}), - "lines": stripeList("/v1/invoices/"+invoice.ID+"/lines", []map[string]any{}), - "created": unix(invoice.CreatedAt), - "created_at": invoice.CreatedAt, - "status_transitions": map[string]any{ - "paid_at": optionalPaidAt(invoice), + "id": invoice.ID, + "object": billing.ObjectInvoice, + "customer": invoice.CustomerID, + "subscription": emptyToNil(invoice.SubscriptionID), + "parent": stripeInvoiceParent(invoice.SubscriptionID), + "status": invoice.Status, + "currency": invoice.Currency, + "subtotal": invoice.Subtotal, + "subtotal_excluding_tax": invoice.Subtotal, + "discount": firstDiscountObject(nil, invoice.Discounts, invoice.CustomerID, invoice.SubscriptionID, invoice.ID), + "discounts": discounts, + "total_discount_amounts": discountAmounts(invoice.Discounts, invoice.DiscountAmount), + "total": invoice.Total, + "total_excluding_tax": invoice.Total, + "amount_due": invoice.AmountDue, + "amount_paid": invoice.AmountPaid, + "amount_remaining": invoice.AmountDue, + "amount_shipping": 0, + "attempt_count": invoice.AttemptCount, + "attempted": invoice.AttemptCount > 0, + "auto_advance": false, + "automatic_tax": stripeAutomaticTax(), + "automatically_finalizes_at": nil, + "billing_reason": stripeInvoiceBillingReason(invoice), + "collection_method": "charge_automatically", + "next_payment_attempt": optionalUnix(invoice.NextPaymentAttempt), + "payment_intent": emptyToNil(invoice.PaymentIntentID), + "payment_settings": stripeInvoicePaymentSettings(), + "payments": stripeList("/v1/invoices/"+invoice.ID+"/payments", []map[string]any{}), + "lines": stripeList("/v1/invoices/"+invoice.ID+"/lines", []map[string]any{}), + "created": created, + "created_at": invoice.CreatedAt, + "effective_at": nil, + "period_start": created, + "period_end": created, + "status_transitions": stripeInvoiceStatusTransitions(paidAt), + "account_country": nil, + "account_name": nil, + "account_tax_ids": nil, + "application": nil, + "application_fee_amount": nil, + "charge": nil, + "custom_fields": nil, + "default_payment_method": nil, + "default_source": nil, + "default_tax_rates": []map[string]any{}, + "due_date": nil, + "ending_balance": 0, + "footer": nil, + "from_invoice": nil, + "hosted_invoice_url": "", + "invoice_pdf": nil, + "last_finalization_error": nil, + "metadata": map[string]string{}, + "number": nil, + "on_behalf_of": nil, + "paid": invoice.Status == "paid", + "paid_out_of_band": false, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": nil, + "receipt_number": nil, + "rendering": nil, + "rendering_options": nil, + "shipping_cost": nil, + "shipping_details": nil, + "starting_balance": 0, + "statement_descriptor": nil, + "tax": nil, + "test_clock": nil, + "total_tax_amounts": []map[string]any{}, + "transfer_data": nil, + "webhooks_delivered_at": nil, + "livemode": false, + } +} + +func stripeInvoiceParent(subscriptionID string) map[string]any { + return map[string]any{ + "type": "subscription_details", + "subscription_details": map[string]any{ + "subscription": emptyToNil(subscriptionID), + "metadata": map[string]string{}, }, - "hosted_invoice_url": "", - "livemode": false, } } +func stripeInvoiceStatusTransitions(paidAt any) map[string]any { + return map[string]any{ + "finalized_at": nil, + "marked_uncollectible_at": nil, + "paid_at": paidAt, + "voided_at": nil, + } +} + +func stripeAutomaticTax() map[string]any { + return map[string]any{ + "enabled": false, + "status": nil, + } +} + +func stripeInvoicePaymentSettings() map[string]any { + return map[string]any{ + "default_mandate": nil, + "payment_method_options": map[string]any{ + "acss_debit": nil, + "bancontact": nil, + "card": nil, + "customer_balance": nil, + "konbini": nil, + "sepa_debit": nil, + "us_bank_account": nil, + }, + "payment_method_types": nil, + } +} + +func stripeInvoiceBillingReason(invoice billing.Invoice) string { + if invoice.SubscriptionID == "" { + return "manual" + } + if strings.HasPrefix(invoice.ID, "in_renewal_") { + return "subscription_cycle" + } + return "subscription_create" +} + func stripePaymentIntent(intent billing.PaymentIntent) map[string]any { captureMethod := stringDefault(intent.CaptureMethod, "automatic") return map[string]any{ diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 6faefc4..c4d4b55 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -3308,9 +3308,10 @@ func TestDiscountApplicationAcrossCheckoutPreviewRenewalAndDelete(t *testing.T) Subtotal int64 `json:"subtotal"` Total int64 `json:"total"` AmountPaid int64 `json:"amount_paid"` + Discounts []map[string]any `json:"discounts"` TotalDiscountAmounts []map[string]any `json:"total_discount_amounts"` }](t, handler, "/v1/invoices/"+completed.InvoiceID) - if invoice.Subtotal != 4000 || invoice.Total != 2000 || invoice.AmountPaid != 2000 || len(invoice.TotalDiscountAmounts) != 1 { + if invoice.Subtotal != 4000 || invoice.Total != 2000 || invoice.AmountPaid != 2000 || len(invoice.Discounts) != 1 || len(invoice.TotalDiscountAmounts) != 1 { t.Fatalf("discounted checkout invoice = %#v, want 50%% off 4000", invoice) } discount := getJSON[struct { @@ -4486,8 +4487,26 @@ func TestInvoicePreviewProrationAndUpcoming(t *testing.T) { } prorationDate := time.Date(2030, 1, 16, 0, 0, 0, 0, time.UTC).Unix() preview := postForm[struct { - Object string `json:"object"` - AmountDue int64 `json:"amount_due"` + Object string `json:"object"` + AmountDue int64 `json:"amount_due"` + AttemptCount int `json:"attempt_count"` + Attempted bool `json:"attempted"` + AutoAdvance bool `json:"auto_advance"` + BillingReason string `json:"billing_reason"` + CollectionMethod string `json:"collection_method"` + Discounts []map[string]any `json:"discounts"` + Metadata map[string]string `json:"metadata"` + Paid bool `json:"paid"` + PaymentIntent *string `json:"payment_intent"` + PeriodStart int64 `json:"period_start"` + PeriodEnd int64 `json:"period_end"` + SubtotalExcludingTax int64 `json:"subtotal_excluding_tax"` + TotalExcludingTax int64 `json:"total_excluding_tax"` + TotalTaxAmounts []map[string]any `json:"total_tax_amounts"` + AutomaticTax struct { + Enabled bool `json:"enabled"` + Status *string `json:"status"` + } `json:"automatic_tax"` BilltapPreview struct { BillingCycleAnchor int64 `json:"billing_cycle_anchor"` } `json:"billtap_preview"` @@ -4505,6 +4524,12 @@ func TestInvoicePreviewProrationAndUpcoming(t *testing.T) { } `json:"parent"` } `json:"data"` } `json:"lines"` + StatusTransitions struct { + FinalizedAt *int64 `json:"finalized_at"` + MarkedUncollectibleAt *int64 `json:"marked_uncollectible_at"` + PaidAt *int64 `json:"paid_at"` + VoidedAt *int64 `json:"voided_at"` + } `json:"status_transitions"` }](t, handler, "/v1/invoices/create_preview", url.Values{ "subscription": {"sub_preview_proration"}, "subscription_details[items][0][price]": {pro.ID}, @@ -4516,6 +4541,18 @@ func TestInvoicePreviewProrationAndUpcoming(t *testing.T) { if preview.Object != "invoice" || preview.AmountDue != 1000 || preview.BilltapPreview.BillingCycleAnchor != prorationDate || len(preview.Lines.Data) != 1 || !preview.Lines.Data[0].Proration || preview.Lines.Data[0].Parent.SubscriptionItemDetails.Price != pro.ID { t.Fatalf("preview = %#v, want one 1000-cent proration line and accepted billing_cycle_anchor", preview) } + if preview.AttemptCount != 0 || preview.Attempted || preview.AutoAdvance || preview.BillingReason != "subscription_update" || preview.CollectionMethod != "charge_automatically" || preview.Paid || preview.PaymentIntent != nil { + t.Fatalf("preview SDK invoice defaults = %#v, want Stripe invoice defaults", preview) + } + if preview.Metadata == nil || preview.Discounts == nil || preview.TotalTaxAmounts == nil || preview.AutomaticTax.Enabled || preview.AutomaticTax.Status != nil { + t.Fatalf("preview schema collections = %#v, want non-nil metadata/arrays and disabled automatic_tax", preview) + } + if preview.PeriodStart != time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC).Unix() || preview.PeriodEnd != time.Date(2030, 1, 31, 0, 0, 0, 0, time.UTC).Unix() || preview.SubtotalExcludingTax != 1000 || preview.TotalExcludingTax != 1000 { + t.Fatalf("preview period/tax totals = %#v, want subscription period and excluding tax totals", preview) + } + if preview.StatusTransitions.FinalizedAt != nil || preview.StatusTransitions.MarkedUncollectibleAt != nil || preview.StatusTransitions.PaidAt != nil || preview.StatusTransitions.VoidedAt != nil { + t.Fatalf("preview status_transitions = %#v, want nullable transition fields", preview.StatusTransitions) + } q := url.Values{} q.Set("subscription", "sub_preview_proration") q.Set("subscription_details[items][0][price]", pro.ID)