From 85471aca85e69fe228bc368fda4d330f193dffca Mon Sep 17 00:00:00 2001 From: midagedev Date: Wed, 19 Aug 2026 14:21:06 +0900 Subject: [PATCH] feat(stripe-compat): close the gaps the platform's billing flows hit A caller census against a Stripe SDK 31.1.0 client turned up parameters this stub rejected or silently ignored across plan upgrade, cancellation, seat changes, member invites, dunning, and one-time export purchases. Rejected parameters now accepted and honoured: - invoiceitems pricing[price]/quantity (amount derived), items without an invoice (pending, swept into the next include-behaviour invoice) - invoices subscription, credit notes out_of_band_amount/memo - customers invoice_settings[default_payment_method] - subscriptions create proration_behavior, update cancel_at standalone, delete cancellation_details - prices transfer_lookup_key Silently ignored parameters now honoured: - prices lookup_keys[] filter (callers taking the first entry got another tenant's product) - subscription period follows the price's recurring interval instead of a hardcoded month, so yearly plans no longer end after one month - subscription list current_period_end range filters - trial_end=now ends the trial instead of only recording evidence - billing_cycle_anchor=now resets the period on every proration behaviour New endpoints: invoices void / mark_uncollectible, checkout sessions expire, each with its webhook event. Response shapes: discount.source.coupon object (SDK 31 dropped the top-level coupon), cancel_at serialized independently of cancel_at_period_end. Migration 020 makes invoice_items.invoice_id nullable (table rebuild, columns preserved from 015) and adds the credit-note out-of-band columns. gofmt clean, go vet clean, go test ./... -count=1 green across all packages. 16 new regression tests, one per gap. Co-Authored-By: Claude Opus 5 (1M context) --- docs/COMPATIBILITY.md | 15 +- internal/api/api.go | 318 +++++++- internal/api/discounts.go | 13 + internal/api/stripe_gaps_v2_test.go | 729 ++++++++++++++++++ internal/api/validation.go | 62 +- internal/billing/models.go | 46 +- internal/billing/service.go | 228 +++++- internal/storage/billing.go | 101 ++- .../020_invoiceitem_pending_creditnote.sql | 29 + internal/storage/storage_test.go | 13 +- internal/stripecompat/registry.go | 13 +- internal/stripecompat/registry_test.go | 4 +- 12 files changed, 1466 insertions(+), 105 deletions(-) create mode 100644 internal/api/stripe_gaps_v2_test.go create mode 100644 internal/storage/migrations/020_invoiceitem_pending_creditnote.sql diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 31784e2..17b7efe 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -96,27 +96,27 @@ traffic can keep using the unprefixed service URL. | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Customers | `POST /v1/customers`, `GET /v1/customers`, `GET /v1/customers/search`, `GET /v1/customers/{id}`, `POST /v1/customers/{id}` | Supported | Create, list, retrieve, update, and search `email`, `name`, and metadata. List supports `email` and `limit` filters. Search supports a measured Stripe Search Query Language subset for `id`, `email`, `name`, and metadata equality clauses joined by `AND`. | +| Customers | `POST /v1/customers`, `GET /v1/customers`, `GET /v1/customers/search`, `GET /v1/customers/{id}`, `POST /v1/customers/{id}` | Supported | Create, list, retrieve, update, and search `email`, `name`, and metadata. List supports `email` and `limit` filters. Create/update accept `invoice_settings[default_payment_method]` (empty clears), surfaced on the customer response and as the first entry of the customer's payment-method list. Search supports a measured Stripe Search Query Language subset for `id`, `email`, `name`, and metadata equality clauses joined by `AND`. | | Products | `POST /v1/products`, `GET /v1/products`, `GET /v1/products/{id}`, `POST /v1/products/{id}` | Supported | Create, list, retrieve, and update local service products with metadata. | | Product search | `GET /v1/products/search` | Partial | Supports metadata equality filters such as `metadata['tenantId']:'saas'` and `active:'true'`. This is not Stripe Search Query Language parity. | -| Prices | `POST /v1/prices`, `GET /v1/prices`, `GET /v1/prices/{id}`, `POST /v1/prices/{id}`, `GET /v1/prices/search` | Supported | Create, list, retrieve, update, and search prices. Supports `product`, `currency`, `unit_amount`, `lookup_key`, recurring interval fields, `active`, and metadata. List supports `product`, `active`, `type=recurring`, and `type=one_time`. Search supports a measured Stripe Search Query Language subset for `active`, `type`, `lookup_key`, and metadata equality clauses joined by `AND`, including one-time price lookup by `lookup_key`. | +| Prices | `POST /v1/prices`, `GET /v1/prices`, `GET /v1/prices/{id}`, `POST /v1/prices/{id}`, `GET /v1/prices/search` | Supported | Create, list, retrieve, update, and search prices. Supports `product`, `currency`, `unit_amount`, `lookup_key`, recurring interval fields, `active`, and metadata. List supports `product`, `active`, `type=recurring`, `type=one_time`, and the Stripe `lookup_keys[0]=...` array filter. Create accepts `transfer_lookup_key=true`, which moves ownership of an existing lookup key to the new price. Search supports a measured Stripe Search Query Language subset for `active`, `type`, `lookup_key`, and metadata equality clauses joined by `AND`, including one-time price lookup by `lookup_key`. | | Coupons | `POST /v1/coupons`, `GET /v1/coupons`, `GET /v1/coupons/{id}`, `POST /v1/coupons/{id}`, `DELETE /v1/coupons/{id}` | Partial | Create/list/retrieve local coupon evidence for SDK and fixture smoke paths. Amount-off, percent-off, duration, metadata, and deletion markers are modeled. A bounded single-discount subset applies coupons to customer defaults, checkout sessions, subscriptions, invoice preview math, and renewal invoices; redemption limits and expiration analytics are not modeled. | | Promotion codes | `POST /v1/promotion_codes`, `GET /v1/promotion_codes`, `GET /v1/promotion_codes/{id}`, `POST /v1/promotion_codes/{id}` | Partial | Create/list/retrieve local promotion-code evidence linked to a local coupon. List supports `code`, `coupon`, `customer`, and `active` filters. Promotion codes can be applied through `discounts[0][promotion_code]`; minimum amounts, expiration rules, and promotion analytics are not modeled. | -| 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]`, `subscription_data[trial_period_days]`, and `subscription_data[default_tax_rates]` (applied to session totals; exclusive/inclusive snapshots; mutually exclusive with `automatic_tax`); 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 sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}`, `POST /v1/checkout/sessions/{id}/expire` | 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]`, `subscription_data[trial_period_days]`, and `subscription_data[default_tax_rates]` (applied to session totals; exclusive/inclusive snapshots; mutually exclusive with `automatic_tax`); trial checkout creates local `trialing` subscription evidence. `expire` immediately expires an open session and emits `checkout.session.expired`; completed or already-expired sessions return `resource_missing`. 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`, `POST/DELETE /api/checkout/sessions/{id}/promotion_code` | 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`. Hosted checkout can apply or remove a promotion code on an open `allow_promotion_codes` session via `POST/DELETE /api/checkout/sessions/{id}/promotion_code` (form `promotion_code=`), refreshing session totals before completion. | | 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`, `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. | +| 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` (now resets the billing cycle in every proration mode), and `trial_end` (`now` or a past timestamp ends the trial and moves `trialing` to `active`); `cancel_at` timestamps are accepted and serialized independently of `cancel_at_period_end`, and delete accepts `cancellation_details[feedback|comment]` evidence. Top-level and nested customer lists support status, metadata equality filters such as `metadata[billtap_fixture_ref]`, and `current_period_end[gte|gt|lt|lte]` unix-second range filters. 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 | `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. | +| 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`, `POST /v1/invoices/{id}/void`, `POST /v1/invoices/{id}/mark_uncollectible`, `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`. Manual invoices accept `customer`, `subscription` (linked through `parent.subscription_details`), `currency`, `collection_method`, `default_payment_method`, `description`, `auto_advance=false`, `pending_invoice_items_behavior` (`include` default sweeps pending invoice items; `exclude` skips), `payment_settings[payment_method_types]`, and `metadata[...]`. `void` (open invoices only) and `mark_uncollectible` (open only) are terminal transitions emitting `invoice.voided` / `invoice.marked_uncollectible`. `pay` retries open checkout invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. 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. `customer`, `invoice`, `amount`, `currency`, `description`, and `metadata[...]` are accepted, plus the Stripe price form `pricing[price]` (or `price`) with `quantity` where the amount is derived as unit amount × quantity and the currency inherits from the price. Items created without an `invoice` stay pending and are swept into the next invoice create unless it passes `pending_invoice_items_behavior=exclude`; the caller's `subscription` reference is preserved. Tax, discounts, 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. | | Customer cash balance | `GET /v1/customers/{id}/cash_balance`, `POST /v1/customers/{id}/cash_balance`, `GET /v1/customers/{id}/cash_balance_transactions`, `GET /v1/customers/{id}/cash_balance_transactions/{id}`, `POST /v1/test_helpers/customers/{id}/fund_cash_balance` | Partial | Local cash-balance evidence for bank-transfer smoke tests. The test-helper funding endpoint records a cash-balance transaction and settles processing bank-transfer PaymentIntents for that customer. Banking rails, reconciliation, currency conversion, and customer-balance invoice application are not modeled. | | Connect platform evidence | `GET /v1/account`, `POST /v1/accounts`, `GET /v1/accounts`, `GET /v1/accounts/{id}`, `POST /v1/accounts/{id}`, `DELETE /v1/accounts/{id}`, `POST /v1/account_links`, `POST /v1/account_sessions`, account capabilities, people/persons, external accounts, transfers/reversals, payouts, application fees/refunds | Partial | Persist local connected-account profiles, capability status, person evidence, bank-account evidence, transfers, transfer reversals, payouts, and application-fee refunds. Account links, account sessions, and login links return local URLs/client secrets. Account deletion returns a local deletion marker. Request traces preserve `Stripe-Account` routing evidence, and local Connect evidence can emit `transfer.*`, `payout.*`, and `application_fee.refunded` webhooks. KYC, identity verification, bank verification, real onboarding, balance movement, account closure, and settlement behavior are not modeled. | | Refunds | `POST /v1/refunds`, `GET /v1/refunds`, `GET/POST /v1/refunds/{id}`, `POST /v1/refunds/{id}/cancel` | Partial | Create/list/retrieve/update local refund evidence against an invoice, payment intent, or charge-like ID. Creation emits `charge.refunded` and `charge.refund.updated`; status updates and test-clock settlement emit `charge.refund.updated`. Refund fixtures can seed `pending` and settle on `settle_at`/`available_on`. Balance transactions and processor accounting are outside the modeled subset. | -| Credit notes | `POST /v1/credit_notes`, `GET /v1/credit_notes`, `GET /v1/credit_notes/{id}`, `POST /v1/credit_notes/{id}/void` | Partial | Create/list/retrieve/void local credit note evidence for an invoice and emit `credit_note.created` / `credit_note.voided`. Line-level tax, discount, PDF, and customer-balance math are outside the modeled subset. | +| Credit notes | `POST /v1/credit_notes`, `GET /v1/credit_notes`, `GET /v1/credit_notes/{id}`, `POST /v1/credit_notes/{id}/void` | Partial | Create/list/retrieve/void local credit note evidence for an invoice and emit `credit_note.created` / `credit_note.voided`. Out-of-band credit notes accept `out_of_band_amount` and `memo`, keep `refunds` empty and never touch the customer balance. Line-level tax, discount, PDF, and customer-balance math are outside the modeled subset. | | Disputes | `GET /v1/disputes`, `GET /v1/disputes/{id}`, `POST /v1/disputes/{id}`, `POST /v1/disputes/{id}/close`, `GET /v1/charges/{id}/dispute`, `POST /v1/charges/{id}/dispute` | Partial | Create, retrieve, list, update evidence, and close local dispute evidence for chargeback-style tests. Fixture seeding and APIs emit `charge.dispute.created`, `charge.dispute.updated`, `charge.dispute.funds_withdrawn`, and `charge.dispute.closed` where applicable. Representment deadlines, balance movements, and processor outcomes are outside the modeled subset. | | Test clocks | `POST /v1/test_helpers/test_clocks`, `GET /v1/test_helpers/test_clocks`, `GET /v1/test_helpers/test_clocks/{id}`, `POST /v1/test_helpers/test_clocks/{id}/advance` | Partial | Create/retrieve/list/advance persisted local clocks. Customers, subscriptions, and pending refunds can be attached with `test_clock`; advancing a clock processes attached trial activation, renewals, configured renewal failures, period-end cancellation, and refund settlement. | | Webhook endpoints | `POST /v1/webhook_endpoints`, `GET /v1/webhook_endpoints`, `GET /v1/webhook_endpoints/{id}`, `POST /v1/webhook_endpoints/{id}`, `PATCH /v1/webhook_endpoints/{id}`, `DELETE /v1/webhook_endpoints/{id}`, `GET /v1/webhook_endpoints/{id}/attempts` | Supported | Manage local webhook endpoints and inspect endpoint-scoped delivery attempts. Secrets are generated when omitted and masked in API responses. `enabled_events` supports exact event names, `*`, and prefix wildcards such as `invoice.*`. `PATCH` accepts the same local mutable fields as `POST`, including the `enabled` alias for `active`. | @@ -168,6 +168,7 @@ Supported generic event types: - `invoice.paid` - `invoice.payment_failed` - `invoice.voided` +- `invoice.marked_uncollectible` - `payment_intent.created` - `payment_intent.succeeded` - `payment_intent.processing` diff --git a/internal/api/api.go b/internal/api/api.go index 9994c68..01cb436 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -263,6 +263,12 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { } else if len(discounts) > 0 { metadata = billing.MergeDiscountMetadata(metadata, discounts) } + if _, ok := p.values["invoice_settings[default_payment_method]"]; ok { + if metadata == nil { + metadata = map[string]string{} + } + metadata[billing.MetadataDefaultPaymentMethod] = strings.TrimSpace(p.string("invoice_settings[default_payment_method]")) + } customer, err := h.billing.CreateCustomer(r.Context(), billing.Customer{ ID: p.string("id"), Email: p.string("email"), @@ -345,7 +351,7 @@ func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request) { return } metadata := p.metadata() - if metadata != nil || p.has("test_clock") || hasDiscountParams(p) { + if metadata != nil || p.has("test_clock") || hasDiscountParams(p) || p.has("invoice_settings[default_payment_method]") { current, err := h.billing.GetCustomer(r.Context(), id) if err != nil { writeResult(w, nil, err) @@ -357,6 +363,13 @@ func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request) { } metadata = merged } + // Stripe contract: invoice_settings[default_payment_method] (possibly empty to clear). + if _, ok := p.values["invoice_settings[default_payment_method]"]; ok { + if metadata == nil { + metadata = map[string]string{} + } + metadata[billing.MetadataDefaultPaymentMethod] = strings.TrimSpace(p.string("invoice_settings[default_payment_method]")) + } if p.string("test_clock") != "" { if metadata == nil { metadata = map[string]string{} @@ -576,6 +589,14 @@ func (h *Handler) handlePrices(w http.ResponseWriter, r *http.Request) { Active: p.boolDefault("active", true), Metadata: p.metadata(), }) + if err == nil && p.boolDefault("transfer_lookup_key", false) { + // Stripe contract: the new price takes the key, others lose it. + if transferErr := h.billing.TransferLookupKey(r.Context(), price.ID, price.LookupKey); transferErr != nil { + writeResult(w, nil, transferErr) + return + } + price, err = h.billing.GetPrice(r.Context(), price.ID) + } writeResult(w, stripePrice(price), err) case http.MethodGet: prices, err := h.billing.ListPrices(r.Context()) @@ -1658,6 +1679,15 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) h.completeCheckout(w, r, id) return } + if strings.HasSuffix(rest, "/expire") { + id := strings.TrimSuffix(rest, "/expire") + if id == "" || strings.Contains(id, "/") { + h.notFound(w, r) + return + } + h.expireCheckoutSession(w, r, id) + return + } if rest == "" || strings.Contains(rest, "/") { h.notFound(w, r) return @@ -1679,6 +1709,29 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) writeResult(w, payload, err) } +// expireCheckoutSession implements POST /v1/checkout/sessions/{id}/expire: +// immediately expire an open session so a stranded hosted page cannot complete. +func (h *Handler) expireCheckoutSession(w http.ResponseWriter, r *http.Request, id string) { + 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 := p.validate(paramSpec{}); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + session, err := h.billing.ExpireCheckoutSession(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "checkout.session.expired", session.ID, h.stripeCheckoutSession(r, session), webhooks.SourceAPI) + } + writeResult(w, h.stripeCheckoutSession(r, session), err) +} + func (h *Handler) handleBillingPortalSessions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -2186,6 +2239,14 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { if prorationBehavior == "" { prorationBehavior = "none" } + // trial_end=now (or a past timestamp) ends the trial immediately: + // trialing subscriptions become active (Stripe update contract). + var trialEnd time.Time + if raw := p.string("trial_end"); raw != "" { + if parsed, parseErr := parseTimestampParam(raw); parseErr == nil && !parsed.After(time.Now().UTC()) { + trialEnd = parsed + } + } // Item change with create_prorations / always_invoice bills (or defers) proration. if replaceItems && (prorationBehavior == "always_invoice" || prorationBehavior == "create_prorations") { prorationDate := time.Now().UTC() @@ -2204,6 +2265,7 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { DefaultTaxRates: defaultTaxRates, Metadata: metadata, CancelAtPeriodEnd: p.boolPtr("cancel_at_period_end"), + TrialEnd: trialEnd, }) if err != nil { writeResult(w, nil, err) @@ -2220,12 +2282,45 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { writeResult(w, h.stripeSubscription(r, result.Subscription), nil) return } - subscription, err := h.billing.PatchSubscription(r.Context(), id, billing.SubscriptionPatch{ + patch := billing.SubscriptionPatch{ Items: items, ReplaceItems: replaceItems, Metadata: metadata, CancelAtPeriodEnd: p.boolPtr("cancel_at_period_end"), - }) + } + // anchor=now on the plain path (proration none / no items): Stripe resets + // the billing cycle regardless of the proration policy. + if p.string("billing_cycle_anchor") == "now" { + anchorItems := items + if len(anchorItems) == 0 { + if current.ID == "" { + if fetched, fetchErr := h.billing.GetSubscription(r.Context(), id); fetchErr == nil { + current = fetched + anchorItems = fetched.Items + } + } else { + anchorItems = current.Items + } + } + if len(anchorItems) > 0 { + if periodEnd, periodErr := h.billing.NextPeriodEnd(r.Context(), anchorItems, time.Now().UTC()); periodErr == nil { + anchorNow := time.Now().UTC() + patch.CurrentPeriodStart = &anchorNow + patch.CurrentPeriodEnd = &periodEnd + } + } + } + subscription, err := h.billing.PatchSubscription(r.Context(), id, patch) + if err == nil && !trialEnd.IsZero() && subscription.Status == "trialing" { + // End the trial on the plain patch path too (no proration invoice). + status := "active" + subscription, err = h.billing.PatchSubscription(r.Context(), id, billing.SubscriptionPatch{ + Status: &status, + Metadata: map[string]string{ + "trial_end": trialEnd.Format(time.RFC3339Nano), + }, + }) + } if err == nil { h.emitSubscriptionWebhook(r, "customer.subscription.updated", subscription, webhooks.SourceAPI) if len(discounts) > 0 { @@ -2234,7 +2329,23 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { } writeResult(w, h.stripeSubscription(r, subscription), err) case http.MethodDelete: + // Stripe accepts cancellation_details on immediate cancel; keep it as + // subscription evidence so BO screens can redisplay the reason. + p, err := parseParamsAllowingDeleteBody(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateSubscriptionCancel(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } subscription, err := h.billing.CancelPortalSubscription(r.Context(), id, billing.PortalCancel{Mode: "immediate"}) + if err == nil && len(subscriptionUpdateMetadata(p)) > 0 { + subscription, err = h.billing.PatchSubscription(r.Context(), id, billing.SubscriptionPatch{ + Metadata: subscriptionUpdateMetadata(p), + }) + } if err == nil { h.emitSubscriptionWebhook(r, "customer.subscription.deleted", subscription, webhooks.SourceAPI) } @@ -3174,14 +3285,27 @@ func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + subscriptionID := p.string("subscription") + if subscriptionID != "" { + if _, err := h.billing.GetSubscription(r.Context(), subscriptionID); err != nil { + writeResult(w, nil, 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, + ID: p.string("id"), + CustomerID: p.string("customer"), + SubscriptionID: subscriptionID, + Currency: p.stringDefault("currency", "usd"), + Status: "draft", + Metadata: metadata, }) + // Stripe default: pending_invoice_items_behavior=include sweeps the + // customer's pending items into the new invoice; exclude skips them. + if err == nil && p.string("pending_invoice_items_behavior") != "exclude" { + invoice, err = h.billing.SweepPendingInvoiceItems(r.Context(), invoice.ID) + } if err == nil { h.emitGenericWebhook(r, "invoice.created", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) } @@ -3246,15 +3370,7 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { 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(), - }) + item, _, err := h.createInvoiceItemFromParams(r.Context(), p) writeResult(w, stripeInvoiceItem(item), err) case http.MethodGet: items, err := h.billing.ListInvoiceItems(r.Context(), billing.InvoiceItemFilter{ @@ -3271,6 +3387,37 @@ func (h *Handler) handleInvoiceItems(w http.ResponseWriter, r *http.Request) { } } +// createInvoiceItemFromParams resolves the two Stripe invoice-item forms: +// amount+currency, or pricing[price]/price where amount = unit_amount × quantity +// and currency inherits from the price. A missing invoice stores a pending item. +func (h *Handler) createInvoiceItemFromParams(ctx context.Context, p params) (billing.InvoiceItem, billing.Invoice, error) { + item := billing.InvoiceItem{ + ID: p.string("id"), + CustomerID: p.string("customer"), + InvoiceID: p.string("invoice"), + SubscriptionID: p.string("subscription"), + Amount: p.int64("amount"), + Currency: p.string("currency"), + Description: p.string("description"), + Metadata: p.metadata(), + } + if priceID := p.first("pricing[price]", "pricing[price_id]", "price", "price_id"); priceID != "" { + price, err := h.billing.GetPrice(ctx, priceID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + quantity := p.int64Default("quantity", 1) + if quantity <= 0 { + quantity = 1 + } + item.Amount = price.UnitAmount * quantity + if item.Currency == "" { + item.Currency = price.Currency + } + } + return h.billing.CreateInvoiceItem(ctx, item) +} + 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, "/") { @@ -3385,6 +3532,48 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { writeResult(w, h.stripeInvoiceWithPaymentIntent(result.Invoice, result.PaymentIntent), err) return } + if len(parts) == 2 && parts[1] == "void" { + 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 := validateInvoiceTerminalAction(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.VoidInvoice(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "invoice.voided", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if len(parts) == 2 && parts[1] == "mark_uncollectible" { + 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 := validateInvoiceTerminalAction(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.MarkInvoiceUncollectible(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "invoice.marked_uncollectible", invoice.ID, h.stripeInvoice(r.Context(), invoice), webhooks.SourceAPI) + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } if len(parts) == 2 && parts[1] == "lines" { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") @@ -3541,14 +3730,16 @@ func (h *Handler) handleCreditNotes(w http.ResponseWriter, r *http.Request) { return } note, err := h.billing.CreateCreditNote(r.Context(), billing.CreditNote{ - ID: p.string("id"), - InvoiceID: p.string("invoice"), - CustomerID: p.string("customer"), - Amount: p.int64("amount"), - Currency: p.string("currency"), - Reason: p.string("reason"), - Status: p.string("status"), - Metadata: p.metadata(), + ID: p.string("id"), + InvoiceID: p.string("invoice"), + CustomerID: p.string("customer"), + Amount: p.int64("amount"), + OutOfBandAmount: p.int64Default("out_of_band_amount", 0), + Memo: p.string("memo"), + Currency: p.string("currency"), + Reason: p.string("reason"), + Status: p.string("status"), + Metadata: p.metadata(), }) if err == nil { h.emitGenericWebhook(r, "credit_note.created", note.ID, stripeCreditNote(note), webhooks.SourceAPI) @@ -7103,13 +7294,15 @@ func subscriptionPauseCollection(sub billing.Subscription) any { } } +// cancel_at is honored independently of cancel_at_period_end: Stripe lets +// callers schedule a cancellation timestamp on its own (BO reserved cancellation). func subscriptionCancelAt(sub billing.Subscription) any { - if !sub.CancelAtPeriodEnd { - return nil - } if value := metadataUnix(sub.Metadata["cancel_at"]); value != nil { return value } + if !sub.CancelAtPeriodEnd { + return nil + } return unix(sub.CurrentPeriodEnd) } @@ -7373,16 +7566,17 @@ func stripeInvoiceWithPaymentIntentAndTaxRates(invoice billing.Invoice, intent * 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, + "id": item.ID, + "object": billing.ObjectInvoiceItem, + "customer": item.CustomerID, + "invoice": emptyToNil(item.InvoiceID), + "subscription": emptyToNil(item.SubscriptionID), + "amount": item.Amount, + "currency": item.Currency, + "description": emptyToNil(item.Description), + "metadata": nonNilMap(item.Metadata), + "created": unix(item.CreatedAt), + "livemode": false, } } @@ -7670,6 +7864,12 @@ func stripeCreditNote(note billing.CreditNote) map[string]any { "created": unix(note.CreatedAt), "livemode": false, "lines": stripeList("/v1/credit_notes/"+note.ID+"/lines", []map[string]any{}), + // Out-of-band notes never touch the customer balance or refunds; ds2's + // out-of-band refund path asserts both stay empty. + "out_of_band_amount": note.OutOfBandAmount, + "memo": emptyToNil(note.Memo), + "customer_balance_transaction": nil, + "refunds": stripeList("/v1/credit_notes/"+note.ID+"/refunds", []map[string]any{}), } } @@ -7689,6 +7889,7 @@ func paymentIntentReceivedAmount(intent billing.PaymentIntent) int64 { func filterPrices(prices []billing.Price, r *http.Request) []billing.Price { query := r.URL.Query() + lookupKeys := lookupKeysFilter(query) out := make([]billing.Price, 0, len(prices)) for _, price := range prices { if product := query.Get("product"); product != "" && price.ProductID != product { @@ -7704,7 +7905,7 @@ func filterPrices(prices []billing.Price, r *http.Request) []billing.Price { // 조회한 뒤 `.firstOrNull()` 을 하는 순간 **전혀 다른 테넌트의 상품**을 집는다. // 실측: 한 테넌트의 플랜 목록이 다른 테넌트 상품으로 채워져 // Upgrade plan 화면이 통째로 비었다. - if keys := lookupKeysFilter(query); len(keys) > 0 && !keys[price.LookupKey] { + if len(lookupKeys) > 0 && !lookupKeys[priceLookupKey(price)] { continue } out = append(out, price) @@ -7747,9 +7948,40 @@ func filterAccounts(accounts []billing.Account, r *http.Request) []billing.Accou return out } +// subscriptionPeriodEndRange extracts current_period_end[gte|gt|lt|lte] filters +// (unix seconds). A zero time.Time means the bound is absent. +func subscriptionPeriodEndRange(query url.Values) (gte, gt, lt, lte time.Time) { + parse := func(key string) time.Time { + for _, value := range query[key] { + if seconds, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil { + return time.Unix(seconds, 0).UTC() + } + } + return time.Time{} + } + return parse("current_period_end[gte]"), parse("current_period_end[gt]"), parse("current_period_end[lt]"), parse("current_period_end[lte]") +} + +func subscriptionMatchesPeriodEndRange(item billing.Subscription, gte, gt, lt, lte time.Time) bool { + if !gte.IsZero() && item.CurrentPeriodEnd.Before(gte) { + return false + } + if !gt.IsZero() && !item.CurrentPeriodEnd.After(gt) { + return false + } + if !lt.IsZero() && !item.CurrentPeriodEnd.Before(lt) { + return false + } + if !lte.IsZero() && !item.CurrentPeriodEnd.Before(lte) && !item.CurrentPeriodEnd.Equal(lte) { + return false + } + return true +} + func filterSubscriptions(items []billing.Subscription, r *http.Request) []billing.Subscription { query := r.URL.Query() metadataFilters := queryMetadataFilters(query) + gte, gt, lt, lte := subscriptionPeriodEndRange(query) out := make([]billing.Subscription, 0, len(items)) for _, item := range items { if customer := query.Get("customer"); customer != "" && item.CustomerID != customer { @@ -7762,6 +7994,9 @@ func filterSubscriptions(items []billing.Subscription, r *http.Request) []billin if !metadataMatches(item.Metadata, metadataFilters) { continue } + if !subscriptionMatchesPeriodEndRange(item, gte, gt, lt, lte) { + continue + } out = append(out, item) } return out @@ -7770,6 +8005,7 @@ func filterSubscriptions(items []billing.Subscription, r *http.Request) []billin func filterSubscriptionsForCustomer(items []billing.Subscription, r *http.Request, customerID string) []billing.Subscription { query := r.URL.Query() metadataFilters := queryMetadataFilters(query) + gte, gt, lt, lte := subscriptionPeriodEndRange(query) out := make([]billing.Subscription, 0, len(items)) for _, item := range items { if item.CustomerID != customerID { @@ -7782,6 +8018,9 @@ func filterSubscriptionsForCustomer(items []billing.Subscription, r *http.Reques if !metadataMatches(item.Metadata, metadataFilters) { continue } + if !subscriptionMatchesPeriodEndRange(item, gte, gt, lt, lte) { + continue + } out = append(out, item) } return out @@ -8204,6 +8443,7 @@ func subscriptionUpdateMetadata(p params) map[string]string { {param: "payment_behavior", key: "payment_behavior"}, {param: "billing_cycle_anchor", key: "billing_cycle_anchor"}, {param: "trial_end", key: "trial_end"}, + {param: "cancel_at", key: "cancel_at"}, } { if value := p.string(item.param); value != "" { if metadata == nil { diff --git a/internal/api/discounts.go b/internal/api/discounts.go index 9fc1904..b2c1a83 100644 --- a/internal/api/discounts.go +++ b/internal/api/discounts.go @@ -286,10 +286,23 @@ func (h *Handler) stripeDiscount(discount billing.Discount, customerID string, s } h.local.mu.Unlock() } + source := map[string]any{ + "type": "coupon", + "coupon": coupon, + } + if discount.PromotionCodeID != "" { + source["type"] = "promotion_code" + source["promotion_code"] = map[string]any{ + "id": discount.PromotionCodeID, + "object": "promotion_code", + "coupon": coupon, + } + } return map[string]any{ "id": discount.ID, "object": "discount", "coupon": coupon, + "source": source, "customer": emptyToNil(customerID), "promotion_code": emptyToNil(discount.PromotionCodeID), "subscription": emptyToNil(subscriptionID), diff --git a/internal/api/stripe_gaps_v2_test.go b/internal/api/stripe_gaps_v2_test.go new file mode 100644 index 0000000..75d7709 --- /dev/null +++ b/internal/api/stripe_gaps_v2_test.go @@ -0,0 +1,729 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/hckim/billtap/internal/billing" +) + +func httptestRequest(t *testing.T, method string, path string, formBody string) *http.Request { + t.Helper() + var req *http.Request + if formBody == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(formBody)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + req.Host = "billtap.test" + return req +} + +func httptestRecord(t *testing.T, handler http.Handler, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +// The tests in this file reproduce Stripe compatibility gaps found by diffing +// the ds2 platform-server Stripe SDK call surface against billtap. One test +// per gap; see docs/COMPATIBILITY_TRACKING.md conventions for claim wiring. + +func gapsV2Customer(t *testing.T, handler http.Handler, email string) billing.Customer { + t.Helper() + return postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {email}}) +} + +func gapsV2Product(t *testing.T, handler http.Handler, name string) billing.Product { + t.Helper() + return postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {name}}) +} + +func gapsV2Price(t *testing.T, handler http.Handler, productID string, unitAmount string, interval string, extra url.Values) billing.Price { + t.Helper() + values := url.Values{ + "product": {productID}, + "currency": {"usd"}, + "unit_amount": {unitAmount}, + } + if interval != "" { + values.Set("recurring[interval]", interval) + } + for key, vals := range extra { + values[key] = vals + } + return postForm[billing.Price](t, handler, "/v1/prices", values) +} + +type gapsV2SubscriptionSeed struct { + ID string `json:"id"` + Status string `json:"status"` + CurrentPeriodStart int64 `json:"current_period_start"` + CurrentPeriodEnd int64 `json:"current_period_end"` + Items struct { + Data []struct { + ID string `json:"id"` + Quantity int64 `json:"quantity"` + } `json:"data"` + } `json:"items"` +} + +func gapsV2Subscription(t *testing.T, handler http.Handler, customerID string, priceID string, extra url.Values) gapsV2SubscriptionSeed { + t.Helper() + values := url.Values{ + "customer": {customerID}, + "items[0][price]": {priceID}, + } + for key, vals := range extra { + values[key] = vals + } + return postForm[gapsV2SubscriptionSeed](t, handler, "/v1/subscriptions", values) +} + +type gapsV2SubscriptionView struct { + ID string `json:"id"` + Status string `json:"status"` + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` + CancelAt any `json:"cancel_at"` + CurrentPeriodStart int64 `json:"current_period_start"` + CurrentPeriodEnd int64 `json:"current_period_end"` + CancellationDetails struct { + Comment any `json:"comment"` + Feedback any `json:"feedback"` + } `json:"cancellation_details"` + Discounts []struct { + ID string `json:"id"` + Source *struct { + Type string `json:"type"` + Coupon *struct { + ID string `json:"id"` + Object string `json:"object"` + } `json:"coupon"` + } `json:"source"` + } `json:"discounts"` +} + +type gapsV2InvoiceView struct { + ID string `json:"id"` + Status string `json:"status"` + Subtotal int64 `json:"subtotal"` + Total int64 `json:"total"` + Parent *struct { + SubscriptionDetails *struct { + Subscription string `json:"subscription"` + } `json:"subscription_details"` + } `json:"parent"` +} + +// Gap: /v1/prices ignored lookup_keys[], so ds2's one-time extra-export price +// resolution could pick an arbitrary active one-time price. +func TestPricesListLookupKeysFilter(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "lookup@example.test") + _ = customer + productA := gapsV2Product(t, handler, "Lookup A") + productB := gapsV2Product(t, handler, "Lookup B") + priceA := gapsV2Price(t, handler, productA.ID, "1500", "", url.Values{"lookup_key": {"tenant_extra_export"}}) + priceB := gapsV2Price(t, handler, productB.ID, "2500", "", url.Values{"lookup_key": {"tenant_extra_export_v2"}}) + + status, body := getStatus(t, handler, "/v1/prices?lookup_keys[0]=tenant_extra_export&active=true&type=one_time") + if status != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200", status, body) + } + var list struct { + Data []billing.Price `json:"data"` + } + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list.Data) != 1 || list.Data[0].ID != priceA.ID { + t.Fatalf("prices = %#v, want only %s (priceB=%s)", list.Data, priceA.ID, priceB.ID) + } + + // Multiple keys return both, and absent params return everything. + status, body = getStatus(t, handler, "/v1/prices?lookup_keys[0]=tenant_extra_export&lookup_keys[1]=tenant_extra_export_v2&type=one_time") + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatalf("decode two-key list: %v", err) + } + if status != http.StatusOK || len(list.Data) != 2 { + t.Fatalf("status=%d data=%d, want 2", status, len(list.Data)) + } +} + +// Gap: checkout-completed subscription periods were hard-coded to +1 month, +// so yearly plans renewed after one month. +func TestCheckoutSubscriptionPeriodFollowsPriceInterval(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "yearly@example.test") + product := gapsV2Product(t, handler, "Yearly") + price := gapsV2Price(t, handler, product.ID, "99000", "year", nil) + + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "mode": {"subscription"}, + "line_items[0][price]": {price.ID}, + }) + completion := postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + var subscription billing.Subscription + if err := json.Unmarshal(completion["subscription"], &subscription); err != nil { + t.Fatalf("decode subscription: %v", err) + } + days := subscription.CurrentPeriodEnd.Sub(subscription.CurrentPeriodStart).Hours() / 24 + if days < 364 || days > 366 { + t.Fatalf("period days = %.1f, want ~365 for a yearly price", days) + } +} + +// Gap: subscription update accepted trial_end=now but left the subscription +// trialing; the trialing→paid upgrade confirm path depends on it. +func TestSubscriptionUpdateTrialEndNowEndsTrial(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "trial@example.test") + product := gapsV2Product(t, handler, "Trial Plan") + price := gapsV2Price(t, handler, product.ID, "4900", "month", nil) + + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "subscription_data[trial_period_days]": {"14"}, + }) + completion := postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + var seeded billing.Subscription + if err := json.Unmarshal(completion["subscription"], &seeded); err != nil { + t.Fatalf("decode subscription: %v", err) + } + if seeded.Status != "trialing" { + t.Fatalf("seeded status = %q, want trialing", seeded.Status) + } + + updated := postForm[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+seeded.ID, url.Values{ + "items[0][id]": {seeded.Items[0].ID}, + "items[0][quantity]": {"1"}, + "trial_end": {"now"}, + "proration_behavior": {"none"}, + }) + if updated.Status != "active" { + t.Fatalf("status after trial_end=now = %q, want active", updated.Status) + } + + // Plain patch path (no item override) also ends the trial. + session2 := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "subscription_data[trial_period_days]": {"7"}, + }) + completion2 := postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+session2.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + var seeded2 billing.Subscription + if err := json.Unmarshal(completion2["subscription"], &seeded2); err != nil { + t.Fatalf("decode subscription2: %v", err) + } + updated2 := postForm[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+seeded2.ID, url.Values{ + "trial_end": {"now"}, + }) + if updated2.Status != "active" { + t.Fatalf("plain-path status after trial_end=now = %q, want active", updated2.Status) + } +} + +// gapsV2TestClock creates a frozen test clock so subscription periods can be +// seeded deterministically (checkout completion runs at the frozen time). +func gapsV2TestClock(t *testing.T, handler http.Handler, frozen time.Time) struct{ ID string } { + t.Helper() + clock := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/test_helpers/test_clocks", url.Values{ + "frozen_time": {fmt.Sprintf("%d", frozen.Unix())}, + }) + return struct{ ID string }{ID: clock.ID} +} + +// Gap: subscription list ignored current_period_end[gte]/[lt], so the ds2 +// reminder and seat-sync sweeps processed every subscription in range-less fashion. +func TestSubscriptionsListCurrentPeriodEndRange(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "range@example.test") + product := gapsV2Product(t, handler, "Range Plan") + price := gapsV2Price(t, handler, product.ID, "1900", "month", nil) + + now := time.Now().UTC() + clock1 := gapsV2TestClock(t, handler, now.Add(-24*time.Hour)) + clock2 := gapsV2TestClock(t, handler, now.Add(-48*time.Hour)) + first := gapsV2Subscription(t, handler, customer.ID, price.ID, url.Values{"test_clock": {clock1.ID}}) + second := gapsV2Subscription(t, handler, customer.ID, price.ID, url.Values{"test_clock": {clock2.ID}}) + if first.ID == second.ID { + t.Fatalf("seeded subscriptions share id %s", first.ID) + } + if first.CurrentPeriodEnd <= second.CurrentPeriodEnd { + t.Fatalf("seeded periods not ordered: first=%d second=%d", first.CurrentPeriodEnd, second.CurrentPeriodEnd) + } + low := second.CurrentPeriodEnd + mid := first.CurrentPeriodEnd + + var list struct { + Data []gapsV2SubscriptionView `json:"data"` + } + body := getJSON[json.RawMessage](t, handler, fmt.Sprintf("/v1/subscriptions?customer=%s¤t_period_end[gte]=%d¤t_period_end[lt]=%d", customer.ID, low, mid)) + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("decode gte/lt list: %v", err) + } + if len(list.Data) != 1 || list.Data[0].ID != second.ID { + t.Fatalf("gte/lt data = %#v, want only %s", list.Data, second.ID) + } + + body = getJSON[json.RawMessage](t, handler, fmt.Sprintf("/v1/subscriptions?customer=%s¤t_period_end[gt]=%d", customer.ID, second.CurrentPeriodEnd)) + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("decode gt list: %v", err) + } + if len(list.Data) != 1 || list.Data[0].ID != first.ID { + t.Fatalf("gt data = %#v, want only %s", list.Data, first.ID) + } + + // Empty bounds return everything for the customer. + body = getJSON[json.RawMessage](t, handler, fmt.Sprintf("/v1/subscriptions?customer=%s", customer.ID)) + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("decode unfiltered list: %v", err) + } + if len(list.Data) != 2 { + t.Fatalf("unfiltered data = %d, want 2", len(list.Data)) + } +} + +// Gap: updateCancelAt (BO reserved cancellation) sent cancel_at without +// cancel_at_period_end and was first rejected, then dropped by the serializer gate. +func TestSubscriptionUpdateCancelAtStandalone(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "reserved@example.test") + product := gapsV2Product(t, handler, "Reserved Plan") + price := gapsV2Price(t, handler, product.ID, "2900", "month", nil) + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, nil) + + cancelAt := time.Now().UTC().Add(72 * time.Hour).Truncate(time.Second) + updated := postForm[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+subscription.ID, url.Values{ + "cancel_at": {fmt.Sprintf("%d", cancelAt.Unix())}, + }) + if updated.CancelAtPeriodEnd { + t.Fatalf("cancel_at_period_end = true, want untouched false") + } + seconds, ok := updated.CancelAt.(float64) + if !ok || int64(seconds) != cancelAt.Unix() { + t.Fatalf("cancel_at = %#v, want %d", updated.CancelAt, cancelAt.Unix()) + } +} + +// Gap: StripeParamBuilder.buildSubscriptionCreateParams always sends +// proration_behavior; subscription create rejected it with parameter_unknown. +func TestSubscriptionCreateAcceptsProrationBehavior(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "create-proration@example.test") + product := gapsV2Product(t, handler, "Create Proration") + price := gapsV2Price(t, handler, product.ID, "3900", "month", nil) + + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, url.Values{ + "proration_behavior": {"none"}, + "collection_method": {"send_invoice"}, + "days_until_due": {"1"}, + "cancel_at": {fmt.Sprintf("%d", time.Now().UTC().Add(240*time.Hour).Unix())}, + }) + if subscription.ID == "" { + t.Fatalf("subscription = %#v, want created with proration_behavior accepted", subscription) + } +} + +// Gap: invoiceitems rejected pricing[price]+quantity and required amount, and +// items without an invoice could not be stored as pending. The extra-export +// card-on-file path (prepareOneTimeInvoice) and the BO out-of-band seeding both +// depend on these forms. +func TestInvoiceItemPricingPriceAndPendingSweep(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "items@example.test") + product := gapsV2Product(t, handler, "Extra Export") + price := gapsV2Price(t, handler, product.ID, "1500", "", nil) + + // Pricing form derives amount = unit_amount × quantity and currency from the price. + item := postForm[struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Invoice any `json:"invoice"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "pricing[price]": {price.ID}, + "quantity": {"2"}, + "description": {"Extra export pack"}, + }) + if item.Amount != 3000 || item.Currency != "usd" { + t.Fatalf("item = %#v, want amount 3000 usd", item) + } + if item.Invoice != nil { + t.Fatalf("item.invoice = %#v, want null pending item", item.Invoice) + } + + // Invoice create sweeps the pending item by default (include). + invoice := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + }) + if invoice.Subtotal != 3000 || invoice.Total != 3000 { + t.Fatalf("swept invoice = %#v, want totals 3000", invoice) + } + + // exclude skips the sweep. + pending := postForm[billing.InvoiceItem](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "amount": {"700"}, + "currency": {"usd"}, + }) + excluded := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "pending_invoice_items_behavior": {"exclude"}, + }) + if excluded.Subtotal != 0 { + t.Fatalf("exclude invoice subtotal = %d, want 0", excluded.Subtotal) + } + status, body := getStatus(t, handler, "/v1/invoiceitems/"+pending.ID) + if status != http.StatusOK { + t.Fatalf("pending item lookup status=%d body=%s", status, body) + } + + // The amount path still attaches directly to an invoice. + attached := postForm[billing.InvoiceItem](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {excluded.ID}, + "amount": {"250"}, + "currency": {"usd"}, + }) + if attached.InvoiceID != excluded.ID || attached.Amount != 250 { + t.Fatalf("attached item = %#v, want invoice %s amount 250", attached, excluded.ID) + } +} + +// Gap: invoice create rejected the subscription param, so manually created +// invoices never linked to their subscription (payment-history joins ran empty). +func TestInvoiceCreateWithSubscriptionParam(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "inv-sub@example.test") + product := gapsV2Product(t, handler, "Linked Plan") + price := gapsV2Price(t, handler, product.ID, "5900", "month", nil) + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, nil) + + invoice := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "subscription": {subscription.ID}, + }) + if invoice.Parent == nil || invoice.Parent.SubscriptionDetails == nil || invoice.Parent.SubscriptionDetails.Subscription != subscription.ID { + t.Fatalf("invoice parent = %#v, want subscription_details.subscription %s", invoice.Parent, subscription.ID) + } + + var list struct { + Data []gapsV2InvoiceView `json:"data"` + } + body := getJSON[json.RawMessage](t, handler, "/v1/invoices?subscription="+subscription.ID) + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("decode invoice list: %v", err) + } + found := false + for _, existing := range list.Data { + if existing.ID == invoice.ID { + found = true + } + } + if !found { + t.Fatalf("invoice list = %#v, want to contain %s", list.Data, invoice.ID) + } + + status, bodyStr := postFormStatus(t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "subscription": {"sub_does_not_exist"}, + }) + if status != http.StatusNotFound { + t.Fatalf("unknown subscription status=%d body=%s, want 404", status, bodyStr) + } +} + +// Gap: customer update rejected invoice_settings[default_payment_method]; the +// extra-export card transition (setSucceededPaymentMethodAsCustomerDefault) 400'd. +func TestCustomerInvoiceSettingsDefaultPaymentMethod(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "dpm@example.test") + + updated := postForm[struct { + InvoiceSettings struct { + DefaultPaymentMethod any `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers/"+customer.ID, url.Values{ + "invoice_settings[default_payment_method]": {"pm_saved_card"}, + }) + if updated.InvoiceSettings.DefaultPaymentMethod != "pm_saved_card" { + t.Fatalf("invoice_settings = %#v, want pm_saved_card", updated.InvoiceSettings) + } + + var methods struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + body := getJSON[json.RawMessage](t, handler, "/v1/payment_methods?customer="+customer.ID+"&type=card") + if err := json.Unmarshal(body, &methods); err != nil { + t.Fatalf("decode payment methods: %v", err) + } + if len(methods.Data) == 0 || methods.Data[0].ID != "pm_saved_card" { + t.Fatalf("payment methods = %#v, want pm_saved_card first", methods.Data) + } + + // Empty string clears the default (Stripe unset form). + cleared := postForm[struct { + InvoiceSettings struct { + DefaultPaymentMethod any `json:"default_payment_method"` + } `json:"invoice_settings"` + }](t, handler, "/v1/customers/"+customer.ID, url.Values{ + "invoice_settings[default_payment_method]": {""}, + }) + if cleared.InvoiceSettings.DefaultPaymentMethod != nil { + t.Fatalf("cleared invoice_settings = %#v, want null", cleared.InvoiceSettings) + } +} + +// Gap: /v1/invoices/{id}/void and /mark_uncollectible were 404; the extra-export +// compensation path (voidInvoice) and SDS §4.0.3.8 (uncollectible) need them. +func TestInvoiceVoidAndMarkUncollectible(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "void@example.test") + product := gapsV2Product(t, handler, "Voidable") + _ = gapsV2Price(t, handler, product.ID, "1200", "", nil) + + newInvoice := func() string { + invoice := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "pending_invoice_items_behavior": {"exclude"}, + }) + postForm[billing.InvoiceItem](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"1200"}, + "currency": {"usd"}, + }) + return invoice.ID + } + + voidable := newInvoice() + postForm[map[string]any](t, handler, "/v1/invoices/"+voidable+"/finalize", url.Values{}) + invalid := newInvoice() // draft: void must refuse + paidID := newInvoice() + postForm[map[string]any](t, handler, "/v1/invoices/"+paidID+"/finalize", url.Values{}) + postForm[map[string]any](t, handler, "/v1/invoices/"+paidID+"/pay", url.Values{"paid_out_of_band": {"true"}}) + + voided := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices/"+voidable+"/void", url.Values{}) + if voided.Status != "void" { + t.Fatalf("voided status = %q, want void", voided.Status) + } + + if status, body := postFormStatus(t, handler, "/v1/invoices/"+invalid+"/void", url.Values{}); status != http.StatusBadRequest { + t.Fatalf("draft void status=%d body=%s, want 400", status, body) + } + if status, body := postFormStatus(t, handler, "/v1/invoices/"+paidID+"/void", url.Values{}); status != http.StatusBadRequest { + t.Fatalf("paid void status=%d body=%s, want 400", status, body) + } + + uncollectibleID := newInvoice() + postForm[map[string]any](t, handler, "/v1/invoices/"+uncollectibleID+"/finalize", url.Values{}) + uncollectible := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices/"+uncollectibleID+"/mark_uncollectible", url.Values{}) + if uncollectible.Status != "uncollectible" { + t.Fatalf("uncollectible status = %q, want uncollectible", uncollectible.Status) + } + if status, body := postFormStatus(t, handler, "/v1/invoices/"+newInvoice()+"/mark_uncollectible", url.Values{}); status != http.StatusBadRequest { + t.Fatalf("draft mark_uncollectible status=%d body=%s, want 400", status, body) + } +} + +// Gap: POST /v1/checkout/sessions/{id}/expire was 404; the orphan-session +// compensation path (expireCheckoutSession) needs it. +func TestCheckoutSessionExpire(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "expire@example.test") + product := gapsV2Product(t, handler, "Expirable") + price := gapsV2Price(t, handler, product.ID, "800", "", nil) + + open := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "mode": {"payment"}, + "line_items[0][price]": {price.ID}, + }) + expired := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions/"+open.ID+"/expire", url.Values{}) + if expired.Status != "expired" { + t.Fatalf("status after expire = %q, want expired", expired.Status) + } + // Expired sessions are terminal: expiring again is resource_missing. + if status, _ := postFormStatus(t, handler, "/v1/checkout/sessions/"+open.ID+"/expire", url.Values{}); status != http.StatusNotFound { + t.Fatalf("re-expire status=%d, want 404", status) + } + + completed := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "mode": {"payment"}, + "line_items[0][price]": {price.ID}, + }) + postJSON[map[string]any](t, handler, "/api/checkout/sessions/"+completed.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + if status, _ := postFormStatus(t, handler, "/v1/checkout/sessions/"+completed.ID+"/expire", url.Values{}); status != http.StatusNotFound { + t.Fatalf("completed expire status=%d, want 404", status) + } +} + +// Gap: prices create rejected transfer_lookup_key; catalog convergence moves a +// lookup key to a new price and the old holder must lose it. +func TestPriceTransferLookupKey(t *testing.T) { + handler := newTestHandler(t) + product := gapsV2Product(t, handler, "Transfer") + first := gapsV2Price(t, handler, product.ID, "5000", "month", url.Values{"lookup_key": {"tenant_plan_pro_monthly"}}) + + second := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"6000"}, + "recurring[interval]": {"month"}, + "lookup_key": {"tenant_plan_pro_monthly"}, + "transfer_lookup_key": {"true"}, + }) + if second.LookupKey != "tenant_plan_pro_monthly" { + t.Fatalf("new price lookup_key = %q, want tenant_plan_pro_monthly", second.LookupKey) + } + reloadedFirst := getJSON[billing.Price](t, handler, "/v1/prices/"+first.ID) + if reloadedFirst.LookupKey != "" { + t.Fatalf("old price lookup_key = %q, want cleared", reloadedFirst.LookupKey) + } +} + +// Gap: credit notes rejected out_of_band_amount/memo; the BO out-of-band refund +// path requires both, plus refunds staying empty and no balance transaction. +func TestCreditNoteOutOfBandAmountAndMemo(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "oob@example.test") + + invoice := postForm[gapsV2InvoiceView](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + "pending_invoice_items_behavior": {"exclude"}, + }) + postForm[billing.InvoiceItem](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"4000"}, + "currency": {"usd"}, + }) + postForm[map[string]any](t, handler, "/v1/invoices/"+invoice.ID+"/finalize", url.Values{}) + postForm[map[string]any](t, handler, "/v1/invoices/"+invoice.ID+"/pay", url.Values{"paid_out_of_band": {"true"}}) + + note := postForm[struct { + ID string `json:"id"` + OutOfBandAmount int64 `json:"out_of_band_amount"` + Memo string `json:"memo"` + CustomerBalanceTransaction any `json:"customer_balance_transaction"` + Refunds struct { + Data []any `json:"data"` + } `json:"refunds"` + Status string `json:"status"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {invoice.ID}, + "amount": {"4000"}, + "out_of_band_amount": {"4000"}, + "memo": {"BO refund"}, + "reason": {"order_change"}, + }) + if note.OutOfBandAmount != 4000 || note.Memo != "BO refund" { + t.Fatalf("note = %#v, want out_of_band_amount 4000 and memo", note) + } + if note.CustomerBalanceTransaction != nil || len(note.Refunds.Data) != 0 { + t.Fatalf("note balance/refunds = %#v, want both empty for out-of-band", note) + } + if note.Status != "issued" { + t.Fatalf("note status = %q, want issued", note.Status) + } +} + +// Gap: discount objects lacked source.coupon, so stripe-java 31.1.0 +// (Discount.getSource().getCouponObject()) read null for coupon-backed discounts. +func TestSubscriptionDiscountSourceCouponObject(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "coupon@example.test") + product := gapsV2Product(t, handler, "Coupon Plan") + price := gapsV2Price(t, handler, product.ID, "3000", "month", nil) + + coupon := postForm[map[string]any](t, handler, "/v1/coupons", url.Values{ + "percent_off": {"10"}, + "duration": {"forever"}, + }) + couponID, _ := coupon["id"].(string) + + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, url.Values{ + "coupon": {couponID}, + }) + view := getJSON[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+subscription.ID) + if len(view.Discounts) != 1 { + t.Fatalf("discounts = %#v, want one", view.Discounts) + } + discount := view.Discounts[0] + if discount.Source == nil || discount.Source.Coupon == nil || discount.Source.Coupon.ID != couponID || discount.Source.Coupon.Object != "coupon" { + t.Fatalf("discount = %#v, want source.coupon %s", discount, couponID) + } +} + +// Gap: billing_cycle_anchor=now only reset the cycle on always_invoice; Stripe +// applies the anchor reset in every proration mode (ds2 bank-transfer path uses none). +func TestSubscriptionUpdateBillingCycleAnchorNowWithNone(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "anchor-none@example.test") + product := gapsV2Product(t, handler, "Anchor Plan") + price := gapsV2Price(t, handler, product.ID, "3300", "month", nil) + // Seed the period 20 days in the past so the reset is observable. + clock := gapsV2TestClock(t, handler, time.Now().UTC().Add(-20*24*time.Hour)) + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, url.Values{"test_clock": {clock.ID}}) + + before := getJSON[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+subscription.ID) + updated := postForm[gapsV2SubscriptionView](t, handler, "/v1/subscriptions/"+subscription.ID, url.Values{ + "items[0][id]": {subscription.Items.Data[0].ID}, + "items[0][quantity]": {"1"}, + "proration_behavior": {"none"}, + "billing_cycle_anchor": {"now"}, + }) + if updated.CurrentPeriodStart <= before.CurrentPeriodStart+86400 { + t.Fatalf("period start %d not reset from %d", updated.CurrentPeriodStart, before.CurrentPeriodStart) + } + days := time.Unix(updated.CurrentPeriodEnd, 0).Sub(time.Unix(updated.CurrentPeriodStart, 0)).Hours() / 24 + if days < 28 || days > 31 { + t.Fatalf("reset period = %.1f days, want one month", days) + } +} + +// Gap: immediate DELETE dropped cancellation_details[feedback|comment]; BO +// screens redisplay the collected reason from the subscription response. +func TestSubscriptionDeleteKeepsCancellationDetails(t *testing.T) { + handler := newTestHandler(t) + customer := gapsV2Customer(t, handler, "cancel-detail@example.test") + product := gapsV2Product(t, handler, "Cancel Detail Plan") + price := gapsV2Price(t, handler, product.ID, "2100", "month", nil) + subscription := gapsV2Subscription(t, handler, customer.ID, price.ID, nil) + + req := httptestRequest(t, http.MethodDelete, "/v1/subscriptions/"+subscription.ID, url.Values{ + "cancellation_details[feedback]": {"too_expensive"}, + "cancellation_details[comment]": {"Switching to annual elsewhere"}, + }.Encode()) + rec := httptestRecord(t, handler, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + var view gapsV2SubscriptionView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatalf("decode canceled subscription: %v", err) + } + if view.Status != "canceled" { + t.Fatalf("status = %q, want canceled", view.Status) + } + if view.CancellationDetails.Feedback != "too_expensive" || view.CancellationDetails.Comment != "Switching to annual elsewhere" { + t.Fatalf("cancellation_details = %#v, want feedback+comment preserved", view.CancellationDetails) + } +} diff --git a/internal/api/validation.go b/internal/api/validation.go index cfa6944..6df0388 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -34,6 +34,10 @@ var ( // (firstValues leaves single-value [] keys unexpanded). checkoutSubscriptionDataRE = regexp.MustCompile(`^subscription_data\[(trial_period_days|default_tax_rates)\](\[\d*\])?$`) defaultTaxRatesParamRE = regexp.MustCompile(`^default_tax_rates(\[\d*\])?$`) + // invoice_settings[default_payment_method] on customer create/update (empty clears). + customerInvoiceSettingsRE = regexp.MustCompile(`^invoice_settings\[default_payment_method\]$`) + // pricing[price] is the current stripe-java form for price-based invoice items. + invoiceItemPricingRE = regexp.MustCompile(`^pricing\[(price|price_id)\]$`) // Item-level tax_rates on subscription_items create: evidence only (not used for totals). taxRatesParamRE = regexp.MustCompile(`^tax_rates(\[\d*\])?$`) discountParamRE = regexp.MustCompile(`^discounts\[\d+\]\[(coupon|promotion_code)\]$`) @@ -398,7 +402,7 @@ func (p params) validateUnixTimestampOrNowOrUnchanged(key string) error { func validateCustomerCreate(p params) error { return p.validate(paramSpec{ Allowed: []string{"id", "email", "name", "test_clock", "coupon", "promotion_code"}, - AllowedRegex: []*regexp.Regexp{discountParamRE}, + AllowedRegex: []*regexp.Regexp{discountParamRE, customerInvoiceSettingsRE}, AllowMetadata: true, }) } @@ -406,7 +410,7 @@ func validateCustomerCreate(p params) error { func validateCustomerUpdate(p params) error { return p.validate(paramSpec{ Allowed: []string{"email", "name", "test_clock", "coupon", "promotion_code"}, - AllowedRegex: []*regexp.Regexp{discountParamRE}, + AllowedRegex: []*regexp.Regexp{discountParamRE, customerInvoiceSettingsRE}, AllowMetadata: true, }) } @@ -456,12 +460,13 @@ func validatePriceCreate(p params) error { "interval", "recurring[interval_count]", "active", + "transfer_lookup_key", }, Required: []string{"currency", "unit_amount"}, RequiredAny: [][]string{{"product", "product_id"}}, Int64Params: []string{"unit_amount", "recurring[interval_count]"}, NonNegative: []string{"unit_amount"}, - BoolParams: []string{"active"}, + BoolParams: []string{"active", "transfer_lookup_key"}, EnumParams: map[string][]string{ "recurring[interval]": {"day", "week", "month", "year"}, "recurring_interval": {"day", "week", "month", "year"}, @@ -934,6 +939,7 @@ func validateSubscriptionCreate(p params) error { "days_until_due", "cancel_at", "billing_cycle_anchor", + "proration_behavior", "outcome", "test_clock", "coupon", @@ -946,7 +952,8 @@ func validateSubscriptionCreate(p params) error { BoolParams: []string{"automatic_tax[enabled]"}, Positive: []string{"days_until_due"}, EnumParams: map[string][]string{ - "collection_method": {"charge_automatically", "send_invoice"}, + "collection_method": {"charge_automatically", "send_invoice"}, + "proration_behavior": {"none", "create_prorations", "always_invoice"}, }, AllowMetadata: true, }); err != nil { @@ -983,6 +990,7 @@ func validateSubscriptionUpdate(p params) error { "payment_behavior", "billing_cycle_anchor", "trial_end", + "cancel_at", "coupon", "promotion_code", // Emptyable clear form: default_tax_rates="" (single empty string). @@ -990,7 +998,7 @@ func validateSubscriptionUpdate(p params) error { }, AllowedRegex: []*regexp.Regexp{subscriptionItemRE, cancellationDetailsRE, discountParamRE, defaultTaxRatesParamRE}, BoolParams: []string{"cancel_at_period_end"}, - Int64Params: []string{"pause_collection[resumes_at]", "proration_date"}, + Int64Params: []string{"pause_collection[resumes_at]", "proration_date", "cancel_at"}, EnumParams: map[string][]string{ "pause_collection[behavior]": {"void", "keep_as_draft", "mark_uncollectible"}, "proration_behavior": {"none", "create_prorations", "always_invoice"}, @@ -1045,6 +1053,15 @@ func validateSubscriptionResume(p params) error { }) } +// validateSubscriptionCancel gates DELETE /v1/subscriptions/{id} bodies: +// Stripe's SubscriptionCancelParams carry cancellation_details (and expand). +func validateSubscriptionCancel(p params) error { + return p.validate(paramSpec{ + AllowedRegex: []*regexp.Regexp{cancellationDetailsRE}, + AllowMetadata: true, + }) +} + func validateSubscriptionItemCreate(p params) error { return p.validate(paramSpec{ Allowed: []string{ @@ -1146,8 +1163,10 @@ func validateInvoiceCreate(p params) error { Allowed: []string{ "id", "customer", + "subscription", "currency", "collection_method", + "days_until_due", "default_payment_method", "description", "auto_advance", @@ -1155,6 +1174,7 @@ func validateInvoiceCreate(p params) error { }, AllowedRegex: []*regexp.Regexp{invoicePaymentSettingsRE}, Required: []string{"customer"}, + Int64Params: []string{"days_until_due"}, BoolParams: []string{"auto_advance"}, EnumParams: map[string][]string{ "collection_method": {"charge_automatically", "send_invoice"}, @@ -1170,18 +1190,32 @@ func validateInvoiceItemCreate(p params) error { "id", "customer", "invoice", + "subscription", "amount", "currency", "description", + "unit_amount", + "quantity", }, - Required: []string{"customer", "invoice", "amount", "currency"}, - Int64Params: []string{"amount"}, + AllowedRegex: []*regexp.Regexp{invoiceItemPricingRE}, + Required: []string{"customer"}, + RequiredAny: [][]string{ + {"amount", "pricing[price]", "pricing[price_id]", "price", "price_id"}, + }, + Int64Params: []string{"amount", "unit_amount", "quantity"}, + Positive: []string{"quantity"}, AllowMetadata: true, }); err != nil { return err } - if p.int64("amount") == 0 { - return invalidParam("amount", "Must be non-zero.") + // Amount-based items need a currency; price-based items inherit the price's. + if p.has("amount") && !p.has("currency") { + return missingParam("currency") + } + if p.has("amount") { + if p.int64("amount") == 0 { + return invalidParam("amount", "Must be non-zero.") + } } return nil } @@ -1193,6 +1227,12 @@ func validateInvoiceFinalize(p params) error { }) } +// Stripe's void / mark_uncollectible accept no request params (expand only, +// which the shared validator already allows). +func validateInvoiceTerminalAction(p params) error { + return p.validate(paramSpec{}) +} + func validateInvoiceSend(p params) error { // Stripe InvoiceSendInvoiceParams only accepts expand (handled globally). return p.validate(paramSpec{}) @@ -1281,9 +1321,9 @@ func validateRefundUpdate(p params) error { func validateCreditNoteCreate(p params) error { return p.validate(paramSpec{ - Allowed: []string{"id", "invoice", "customer", "amount", "currency", "reason", "status"}, + Allowed: []string{"id", "invoice", "customer", "amount", "out_of_band_amount", "memo", "currency", "reason", "status"}, Required: []string{"invoice", "amount"}, - Int64Params: []string{"amount"}, + Int64Params: []string{"amount", "out_of_band_amount"}, Positive: []string{"amount"}, EnumParams: map[string][]string{ "status": {"issued", "void"}, diff --git a/internal/billing/models.go b/internal/billing/models.go index ab012df..dadb8ea 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -187,15 +187,16 @@ type Invoice struct { } 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"` + ID string `json:"id"` + Object string `json:"object"` + CustomerID string `json:"customer"` + InvoiceID string `json:"invoice"` + SubscriptionID string `json:"subscription,omitempty"` + 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 { @@ -285,16 +286,18 @@ type Refund struct { } type CreditNote struct { - ID string `json:"id"` - Object string `json:"object"` - InvoiceID string `json:"invoice"` - CustomerID string `json:"customer,omitempty"` - Amount int64 `json:"amount"` - Currency string `json:"currency"` - Reason string `json:"reason,omitempty"` - Status string `json:"status"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Object string `json:"object"` + InvoiceID string `json:"invoice"` + CustomerID string `json:"customer,omitempty"` + Amount int64 `json:"amount"` + OutOfBandAmount int64 `json:"out_of_band_amount,omitempty"` + Memo string `json:"memo,omitempty"` + Currency string `json:"currency"` + Reason string `json:"reason,omitempty"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` } type Account struct { @@ -371,8 +374,9 @@ type InvoiceFilter struct { } type InvoiceItemFilter struct { - CustomerID string - InvoiceID string + CustomerID string + InvoiceID string + PendingOnly bool } type PaymentIntentFilter struct { diff --git a/internal/billing/service.go b/internal/billing/service.go index e5a8d4b..c056418 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -77,11 +77,13 @@ type Repository interface { GetPrice(context.Context, string) (Price, error) ListPrices(context.Context) ([]Price, error) UpdatePrice(context.Context, string, Price) (Price, error) + ClearPriceLookupKey(context.Context, string) error CreateCheckoutSession(context.Context, CheckoutSession) (CheckoutSession, error) GetCheckoutSession(context.Context, string) (CheckoutSession, error) ListCheckoutSessions(context.Context) ([]CheckoutSession, error) UpdateCheckoutSessionDiscounts(context.Context, string, []Discount) (CheckoutSession, error) + ExpireCheckoutSession(context.Context, string, time.Time) (CheckoutSession, error) RecordCheckoutCompletion(context.Context, CheckoutCompletion) (CheckoutSession, error) GetSubscription(context.Context, string) (Subscription, error) @@ -94,6 +96,7 @@ type Repository interface { ListInvoicesFiltered(context.Context, InvoiceFilter) ([]Invoice, error) UpdateInvoice(context.Context, Invoice, []TimelineEntry) (Invoice, error) CreateInvoiceItem(context.Context, InvoiceItem, Invoice, []TimelineEntry) (InvoiceItem, Invoice, error) + AttachInvoiceItem(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) @@ -238,6 +241,29 @@ func (s *Service) UpdatePrice(ctx context.Context, id string, in Price) (Price, return s.repo.UpdatePrice(ctx, id, in) } +// TransferLookupKey implements Stripe's transfer_lookup_key price-create +// contract: the new price takes ownership of the lookup key and every other +// price that held it loses it. +func (s *Service) TransferLookupKey(ctx context.Context, keepPriceID string, lookupKey string) error { + lookupKey = strings.TrimSpace(lookupKey) + if lookupKey == "" { + return nil + } + prices, err := s.repo.ListPrices(ctx) + if err != nil { + return err + } + for _, price := range prices { + if price.ID == keepPriceID || price.LookupKey != lookupKey { + continue + } + if err := s.repo.ClearPriceLookupKey(ctx, price.ID); err != nil { + return err + } + } + return nil +} + func (s *Service) CreateAccount(ctx context.Context, in Account) (Account, error) { if strings.TrimSpace(in.ID) == "" { in.ID = id("acct") @@ -425,6 +451,20 @@ func (s *Service) CompleteCheckoutWithOptions(ctx context.Context, sessionID str return s.completeCheckout(ctx, sessionID, outcome, opts) } +// ExpireCheckoutSession immediately expires an open session (Stripe +// POST /v1/checkout/sessions/{id}/expire). Completed or already expired +// sessions are not found, matching Stripe's resource_missing behavior. +func (s *Service) ExpireCheckoutSession(ctx context.Context, sessionID string) (CheckoutSession, error) { + session, err := s.repo.GetCheckoutSession(ctx, sessionID) + if err != nil { + return CheckoutSession{}, err + } + if session.Status != "open" { + return CheckoutSession{}, ErrNotFound + } + return s.repo.ExpireCheckoutSession(ctx, sessionID, s.now()) +} + func (s *Service) completeCheckout(ctx context.Context, sessionID string, outcome string, opts CheckoutCompletionOptions) (CheckoutSession, error) { session, err := s.repo.GetCheckoutSession(ctx, sessionID) if err != nil { @@ -472,7 +512,12 @@ func (s *Service) completeCheckout(ctx context.Context, sessionID string, outcom return s.completePaymentCheckout(ctx, session, outcomeSpec, opts, currency, subtotal, discountedTotal, discountAmount, now) } - periodEnd := now.AddDate(0, 1, 0) + // Period follows the first line item's price interval (day/week/month/year × + // count); a missing price falls back to one month inside nextPeriodEnd. + periodEnd, err := s.nextPeriodEnd(ctx, session.LineItems, now) + if err != nil { + return CheckoutSession{}, err + } paid := outcomeSpec.Paid trialing := paid && session.TrialPeriodDays > 0 if trialing { @@ -832,9 +877,156 @@ func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { return s.repo.ListInvoices(ctx) } +// SweepPendingInvoiceItems attaches the customer's pending (invoice-less) invoice +// items to the given invoice, mirroring Stripe's default +// pending_invoice_items_behavior=include on invoice creation. Callers that pass +// exclude skip the sweep before reaching here. +func (s *Service) SweepPendingInvoiceItems(ctx context.Context, invoiceID string) (Invoice, error) { + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if invoice.Status != "draft" { + return invoice, nil + } + pending, err := s.repo.ListInvoiceItemsFiltered(ctx, InvoiceItemFilter{CustomerID: invoice.CustomerID, PendingOnly: true}) + if err != nil { + return Invoice{}, err + } + now := s.now() + for _, item := range pending { + invoice.Subtotal += item.Amount + invoice.Total += item.Amount + if invoice.Total < 0 { + invoice.Total = 0 + } + invoice.AmountDue = invoice.Total - invoice.AmountPaid + if invoice.AmountDue < 0 { + invoice.AmountDue = 0 + } + _, updated, err := s.repo.AttachInvoiceItem(ctx, item, invoice, []TimelineEntry{billingTimelineEntry( + "invoiceitem_attached_"+item.ID, + "invoiceitem.attached", + "Pending invoice item attached", + ObjectInvoiceItem, + item.ID, + item.CustomerID, + "", + item.SubscriptionID, + invoice.ID, + "", + map[string]string{"source": "invoice.sweep", "status": invoice.Status}, + now, + )}) + if err != nil { + return Invoice{}, err + } + invoice = updated + } + return invoice, nil +} + +// VoidInvoice voids a finalized (open) invoice. Stripe requires open status; +// draft invoices must be finalized first and terminal states cannot be voided. +func (s *Service) VoidInvoice(ctx context.Context, invoiceID string) (Invoice, error) { + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if invoice.Status != "open" { + return Invoice{}, fmt.Errorf("%w: only open invoices can be voided, current status: %s", ErrInvalidInput, invoice.Status) + } + now := s.now() + invoice.Status = "void" + invoice.AmountDue = 0 + invoice.NextPaymentAttempt = nil + invoice.Metadata = copyMap(invoice.Metadata) + invoice.Metadata["billtap_voided_at"] = now.Format(time.RFC3339Nano) + return s.repo.UpdateInvoice(ctx, invoice, []TimelineEntry{billingTimelineEntry( + "invoice_voided_"+invoice.ID+"_"+now.Format(time.RFC3339Nano), + "invoice.voided", + "Invoice voided", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.void", "status": invoice.Status}, + now, + )}) +} + +// MarkInvoiceUncollectible gives up collection on an open invoice while keeping +// the billing record (SDS §4.0.3.8 out-of-band settlement semantics). +func (s *Service) MarkInvoiceUncollectible(ctx context.Context, invoiceID string) (Invoice, error) { + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if invoice.Status != "open" { + return Invoice{}, fmt.Errorf("%w: only open invoices can be marked uncollectible, current status: %s", ErrInvalidInput, invoice.Status) + } + now := s.now() + invoice.Status = "uncollectible" + invoice.AmountDue = 0 + invoice.NextPaymentAttempt = nil + invoice.Metadata = copyMap(invoice.Metadata) + invoice.Metadata["billtap_marked_uncollectible_at"] = now.Format(time.RFC3339Nano) + return s.repo.UpdateInvoice(ctx, invoice, []TimelineEntry{billingTimelineEntry( + "invoice_marked_uncollectible_"+invoice.ID+"_"+now.Format(time.RFC3339Nano), + "invoice.marked_uncollectible", + "Invoice marked uncollectible", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.mark_uncollectible", "status": invoice.Status}, + now, + )}) +} + func (s *Service) CreateInvoiceItem(ctx context.Context, in InvoiceItem) (InvoiceItem, Invoice, error) { + // Pending item: no invoice yet; it attaches to the next invoice that sweeps + // pending items (Stripe pending invoice items semantics). if strings.TrimSpace(in.InvoiceID) == "" { - return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) + if strings.TrimSpace(in.CustomerID) == "" { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: customer is required", ErrInvalidInput) + } + if in.Amount == 0 { + return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: amount is required", ErrInvalidInput) + } + if _, err := s.repo.GetCustomer(ctx, in.CustomerID); err != nil { + return InvoiceItem{}, Invoice{}, err + } + now := s.now() + if strings.TrimSpace(in.ID) == "" { + in.ID = id("ii") + } + in.Object = ObjectInvoiceItem + in.Currency = strings.ToLower(firstNonEmpty(strings.TrimSpace(in.Currency), "usd")) + in.Metadata = copyMap(in.Metadata) + if in.CreatedAt.IsZero() { + in.CreatedAt = now + } + return s.repo.CreateInvoiceItem(ctx, in, Invoice{}, []TimelineEntry{billingTimelineEntry( + "invoiceitem_created_"+in.ID, + "invoiceitem.created", + "Pending invoice item created", + ObjectInvoiceItem, + in.ID, + in.CustomerID, + "", + in.SubscriptionID, + "", + "", + map[string]string{"source": "invoiceitem.create", "status": "pending"}, + in.CreatedAt, + )}) } if in.Amount == 0 { return InvoiceItem{}, Invoice{}, fmt.Errorf("%w: amount is required", ErrInvalidInput) @@ -2211,6 +2403,9 @@ type SubscriptionProrationRequest struct { DefaultTaxRates []AppliedTaxRate // optional override; empty uses subscription metadata Metadata map[string]string CancelAtPeriodEnd *bool + // TrialEnd set to a non-zero time ends the trial immediately (Stripe + // trial_end=now on update): trialing subscriptions become active. + TrialEnd time.Time } // SubscriptionProrationResult is the outcome of a proration-aware subscription update. @@ -2302,6 +2497,12 @@ func (s *Service) UpdateSubscriptionItemsWithProration(ctx context.Context, req } } updated.Metadata["stripe_compat_updated_at"] = at.Format(time.RFC3339Nano) + if !req.TrialEnd.IsZero() { + updated.Metadata["trial_end"] = req.TrialEnd.Format(time.RFC3339Nano) + if updated.Status == "trialing" { + updated.Status = "active" + } + } rates := req.DefaultTaxRates if len(rates) == 0 { @@ -2309,8 +2510,17 @@ func (s *Service) UpdateSubscriptionItemsWithProration(ctx context.Context, req } automaticTax, taxPercent := AutomaticTaxFromMetadata(updated.Metadata) - // none: items + metadata only, no invoice, period unchanged. + // none: items + metadata only, no invoice; anchor=now still resets the cycle + // (Stripe applies billing_cycle_anchor independent of proration_behavior). if behavior == "none" { + if anchor == "now" { + periodEnd, err := s.nextPeriodEnd(ctx, req.NewItems, at) + if err != nil { + return SubscriptionProrationResult{}, err + } + updated.CurrentPeriodStart = at + updated.CurrentPeriodEnd = periodEnd + } saved, err := s.persistSubscription(ctx, updated, []TimelineEntry{portalTimeline( "stripe_compat_update_"+updated.ID+"_"+at.Format(time.RFC3339Nano), "customer.subscription.updated", @@ -2327,8 +2537,18 @@ func (s *Service) UpdateSubscriptionItemsWithProration(ctx context.Context, req remaining, periodSeconds, periodOK := ProrationFactor(sub.CurrentPeriodStart, sub.CurrentPeriodEnd, at) - // create_prorations: accumulate pending delta, no invoice. + // create_prorations: accumulate pending delta, no invoice. anchor=now resets + // the cycle so the proration window starts from the new anchor (Stripe keeps + // anchor handling independent of the proration policy). if behavior == "create_prorations" { + if anchor == "now" { + periodEnd, err := s.nextPeriodEnd(ctx, req.NewItems, at) + if err != nil { + return SubscriptionProrationResult{}, err + } + updated.CurrentPeriodStart = at + updated.CurrentPeriodEnd = periodEnd + } delta := int64(0) if periodOK { delta = ProrateDelta(newDiscounted-oldDiscounted, remaining, periodSeconds) diff --git a/internal/storage/billing.go b/internal/storage/billing.go index 329faef..a23b9df 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -198,6 +198,13 @@ func (s *SQLiteStore) UpdatePrice(ctx context.Context, id string, in billing.Pri return s.GetPrice(ctx, id) } +// ClearPriceLookupKey empties one price's lookup key. UpdatePrice merge semantics +// keep empty strings out (archive flows must not clear keys), so transfers use this. +func (s *SQLiteStore) ClearPriceLookupKey(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `UPDATE prices SET lookup_key = '' WHERE id = ?`, id) + return err +} + func (s *SQLiteStore) CreateAccount(ctx context.Context, account billing.Account) (billing.Account, error) { if account.CreatedAt.IsZero() { account.CreatedAt = time.Now().UTC() @@ -471,6 +478,16 @@ func (s *SQLiteStore) UpdateCheckoutSessionDiscounts(ctx context.Context, id str return s.GetCheckoutSession(ctx, id) } +func (s *SQLiteStore) ExpireCheckoutSession(ctx context.Context, id string, at time.Time) (billing.CheckoutSession, error) { + if _, err := s.GetCheckoutSession(ctx, id); err != nil { + return billing.CheckoutSession{}, err + } + if _, err := s.db.ExecContext(ctx, `UPDATE checkout_sessions SET status = 'expired', completed_at = ? WHERE id = ?`, encodeTime(at), id); err != nil { + return billing.CheckoutSession{}, err + } + return s.GetCheckoutSession(ctx, id) +} + func (s *SQLiteStore) RecordCheckoutCompletion(ctx context.Context, c billing.CheckoutCompletion) (billing.CheckoutSession, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -802,14 +819,32 @@ func (s *SQLiteStore) UpdateInvoice(ctx context.Context, invoice billing.Invoice } func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.InvoiceItem, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.InvoiceItem, billing.Invoice, error) { + // Pending item (no invoice): stored unattached; a later invoice create sweeps it in. + if strings.TrimSpace(item.InvoiceID) == "" { + if _, err := s.db.ExecContext(ctx, `INSERT INTO invoice_items (id, customer_id, invoice_id, subscription_id, amount, currency, description, metadata, created_at) + VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, + item.ID, item.CustomerID, item.SubscriptionID, item.Amount, item.Currency, item.Description, encodeMap(item.Metadata), encodeTime(item.CreatedAt)); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + for _, entry := range timeline { + if err := s.RecordTimeline(ctx, entry); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + } + createdItem, err := s.GetInvoiceItem(ctx, item.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + return createdItem, billing.Invoice{}, nil + } 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 { + if _, err := tx.ExecContext(ctx, `INSERT INTO invoice_items (id, customer_id, invoice_id, subscription_id, amount, currency, description, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + item.ID, item.CustomerID, item.InvoiceID, item.SubscriptionID, 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 { @@ -835,7 +870,7 @@ func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.Invoic } 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) + row := s.db.QueryRowContext(ctx, `SELECT id, customer_id, invoice_id, subscription_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 @@ -843,6 +878,39 @@ func (s *SQLiteStore) GetInvoiceItem(ctx context.Context, id string) (billing.In return item, err } +// AttachInvoiceItem moves a pending (invoice-less) item onto an invoice and adds +// its amount to the invoice totals, in one transaction. +func (s *SQLiteStore) AttachInvoiceItem(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, `UPDATE invoice_items SET invoice_id = ? WHERE id = ? AND invoice_id IS NULL`, invoice.ID, item.ID); 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) ListInvoiceItemsFiltered(ctx context.Context, filter billing.InvoiceItemFilter) ([]billing.InvoiceItem, error) { clauses := []string{"1=1"} args := []any{} @@ -854,7 +922,10 @@ func (s *SQLiteStore) ListInvoiceItemsFiltered(ctx context.Context, filter billi 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 + if filter.PendingOnly { + clauses = append(clauses, "invoice_id IS NULL") + } + rows, err := s.db.QueryContext(ctx, `SELECT id, customer_id, invoice_id, subscription_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 @@ -1325,9 +1396,9 @@ func (s *SQLiteStore) CreateCreditNote(ctx context.Context, note billing.CreditN return billing.CreditNote{}, err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `INSERT INTO credit_notes (id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - note.ID, note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), encodeTime(note.CreatedAt)); err != nil { + if _, err := tx.ExecContext(ctx, `INSERT INTO credit_notes (id, invoice_id, customer_id, amount, out_of_band_amount, memo, currency, reason, status, metadata, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + note.ID, note.InvoiceID, note.CustomerID, note.Amount, note.OutOfBandAmount, note.Memo, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), encodeTime(note.CreatedAt)); err != nil { return billing.CreditNote{}, err } for _, entry := range timeline { @@ -1342,7 +1413,7 @@ func (s *SQLiteStore) CreateCreditNote(ctx context.Context, note billing.CreditN } func (s *SQLiteStore) GetCreditNote(ctx context.Context, id string) (billing.CreditNote, error) { - row := s.db.QueryRowContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at FROM credit_notes WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, `SELECT id, invoice_id, customer_id, amount, out_of_band_amount, memo, currency, reason, status, metadata, created_at FROM credit_notes WHERE id = ?`, id) note, err := scanCreditNote(row) if errors.Is(err, sql.ErrNoRows) { return billing.CreditNote{}, billing.ErrNotFound @@ -1361,7 +1432,7 @@ func (s *SQLiteStore) ListCreditNotesFiltered(ctx context.Context, filter billin clauses = append(clauses, "customer_id = ?") args = append(args, filter.CustomerID) } - rows, err := s.db.QueryContext(ctx, `SELECT id, invoice_id, customer_id, amount, currency, reason, status, metadata, created_at + rows, err := s.db.QueryContext(ctx, `SELECT id, invoice_id, customer_id, amount, out_of_band_amount, memo, currency, reason, status, metadata, created_at FROM credit_notes WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at DESC, id DESC`, args...) if err != nil { return nil, err @@ -1385,9 +1456,9 @@ func (s *SQLiteStore) UpdateCreditNote(ctx context.Context, note billing.CreditN } defer tx.Rollback() result, err := tx.ExecContext(ctx, `UPDATE credit_notes - SET invoice_id = ?, customer_id = ?, amount = ?, currency = ?, reason = ?, status = ?, metadata = ? + SET invoice_id = ?, customer_id = ?, amount = ?, out_of_band_amount = ?, memo = ?, currency = ?, reason = ?, status = ?, metadata = ? WHERE id = ?`, - note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), note.ID) + note.InvoiceID, note.CustomerID, note.Amount, note.OutOfBandAmount, note.Memo, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), note.ID) if err != nil { return billing.CreditNote{}, err } @@ -1654,10 +1725,12 @@ func scanInvoice(row scanner) (billing.Invoice, error) { 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 { + var invoiceID sql.NullString + if err := row.Scan(&item.ID, &item.CustomerID, &invoiceID, &item.SubscriptionID, &item.Amount, &item.Currency, &item.Description, &metadataRaw, &createdAt); err != nil { return item, err } item.Object = billing.ObjectInvoiceItem + item.InvoiceID = invoiceID.String item.Metadata = decodeMap(metadataRaw) item.CreatedAt = decodeTime(createdAt) return item, nil @@ -1719,7 +1792,7 @@ func scanRefund(row scanner) (billing.Refund, error) { func scanCreditNote(row scanner) (billing.CreditNote, error) { var note billing.CreditNote var metadata, createdAt string - if err := row.Scan(¬e.ID, ¬e.InvoiceID, ¬e.CustomerID, ¬e.Amount, ¬e.Currency, ¬e.Reason, ¬e.Status, &metadata, &createdAt); err != nil { + if err := row.Scan(¬e.ID, ¬e.InvoiceID, ¬e.CustomerID, ¬e.Amount, ¬e.OutOfBandAmount, ¬e.Memo, ¬e.Currency, ¬e.Reason, ¬e.Status, &metadata, &createdAt); err != nil { return note, err } note.Object = billing.ObjectCreditNote diff --git a/internal/storage/migrations/020_invoiceitem_pending_creditnote.sql b/internal/storage/migrations/020_invoiceitem_pending_creditnote.sql new file mode 100644 index 0000000..099283e --- /dev/null +++ b/internal/storage/migrations/020_invoiceitem_pending_creditnote.sql @@ -0,0 +1,29 @@ +-- Invoice items without an invoice are pending items (Stripe semantics): they attach +-- to the next invoice created for the customer unless that create passes +-- pending_invoice_items_behavior=exclude. The subscription column preserves the +-- caller's subscription reference (BO out-of-band subscription seeding). +CREATE TABLE invoice_items_backup ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES customers(id), + invoice_id TEXT REFERENCES invoices(id), + subscription_id TEXT NOT NULL DEFAULT '', + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +INSERT INTO invoice_items_backup (id, customer_id, invoice_id, subscription_id, amount, currency, description, metadata, created_at) +SELECT id, customer_id, invoice_id, '', amount, currency, description, metadata, created_at FROM invoice_items; + +DROP TABLE invoice_items; +ALTER TABLE invoice_items_backup RENAME TO invoice_items; + +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); + +-- Out-of-band credit notes (paid outside Stripe) keep refunds empty and skip the +-- customer balance; ds2's out-of-band refund path requires both amounts verbatim. +ALTER TABLE credit_notes ADD COLUMN out_of_band_amount INTEGER NOT NULL DEFAULT 0; +ALTER TABLE credit_notes ADD COLUMN memo TEXT NOT NULL DEFAULT ''; diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 456ab76..9940cbb 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -22,8 +22,17 @@ func TestSQLiteMigrationsRun(t *testing.T) { if err != nil { t.Fatalf("MigrationVersions returned error: %v", err) } - if len(versions) != 19 || 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 || versions[15] != 16 || versions[16] != 17 || versions[17] != 18 || versions[18] != 19 { - t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19]", versions) + want := make([]int, len(versions)) + for i := range want { + want[i] = i + 1 + } + if len(versions) != 20 { + t.Fatalf("versions = %#v, want contiguous 1..%d (migration 020 = invoiceitem pending + credit note out_of_band)", versions, len(want)) + } + for i := range want { + if versions[i] != want[i] { + t.Fatalf("versions = %#v, want %#v", versions, want) + } } } diff --git a/internal/stripecompat/registry.go b/internal/stripecompat/registry.go index 73e029f..3baccbc 100644 --- a/internal/stripecompat/registry.go +++ b/internal/stripecompat/registry.go @@ -154,13 +154,13 @@ func DefaultClaims() []Claim { statefulL3 := Claim{Level: "L3", Stateful: true, SDKSmoke: []string{"stripe-node"}} for _, method := range []string{http.MethodGet, http.MethodPost} { add(method, "/v1/customers", statefulL3) - add(method, "/v1/customers/{id}", statefulL3) + add(method, "/v1/customers/{id}", Claim{Level: "L3", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"invoice_settings[default_payment_method] accepted and stored on customer metadata; other invoice_settings fields are not modeled"}}) add(method, "/v1/products", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"products.create.success"}, SDKSmoke: []string{"stripe-node"}}) add(method, "/v1/products/{id}", statefulL3) - add(method, "/v1/prices", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"prices.create.invalid_json_amount_type"}, SDKSmoke: []string{"stripe-node"}}) + add(method, "/v1/prices", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"prices.create.invalid_json_amount_type"}, SDKSmoke: []string{"stripe-node"}, Risks: []string{"list supports the lookup_keys[] filter; create supports transfer_lookup_key ownership moves; currency_options and tiered pricing are not modeled"}}) add(method, "/v1/prices/{id}", statefulL3) add(method, "/v1/subscriptions", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted"}}) - add(method, "/v1/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated", "customer.subscription.deleted"}}) + add(method, "/v1/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated", "customer.subscription.deleted"}, Risks: []string{"update accepts cancel_at timestamps (independent of cancel_at_period_end), trial_end=now (ends trial, trialing→active), and billing_cycle_anchor=now (resets the cycle in every proration mode); pause/trial scheduling beyond that is evidence-only"}}) } add(http.MethodGet, "/v1/customers/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured customer search subset for id, email, name, and metadata equality clauses joined by AND"}}) add(http.MethodGet, "/v1/products/search", Claim{Level: "L2", Risks: []string{"metadata equality filters only; no Stripe Search Query Language parity"}}) @@ -209,6 +209,7 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/checkout/sessions", Claim{Level: "L4", Stateful: true, ScorecardCases: []string{"checkout.sessions.create.java_sdk_optional_params"}, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)", "automatic_tax is a metadata-driven simulation (customer metadata tax_percent); no jurisdiction or address-based calculation", "subscription_data[default_tax_rates] snapshots tax rates onto checkout/subscription totals (exclusive/inclusive math); mutually exclusive with automatic_tax"}}) add(http.MethodGet, "/v1/checkout/sessions", Claim{Level: "L4", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)"}}) add(http.MethodGet, "/v1/checkout/sessions/{id}", Claim{Level: "L4", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)"}}) + add(http.MethodPost, "/v1/checkout/sessions/{id}/expire", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"checkout.session.expired"}, Risks: []string{"open sessions only; completed or already-expired sessions return resource_missing"}}) add(http.MethodPost, "/v1/billing_portal/sessions", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated", "customer.subscription.deleted", "payment_method.attached", "customer.updated"}, Risks: []string{"hosted portal is a local stub; portal configuration rendering and full Stripe-hosted portal behavior are not modeled"}}) taxRateRisk := []string{"local tax-rate evidence; default_tax_rates snapshots apply rates to checkout/subscription/invoice/renewal totals (inclusive+exclusive math); automatic_tax remains a separate customer-metadata simulation"} @@ -254,11 +255,13 @@ 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.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 with subscription linking and pending-item sweep (pending_invoice_items_behavior include default / exclude opt-out); automatic collection, rendering, dunning, and full line mutation parity are not modeled"}}) add(http.MethodGet, "/v1/invoices/{id}", statefulL3) 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", "invoice.sent"}, Risks: []string{"manual one-time invoice finalization only; automatic collection and full invoice lifecycle automation are not modeled"}}) add(http.MethodPost, "/v1/invoices/{id}/send", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.sent"}, Risks: []string{"local email evidence only (metadata + invoice.sent event); no real email delivery", "charge_automatically invoices also allowed as evidence-only send", "paid invoices can be re-sent (evidence only)"}}) 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.MethodPost, "/v1/invoices/{id}/void", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.voided"}, Risks: []string{"open (finalized) invoices only; draft invoices must be finalized first"}}) + add(http.MethodPost, "/v1/invoices/{id}/mark_uncollectible", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.marked_uncollectible"}, Risks: []string{"open invoices only; keeps the billing record while giving up collection"}}) 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 next-period upcoming when no item overrides (with pending create_prorations + default_tax_rates/automatic_tax); item overrides keep subscription-update proration; pending invoice items and full multi-item Stripe parity are not modeled"}}) @@ -274,7 +277,7 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/refunds/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refund.updated"}}) add(http.MethodPost, "/v1/refunds/{id}/cancel", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refund.updated"}}) add(http.MethodGet, "/v1/credit_notes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"credit_note.created", "credit_note.voided"}, Risks: []string{"local credit note evidence only; line/tax/customer-balance math is not modeled"}}) - add(http.MethodPost, "/v1/credit_notes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"credit_note.created", "credit_note.voided"}, Risks: []string{"local credit note evidence only; line/tax/customer-balance math is not modeled"}}) + add(http.MethodPost, "/v1/credit_notes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"credit_note.created", "credit_note.voided"}, Risks: []string{"local credit note evidence with out_of_band_amount/memo passthrough (no balance or refund movement); line/tax/customer-balance math is not modeled"}}) add(http.MethodGet, "/v1/credit_notes/{id}", Claim{Level: "L2", Stateful: true}) add(http.MethodPost, "/v1/credit_notes/{id}/void", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"credit_note.voided"}}) diff --git a/internal/stripecompat/registry_test.go b/internal/stripecompat/registry_test.go index 451aaaf..0686bb4 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) != 181 { - t.Fatalf("default claims = %d, want 181", len(claims)) + if len(claims) != 184 { + t.Fatalf("default claims = %d, want 184", len(claims)) } checkout, ok := registry.Lookup(http.MethodPost, "/v1/checkout/sessions")