diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe5b9a..facd65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Added a manual invoice-backed one-time payment flow for local SaaS usage + charges, including `POST /v1/invoices`, `POST /v1/invoiceitems`, + `POST /v1/invoices/{id}/finalize`, metadata preservation, expanded + payment-intent evidence, and customer-level default outcomes for invoice pay. +- Accepted Stripe-compatible `proration_date` on subscription updates and + retained subscription update billing/proration parameters as local evidence. - Added customer-level default PaymentIntent outcomes so fixture-seeded customers can drive confirmed one-time payment failures without changing app-created PaymentIntent metadata. diff --git a/cmd/billtap/main_test.go b/cmd/billtap/main_test.go index 6852339..8679d81 100644 --- a/cmd/billtap/main_test.go +++ b/cmd/billtap/main_test.go @@ -81,7 +81,7 @@ func TestRunCompatibilityWritesScorecard(t *testing.T) { if !fileContains(t, filepath.Join(dir, "compatibility-scorecard.json"), `"passed": true`) { t.Fatalf("JSON scorecard missing passed true") } - if !fileContains(t, filepath.Join(dir, "compatibility-scorecard.json"), `"release_blocking": 49`) { + if !fileContains(t, filepath.Join(dir, "compatibility-scorecard.json"), `"release_blocking": 50`) { t.Fatalf("JSON scorecard missing expected release-blocking count") } if !fileContains(t, filepath.Join(dir, "compatibility-scorecard.md"), "# Compatibility Scorecard") { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index e55f4ae..82103dc 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -103,10 +103,11 @@ using the unprefixed service URL. | Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. `PUBLIC_BASE_PATH`, `BILLTAP_PUBLIC_BASE_PATH`, and `X-Forwarded-Prefix` are reflected in hosted browser URLs. | | Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. | | Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. | -| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `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`, `cancel_at_period_end`, and `pause_collection` evidence; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. 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. | +| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `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`, `cancel_at_period_end`, `pause_collection`, and accepted billing/proration evidence including `proration_date`, `proration_behavior`, `payment_behavior`, `billing_cycle_anchor`, and `trial_end`; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. 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/search`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve/search invoices created by checkout. Search supports `id`, `customer`, `subscription`, and `status` clauses joined by `AND`. `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. | +| Invoices | `POST /v1/invoices`, `GET /v1/invoices`, `GET /v1/invoices/search`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/finalize`, `POST /v1/invoices/{id}/pay`, `GET /v1/invoices/{id}/lines`, `GET /v1/invoices/{id}/payments`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve/search invoices created by checkout and create a bounded manual one-time invoice flow. Search supports `id`, `customer`, `subscription`, and `status` clauses joined by `AND`. `pay` retries open checkout invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Manual invoices accept `customer`, `currency`, `collection_method`, `default_payment_method`, `description`, `auto_advance=false`, `pending_invoice_items_behavior`, `payment_settings[payment_method_types]`, and `metadata[...]`; `finalize` creates local PaymentIntent evidence, and `pay` applies per-intent or customer-level default outcomes including success, card decline, and `requires_action`. Invoice responses include `confirmation_secret`, local hosted/PDF paths, `payments.data.payment.payment_intent`, metadata, status transitions, automatic tax state, tax totals, payment settings, and array-shaped `discounts`. 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, invoice rendering, automatic collection, and full dunning lifecycle are outside the modeled subset. | +| Invoice items | `POST /v1/invoiceitems`, `GET /v1/invoiceitems`, `GET /v1/invoiceitems/{id}` | Partial | Creates and lists local invoice item evidence for manual one-time invoices. `customer`, `invoice`, `amount`, `currency`, `description`, and `metadata[...]` are accepted. Tax, discount, pending invoice-item collection, and line mutation parity are not modeled. | | Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/search`, `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/search and mutate local payment intents. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. `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/POST /v1/payment_methods`, `GET/POST /v1/payment_methods/{id}`, `POST /v1/payment_methods/{id}/attach`, `POST /v1/payment_methods/{id}/detach`, `GET /v1/customers/{id}/payment_methods?type=card`, `GET /v1/customers/{id}/payment_methods/{id}` | 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 mutate local customer payment-method metadata and reject real card data; general update is accepted as a compatibility no-op for projected local cards. | @@ -328,7 +329,7 @@ Fixture packs support repeatable local setup and assertions for: `payment_methods_fixture: empty` for no-card setup paths - customer default PaymentIntent outcome fixture controls through `default_payment_intent_outcome` or `billtap_default_payment_intent_outcome` - metadata + metadata for direct and invoice-backed one-time payments - test clocks - subscription graphs created through the normal checkout-completion path - explicit subscription status/time fields for local lifecycle setup @@ -387,9 +388,9 @@ Billtap does not support or claim: - Provider-specific settlement, risk, tax, invoice rendering, fraud, account, payout, or dispute behavior. - Complete webhook event coverage. -- Direct invoice create, finalize, send, void, line mutation, collection, and - full dunning automation. Billtap only supports the local `pay` retry subset - documented above. +- Full invoice rendering, send, void, line mutation, automatic collection, tax, + and dunning automation. Billtap supports the checkout retry subset and the + bounded manual one-time invoice flow documented above. Use Stripe testmode or the real provider sandbox as the fallback lane for these behaviors. diff --git a/internal/api/api.go b/internal/api/api.go index 05f8e80..a3b9a7e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -103,6 +103,8 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/subscription_schedules/", h.handleSubscriptionSchedule) h.mux.HandleFunc("/v1/subscription_items", h.handleSubscriptionItems) h.mux.HandleFunc("/v1/subscription_items/", h.handleSubscriptionItem) + h.mux.HandleFunc("/v1/invoiceitems", h.handleInvoiceItems) + h.mux.HandleFunc("/v1/invoiceitems/", h.handleInvoiceItem) h.mux.HandleFunc("/v1/invoices/create_preview", h.handleInvoicePreview) h.mux.HandleFunc("/v1/invoices/upcoming", h.handleInvoicePreview) h.mux.HandleFunc("/v1/invoices", h.handleInvoices) @@ -2279,7 +2281,7 @@ func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (ma "shipping_details": nil, "starting_balance": 0, "statement_descriptor": nil, - "status_transitions": stripeInvoiceStatusTransitions(nil), + "status_transitions": stripeInvoiceStatusTransitions(nil, nil), "tax": nil, "test_clock": testClock, "total_tax_amounts": []map[string]any{}, @@ -2392,21 +2394,44 @@ func invoicePreviewLineItems(p params) []billing.LineItem { } func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - h.methodNotAllowed(w, r, "GET") - return - } - items, err := h.billing.ListInvoices(r.Context()) - if err != nil { - writeResult(w, nil, err) - return - } - filtered := filterInvoices(items, r) - data := make([]map[string]any, 0, len(filtered)) - for _, item := range filtered { - data = append(data, stripeInvoice(item)) + switch r.Method { + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceCreate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + 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, + }) + if err == nil { + h.emitGenericWebhook(r, "invoice.created", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + case http.MethodGet: + items, err := h.billing.ListInvoices(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterInvoices(items, r) + data := make([]map[string]any, 0, len(filtered)) + for _, item := range filtered { + data = append(data, h.stripeInvoice(r.Context(), item)) + } + writeResult(w, stripeList(r.URL.Path, data), nil) + default: + h.methodNotAllowed(w, r, "GET, POST") } - writeResult(w, stripeList(r.URL.Path, data), nil) } func (h *Handler) handleInvoiceSearch(w http.ResponseWriter, r *http.Request) { @@ -2435,11 +2460,72 @@ func (h *Handler) handleInvoiceSearch(w http.ResponseWriter, r *http.Request) { } data := make([]map[string]any, 0, len(filtered)) for _, item := range filtered { - data = append(data, stripeInvoice(item)) + data = append(data, h.stripeInvoice(r.Context(), item)) } writeResult(w, stripeSearchResult(r.URL.Path, p.string("query"), data), nil) } +func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceItemCreate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + item, _, err := h.billing.CreateInvoiceItem(r.Context(), 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(), + }) + writeResult(w, stripeInvoiceItem(item), err) + case http.MethodGet: + items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{ + 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) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func (h *Handler) handleInvoiceItem(w http.ResponseWriter, r *http.Request) { + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/invoiceitems/"), "/") + if id == "" || strings.Contains(id, "/") { + h.notFound(w, r) + return + } + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{}) + if err != nil { + writeResult(w, nil, err) + return + } + for _, item := range items { + if item.ID == id { + writeJSON(w, http.StatusOK, stripeInvoiceItem(item)) + return + } + } + writeResult(w, nil, billing.ErrNotFound) +} + func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/invoices/"), "/") if rest == "" { @@ -2448,6 +2534,30 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { } parts := strings.Split(rest, "/") id := parts[0] + if len(parts) == 2 && parts[1] == "finalize" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceFinalize(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + 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) + if result.PaymentIntent.ID != "" { + h.emitPaymentIntentWebhook(r, "payment_intent.created", result.PaymentIntent) + } + } + writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err) + return + } if len(parts) == 2 && parts[1] == "pay" { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -2476,7 +2586,34 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { if err == nil { h.emitInvoicePaymentWebhooks(r, result, webhooks.SourceAPI) } - writeResult(w, stripeInvoice(result.Invoice), err) + writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err) + return + } + if len(parts) == 2 && parts[1] == "lines" { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + 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) + return + } + if len(parts) == 2 && parts[1] == "payments" { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + invoice, err := h.billing.GetInvoice(r.Context(), id) + if err != nil { + writeResult(w, nil, err) + return + } + payments := h.stripeInvoicePayments(r.Context(), invoice, nil) + writeResult(w, stripeList(r.URL.Path, payments), nil) return } if len(parts) != 1 { @@ -2488,7 +2625,7 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { return } invoice, err := h.billing.GetInvoice(r.Context(), id) - writeResult(w, stripeInvoice(invoice), err) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) } func (h *Handler) handleRefunds(w http.ResponseWriter, r *http.Request) { @@ -4630,6 +4767,30 @@ func (p params) metadata() map[string]string { return out } +func invoiceMetadataFromParams(p params) map[string]string { + metadata := p.metadata() + for _, item := range []struct { + param string + key string + }{ + {param: "description", key: "description"}, + {param: "default_payment_method", key: billing.MetadataDefaultPaymentMethod}, + {param: "collection_method", key: "collection_method"}, + {param: "auto_advance", key: "auto_advance"}, + {param: "pending_invoice_items_behavior", key: "pending_invoice_items_behavior"}, + {param: "payment_settings[payment_method_types][]", key: "payment_method_types"}, + {param: "payment_settings[payment_method_types][0]", key: "payment_method_types"}, + } { + if value := p.string(item.param); value != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata[item.key] = value + } + } + return metadata +} + func paymentIntentMetadata(p params) map[string]string { metadata := p.metadata() outcome := firstNonEmptyString( @@ -5452,10 +5613,53 @@ func subscriptionItemID(sub billing.Subscription, idx int) string { return "si_" + sanitizeID(sub.ID+"_"+strconv.Itoa(idx)) } +func (h *Handler) stripeInvoice(ctx context.Context, invoice billing.Invoice) map[string]any { + var intent *billing.PaymentIntent + if invoice.PaymentIntentID != "" { + if pi, err := h.billing.GetPaymentIntent(ctx, invoice.PaymentIntentID); err == nil { + intent = &pi + } + } + return stripeInvoiceWithPaymentIntent(invoice, intent) +} + +func (h *Handler) stripeInvoiceWithPaymentIntent(invoice billing.Invoice, intent billing.PaymentIntent) map[string]any { + if intent.ID == "" { + return stripeInvoiceWithPaymentIntent(invoice, nil) + } + return stripeInvoiceWithPaymentIntent(invoice, &intent) +} + +func (h *Handler) stripeInvoicePayments(ctx context.Context, invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any { + if invoice.PaymentIntentID == "" { + return []map[string]any{} + } + if intent == nil { + if pi, err := h.billing.GetPaymentIntent(ctx, invoice.PaymentIntentID); err == nil { + intent = &pi + } + } + return stripeInvoicePaymentRecords(invoice, intent) +} + func stripeInvoice(invoice billing.Invoice) map[string]any { + return stripeInvoiceWithPaymentIntent(invoice, nil) +} + +func stripeInvoiceWithPaymentIntent(invoice billing.Invoice, intent *billing.PaymentIntent) map[string]any { paidAt := optionalPaidAt(invoice) + finalizedAt := optionalFinalizedAt(invoice) created := unix(invoice.CreatedAt) discounts := discountObjects(nil, invoice.Discounts, invoice.CustomerID, invoice.SubscriptionID, invoice.ID) + payments := stripeInvoicePaymentRecords(invoice, intent) + confirmationSecret := any(nil) + if invoice.PaymentIntentID != "" { + confirmationSecret = map[string]any{ + "object": "confirmation_secret", + "type": "payment_intent", + "client_secret": invoice.PaymentIntentID + "_secret_billtap", + } + } return map[string]any{ "id": invoice.ID, "object": billing.ObjectInvoice, @@ -5481,18 +5685,20 @@ func stripeInvoice(invoice billing.Invoice) map[string]any { "automatic_tax": stripeAutomaticTax(), "automatically_finalizes_at": nil, "billing_reason": stripeInvoiceBillingReason(invoice), - "collection_method": "charge_automatically", + "collection_method": stringDefault(invoice.Metadata["collection_method"], "charge_automatically"), + "description": emptyToNil(invoice.Metadata["description"]), "next_payment_attempt": optionalUnix(invoice.NextPaymentAttempt), "payment_intent": emptyToNil(invoice.PaymentIntentID), - "payment_settings": stripeInvoicePaymentSettings(), - "payments": stripeList("/v1/invoices/"+invoice.ID+"/payments", []map[string]any{}), + "payment_settings": stripeInvoicePaymentSettings(invoice.Metadata), + "payments": stripeList("/v1/invoices/"+invoice.ID+"/payments", payments), "lines": stripeList("/v1/invoices/"+invoice.ID+"/lines", []map[string]any{}), + "confirmation_secret": confirmationSecret, "created": created, "created_at": invoice.CreatedAt, "effective_at": nil, "period_start": created, "period_end": created, - "status_transitions": stripeInvoiceStatusTransitions(paidAt), + "status_transitions": stripeInvoiceStatusTransitions(finalizedAt, paidAt), "account_country": nil, "account_name": nil, "account_tax_ids": nil, @@ -5500,17 +5706,17 @@ func stripeInvoice(invoice billing.Invoice) map[string]any { "application_fee_amount": nil, "charge": nil, "custom_fields": nil, - "default_payment_method": nil, + "default_payment_method": emptyToNil(invoice.Metadata[billing.MetadataDefaultPaymentMethod]), "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, + "hosted_invoice_url": "/invoices/" + invoice.ID + "/hosted", + "invoice_pdf": "/invoices/" + invoice.ID + ".pdf", "last_finalization_error": nil, - "metadata": map[string]string{}, + "metadata": nonNilMap(invoice.Metadata), "number": nil, "on_behalf_of": nil, "paid": invoice.Status == "paid", @@ -5534,6 +5740,67 @@ func stripeInvoice(invoice billing.Invoice) map[string]any { } } +func stripeInvoiceItem(item billing.InvoiceItem) map[string]any { + return 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), + "created": unix(item.CreatedAt), + "livemode": false, + } +} + +func stripeInvoicePaymentRecords(invoice billing.Invoice, intent *billing.PaymentIntent) []map[string]any { + if invoice.PaymentIntentID == "" { + return []map[string]any{} + } + paymentIntent := any(invoice.PaymentIntentID) + status := invoicePaymentRecordStatus(invoice, "") + if intent != nil && intent.ID != "" { + paymentIntent = stripePaymentIntent(*intent) + status = invoicePaymentRecordStatus(invoice, intent.Status) + } + payment := map[string]any{ + "id": "py_" + sanitizeID(invoice.PaymentIntentID), + "object": "payment", + "payment_intent": paymentIntent, + "status": status, + } + return []map[string]any{{ + "id": "inpay_" + sanitizeID(invoice.ID), + "object": "invoice_payment", + "amount_requested": invoice.Total, + "amount_paid": invoice.AmountPaid, + "currency": invoice.Currency, + "invoice": invoice.ID, + "is_default": true, + "payment": payment, + "payment_intent": paymentIntent, + "status": status, + }} +} + +func invoicePaymentRecordStatus(invoice billing.Invoice, paymentIntentStatus string) string { + if invoice.Status == "paid" { + return "paid" + } + switch paymentIntentStatus { + case "requires_action": + return "requires_action" + case "requires_payment_method": + return "failed" + case "processing": + return "processing" + default: + return invoice.Status + } +} + func stripeInvoiceParent(subscriptionID string) map[string]any { return map[string]any{ "type": "subscription_details", @@ -5544,9 +5811,9 @@ func stripeInvoiceParent(subscriptionID string) map[string]any { } } -func stripeInvoiceStatusTransitions(paidAt any) map[string]any { +func stripeInvoiceStatusTransitions(finalizedAt any, paidAt any) map[string]any { return map[string]any{ - "finalized_at": nil, + "finalized_at": finalizedAt, "marked_uncollectible_at": nil, "paid_at": paidAt, "voided_at": nil, @@ -5560,7 +5827,13 @@ func stripeAutomaticTax() map[string]any { } } -func stripeInvoicePaymentSettings() map[string]any { +func stripeInvoicePaymentSettings(metadata ...map[string]string) map[string]any { + paymentMethodTypes := any(nil) + if len(metadata) > 0 { + if value := strings.TrimSpace(metadata[0]["payment_method_types"]); value != "" { + paymentMethodTypes = []string{value} + } + } return map[string]any{ "default_mandate": nil, "payment_method_options": map[string]any{ @@ -5572,7 +5845,7 @@ func stripeInvoicePaymentSettings() map[string]any { "sepa_debit": nil, "us_bank_account": nil, }, - "payment_method_types": nil, + "payment_method_types": paymentMethodTypes, } } @@ -6226,6 +6499,11 @@ func subscriptionUpdateMetadata(p params) map[string]string { }{ {param: "cancellation_details[comment]", key: "cancellation_details_comment"}, {param: "cancellation_details[feedback]", key: "cancellation_details_feedback"}, + {param: "proration_behavior", key: "proration_behavior"}, + {param: "proration_date", key: "proration_date"}, + {param: "payment_behavior", key: "payment_behavior"}, + {param: "billing_cycle_anchor", key: "billing_cycle_anchor"}, + {param: "trial_end", key: "trial_end"}, } { if value := p.string(item.param); value != "" { if metadata == nil { @@ -6319,6 +6597,16 @@ func optionalPaidAt(invoice billing.Invoice) any { return unix(invoice.CreatedAt) } +func optionalFinalizedAt(invoice billing.Invoice) any { + if invoice.Status == "draft" { + return nil + } + if value := metadataUnix(invoice.Metadata["billtap_finalized_at"]); value != nil { + return value + } + return unix(invoice.CreatedAt) +} + func paymentIntentError(intent billing.PaymentIntent) any { if intent.FailureCode == "" && intent.FailureMessage == "" { return nil diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 4d4cc77..6222457 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -3577,8 +3577,9 @@ func TestSubscriptionUpdatePreservesItemsAndSupportsAdditiveSeatItems(t *testing } `json:"price"` } type subscriptionResponse struct { - ID string `json:"id"` - Items struct { + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` + Items struct { Data []subscriptionItem `json:"data"` } `json:"items"` } @@ -3596,17 +3597,22 @@ func TestSubscriptionUpdatePreservesItemsAndSupportsAdditiveSeatItems(t *testing "items[0][price]": {proYearly.ID}, "items[0][quantity]": {"1"}, "proration_behavior": {"always_invoice"}, + "proration_date": {"1894752000"}, "payment_behavior": {"error_if_incomplete"}, "billing_cycle_anchor": {"now"}, }) if len(upgraded.Items.Data) != 1 || upgraded.Items.Data[0].Price.ID != proYearly.ID { t.Fatalf("upgraded items = %#v, want existing item price replaced", upgraded.Items.Data) } + if upgraded.Metadata["proration_date"] != "1894752000" || upgraded.Metadata["payment_behavior"] != "error_if_incomplete" { + t.Fatalf("upgraded metadata = %#v, want accepted proration/payment behavior params", upgraded.Metadata) + } withSeats := postForm[subscriptionResponse](t, handler, "/v1/subscriptions/"+created.ID, url.Values{ "items[0][price]": {seatYearly.ID}, "items[0][quantity]": {"2"}, "proration_behavior": {"always_invoice"}, + "proration_date": {"1894752000"}, "payment_behavior": {"error_if_incomplete"}, "billing_cycle_anchor": {"now"}, }) @@ -3621,6 +3627,126 @@ func TestSubscriptionUpdatePreservesItemsAndSupportsAdditiveSeatItems(t *testing } } +func TestInvoiceBackedOneTimePaymentFlow(t *testing.T) { + handler := newTestHandler(t) + + type paymentIntentResponse struct { + ID string `json:"id"` + Status string `json:"status"` + ClientSecret string `json:"client_secret"` + LastPaymentError *struct { + Code string `json:"code"` + } `json:"last_payment_error"` + Metadata map[string]string `json:"metadata"` + } + type invoiceResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Customer string `json:"customer"` + Status string `json:"status"` + AmountDue int64 `json:"amount_due"` + AmountPaid int64 `json:"amount_paid"` + Currency string `json:"currency"` + HostedInvoiceURL string `json:"hosted_invoice_url"` + InvoicePDF string `json:"invoice_pdf"` + PaymentIntent string `json:"payment_intent"` + Metadata map[string]string `json:"metadata"` + ConfirmationSecret *struct { + ClientSecret string `json:"client_secret"` + } `json:"confirmation_secret"` + Payments struct { + Data []struct { + Status string `json:"status"` + Payment struct { + PaymentIntent paymentIntentResponse `json:"payment_intent"` + } `json:"payment"` + } `json:"data"` + } `json:"payments"` + } + createInvoice := func(t *testing.T, customerID string) invoiceResponse { + t.Helper() + invoice := postForm[invoiceResponse](t, handler, "/v1/invoices", url.Values{ + "customer": {customerID}, + "currency": {"usd"}, + "collection_method": {"charge_automatically"}, + "default_payment_method": {"pm_card_visa"}, + "description": {"One-time usage"}, + "auto_advance": {"false"}, + "pending_invoice_items_behavior": {"exclude"}, + "payment_settings[payment_method_types][0]": {"card"}, + "metadata[paymentType]": {"EXTRA_USAGE"}, + "metadata[accountId]": {"acct_local"}, + "metadata[exportRequestHash]": {"hash_local"}, + }) + postForm[struct { + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customerID}, + "invoice": {invoice.ID}, + "amount": {"100"}, + "currency": {"usd"}, + "description": {"One-time usage"}, + "metadata[paymentType]": {"EXTRA_USAGE"}, + "metadata[provisionRunId]": {"prov_local"}, + }) + return invoice + } + + successCustomer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"id": {"cus_invoice_success"}, "email": {"invoice-success@example.test"}}) + successInvoice := createInvoice(t, successCustomer.ID) + finalized := postForm[invoiceResponse](t, handler, "/v1/invoices/"+successInvoice.ID+"/finalize", url.Values{ + "expand[]": {"confirmation_secret"}, + }) + if finalized.Status != "open" || finalized.AmountDue != 100 || finalized.ConfirmationSecret == nil || finalized.ConfirmationSecret.ClientSecret == "" { + t.Fatalf("finalized invoice = %#v, want open invoice with confirmation secret", finalized) + } + paid := postForm[invoiceResponse](t, handler, "/v1/invoices/"+successInvoice.ID+"/pay", url.Values{ + "expand[]": {"payments.data.payment.payment_intent"}, + }) + if paid.Status != "paid" || paid.AmountPaid != 100 || paid.AmountDue != 0 || paid.Currency != "usd" || paid.HostedInvoiceURL == "" || paid.InvoicePDF == "" { + t.Fatalf("paid invoice = %#v, want paid one-time invoice", paid) + } + if paid.Metadata["accountId"] != "acct_local" || paid.Metadata["paymentType"] != "EXTRA_USAGE" { + t.Fatalf("paid metadata = %#v, want invoice metadata preserved", paid.Metadata) + } + if len(paid.Payments.Data) != 1 || paid.Payments.Data[0].Payment.PaymentIntent.Status != "succeeded" || paid.Payments.Data[0].Payment.PaymentIntent.ClientSecret == "" { + t.Fatalf("paid payments = %#v, want expanded succeeded payment intent", paid.Payments.Data) + } + + failCustomer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "id": {"cus_invoice_fail"}, + "email": {"invoice-fail@example.test"}, + "metadata[default_payment_intent_outcome]": {"card_declined"}, + }) + failInvoice := createInvoice(t, failCustomer.ID) + _ = postForm[invoiceResponse](t, handler, "/v1/invoices/"+failInvoice.ID+"/finalize", nil) + failed := postForm[invoiceResponse](t, handler, "/v1/invoices/"+failInvoice.ID+"/pay", url.Values{ + "expand[]": {"payments.data.payment.payment_intent"}, + }) + if failed.Status != "open" || failed.AmountPaid != 0 || failed.AmountDue != 100 || len(failed.Payments.Data) != 1 { + t.Fatalf("failed invoice = %#v, want open invoice with amount due", failed) + } + failedPI := failed.Payments.Data[0].Payment.PaymentIntent + if failedPI.Status != "requires_payment_method" || failedPI.LastPaymentError == nil || failedPI.LastPaymentError.Code != "card_declined" { + t.Fatalf("failed payment intent = %#v, want card_declined", failedPI) + } + + actionCustomer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "id": {"cus_invoice_action"}, + "email": {"invoice-action@example.test"}, + "metadata[default_payment_intent_outcome]": {"requires_action"}, + }) + actionInvoice := createInvoice(t, actionCustomer.ID) + _ = postForm[invoiceResponse](t, handler, "/v1/invoices/"+actionInvoice.ID+"/finalize", nil) + action := postForm[invoiceResponse](t, handler, "/v1/invoices/"+actionInvoice.ID+"/pay", url.Values{ + "expand[]": {"payments.data.payment.payment_intent"}, + }) + if action.Status != "open" || len(action.Payments.Data) != 1 || action.Payments.Data[0].Payment.PaymentIntent.Status != "requires_action" || action.ConfirmationSecret == nil || action.ConfirmationSecret.ClientSecret == "" { + t.Fatalf("requires-action invoice = %#v, want open invoice with requires_action intent and confirmation secret", action) + } +} + func TestCustomerSearchAndNestedSubscriptionRoutes(t *testing.T) { handler := newTestHandler(t) diff --git a/internal/api/validation.go b/internal/api/validation.go index b5f8fc8..d59bde5 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -36,6 +36,7 @@ var ( promotionRestrictionParamRE = regexp.MustCompile(`^restrictions(\[[^\]]+\])+$`) schedulePhaseParamRE = regexp.MustCompile(`^phases\[\d+\]\[(start_date|end_date|iterations|items|plans)\].*$`) invoicePreviewItemParamRE = regexp.MustCompile(`^((subscription_details|subscriptionDetails)\[items\]\[\d+\]\[(id|price|price_id|quantity)\]|(subscription_items|items)\[\d+\]\[(id|price|price_id|quantity)\])$`) + invoicePaymentSettingsRE = regexp.MustCompile(`^payment_settings(\[[^\]]+\])+$`) ) var stripePaymentMethodTypes = []string{ @@ -731,6 +732,7 @@ func validateSubscriptionUpdate(p params) error { "pause_collection[behavior]", "pause_collection[resumes_at]", "proration_behavior", + "proration_date", "payment_behavior", "billing_cycle_anchor", "trial_end", @@ -739,7 +741,7 @@ func validateSubscriptionUpdate(p params) error { }, AllowedRegex: []*regexp.Regexp{subscriptionItemRE, cancellationDetailsRE, discountParamRE}, BoolParams: []string{"cancel_at_period_end"}, - Int64Params: []string{"pause_collection[resumes_at]"}, + Int64Params: []string{"pause_collection[resumes_at]", "proration_date"}, EnumParams: map[string][]string{ "pause_collection[behavior]": {"void", "keep_as_draft", "mark_uncollectible"}, "proration_behavior": {"none", "create_prorations", "always_invoice"}, @@ -854,6 +856,58 @@ func validateInvoicePay(p params) error { }) } +func validateInvoiceCreate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "id", + "customer", + "currency", + "collection_method", + "default_payment_method", + "description", + "auto_advance", + "pending_invoice_items_behavior", + }, + AllowedRegex: []*regexp.Regexp{invoicePaymentSettingsRE}, + Required: []string{"customer"}, + BoolParams: []string{"auto_advance"}, + EnumParams: map[string][]string{ + "collection_method": {"charge_automatically", "send_invoice"}, + "pending_invoice_items_behavior": {"exclude", "include"}, + }, + AllowMetadata: true, + }) +} + +func validateInvoiceItemCreate(p params) error { + if err := p.validate(paramSpec{ + Allowed: []string{ + "id", + "customer", + "invoice", + "amount", + "currency", + "description", + }, + Required: []string{"customer", "invoice", "amount", "currency"}, + Int64Params: []string{"amount"}, + AllowMetadata: true, + }); err != nil { + return err + } + if p.int64("amount") == 0 { + return invalidParam("amount", "Must be non-zero.") + } + return nil +} + +func validateInvoiceFinalize(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"auto_advance"}, + BoolParams: []string{"auto_advance"}, + }) +} + func validateInvoicePreview(p params) error { if err := p.validate(paramSpec{ Allowed: []string{ diff --git a/internal/billing/models.go b/internal/billing/models.go index 1d6f3b7..8a4ac49 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -9,6 +9,7 @@ const ( ObjectCheckoutSession = "checkout.session" ObjectSubscription = "subscription" ObjectInvoice = "invoice" + ObjectInvoiceItem = "invoiceitem" ObjectPaymentIntent = "payment_intent" ObjectSetupIntent = "setup_intent" ObjectTestClock = "test_helpers.test_clock" @@ -128,22 +129,35 @@ type Subscription struct { } type Invoice struct { - ID string `json:"id"` - Object string `json:"object"` - CustomerID string `json:"customer"` - SubscriptionID string `json:"subscription,omitempty"` - Status string `json:"status"` - Currency string `json:"currency"` - Subtotal int64 `json:"subtotal"` - DiscountAmount int64 `json:"discount_amount,omitempty"` - Discounts []Discount `json:"discounts,omitempty"` - Total int64 `json:"total"` - AmountDue int64 `json:"amount_due"` - AmountPaid int64 `json:"amount_paid"` - AttemptCount int `json:"attempt_count"` - NextPaymentAttempt *time.Time `json:"next_payment_attempt,omitempty"` - PaymentIntentID string `json:"payment_intent,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Object string `json:"object"` + CustomerID string `json:"customer"` + SubscriptionID string `json:"subscription,omitempty"` + Status string `json:"status"` + Currency string `json:"currency"` + Subtotal int64 `json:"subtotal"` + DiscountAmount int64 `json:"discount_amount,omitempty"` + Discounts []Discount `json:"discounts,omitempty"` + Total int64 `json:"total"` + AmountDue int64 `json:"amount_due"` + AmountPaid int64 `json:"amount_paid"` + AttemptCount int `json:"attempt_count"` + NextPaymentAttempt *time.Time `json:"next_payment_attempt,omitempty"` + PaymentIntentID string `json:"payment_intent,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type InvoiceItem struct { + ID string `json:"id"` + Object string `json:"object"` + CustomerID string `json:"customer"` + InvoiceID string `json:"invoice"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` } type InvoicePaymentOptions struct { @@ -318,6 +332,11 @@ type InvoiceFilter struct { SubscriptionID string } +type InvoiceItemFilter struct { + CustomerID string + InvoiceID string +} + type PaymentIntentFilter struct { CustomerID string InvoiceIDs []string diff --git a/internal/billing/service.go b/internal/billing/service.go index 675d32f..c3c6f08 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -52,10 +52,16 @@ type Repository interface { ListSubscriptions(context.Context) ([]Subscription, error) ListSubscriptionsByCustomer(context.Context, string) ([]Subscription, error) UpdateSubscription(context.Context, Subscription, []TimelineEntry) (Subscription, error) + CreateInvoice(context.Context, Invoice, []TimelineEntry) (Invoice, error) GetInvoice(context.Context, string) (Invoice, error) ListInvoices(context.Context) ([]Invoice, error) ListInvoicesFiltered(context.Context, InvoiceFilter) ([]Invoice, error) + UpdateInvoice(context.Context, Invoice, []TimelineEntry) (Invoice, error) + CreateInvoiceItem(context.Context, InvoiceItem, Invoice, []TimelineEntry) (InvoiceItem, Invoice, error) + ListInvoiceItemsFiltered(context.Context, InvoiceItemFilter) ([]InvoiceItem, error) + FinalizeInvoice(context.Context, Invoice, PaymentIntent, []TimelineEntry) (Invoice, PaymentIntent, error) UpdateInvoicePayment(context.Context, Subscription, Invoice, PaymentIntent, []TimelineEntry) (Subscription, Invoice, PaymentIntent, error) + UpdateManualInvoicePayment(context.Context, Invoice, PaymentIntent, []TimelineEntry) (Invoice, PaymentIntent, error) RecordSubscriptionRenewal(context.Context, Subscription, Invoice, PaymentIntent, []TimelineEntry) (Subscription, Invoice, PaymentIntent, error) GetPaymentIntent(context.Context, string) (PaymentIntent, error) CreatePaymentIntent(context.Context, PaymentIntent) (PaymentIntent, error) @@ -600,10 +606,174 @@ func (s *Service) GetInvoice(ctx context.Context, id string) (Invoice, error) { return s.repo.GetInvoice(ctx, id) } +func (s *Service) CreateInvoice(ctx context.Context, in Invoice) (Invoice, error) { + customerID := strings.TrimSpace(in.CustomerID) + if customerID == "" { + return Invoice{}, fmt.Errorf("%w: customer is required", ErrInvalidInput) + } + customer, err := s.repo.GetCustomer(ctx, customerID) + if err != nil { + return Invoice{}, err + } + now := s.now() + if strings.TrimSpace(in.ID) == "" { + in.ID = id("in") + } + in.Object = ObjectInvoice + in.CustomerID = customer.ID + in.SubscriptionID = strings.TrimSpace(in.SubscriptionID) + in.Status = firstNonEmpty(strings.ToLower(strings.TrimSpace(in.Status)), "draft") + in.Currency = strings.ToLower(firstNonEmpty(strings.TrimSpace(in.Currency), "usd")) + in.Metadata = copyMap(in.Metadata) + in.Subtotal = 0 + in.DiscountAmount = 0 + in.Total = 0 + in.AmountDue = 0 + in.AmountPaid = 0 + in.AttemptCount = 0 + in.PaymentIntentID = "" + if in.CreatedAt.IsZero() { + in.CreatedAt = now + } + return s.repo.CreateInvoice(ctx, in, []TimelineEntry{billingTimelineEntry( + "invoice_created_"+in.ID, + "invoice.created", + "Invoice created", + ObjectInvoice, + in.ID, + in.CustomerID, + "", + in.SubscriptionID, + in.ID, + "", + map[string]string{"source": "invoice.create", "status": in.Status}, + in.CreatedAt, + )}) +} + func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { return s.repo.ListInvoices(ctx) } +func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (InvoiceItem, Invoice, error) { + if strings.TrimSpace(in.InvoiceID) == "" { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) + } + if in.Amount == 0 { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: amount is required", ErrInvalidInput) + } + invoice, err := s.repo.GetInvoice(ctx, in.InvoiceID) + if err != nil { + return InvoiceItem{}, Invoice{}, err + } + customerID := firstNonEmpty(strings.TrimSpace(in.CustomerID), invoice.CustomerID) + if customerID != invoice.CustomerID { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: customer must match invoice customer", ErrInvalidInput) + } + if _, err := s.repo.GetCustomer(ctx, customerID); err != nil { + return InvoiceItem{}, Invoice{}, err + } + now := s.now() + if strings.TrimSpace(in.ID) == "" { + in.ID = id("ii") + } + in.Object = ObjectInvoiceItem + in.CustomerID = customerID + in.Currency = strings.ToLower(firstNonEmpty(strings.TrimSpace(in.Currency), invoice.Currency, "usd")) + in.Metadata = copyMap(in.Metadata) + if in.CreatedAt.IsZero() { + in.CreatedAt = now + } + invoice.Subtotal += in.Amount + invoice.Total += in.Amount + if invoice.Total < 0 { + invoice.Total = 0 + } + invoice.AmountDue = invoice.Total - invoice.AmountPaid + if invoice.AmountDue < 0 { + invoice.AmountDue = 0 + } + invoice.Currency = firstNonEmpty(invoice.Currency, in.Currency) + createdItem, updatedInvoice, err := s.repo.CreateInvoiceItem(ctx, in, invoice, []TimelineEntry{billingTimelineEntry( + "invoiceitem_created_"+in.ID, + "invoiceitem.created", + "Invoice item created", + ObjectInvoiceItem, + in.ID, + in.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoiceitem.create", "amount": strconv.FormatInt(in.Amount, 10), "currency": in.Currency}, + in.CreatedAt, + )}) + return createdItem, updatedInvoice, err +} + +func (s *Service) ListInvoiceItems(ctx context.Context, filter InvoiceItemFilter) ([]InvoiceItem, error) { + return s.repo.ListInvoiceItemsFiltered(ctx, filter) +} + +func (s *Service) FinalizeInvoice(ctx context.Context, invoiceID string) (InvoicePaymentResult, error) { + if strings.TrimSpace(invoiceID) == "" { + return InvoicePaymentResult{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) + } + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return InvoicePaymentResult{}, err + } + if invoice.Status != "draft" { + var intent PaymentIntent + if invoice.PaymentIntentID != "" { + intent, _ = s.repo.GetPaymentIntent(ctx, invoice.PaymentIntentID) + } + return InvoicePaymentResult{Invoice: invoice, PaymentIntent: intent}, nil + } + at := s.now() + invoice.Status = "open" + invoice.AmountDue = invoice.Total + invoice.AmountPaid = 0 + invoice.NextPaymentAttempt = nil + invoice.AttemptCount = 0 + intent := PaymentIntent{ + ID: id("pi"), + Object: ObjectPaymentIntent, + CustomerID: invoice.CustomerID, + InvoiceID: invoice.ID, + Amount: invoice.Total, + Currency: invoice.Currency, + Status: "requires_payment_method", + CaptureMethod: "automatic", + Metadata: copyMap(invoice.Metadata), + CreatedAt: at, + } + if paymentIntentConfiguredOutcome(intent.Metadata) == "" && invoice.CustomerID != "" { + if customer, err := s.repo.GetCustomer(ctx, invoice.CustomerID); err == nil { + if outcome := CustomerDefaultPaymentIntentOutcome(customer.Metadata); outcome != "" { + if intent.Metadata == nil { + intent.Metadata = map[string]string{} + } + intent.Metadata[MetadataPaymentIntentOutcome] = outcome + } + } + } + if outcome := paymentIntentConfiguredOutcome(intent.Metadata); outcome != "" && !IsSupportedPaymentIntentOutcome(outcome) { + return InvoicePaymentResult{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, outcome) + } + invoice.PaymentIntentID = intent.ID + invoice.Metadata = copyMap(invoice.Metadata) + invoice.Metadata["billtap_finalized_at"] = at.Format(time.RFC3339Nano) + updatedInvoice, createdIntent, err := s.repo.FinalizeInvoice(ctx, invoice, intent, []TimelineEntry{ + billingTimelineEntry("invoice_finalized_"+invoice.ID, "invoice.finalized", "Invoice finalized", ObjectInvoice, invoice.ID, invoice.CustomerID, "", invoice.SubscriptionID, invoice.ID, intent.ID, map[string]string{"source": "invoice.finalize", "status": invoice.Status}, at), + billingTimelineEntry("invoice_payment_intent_created_"+intent.ID, "payment_intent.created", "Invoice payment intent created", ObjectPaymentIntent, intent.ID, intent.CustomerID, "", invoice.SubscriptionID, invoice.ID, intent.ID, map[string]string{"source": "invoice.finalize", "status": intent.Status}, at), + }) + if err != nil { + return InvoicePaymentResult{}, err + } + return InvoicePaymentResult{Invoice: updatedInvoice, PaymentIntent: createdIntent}, nil +} + func (s *Service) PayInvoice(ctx context.Context, invoiceID string, opts InvoicePaymentOptions) (InvoicePaymentResult, error) { if strings.TrimSpace(invoiceID) == "" { return InvoicePaymentResult{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) @@ -616,6 +786,9 @@ func (s *Service) PayInvoice(ctx context.Context, invoiceID string, opts Invoice if err != nil { return InvoicePaymentResult{}, err } + if invoice.SubscriptionID == "" { + return s.payManualInvoice(ctx, invoice, opts, at) + } subscription, err := s.repo.GetSubscription(ctx, invoice.SubscriptionID) if err != nil { return InvoicePaymentResult{}, err @@ -686,6 +859,82 @@ func (s *Service) PayInvoice(ctx context.Context, invoiceID string, opts Invoice return InvoicePaymentResult{Invoice: invoice, Subscription: subscription, PaymentIntent: intent}, nil } +func (s *Service) payManualInvoice(ctx context.Context, invoice Invoice, opts InvoicePaymentOptions, at time.Time) (InvoicePaymentResult, error) { + if invoice.Status == "draft" { + finalized, err := s.FinalizeInvoice(ctx, invoice.ID) + if err != nil { + return InvoicePaymentResult{}, err + } + invoice = finalized.Invoice + } + if invoice.Status != "open" { + return InvoicePaymentResult{}, fmt.Errorf("%w: status must be open", ErrInvalidInput) + } + intent, err := s.repo.GetPaymentIntent(ctx, invoice.PaymentIntentID) + if err != nil { + return InvoicePaymentResult{}, err + } + configuredOutcome := paymentIntentConfiguredOutcome(intent.Metadata) + outcome := firstNonEmpty(opts.Outcome, configuredOutcome) + if opts.PaidOutOfBand { + outcome = "payment_succeeded" + } + if outcome == "" && invoice.CustomerID != "" { + if customer, err := s.repo.GetCustomer(ctx, invoice.CustomerID); err == nil { + outcome = CustomerDefaultPaymentIntentOutcome(customer.Metadata) + } + } + outcome = firstNonEmpty(outcome, opts.PaymentMethodID, "payment_succeeded") + spec, ok := intentOutcomeSpec(outcome) + if !ok { + return InvoicePaymentResult{}, fmt.Errorf("%w: %s", ErrUnsupportedOutcome, outcome) + } + if opts.PaymentMethodID != "" { + intent.PaymentMethodID = opts.PaymentMethodID + } + intent.PaymentMethodID = firstNonEmpty(intent.PaymentMethodID, spec.PaymentMethodID) + intent.Status = spec.PaymentIntentStatus + intent.FailureCode = spec.FailureCode + intent.DeclineCode = spec.DeclineCode + intent.FailureMessage = spec.FailureMessage + invoice.AttemptCount++ + if invoice.AttemptCount <= 0 { + invoice.AttemptCount = 1 + } + if intent.Status == "succeeded" { + invoice.Status = "paid" + invoice.AmountPaid = invoice.Total + invoice.AmountDue = 0 + invoice.NextPaymentAttempt = nil + intent.FailureCode = "" + intent.DeclineCode = "" + intent.FailureMessage = "" + } else { + invoice.Status = "open" + invoice.AmountPaid = 0 + invoice.AmountDue = invoice.Total + nextAttempt := at.Add(24 * time.Hour) + invoice.NextPaymentAttempt = &nextAttempt + } + invoice.Metadata = copyMap(invoice.Metadata) + invoice.Metadata["billtap_last_invoice_payment_attempt"] = at.Format(time.RFC3339Nano) + invoice.Metadata["billtap_last_invoice_payment_outcome"] = outcome + timeline := []TimelineEntry{ + billingTimelineEntry("manual_invoice_payment_intent_"+intent.ID+"_"+at.Format(time.RFC3339Nano), paymentIntentEvent(intent.Status), "Invoice payment intent "+intent.Status, ObjectPaymentIntent, intent.ID, intent.CustomerID, "", "", invoice.ID, intent.ID, map[string]string{"source": "invoice.pay", "status": intent.Status, "outcome": outcome}, at), + } + for _, eventType := range invoiceEventTypesForPayment(invoice.Status, intent.Status) { + timeline = append(timeline, billingTimelineEntry("manual_invoice_"+eventType+"_"+invoice.ID+"_"+at.Format(time.RFC3339Nano), eventType, "Invoice "+invoice.Status, ObjectInvoice, invoice.ID, invoice.CustomerID, "", "", invoice.ID, intent.ID, map[string]string{"source": "invoice.pay", "status": invoice.Status, "outcome": outcome}, at)) + } + updatedInvoice, updatedIntent, err := s.repo.UpdateManualInvoicePayment(ctx, invoice, intent, timeline) + if err != nil { + if errors.Is(err, ErrInvalidInput) { + return InvoicePaymentResult{}, fmt.Errorf("%w: status must be open or payment attempt is stale", ErrInvalidInput) + } + return InvoicePaymentResult{}, err + } + return InvoicePaymentResult{Invoice: updatedInvoice, PaymentIntent: updatedIntent}, nil +} + func (s *Service) AdvanceClock(ctx context.Context, at time.Time) (ClockAdvanceResult, error) { return s.advanceClock(ctx, at, "") } @@ -2394,6 +2643,22 @@ func paymentIntentEvent(status string) string { } } +func invoiceEventTypesForPayment(status string, paymentIntentStatus string) []string { + switch status { + case "paid": + return []string{"invoice.payment_succeeded", "invoice.paid"} + case "void": + return []string{"invoice.voided"} + case "open": + if paymentIntentStatus == "processing" { + return nil + } + return []string{"invoice.payment_failed"} + default: + return []string{"invoice.payment_failed"} + } +} + func setupIntentEvent(status string) string { switch status { case "succeeded": diff --git a/internal/compatibility/corpus.go b/internal/compatibility/corpus.go index 0b44b85..2ac691c 100644 --- a/internal/compatibility/corpus.go +++ b/internal/compatibility/corpus.go @@ -843,6 +843,16 @@ func builtinCorpus() []caseSpec { Expect: Observation{HTTPStatus: http.StatusOK, Object: "invoice", ObjectStatus: "open"}, Run: runInvoicePayRetry("pm_card_visa_chargeDeclined", "open"), }, + { + ID: "invoices.one_time_invoice_flow.succeeds", + Name: "Manual invoice-backed one-time payment succeeds", + Category: "billing-lifecycle", + Level: "L3", + ReleaseBlocking: true, + Reference: "docs/COMPATIBILITY.md#supported-stripe-like-api-subset", + Expect: Observation{HTTPStatus: http.StatusOK, Object: "invoice", ObjectStatus: "paid"}, + Run: runOneTimeInvoiceFlow(), + }, { ID: "scenarios.invoice_retry.mutates_state", Name: "Scenario invoice.retry mutates invoice, subscription, and payment intent state", @@ -1245,6 +1255,90 @@ func runInvoicePayRetry(paymentMethod string, expectedStatus string) func(contex } } +func runOneTimeInvoiceFlow() func(context.Context, *harness) (caseExecution, error) { + return func(_ context.Context, h *harness) (caseExecution, error) { + specs := []requestSpec{ + { + Name: "create customer", + Method: http.MethodPost, + Path: "/v1/customers", + Params: map[string]string{ + "id": "cus_scorecard_one_time_invoice", + "email": "scorecard-one-time-invoice@example.test", + }, + ExpectHTTPStatus: http.StatusOK, + }, + { + Name: "create invoice", + Method: http.MethodPost, + Path: "/v1/invoices", + Params: map[string]string{ + "customer": "cus_scorecard_one_time_invoice", + "currency": "usd", + "collection_method": "charge_automatically", + "auto_advance": "false", + "pending_invoice_items_behavior": "exclude", + "payment_settings[payment_method_types][0]": "card", + "metadata[paymentType]": "ONE_TIME", + }, + ExpectHTTPStatus: http.StatusOK, + }, + } + execution, err := h.runSteps(specs) + if err != nil { + return execution, err + } + invoiceID := stringField(execution.Steps[len(execution.Steps)-1].Response.JSON.(map[string]any), "id") + item, err := h.do(requestSpec{ + Name: "create invoice item", + Method: http.MethodPost, + Path: "/v1/invoiceitems", + Params: map[string]string{ + "customer": "cus_scorecard_one_time_invoice", + "invoice": invoiceID, + "amount": "100", + "currency": "usd", + "description": "One-time usage", + "metadata[paymentType]": "ONE_TIME", + }, + ExpectHTTPStatus: http.StatusOK, + }) + execution.Steps = append(execution.Steps, item) + if err != nil { + return execution, err + } + finalize, err := h.do(requestSpec{ + Name: "finalize invoice", + Method: http.MethodPost, + Path: "/v1/invoices/" + invoiceID + "/finalize", + Params: map[string]string{"expand[]": "confirmation_secret"}, + ExpectHTTPStatus: http.StatusOK, + }) + execution.Steps = append(execution.Steps, finalize) + if err != nil { + return execution, err + } + pay, err := h.do(requestSpec{ + Name: "pay invoice", + Method: http.MethodPost, + Path: "/v1/invoices/" + invoiceID + "/pay", + Params: map[string]string{ + "expand[]": "payments.data.payment.payment_intent", + }, + ExpectHTTPStatus: http.StatusOK, + }) + execution.Steps = append(execution.Steps, pay) + if err != nil { + return execution, err + } + if pay.Actual.ObjectStatus != "paid" || responseNumber(pay.Response.JSON, "amount_paid") != 100 { + return execution, fmt.Errorf("paid invoice status/amount = %q/%.0f, want paid/100", pay.Actual.ObjectStatus, responseNumber(pay.Response.JSON, "amount_paid")) + } + execution.Actual = *pay.Actual + return execution, nil + } +} + func runInvoiceRetryScenario() func(context.Context, *harness) (caseExecution, error) { return func(_ context.Context, h *harness) (caseExecution, error) { scenario := map[string]any{ diff --git a/internal/storage/billing.go b/internal/storage/billing.go index c9df99d..45f34f4 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -473,9 +473,9 @@ func (s *SQLiteStore) RecordCheckoutCompletion(ctx context.Context, c billing.Ch c.Subscription.ID, c.Subscription.CustomerID, c.Subscription.Status, encodeLineItems(c.Subscription.Items), encodeTime(c.Subscription.CurrentPeriodStart), encodeTime(c.Subscription.CurrentPeriodEnd), boolInt(c.Subscription.CancelAtPeriodEnd), encodeOptionalTime(c.Subscription.CanceledAt), c.Subscription.LatestInvoiceID, encodeMap(c.Subscription.Metadata)); err != nil { return billing.CheckoutSession{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO invoices (id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - c.Invoice.ID, c.Invoice.CustomerID, c.Invoice.SubscriptionID, c.Invoice.Status, c.Invoice.Currency, c.Invoice.Subtotal, c.Invoice.DiscountAmount, encodeDiscounts(c.Invoice.Discounts), c.Invoice.Total, c.Invoice.AmountDue, c.Invoice.AmountPaid, c.Invoice.AttemptCount, encodeOptionalTime(c.Invoice.NextPaymentAttempt), c.Invoice.PaymentIntentID, encodeTime(c.Invoice.CreatedAt)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO invoices (id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + c.Invoice.ID, c.Invoice.CustomerID, encodeOptionalString(c.Invoice.SubscriptionID), c.Invoice.Status, c.Invoice.Currency, c.Invoice.Subtotal, c.Invoice.DiscountAmount, encodeDiscounts(c.Invoice.Discounts), c.Invoice.Total, c.Invoice.AmountDue, c.Invoice.AmountPaid, c.Invoice.AttemptCount, encodeOptionalTime(c.Invoice.NextPaymentAttempt), c.Invoice.PaymentIntentID, encodeMap(c.Invoice.Metadata), encodeTime(c.Invoice.CreatedAt)); err != nil { return billing.CheckoutSession{}, err } if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) @@ -594,9 +594,9 @@ func updateSubscriptionTx(ctx context.Context, tx *sql.Tx, sub billing.Subscript func updateInvoiceTx(ctx context.Context, tx *sql.Tx, invoice billing.Invoice) error { result, err := tx.ExecContext(ctx, `UPDATE invoices - SET status = ?, currency = ?, subtotal = ?, total = ?, amount_due = ?, amount_paid = ?, attempt_count = ?, next_payment_attempt = ?, payment_intent_id = ? + SET status = ?, currency = ?, subtotal = ?, discount_amount = ?, discounts = ?, total = ?, amount_due = ?, amount_paid = ?, attempt_count = ?, next_payment_attempt = ?, payment_intent_id = ?, metadata = ? WHERE id = ?`, - invoice.Status, invoice.Currency, invoice.Subtotal, invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, invoice.ID) + invoice.Status, invoice.Currency, invoice.Subtotal, invoice.DiscountAmount, encodeDiscounts(invoice.Discounts), invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, encodeMap(invoice.Metadata), invoice.ID) if err != nil { return err } @@ -616,9 +616,9 @@ func updateOpenInvoicePaymentTx(ctx context.Context, tx *sql.Tx, invoice billing expectedAttemptCount = 0 } result, err := tx.ExecContext(ctx, `UPDATE invoices - SET status = ?, currency = ?, subtotal = ?, total = ?, amount_due = ?, amount_paid = ?, attempt_count = ?, next_payment_attempt = ?, payment_intent_id = ? + SET status = ?, currency = ?, subtotal = ?, discount_amount = ?, discounts = ?, total = ?, amount_due = ?, amount_paid = ?, attempt_count = ?, next_payment_attempt = ?, payment_intent_id = ?, metadata = ? WHERE id = ? AND status = 'open' AND attempt_count = ?`, - invoice.Status, invoice.Currency, invoice.Subtotal, invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, invoice.ID, expectedAttemptCount) + invoice.Status, invoice.Currency, invoice.Subtotal, invoice.DiscountAmount, encodeDiscounts(invoice.Discounts), invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, encodeMap(invoice.Metadata), invoice.ID, expectedAttemptCount) if err != nil { return err } @@ -640,6 +640,29 @@ func updateOpenInvoicePaymentTx(ctx context.Context, tx *sql.Tx, invoice billing return billing.ErrInvalidInput } +func insertInvoiceTx(ctx context.Context, tx *sql.Tx, invoice billing.Invoice) error { + _, err := tx.ExecContext(ctx, `INSERT INTO invoices (id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + invoice.ID, + invoice.CustomerID, + encodeOptionalString(invoice.SubscriptionID), + invoice.Status, + invoice.Currency, + invoice.Subtotal, + invoice.DiscountAmount, + encodeDiscounts(invoice.Discounts), + invoice.Total, + invoice.AmountDue, + invoice.AmountPaid, + invoice.AttemptCount, + encodeOptionalTime(invoice.NextPaymentAttempt), + invoice.PaymentIntentID, + encodeMap(invoice.Metadata), + encodeTime(invoice.CreatedAt), + ) + return err +} + func updatePaymentIntentTx(ctx context.Context, tx *sql.Tx, pi billing.PaymentIntent) error { result, err := tx.ExecContext(ctx, `UPDATE payment_intents SET customer_id = ?, invoice_id = ?, amount = ?, currency = ?, status = ?, capture_method = ?, failure_code = ?, failure_decline_code = ?, failure_message = ?, payment_method_id = ?, metadata = ? @@ -658,8 +681,28 @@ func updatePaymentIntentTx(ctx context.Context, tx *sql.Tx, pi billing.PaymentIn return nil } +func (s *SQLiteStore) CreateInvoice(ctx context.Context, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.Invoice, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, err + } + defer tx.Rollback() + if err := insertInvoiceTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, err + } + return s.GetInvoice(ctx, invoice.ID) +} + func (s *SQLiteStore) GetInvoice(ctx context.Context, id string) (billing.Invoice, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, created_at FROM invoices WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, metadata, created_at FROM invoices WHERE id = ?`, id) inv, err := scanInvoice(row) if errors.Is(err, sql.ErrNoRows) { return billing.Invoice{}, billing.ErrNotFound @@ -668,7 +711,7 @@ func (s *SQLiteStore) GetInvoice(ctx context.Context, id string) (billing.Invoic } func (s *SQLiteStore) ListInvoices(ctx context.Context) ([]billing.Invoice, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, created_at FROM invoices ORDER BY created_at DESC, id DESC`) + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, metadata, created_at FROM invoices ORDER BY created_at DESC, id DESC`) if err != nil { return nil, err } @@ -695,7 +738,7 @@ func (s *SQLiteStore) ListInvoicesFiltered(ctx context.Context, filter billing.I clauses = append(clauses, "subscription_id = ?") args = append(args, filter.SubscriptionID) } - rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, created_at + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, metadata, created_at FROM invoices WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at DESC, id DESC`, args...) if err != nil { return nil, err @@ -712,6 +755,129 @@ func (s *SQLiteStore) ListInvoicesFiltered(ctx context.Context, filter billing.I return out, rows.Err() } +func (s *SQLiteStore) UpdateInvoice(ctx context.Context, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.Invoice, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, err + } + defer tx.Rollback() + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, err + } + return s.GetInvoice(ctx, invoice.ID) +} + +func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.InvoiceItem, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.InvoiceItem, billing.Invoice, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `INSERT INTO invoice_items (id, customer_id, invoice_id, amount, currency, description, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + item.ID, item.CustomerID, item.InvoiceID, item.Amount, item.Currency, item.Description, encodeMap(item.Metadata), encodeTime(item.CreatedAt)); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + createdItem, err := s.GetInvoiceItem(ctx, item.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + updatedInvoice, err := s.GetInvoice(ctx, invoice.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + return createdItem, updatedInvoice, nil +} + +func (s *SQLiteStore) GetInvoiceItem(ctx context.Context, id string) (billing.InvoiceItem, error) { + row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, description, metadata, created_at FROM invoice_items WHERE id = ?`, id) + item, err := scanInvoiceItem(row) + if errors.Is(err, sql.ErrNoRows) { + return billing.InvoiceItem{}, billing.ErrNotFound + } + return item, err +} + +func (s *SQLiteStore) ListInvoiceItemsFiltered(ctx context.Context, filter billing.InvoiceItemFilter) ([]billing.InvoiceItem, error) { + clauses := []string{"1=1"} + args := []any{} + if filter.CustomerID != "" { + clauses = append(clauses, "customer_id = ?") + args = append(args, filter.CustomerID) + } + if filter.InvoiceID != "" { + clauses = append(clauses, "invoice_id = ?") + args = append(args, filter.InvoiceID) + } + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, amount, currency, description, metadata, created_at + FROM invoice_items WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at ASC, id ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []billing.InvoiceItem + for rows.Next() { + item, err := scanInvoiceItem(rows) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *SQLiteStore) FinalizeInvoice(ctx context.Context, invoice billing.Invoice, pi billing.PaymentIntent, timeline []billing.TimelineEntry) (billing.Invoice, billing.PaymentIntent, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + defer tx.Rollback() + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + pi.ID, encodeOptionalString(pi.CustomerID), encodeOptionalString(pi.InvoiceID), pi.Amount, pi.Currency, pi.Status, pi.CaptureMethod, pi.FailureCode, pi.DeclineCode, pi.FailureMessage, pi.PaymentMethodID, encodeMap(pi.Metadata), encodeTime(pi.CreatedAt)); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + updatedInvoice, err := s.GetInvoice(ctx, invoice.ID) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + createdPI, err := s.GetPaymentIntent(ctx, pi.ID) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + return updatedInvoice, createdPI, nil +} + func (s *SQLiteStore) UpdateInvoicePayment(ctx context.Context, sub billing.Subscription, invoice billing.Invoice, pi billing.PaymentIntent, timeline []billing.TimelineEntry) (billing.Subscription, billing.Invoice, billing.PaymentIntent, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -751,6 +917,37 @@ func (s *SQLiteStore) UpdateInvoicePayment(ctx context.Context, sub billing.Subs return updatedSub, updatedInvoice, updatedPI, nil } +func (s *SQLiteStore) UpdateManualInvoicePayment(ctx context.Context, invoice billing.Invoice, pi billing.PaymentIntent, timeline []billing.TimelineEntry) (billing.Invoice, billing.PaymentIntent, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + defer tx.Rollback() + if err := updateOpenInvoicePaymentTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + if err := updatePaymentIntentTx(ctx, tx, pi); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + updatedInvoice, err := s.GetInvoice(ctx, invoice.ID) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + updatedPI, err := s.GetPaymentIntent(ctx, pi.ID) + if err != nil { + return billing.Invoice{}, billing.PaymentIntent{}, err + } + return updatedInvoice, updatedPI, nil +} + func (s *SQLiteStore) RecordSubscriptionRenewal(ctx context.Context, sub billing.Subscription, invoice billing.Invoice, pi billing.PaymentIntent, timeline []billing.TimelineEntry) (billing.Subscription, billing.Invoice, billing.PaymentIntent, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -761,9 +958,7 @@ func (s *SQLiteStore) RecordSubscriptionRenewal(ctx context.Context, sub billing if err := updateSubscriptionTx(ctx, tx, sub); err != nil { return billing.Subscription{}, billing.Invoice{}, billing.PaymentIntent{}, err } - if _, err := tx.ExecContext(ctx, `INSERT INTO invoices (id, customer_id, subscription_id, status, currency, subtotal, discount_amount, discounts, total, amount_due, amount_paid, attempt_count, next_payment_attempt, payment_intent_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - invoice.ID, invoice.CustomerID, invoice.SubscriptionID, invoice.Status, invoice.Currency, invoice.Subtotal, invoice.DiscountAmount, encodeDiscounts(invoice.Discounts), invoice.Total, invoice.AmountDue, invoice.AmountPaid, invoice.AttemptCount, encodeOptionalTime(invoice.NextPaymentAttempt), invoice.PaymentIntentID, encodeTime(invoice.CreatedAt)); err != nil { + if err := insertInvoiceTx(ctx, tx, invoice); err != nil { return billing.Subscription{}, billing.Invoice{}, billing.PaymentIntent{}, err } if _, err := tx.ExecContext(ctx, `INSERT INTO payment_intents (id, customer_id, invoice_id, amount, currency, status, capture_method, failure_code, failure_decline_code, failure_message, payment_method_id, metadata, created_at) @@ -1364,12 +1559,15 @@ func scanSubscription(row scanner) (billing.Subscription, error) { func scanInvoice(row scanner) (billing.Invoice, error) { var inv billing.Invoice var nextPaymentAttempt sql.NullString - var discounts, createdAt string - if err := row.Scan(&inv.ID, &inv.CustomerID, &inv.SubscriptionID, &inv.Status, &inv.Currency, &inv.Subtotal, &inv.DiscountAmount, &discounts, &inv.Total, &inv.AmountDue, &inv.AmountPaid, &inv.AttemptCount, &nextPaymentAttempt, &inv.PaymentIntentID, &createdAt); err != nil { + var subscriptionID sql.NullString + var discounts, metadataRaw, createdAt string + if err := row.Scan(&inv.ID, &inv.CustomerID, &subscriptionID, &inv.Status, &inv.Currency, &inv.Subtotal, &inv.DiscountAmount, &discounts, &inv.Total, &inv.AmountDue, &inv.AmountPaid, &inv.AttemptCount, &nextPaymentAttempt, &inv.PaymentIntentID, &metadataRaw, &createdAt); err != nil { return inv, err } inv.Object = billing.ObjectInvoice + inv.SubscriptionID = subscriptionID.String inv.Discounts = decodeDiscounts(discounts) + inv.Metadata = decodeMap(metadataRaw) if nextPaymentAttempt.Valid { t := decodeTime(nextPaymentAttempt.String) inv.NextPaymentAttempt = &t @@ -1378,6 +1576,18 @@ func scanInvoice(row scanner) (billing.Invoice, error) { return inv, nil } +func scanInvoiceItem(row scanner) (billing.InvoiceItem, error) { + var item billing.InvoiceItem + var metadataRaw, createdAt string + if err := row.Scan(&item.ID, &item.CustomerID, &item.InvoiceID, &item.Amount, &item.Currency, &item.Description, &metadataRaw, &createdAt); err != nil { + return item, err + } + item.Object = billing.ObjectInvoiceItem + item.Metadata = decodeMap(metadataRaw) + item.CreatedAt = decodeTime(createdAt) + return item, nil +} + func scanPaymentIntent(row scanner) (billing.PaymentIntent, error) { var pi billing.PaymentIntent var createdAt, metadataRaw string diff --git a/internal/storage/migrations/015_manual_invoice_flow.sql b/internal/storage/migrations/015_manual_invoice_flow.sql new file mode 100644 index 0000000..79a8160 --- /dev/null +++ b/internal/storage/migrations/015_manual_invoice_flow.sql @@ -0,0 +1,145 @@ +CREATE TABLE payment_intents_backup AS +SELECT + id, + customer_id, + invoice_id, + amount, + currency, + status, + capture_method, + failure_code, + failure_decline_code, + failure_message, + payment_method_id, + metadata, + created_at +FROM payment_intents; + +DROP TABLE payment_intents; + +CREATE TABLE invoices_new ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES customers(id), + subscription_id TEXT REFERENCES subscriptions(id), + status TEXT NOT NULL, + currency TEXT NOT NULL, + subtotal INTEGER NOT NULL, + discount_amount INTEGER NOT NULL DEFAULT 0, + discounts TEXT NOT NULL DEFAULT '[]', + total INTEGER NOT NULL, + amount_due INTEGER NOT NULL, + amount_paid INTEGER NOT NULL, + attempt_count INTEGER NOT NULL, + next_payment_attempt TEXT, + payment_intent_id TEXT, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +INSERT INTO invoices_new ( + id, + customer_id, + subscription_id, + status, + currency, + subtotal, + discount_amount, + discounts, + total, + amount_due, + amount_paid, + attempt_count, + next_payment_attempt, + payment_intent_id, + metadata, + created_at +) +SELECT + id, + customer_id, + subscription_id, + status, + currency, + subtotal, + discount_amount, + discounts, + total, + amount_due, + amount_paid, + attempt_count, + next_payment_attempt, + payment_intent_id, + '{}', + created_at +FROM invoices; + +DROP TABLE invoices; + +ALTER TABLE invoices_new RENAME TO invoices; + +CREATE TABLE payment_intents ( + id TEXT PRIMARY KEY, + customer_id TEXT REFERENCES customers(id), + invoice_id TEXT REFERENCES invoices(id), + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + capture_method TEXT NOT NULL DEFAULT 'automatic', + failure_code TEXT NOT NULL DEFAULT '', + failure_decline_code TEXT NOT NULL DEFAULT '', + failure_message TEXT NOT NULL DEFAULT '', + payment_method_id TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +INSERT INTO payment_intents ( + id, + customer_id, + invoice_id, + amount, + currency, + status, + capture_method, + failure_code, + failure_decline_code, + failure_message, + payment_method_id, + metadata, + created_at +) +SELECT + id, + customer_id, + invoice_id, + amount, + currency, + status, + capture_method, + failure_code, + failure_decline_code, + failure_message, + payment_method_id, + metadata, + created_at +FROM payment_intents_backup; + +DROP TABLE payment_intents_backup; + +CREATE TABLE IF NOT EXISTS invoice_items ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES customers(id), + invoice_id TEXT NOT NULL REFERENCES invoices(id), + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoices_subscription ON invoices(subscription_id); +CREATE INDEX IF NOT EXISTS idx_payment_intents_customer ON payment_intents(customer_id); +CREATE INDEX IF NOT EXISTS idx_payment_intents_invoice ON payment_intents(invoice_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_customer ON invoice_items(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 91d0de1..6bf66ec 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -22,8 +22,8 @@ func TestSQLiteMigrationsRun(t *testing.T) { if err != nil { t.Fatalf("MigrationVersions returned error: %v", err) } - if len(versions) != 14 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 || versions[13] != 14 { - t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14]", versions) + if len(versions) != 15 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 || versions[13] != 14 || versions[14] != 15 { + t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15]", versions) } } diff --git a/internal/stripecompat/registry.go b/internal/stripecompat/registry.go index 7065daa..cbb4c09 100644 --- a/internal/stripecompat/registry.go +++ b/internal/stripecompat/registry.go @@ -243,12 +243,19 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/subscription_schedules/{id}/release", Claim{Level: "L2", Stateful: true}) add(http.MethodGet, "/v1/invoices", statefulL3) + add(http.MethodPost, "/v1/invoices", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.one_time_invoice_flow.succeeds"}, WebhookEvents: []string{"invoice.created"}, Risks: []string{"manual one-time invoice subset only; automatic collection, tax, rendering, dunning, and full line mutation parity are not modeled"}}) add(http.MethodGet, "/v1/invoices/{id}", statefulL3) - add(http.MethodPost, "/v1/invoices/{id}/pay", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.pay.failed_invoice_succeeds", "invoices.pay.failed_invoice_declines_again"}, WebhookEvents: []string{"payment_intent.succeeded", "payment_intent.payment_failed", "invoice.payment_succeeded", "invoice.payment_failed", "invoice.paid", "customer.subscription.updated"}, Risks: []string{"local retry/payment mutation only; finalize, send, void, collection, and dunning automation are not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/finalize", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.one_time_invoice_flow.succeeds"}, WebhookEvents: []string{"invoice.finalized", "payment_intent.created"}, Risks: []string{"manual one-time invoice finalization only; automatic collection and full invoice lifecycle automation are not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/pay", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.pay.failed_invoice_succeeds", "invoices.pay.failed_invoice_declines_again", "invoices.one_time_invoice_flow.succeeds"}, WebhookEvents: []string{"payment_intent.succeeded", "payment_intent.payment_failed", "payment_intent.requires_action", "invoice.payment_succeeded", "invoice.payment_failed", "invoice.paid", "customer.subscription.updated"}, Risks: []string{"local retry/payment mutation only; send, void, collection, and dunning automation are not modeled"}}) + add(http.MethodGet, "/v1/invoices/{id}/lines", Claim{Level: "L2", Stateful: true, Risks: []string{"returns local invoice items for manual one-time invoices only"}}) + add(http.MethodGet, "/v1/invoices/{id}/payments", Claim{Level: "L2", Stateful: true, Risks: []string{"returns local invoice payment evidence only"}}) add(http.MethodPost, "/v1/invoices/create_preview", Claim{Level: "L3", Stateful: true, Risks: []string{"local subscription-update proration and single-discount subset; taxes, pending invoice items, and full invoice preview parity are not modeled"}}) add(http.MethodGet, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"Stripe-compatible upcoming preview alias backed by the same local proration subset"}}) add(http.MethodPost, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"local form-compatible upcoming preview convenience"}}) add(http.MethodGet, "/v1/invoices/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured invoice search subset for id, customer, subscription, and status clauses joined by AND"}}) + add(http.MethodGet, "/v1/invoiceitems", Claim{Level: "L2", Stateful: true, Risks: []string{"manual one-time invoice item evidence only"}}) + add(http.MethodPost, "/v1/invoiceitems", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.one_time_invoice_flow.succeeds"}, Risks: []string{"manual one-time invoice item evidence only; tax, discounts, and pending invoice-item collection are not modeled"}}) + add(http.MethodGet, "/v1/invoiceitems/{id}", Claim{Level: "L2", Stateful: true, Risks: []string{"manual one-time invoice item evidence only"}}) add(http.MethodGet, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) add(http.MethodPost, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) add(http.MethodGet, "/v1/refunds/{id}", Claim{Level: "L3", Stateful: true}) diff --git a/internal/stripecompat/registry_test.go b/internal/stripecompat/registry_test.go index f476d5c..ac49491 100644 --- a/internal/stripecompat/registry_test.go +++ b/internal/stripecompat/registry_test.go @@ -8,8 +8,8 @@ import ( func TestDefaultRegistryContainsCurrentPublicClaims(t *testing.T) { registry := DefaultRegistry() claims := registry.Claims() - if len(claims) != 165 { - t.Fatalf("default claims = %d, want 165", len(claims)) + if len(claims) != 172 { + t.Fatalf("default claims = %d, want 172", len(claims)) } checkout, ok := registry.Lookup(http.MethodPost, "/v1/checkout/sessions") @@ -45,6 +45,14 @@ func TestDefaultRegistryContainsCurrentPublicClaims(t *testing.T) { if !ok || intentSearch.Level != "L3" || !intentSearch.Stateful { t.Fatalf("payment intent search claim = %#v ok=%t, want L3 stateful", intentSearch, ok) } + invoiceCreate, ok := registry.Lookup(http.MethodPost, "/v1/invoices") + if !ok || invoiceCreate.Level != "L3" || !invoiceCreate.Stateful { + t.Fatalf("invoice create claim = %#v ok=%t, want L3 stateful", invoiceCreate, ok) + } + invoiceItemCreate, ok := registry.Lookup(http.MethodPost, "/v1/invoiceitems") + if !ok || invoiceItemCreate.Level != "L3" || !invoiceItemCreate.Stateful { + t.Fatalf("invoiceitem create claim = %#v ok=%t, want L3 stateful", invoiceItemCreate, ok) + } account, ok := registry.Lookup(http.MethodGet, "/v1/accounts/{account}") if !ok || account.Level != "L3" || !account.Stateful { diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index 4f9a218..81eaf82 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -111,9 +111,16 @@ invoicing, and full schedule lifecycle parity are not modeled. ### Invoices +- `POST /v1/invoices` - `GET /v1/invoices/{id}` - `GET /v1/invoices` +- `POST /v1/invoices/{id}/finalize` - `POST /v1/invoices/{id}/pay` +- `GET /v1/invoices/{id}/lines` +- `GET /v1/invoices/{id}/payments` +- `POST /v1/invoiceitems` +- `GET /v1/invoiceitems` +- `GET /v1/invoiceitems/{id}` - `POST /v1/invoices/create_preview` - `GET /v1/invoices/upcoming` - `POST /v1/invoices/upcoming` @@ -121,9 +128,28 @@ invoicing, and full schedule lifecycle parity are not modeled. Direct invoice `pay` is a local retry mutation for open invoices created by Billtap checkout and scenarios. It accepts deterministic sandbox `payment_method` or legacy `source` aliases plus bounded protocol flags such as -`paid_out_of_band`, `forgive`, `off_session`, and `mandate`. Direct invoice -create, finalize, send, void, line mutation, collection, and full dunning -automation are not part of the current release-compatible subset. +`paid_out_of_band`, `forgive`, `off_session`, and `mandate`. + +Manual one-time invoices support a bounded local flow: + +1. `POST /v1/invoices` creates a draft invoice for a customer and accepts + Stripe SDK-style fields including `currency`, `collection_method`, + `default_payment_method`, `description`, `auto_advance`, + `pending_invoice_items_behavior`, `payment_settings[payment_method_types]`, + and `metadata[...]`. +2. `POST /v1/invoiceitems` attaches a one-time amount and preserves + `metadata[...]`. +3. `POST /v1/invoices/{id}/finalize` opens the invoice and creates local + PaymentIntent evidence. +4. `POST /v1/invoices/{id}/pay` applies deterministic PaymentIntent outcomes, + including customer-level `default_payment_intent_outcome` fixture metadata. + +Invoice responses include local fallback fields for SDK adoption paths: +`hosted_invoice_url`, `invoice_pdf`, `confirmation_secret.client_secret`, and +`payments.data.payment.payment_intent` with id, status, client secret, metadata, +and `last_payment_error` where applicable. Full invoice rendering, send, void, +line mutation, automatic collection, tax, and dunning automation are not part of +the current release-compatible subset. Preview endpoints accept Stripe SDK-style `subscription`, `subscription_details[items][0][price]`, @@ -162,7 +188,8 @@ PaymentIntents can store a deferred per-intent outcome with `deferred_outcome`; the stored outcome is applied when the intent is confirmed. If no per-intent outcome is present, customer metadata `billtap_default_payment_intent_outcome` or `default_payment_intent_outcome` -can provide the default outcome for direct one-time PaymentIntents. +can provide the default outcome for direct and invoice-backed one-time +PaymentIntents. ### Setup Intents