diff --git a/docs/API_VALIDATION_AND_ERROR_SIMULATION.md b/docs/API_VALIDATION_AND_ERROR_SIMULATION.md index 340de56..46e2aa4 100644 --- a/docs/API_VALIDATION_AND_ERROR_SIMULATION.md +++ b/docs/API_VALIDATION_AND_ERROR_SIMULATION.md @@ -162,6 +162,14 @@ Required behavior: parameter mismatch, and concurrent conflict. - Webhook simulations cover duplicate delivery, delayed delivery, out-of-order delivery, endpoint 4xx/5xx failure, timeout, and signature mismatch evidence. +- Subscription renewal failures can be driven from subscription metadata + (`billtap_renewal_outcome`) or customer defaults + (`billtap_default_invoice_outcome`) and emit invoice/payment-intent failure + evidence with Stripe-style decline codes. +- Direct PaymentIntent `requires_action` simulations can return either + `next_action.use_stripe_sdk` or a local `next_action.redirect_to_url` payload. +- Refunds can be seeded as `pending` and settled through attached test-clock + advancement for pending-to-succeeded history assertions. Gate: @@ -199,8 +207,10 @@ Current lane: OpenAPI-backed fallback validation, idempotency mismatch, deterministic checkout payment-error aliases, and direct PaymentIntent/SetupIntent state transitions, including capture, cancel, failure aliases, invalid - terminal-state transitions, invoice retry/payment mutation, and local - clock-driven subscription renewal. + terminal-state transitions, invoice retry/payment mutation, direct + PaymentIntent webhook emission, local clock-driven subscription renewal, + fixture validation, subscription-update proration preview, dispute evidence, + credit-note voiding, and pending-refund settlement. Gate: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 49d3bb1..9090b48 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -100,19 +100,19 @@ Base path: `/v1` | 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` and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. | | Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. | | Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. | -| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, `test_clock`, and `cancel_at_period_end`. List supports arbitrary metadata equality filters such as `metadata[billtap_fixture_ref]`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods, fail configured renewals, and cancel period-end subscriptions in the local billing graph. This is not full Stripe subscription parity. | +| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, `test_clock`, and `cancel_at_period_end`. List supports arbitrary metadata equality filters such as `metadata[billtap_fixture_ref]`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | | Subscription schedules | `POST /v1/subscription_schedules`, `GET /v1/subscription_schedules`, `GET /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/cancel`, `POST /v1/subscription_schedules/{id}/release` | Partial | Local schedule evidence for one due phase on an existing subscription. Test-clock advance applies a due phase by replacing subscription items and emits `customer.subscription.updated`; complex multi-phase billing, proration, invoices, and schedule lifecycle parity are not modeled. | | Subscription items | `POST /v1/subscription_items`, `DELETE /v1/subscription_items/{id}` | Partial | Add or remove local subscription items for integration smoke paths. Billing proration and invoice recalculation are not modeled. | -| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview returns a zero-value local smoke-test invoice. Invoice create, finalize, send, void, collection, and dunning automation are not modeled. | -| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents can store a deferred per-intent outcome with `metadata[billtap_payment_intent_outcome]`, `billtap_outcome`, or `deferred_outcome`, then apply it on `confirm`; if absent, a customer's `billtap_default_payment_intent_outcome` metadata can provide the default. Billtap-specific action callback endpoints can complete or cancel a local SCA-required intent. | +| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview endpoints now calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, and `subscription_details[proration_behavior]`. Taxes, discounts, pending invoice items, and full invoice collection lifecycle are outside the modeled subset. | +| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | | Setup intents | `POST /v1/setup_intents`, `GET /v1/setup_intents`, `GET /v1/setup_intents/{id}`, `POST /v1/setup_intents/{id}/confirm`, `POST /v1/setup_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local setup intents with deterministic success, decline, and authentication-required aliases. Mandates, bank-account verification, and full SCA behavior are not modeled. | | Payment methods | `GET /v1/payment_methods?customer={id}&type=card`, `GET /v1/customers/{id}/payment_methods?type=card` | Partial | Returns deterministic sandbox card PaymentMethod projections for known customers, including SDK-friendly `billing_details`, `card.checks`, `country`, `fingerprint`, `funding`, `networks`, `three_d_secure_usage`, `metadata`, and `redaction` fields. Query validation covers `type`, `allow_redisplay`, `limit`, and unknown parameters; non-card valid types return an empty local list. Customer fixtures can opt into empty or explicit payment-method lists for no-card and multi-card local scenarios. Create, attach, detach, and general update are not supported. | | 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 /v1/refunds/{id}` | Partial | Create/list/retrieve local refund evidence against an invoice, payment intent, or charge-like ID. Creation emits `charge.refunded` and `charge.refund.updated` webhook evidence. Balance transactions, dispute linkage, failed refunds, and processor accounting are not modeled. | -| Credit notes | `POST /v1/credit_notes`, `GET /v1/credit_notes`, `GET /v1/credit_notes/{id}` | Partial | Create/list/retrieve local credit note evidence for an invoice and emit `credit_note.created`. Line-level tax/discount accounting, PDF rendering, voiding, and preview semantics are not modeled. | -| 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, and close local dispute evidence for chargeback-style tests. Creation emits `charge.dispute.created`; close emits `charge.dispute.closed`. Evidence upload, representment workflow, deadlines, balance movements, and processor outcomes are not modeled. | -| 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 and subscriptions can be attached with `test_clock`; advancing a clock processes attached trial activation, renewals, configured renewal failures, and period-end cancellation. This is a practical local subset, not full Stripe Test Clock object parity. | +| 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. | +| 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`. | | Events | `GET /v1/events`, `GET /v1/events/{id}` | Supported | List and retrieve Billtap-created events. Filters include `type`, `scenarioRunId`, `created[gte]`, `created[gt]`, `created[lte]`, `created[lt]`, `data.object.customer`, and `data.object.metadata[key]`. | @@ -124,11 +124,11 @@ Base path: `/api` | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | Checkout | `POST /api/checkout/sessions/{id}/complete` | Billtap-only checkout completion endpoint used by the hosted checkout UI and local tests. | | Portal | `GET /api/portal`, `GET /api/portal/customers/{id}`, `POST /api/portal/subscriptions/{id}/plan-change`, `POST /api/portal/subscriptions/{id}/seat-change`, `POST /api/portal/subscriptions/{id}/cancel`, `POST /api/portal/subscriptions/{id}/resume`, `POST /api/portal/customers/{id}/payment-method` | Sandbox portal state and actions. These update local billing state and timeline evidence; they do not claim Stripe Billing Portal parity. | -| Dashboard evidence | `GET /api/objects`, `GET /api/timeline`, `GET /api/delivery-attempts`, `POST /api/debug-bundles` | Object lists, timelines, delivery evidence, and debug bundle data for local investigation. | +| Dashboard evidence | `GET /api/objects`, `GET /api/timeline`, `GET /api/delivery-attempts`, `POST /api/debug-bundles` | Object lists, timelines, delivery evidence, and debug bundle data for local investigation. Timeline filters accept both object-specific IDs and generic `object_type`/`object_id`. | | Webhook operations | `POST /api/events/{id}/replay`, `POST /api/events/replay-group`, `POST /api/webhooks/endpoints/{id}/replay-historical` | Replays one event or a group of events and can schedule duplicate, delayed, out-of-order, signature-mismatch, simulated endpoint response, and fail-first-then-deliver attempts. Endpoint-scoped historical replay catches up matching events that were emitted before an app registered its webhook endpoint. | | Payment intent actions | `POST /api/payment_intents/{id}/complete_action`, `POST /api/payment_intents/{id}/cancel_action`, `POST /api/payment_intents/{id}/outcome` | Billtap-only callbacks used by hosted/client smoke tests to complete or abandon a local `requires_action` PaymentIntent, or to attach a deferred local outcome before confirming a one-time PaymentIntent. | | Dispute simulation | `POST /api/disputes` | Billtap-only shortcut for creating local dispute evidence when an app does not already have a charge-like ID. | -| Fixtures | `POST /api/fixtures/apply`, `GET /api/fixtures/resolve`, `GET /api/fixtures/snapshot`, `POST /api/fixtures/assert` | Data-driven setup and assertion APIs for customers, products, prices, test clocks, subscription graphs, invoices, payment intents, refunds, credit notes, and timeline evidence. | +| Fixtures | `POST /api/fixtures/apply`, `POST /api/fixtures/validate`, `GET /api/fixtures/resolve`, `GET /api/fixtures/snapshot`, `POST /api/fixtures/assert` | Data-driven setup and dry-run validation APIs for customers, connected accounts, products, prices, test clocks, subscription graphs, invoices, payment intents, refunds, credit notes, disputes, and timeline evidence. | | Scenarios | `POST /api/scenarios/run` | Runs a scenario JSON object or YAML payload and returns the scenario report. | | Boundary controls | `GET /api/audit-log`, `POST /api/retention/apply` | Audit and retention controls for replay, delivery overrides, and raw evidence redaction. | @@ -164,8 +164,11 @@ Supported generic event types: - `charge.refunded` - `charge.refund.updated` - `charge.dispute.created` +- `charge.dispute.updated` +- `charge.dispute.funds_withdrawn` - `charge.dispute.closed` - `credit_note.created` +- `credit_note.voided` - `setup_intent.created` - `setup_intent.succeeded` - `setup_intent.canceled` diff --git a/internal/api/api.go b/internal/api/api.go index b0dbbcc..cd8446b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -102,6 +102,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/subscription_items", h.handleSubscriptionItems) h.mux.HandleFunc("/v1/subscription_items/", h.handleSubscriptionItem) h.mux.HandleFunc("/v1/invoices/create_preview", h.handleInvoicePreview) + h.mux.HandleFunc("/v1/invoices/upcoming", h.handleInvoicePreview) h.mux.HandleFunc("/v1/invoices", h.handleInvoices) h.mux.HandleFunc("/v1/invoices/", h.handleInvoice) h.mux.HandleFunc("/v1/refunds", h.handleRefunds) @@ -133,6 +134,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("/api/diagnostics", h.handleDiagnostics) h.mux.HandleFunc("/api/request-traces", h.handleRequestTraces) h.mux.HandleFunc("/api/fixtures/apply", h.handleFixtureApply) + h.mux.HandleFunc("/api/fixtures/validate", h.handleFixtureValidate) h.mux.HandleFunc("/api/fixtures/snapshot", h.handleFixtureSnapshot) h.mux.HandleFunc("/api/fixtures/resolve", h.handleFixtureResolve) h.mux.HandleFunc("/api/fixtures/assert", h.handleFixtureAssert) @@ -185,8 +187,13 @@ func (h *Handler) writeKnownUnsupportedRoute(w http.ResponseWriter, r *http.Requ if !ok { return false } - if claim, ok := h.compatibilityClaim(r); ok && stripecompat.NormalizePath(claim.Path) == stripecompat.NormalizePath(route.Path) { - return false + if claim, ok := h.compatibilityClaim(r); ok { + claimPath := stripecompat.NormalizePath(claim.Path) + routePath := stripecompat.NormalizePath(route.Path) + requestPath := stripecompat.NormalizePath(r.URL.Path) + if claimPath == routePath || (!strings.Contains(claim.Path, "{") && claimPath == requestPath) { + return false + } } if validationErr := h.validation.Validate(r); validationErr != nil { writeStripeError(w, http.StatusBadRequest, stripeAPIError{ @@ -1823,24 +1830,215 @@ func (h *Handler) handleSubscriptionItem(w http.ResponseWriter, r *http.Request) } func (h *Handler) handleInvoicePreview(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.URL.Path == "/v1/invoices/create_preview" && r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") return } - now := time.Now().UTC().Unix() - writeJSON(w, http.StatusOK, map[string]any{ - "id": "upcoming_in_" + strconv.FormatInt(now, 10), - "object": "invoice", - "amount_due": 0, - "subtotal": 0, - "total": 0, - "currency": "usd", - "created": now, - "status": "draft", - "lines": stripeList("/v1/invoices/create_preview/lines", []map[string]any{}), - "livemode": false, - "description": "Billtap preview uses zero-value proration for local smoke tests", - }) + if r.URL.Path == "/v1/invoices/upcoming" && r.Method != http.MethodGet && r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "GET, POST") + return + } + var ( + p params + err error + ) + if r.Method == http.MethodGet { + p = params{values: firstValues(r.URL.Query())} + } else { + p, err = parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + if err := validateInvoicePreview(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + preview, err := h.invoicePreview(r.Context(), r.URL.Path, p) + writeResult(w, preview, err) +} + +func (h *Handler) invoicePreview(ctx context.Context, path string, p params) (map[string]any, error) { + now := time.Now().UTC() + subscriptionID := p.string("subscription") + customerID := p.string("customer") + var subscription billing.Subscription + var err error + if subscriptionID != "" { + subscription, err = h.billing.GetSubscription(ctx, subscriptionID) + if err != nil { + return nil, err + } + if customerID == "" { + customerID = subscription.CustomerID + } + } + items := invoicePreviewLineItems(p) + if len(items) == 0 && subscription.ID != "" { + items = append([]billing.LineItem{}, subscription.Items...) + } + newTotal, currency, prices, err := h.lineItemTotal(ctx, items) + if err != nil { + return nil, err + } + if currency == "" { + currency = strings.ToLower(p.stringDefault("currency", "usd")) + } + behavior := invoicePreviewProrationBehavior(p) + createdAt := invoicePreviewProrationDate(p, now) + amount := newTotal + lines := []map[string]any{} + description := "Upcoming invoice preview" + if subscription.ID != "" { + oldTotal, oldCurrency, _, err := h.lineItemTotal(ctx, subscription.Items) + if err != nil { + return nil, err + } + if currency == "" { + currency = oldCurrency + } + amount = 0 + if behavior != "none" && !subscription.CurrentPeriodStart.IsZero() && !subscription.CurrentPeriodEnd.IsZero() && subscription.CurrentPeriodEnd.After(createdAt) && subscription.CurrentPeriodEnd.After(subscription.CurrentPeriodStart) { + periodSeconds := subscription.CurrentPeriodEnd.Unix() - subscription.CurrentPeriodStart.Unix() + remainingSeconds := subscription.CurrentPeriodEnd.Unix() - createdAt.Unix() + if periodSeconds > 0 && remainingSeconds > 0 { + amount = (newTotal - oldTotal) * remainingSeconds / periodSeconds + } + } + description = "Subscription update preview" + if amount != 0 { + priceID := "" + quantity := int64(1) + var pricePayload any + if len(items) > 0 { + priceID = items[0].PriceID + quantity = items[0].Quantity + pricePayload = prices[priceID] + } + lines = append(lines, map[string]any{ + "id": "il_preview_" + sanitizeID(subscription.ID), + "object": "line_item", + "amount": amount, + "currency": currency, + "description": "Proration for subscription update", + "discountable": false, + "period": map[string]any{ + "start": createdAt.Unix(), + "end": subscription.CurrentPeriodEnd.Unix(), + }, + "proration": true, + "price": pricePayload, + "quantity": quantity, + "subscription": subscription.ID, + "type": "invoiceitem", + "parent": map[string]any{ + "type": "subscription_item_details", + "subscription_item_details": map[string]any{ + "price": priceID, + "proration": true, + "subscription": subscription.ID, + }, + }, + }) + } + } + if behavior == "none" { + amount = 0 + lines = nil + } + return map[string]any{ + "id": "upcoming_in_" + strconv.FormatInt(now.Unix(), 10), + "object": "invoice", + "customer": emptyToNil(customerID), + "subscription": emptyToNil(subscriptionID), + "amount_due": amount, + "amount_paid": 0, + "amount_remaining": amount, + "subtotal": amount, + "total": amount, + "currency": currency, + "created": now.Unix(), + "status": "draft", + "lines": stripeList(path+"/lines", lines), + "livemode": false, + "description": description, + "billtap_preview": map[string]any{ + "proration_behavior": behavior, + "proration_date": createdAt.Unix(), + }, + }, nil +} + +func (h *Handler) lineItemTotal(ctx context.Context, items []billing.LineItem) (int64, string, map[string]map[string]any, error) { + total := int64(0) + currency := "" + prices := map[string]map[string]any{} + for _, item := range items { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + price, err := h.billing.GetPrice(ctx, item.PriceID) + if err != nil { + return 0, "", nil, err + } + total += price.UnitAmount * quantity + if currency == "" { + currency = price.Currency + } + prices[price.ID] = stripePrice(price) + } + return total, currency, prices, nil +} + +func invoicePreviewProrationBehavior(p params) string { + behavior := p.first("subscription_details[proration_behavior]", "subscriptionDetails[prorationBehavior]", "proration_behavior") + switch behavior { + case "none", "create_prorations", "always_invoice": + return behavior + default: + return "create_prorations" + } +} + +func invoicePreviewProrationDate(p params, fallback time.Time) time.Time { + raw := p.first("subscription_details[proration_date]", "subscriptionDetails[prorationDate]", "proration_date") + if raw == "" { + return fallback + } + seconds, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return time.Unix(seconds, 0).UTC() +} + +func invoicePreviewLineItems(p params) []billing.LineItem { + var out []billing.LineItem + for i := 0; i < 100; i++ { + price := p.first( + fmt.Sprintf("subscription_details[items][%d][price]", i), + fmt.Sprintf("subscriptionDetails[items][%d][price]", i), + fmt.Sprintf("subscription_items[%d][price]", i), + fmt.Sprintf("items[%d][price]", i), + ) + if price == "" && i == 0 { + price = p.string("price") + } + if price == "" { + continue + } + quantity := p.int64Default(fmt.Sprintf("subscription_details[items][%d][quantity]", i), 0) + if quantity == 0 { + quantity = p.int64Default(fmt.Sprintf("subscription_items[%d][quantity]", i), 0) + } + if quantity == 0 { + quantity = p.int64Default(fmt.Sprintf("items[%d][quantity]", i), 1) + } + out = append(out, billing.LineItem{PriceID: price, Quantity: quantity}) + } + return out } func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { @@ -1945,6 +2143,7 @@ func (h *Handler) handleRefunds(w http.ResponseWriter, r *http.Request) { Amount: p.int64("amount"), Currency: p.string("currency"), Reason: p.string("reason"), + Status: p.string("status"), Metadata: p.metadata(), }) if err == nil { @@ -1957,17 +2156,52 @@ func (h *Handler) handleRefunds(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleRefund(w http.ResponseWriter, r *http.Request) { - id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/refunds/"), "/") - if id == "" || strings.Contains(id, "/") { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/refunds/"), "/") + if rest == "" { h.notFound(w, r) return } - if r.Method != http.MethodGet { - h.methodNotAllowed(w, r, "GET") + id, action, hasAction := strings.Cut(rest, "/") + if hasAction && action == "cancel" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + refund, err := h.billing.UpdateRefundStatus(r.Context(), id, "canceled", time.Time{}) + if err == nil { + h.emitGenericWebhook(r, "charge.refund.updated", refund.ID, stripeRefund(refund), webhooks.SourceAPI) + } + writeResult(w, stripeRefund(refund), err) + return + } + if hasAction { + h.notFound(w, r) + return + } + switch r.Method { + case http.MethodGet: + refund, err := h.billing.GetRefund(r.Context(), id) + writeResult(w, stripeRefund(refund), err) + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateRefundUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + status := p.stringDefault("status", "succeeded") + refund, err := h.billing.UpdateRefundStatus(r.Context(), id, status, time.Time{}) + if err == nil { + h.emitGenericWebhook(r, "charge.refund.updated", refund.ID, stripeRefund(refund), webhooks.SourceAPI) + } + writeResult(w, stripeRefund(refund), err) + default: + h.methodNotAllowed(w, r, "GET, POST") return } - refund, err := h.billing.GetRefund(r.Context(), id) - writeResult(w, stripeRefund(refund), err) } func (h *Handler) handleCreditNotes(w http.ResponseWriter, r *http.Request) { @@ -1999,6 +2233,7 @@ func (h *Handler) handleCreditNotes(w http.ResponseWriter, r *http.Request) { Amount: p.int64("amount"), Currency: p.string("currency"), Reason: p.string("reason"), + Status: p.string("status"), Metadata: p.metadata(), }) if err == nil { @@ -2017,6 +2252,18 @@ func (h *Handler) handleCreditNote(w http.ResponseWriter, r *http.Request) { return } id, action, hasAction := strings.Cut(rest, "/") + if hasAction && action == "void" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + note, err := h.billing.VoidCreditNote(r.Context(), id) + if err == nil { + h.emitGenericWebhook(r, "credit_note.voided", note.ID, stripeCreditNote(note), webhooks.SourceAPI) + } + writeResult(w, stripeCreditNote(note), err) + return + } if hasAction && action != "" { h.notFound(w, r) return @@ -2645,14 +2892,7 @@ func (h *Handler) handleTimeline(w http.ResponseWriter, r *http.Request) { h.methodNotAllowed(w, r, "GET") return } - q := r.URL.Query() - entries, err := h.billing.Timeline(r.Context(), billing.TimelineFilter{ - CustomerID: q.Get("customerId"), - CheckoutSessionID: q.Get("checkoutSessionId"), - SubscriptionID: q.Get("subscriptionId"), - InvoiceID: q.Get("invoiceId"), - PaymentIntentID: q.Get("paymentIntentId"), - }) + entries, err := h.billing.Timeline(r.Context(), diagnosticTimelineFilter(r)) writeResult(w, map[string]any{"object": "list", "data": entries}, err) } @@ -2781,10 +3021,113 @@ func (h *Handler) handleFixtureApply(w http.ResponseWriter, r *http.Request) { writeResult(w, result, err) return } + if disputes, err := h.applyFixtureDisputes(r, pack); err != nil { + writeResult(w, result, err) + return + } else if len(disputes) > 0 { + result.Disputes = disputes + result.Summary["disputes"] = len(disputes) + } h.emitFixtureApplyWebhooks(r, result) writeJSON(w, http.StatusOK, result) } +func (h *Handler) handleFixtureValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + body, err := readSafeFixtureBody(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + pack, err := fixtures.LoadPack(body, r.Header.Get("Content-Type")) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) + return + } + if err := fixtures.NewService(h.billing).Validate(pack); err != nil { + writeResult(w, nil, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": "fxval_" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36), + "object": "fixture_validation", + "valid": true, + "summary": map[string]int{ + "customers": len(pack.Customers), + "products": len(pack.Products) + len(pack.Catalog.Products), + "prices": len(pack.Prices) + len(pack.Catalog.Prices), + "connected_accounts": len(pack.ConnectedAccounts), + "test_clocks": len(pack.TestClocks), + "subscriptions": len(pack.Subscriptions), + "refunds": len(pack.Refunds), + "credit_notes": len(pack.CreditNotes), + "disputes": len(pack.Disputes), + }, + }) +} + +func (h *Handler) applyFixtureDisputes(r *http.Request, pack fixtures.Pack) ([]map[string]any, error) { + if len(pack.Disputes) == 0 { + return nil, nil + } + out := make([]map[string]any, 0, len(pack.Disputes)) + for _, fixture := range pack.Disputes { + dispute := disputeFixturePayload(fixture) + h.local.mu.Lock() + h.local.disputes[fmt.Sprint(dispute["id"])] = dispute + h.local.mu.Unlock() + out = append(out, cloneEvidence(dispute)) + h.emitGenericWebhook(r, "charge.dispute.created", fmt.Sprint(dispute["id"]), dispute, webhooks.SourceFixture) + if fmt.Sprint(dispute["status"]) != "needs_response" { + h.emitGenericWebhook(r, "charge.dispute.updated", fmt.Sprint(dispute["id"]), dispute, webhooks.SourceFixture) + h.emitGenericWebhook(r, "charge.dispute.funds_withdrawn", fmt.Sprint(dispute["id"]), dispute, webhooks.SourceFixture) + } + if status := fmt.Sprint(dispute["status"]); status == "won" || status == "lost" { + h.emitGenericWebhook(r, "charge.dispute.closed", fmt.Sprint(dispute["id"]), dispute, webhooks.SourceFixture) + } + } + return out, nil +} + +func disputeFixturePayload(fixture fixtures.DisputeFixture) map[string]any { + now := time.Now().UTC() + id := strings.TrimSpace(fixture.ID) + if id == "" { + id = "dp_" + strconv.FormatInt(now.UnixNano(), 36) + } + amount := fixture.Amount + if amount <= 0 { + amount = 1000 + } + currency := strings.ToLower(strings.TrimSpace(fixture.Currency)) + if currency == "" { + currency = "usd" + } + status := strings.ToLower(strings.TrimSpace(fixture.Status)) + if status == "" { + status = "needs_response" + } + return map[string]any{ + "id": id, + "object": "dispute", + "charge": strings.TrimSpace(fixture.Charge), + "amount": amount, + "currency": currency, + "reason": firstNonEmptyString(fixture.Reason, "general"), + "status": status, + "evidence": map[string]any{}, + "evidence_details": map[string]any{"has_evidence": false, "submission_count": 0, "past_due": false}, + "balance_transactions": []map[string]any{}, + "is_charge_refundable": true, + "metadata": nonNilMap(fixture.Metadata), + "created": now.Unix(), + "livemode": false, + } +} + func (h *Handler) handleFixtureSnapshot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") @@ -2869,40 +3212,51 @@ func fixtureSnapshotFilter(r *http.Request) fixtures.SnapshotFilter { } func debugBundleTimelineFilter(p params) billing.TimelineFilter { + objectType := dashboardObjectType(p.first("objectType", "object_type", "targetType", "target_type", "type")) + objectID := p.first("objectId", "object_id", "targetId", "target_id", "id") filter := billing.TimelineFilter{ CustomerID: p.first("customerId", "customer_id"), CheckoutSessionID: p.first("checkoutSessionId", "checkout_session_id"), SubscriptionID: p.first("subscriptionId", "subscription_id"), InvoiceID: p.first("invoiceId", "invoice_id"), PaymentIntentID: p.first("paymentIntentId", "payment_intent_id"), + ObjectType: objectType, } - objectID := p.first("objectId", "object_id", "targetId", "target_id", "id") if objectID == "" { return filter } - switch dashboardObjectType(p.first("objectType", "object_type", "targetType", "target_type", "type")) { + switch objectType { case "customer": + filter.ObjectType = "" if filter.CustomerID == "" { filter.CustomerID = objectID } case "checkout_session": + filter.ObjectType = "" if filter.CheckoutSessionID == "" { filter.CheckoutSessionID = objectID } case "subscription": + filter.ObjectType = "" if filter.SubscriptionID == "" { filter.SubscriptionID = objectID } case "invoice": + filter.ObjectType = "" if filter.InvoiceID == "" { filter.InvoiceID = objectID } case "payment_intent": + filter.ObjectType = "" if filter.PaymentIntentID == "" { filter.PaymentIntentID = objectID } default: + if objectType != "" { + filter.ObjectID = objectID + return filter + } switch { case strings.HasPrefix(objectID, "cus_") && filter.CustomerID == "": filter.CustomerID = objectID @@ -3029,6 +3383,8 @@ func timelineFilterMap(filter billing.TimelineFilter) map[string]string { "subscription_id": filter.SubscriptionID, "invoice_id": filter.InvoiceID, "payment_intent_id": filter.PaymentIntentID, + "object_type": filter.ObjectType, + "object_id": filter.ObjectID, }) } @@ -3689,6 +4045,18 @@ func paymentIntentMetadata(p params) map[string]string { p.first("billtap_outcome", "deferred_outcome", "payment_intent_outcome"), metadataValue(metadata, "billtap_outcome"), ) + if actionType := p.first("billtap_next_action_type", "next_action_type"); actionType != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["billtap_next_action_type"] = actionType + } + if returnURL := p.string("return_url"); returnURL != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["billtap_return_url"] = returnURL + } if outcome == "" { return metadata } @@ -4516,6 +4884,16 @@ func paymentIntentNextAction(intent billing.PaymentIntent) any { if intent.Status != "requires_action" { return nil } + if strings.TrimSpace(intent.Metadata["billtap_next_action_type"]) == "redirect_to_url" { + returnURL := firstNonEmptyString(intent.Metadata["billtap_return_url"], "http://127.0.0.1:18080/payment_intents/"+intent.ID+"/return") + return map[string]any{ + "type": "redirect_to_url", + "redirect_to_url": map[string]any{ + "url": "/api/payment_intents/" + intent.ID + "/complete_action?return_url=" + url.QueryEscape(returnURL), + "return_url": returnURL, + }, + } + } return map[string]any{ "type": "use_stripe_sdk", "use_stripe_sdk": map[string]any{ @@ -5269,6 +5647,9 @@ func (h *Handler) emitClockAdvanceWebhooks(r *http.Request, advance billing.Cloc for _, subscription := range advance.Canceled { emitted = append(emitted, h.emitSubscriptionWebhook(r, "customer.subscription.deleted", subscription, webhooks.SourceAPI)...) } + for _, refund := range advance.SettledRefunds { + emitted = append(emitted, h.emitGenericWebhook(r, "charge.refund.updated", refund.ID, stripeRefund(refund), webhooks.SourceAPI)...) + } return emitted } @@ -5329,6 +5710,9 @@ func (h *Handler) emitFixtureApplyWebhooks(r *http.Request, result fixtures.Appl } for _, note := range result.CreditNotes { emitted = append(emitted, h.emitGenericWebhook(r, "credit_note.created", note.ID, stripeCreditNote(note), webhooks.SourceAPI)...) + if note.Status == "void" { + emitted = append(emitted, h.emitGenericWebhook(r, "credit_note.voided", note.ID, stripeCreditNote(note), webhooks.SourceAPI)...) + } } return emitted } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 47d0753..5873daa 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -582,6 +582,21 @@ func TestDirectPaymentIntentAndSetupIntentStateMachines(t *testing.T) { if confirmedSCA.Status != "requires_action" || confirmedSCA.PaymentMethod != "pm_card_visa" || confirmedSCA.NextAction["type"] != "use_stripe_sdk" { t.Fatalf("confirmed SCA intent = %#v, want configured requires_action outcome", confirmedSCA) } + redirectSCA := postForm[struct { + Status string `json:"status"` + NextAction map[string]any `json:"next_action"` + }](t, handler, "/v1/payment_intents", url.Values{ + "amount": {"3300"}, + "currency": {"usd"}, + "customer": {customer.ID}, + "confirm": {"true"}, + "metadata[billtap_payment_intent_outcome]": {"requires_action"}, + "billtap_next_action_type": {"redirect_to_url"}, + "return_url": {"http://app.example.test/return"}, + }) + if redirectSCA.Status != "requires_action" || redirectSCA.NextAction["type"] != "redirect_to_url" { + t.Fatalf("redirect SCA intent = %#v, want redirect_to_url next_action", redirectSCA) + } setup := postForm[billing.SetupIntent](t, handler, "/v1/setup_intents", url.Values{ "customer": {customer.ID}, @@ -4298,6 +4313,291 @@ func TestRefundCreditNoteAPIsAndEvents(t *testing.T) { } } +func TestInvoicePreviewProrationAndUpcoming(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"preview@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Preview Team"}}) + lite := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"1000"}, + "recurring[interval]": {"month"}, + }) + pro := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"3000"}, + "recurring[interval]": {"month"}, + }) + applied := postJSON[fixtures.ApplyResult](t, handler, "/api/fixtures/apply", map[string]any{ + "name": "preview-proration", + "runId": "preview-run-1", + "subscriptions": []map[string]any{{ + "id": "sub_preview_proration", + "customer": customer.ID, + "price": lite.ID, + "status": "active", + "current_period_start": "2030-01-01T00:00:00Z", + "current_period_end": "2030-01-31T00:00:00Z", + }}, + }) + if len(applied.Subscriptions) != 1 { + t.Fatalf("fixture apply = %#v, want subscription", applied) + } + prorationDate := time.Date(2030, 1, 16, 0, 0, 0, 0, time.UTC).Unix() + preview := postForm[struct { + Object string `json:"object"` + AmountDue int64 `json:"amount_due"` + Lines struct { + Object string `json:"object"` + Data []struct { + Amount int64 `json:"amount"` + Proration bool `json:"proration"` + Parent struct { + Type string `json:"type"` + SubscriptionItemDetails struct { + Proration bool `json:"proration"` + Price string `json:"price"` + } `json:"subscription_item_details"` + } `json:"parent"` + } `json:"data"` + } `json:"lines"` + }](t, handler, "/v1/invoices/create_preview", url.Values{ + "subscription": {"sub_preview_proration"}, + "subscription_details[items][0][price]": {pro.ID}, + "subscription_details[items][0][quantity]": {"1"}, + "subscription_details[proration_behavior]": {"create_prorations"}, + "subscription_details[proration_date]": {strconv.FormatInt(prorationDate, 10)}, + }) + if preview.Object != "invoice" || preview.AmountDue != 1000 || len(preview.Lines.Data) != 1 || !preview.Lines.Data[0].Proration || preview.Lines.Data[0].Parent.SubscriptionItemDetails.Price != pro.ID { + t.Fatalf("preview = %#v, want one 1000-cent proration line for pro price", preview) + } + q := url.Values{} + q.Set("subscription", "sub_preview_proration") + q.Set("subscription_details[items][0][price]", pro.ID) + q.Set("subscription_details[proration_date]", strconv.FormatInt(prorationDate, 10)) + upcoming := getJSON[struct { + Object string `json:"object"` + AmountDue int64 `json:"amount_due"` + }](t, handler, "/v1/invoices/upcoming?"+q.Encode()) + if upcoming.Object != "invoice" || upcoming.AmountDue != 1000 { + t.Fatalf("upcoming = %#v, want Stripe upcoming-compatible preview", upcoming) + } + none := postForm[struct { + AmountDue int64 `json:"amount_due"` + Lines struct { + Data []any `json:"data"` + } `json:"lines"` + }](t, handler, "/v1/invoices/create_preview", url.Values{ + "subscription": {"sub_preview_proration"}, + "subscription_details[items][0][price]": {pro.ID}, + "subscription_details[proration_behavior]": {"none"}, + }) + if none.AmountDue != 0 || len(none.Lines.Data) != 0 { + t.Fatalf("none proration preview = %#v, want no proration line", none) + } +} + +func TestCustomerDefaultInvoiceOutcomeFailsRenewal(t *testing.T) { + handler := newTestHandler(t) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Renewal Team"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"2400"}, + "recurring[interval]": {"month"}, + }) + applied := postJSON[fixtures.ApplyResult](t, handler, "/api/fixtures/apply", map[string]any{ + "name": "renewal-failure", + "test_clocks": []map[string]any{{ + "id": "clock_renewal_failure", + "frozen_time": "2030-02-01T00:00:00Z", + }}, + "customers": []map[string]any{{ + "id": "cus_renewal_failure", + "email": "renewal-failure@example.test", + "test_clock": "clock_renewal_failure", + "default_invoice_outcome": "insufficient_funds", + }}, + "subscriptions": []map[string]any{{ + "id": "sub_renewal_failure", + "customer": "cus_renewal_failure", + "price": price.ID, + "status": "active", + "test_clock": "clock_renewal_failure", + "current_period_start": "2030-02-01T00:00:00Z", + "current_period_end": "2030-03-01T00:00:00Z", + }}, + }) + if got := applied.Customers[0].Metadata[billing.MetadataDefaultInvoiceOutcome]; got != "insufficient_funds" { + t.Fatalf("customer metadata outcome = %q, want insufficient_funds", got) + } + advance := postForm[struct { + BilltapAdvanceResult struct { + Renewed int `json:"renewed"` + Renewals []struct { + Invoice struct { + Status string `json:"status"` + AmountDue int64 `json:"amount_due"` + AmountPaid int64 `json:"amount_paid"` + AttemptCount int `json:"attempt_count"` + } `json:"invoice"` + Subscription struct { + Status string `json:"status"` + } `json:"subscription"` + PaymentIntent struct { + Status string `json:"status"` + FailureCode string `json:"failure_code"` + DeclineCode string `json:"decline_code"` + } `json:"payment_intent"` + } `json:"renewals"` + } `json:"billtap_advance_result"` + }](t, handler, "/v1/test_helpers/test_clocks/clock_renewal_failure/advance", url.Values{ + "frozen_time": {strconv.FormatInt(time.Date(2030, 3, 1, 0, 0, 0, 0, time.UTC).Unix(), 10)}, + }) + if advance.BilltapAdvanceResult.Renewed != 1 || len(advance.BilltapAdvanceResult.Renewals) != 1 { + t.Fatalf("advance = %#v, want one failed renewal", advance) + } + renewal := advance.BilltapAdvanceResult.Renewals[0] + if renewal.Invoice.Status != "open" || renewal.Invoice.AmountDue != 2400 || renewal.Invoice.AmountPaid != 0 || renewal.Subscription.Status != "past_due" || renewal.PaymentIntent.Status != "requires_payment_method" || renewal.PaymentIntent.FailureCode != "card_declined" || renewal.PaymentIntent.DeclineCode != "insufficient_funds" { + t.Fatalf("failed renewal = %#v, want open invoice, past_due subscription, insufficient_funds error", renewal) + } + events := getJSON[struct { + Data []webhooks.Event `json:"data"` + }](t, handler, "/v1/events?type=invoice.payment_failed") + if len(events.Data) == 0 { + t.Fatalf("invoice.payment_failed events = %#v, want renewal failure webhook", events.Data) + } +} + +func TestRefundTimingCreditVoidFixtureValidateAndDisputes(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"history@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"History Team"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"5000"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + }) + completion := postJSON[struct { + Invoice billing.Invoice `json:"invoice"` + }](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + validation := postJSON[struct { + Valid bool `json:"valid"` + Summary map[string]int `json:"summary"` + }](t, handler, "/api/fixtures/validate", map[string]any{ + "name": "surface-fixture", + "connected_accounts": []map[string]any{{ + "id": "acct_fixture_surface", + "country": "US", + "default_currency": "usd", + "details_submitted": true, + }}, + "refunds": []map[string]any{{ + "id": "re_pending_clock", + "invoice": completion.Invoice.ID, + "amount": 1200, + "status": "pending", + "settle_at": "2030-05-03T00:00:00Z", + }}, + "credit_notes": []map[string]any{{ + "id": "cn_void_fixture", + "invoice": completion.Invoice.ID, + "amount": 1200, + "status": "void", + }}, + "disputes": []map[string]any{{ + "id": "dp_fixture_surface", + "charge": "ch_fixture_surface", + "amount": 1200, + "status": "needs_response", + }}, + }) + if !validation.Valid || validation.Summary["connected_accounts"] != 1 || validation.Summary["disputes"] != 1 { + t.Fatalf("fixture validation = %#v, want valid account/dispute counts", validation) + } + applied := postJSON[fixtures.ApplyResult](t, handler, "/api/fixtures/apply", map[string]any{ + "name": "surface-fixture", + "connected_accounts": []map[string]any{{ + "id": "acct_fixture_surface", + "country": "US", + "default_currency": "usd", + }}, + "test_clocks": []map[string]any{{ + "id": "clock_refund_settle", + "frozen_time": "2030-05-01T00:00:00Z", + }}, + "customers": []map[string]any{{ + "id": customer.ID, + "test_clock": "clock_refund_settle", + }}, + "refunds": []map[string]any{{ + "id": "re_pending_clock", + "invoice": completion.Invoice.ID, + "amount": 1200, + "status": "pending", + "settle_at": "2030-05-03T00:00:00Z", + "test_clock": "clock_refund_settle", + }}, + "credit_notes": []map[string]any{{ + "id": "cn_void_fixture", + "invoice": completion.Invoice.ID, + "amount": 1200, + "status": "void", + }}, + "disputes": []map[string]any{{ + "id": "dp_fixture_surface", + "charge": "ch_fixture_surface", + "amount": 1200, + "currency": "usd", + "reason": "fraudulent", + "status": "needs_response", + }}, + }) + if len(applied.ConnectedAccounts) != 1 || len(applied.Refunds) != 1 || applied.Refunds[0].Status != "pending" || len(applied.CreditNotes) != 1 || applied.CreditNotes[0].Status != "void" || len(applied.Disputes) != 1 { + t.Fatalf("fixture apply = %#v, want account, pending refund, void credit note, dispute", applied) + } + advance := postForm[struct { + BilltapAdvanceResult struct { + RefundCount int `json:"refund_count"` + SettledRefunds []struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"settled_refunds"` + } `json:"billtap_advance_result"` + }](t, handler, "/v1/test_helpers/test_clocks/clock_refund_settle/advance", url.Values{ + "frozen_time": {strconv.FormatInt(time.Date(2030, 5, 3, 0, 0, 0, 0, time.UTC).Unix(), 10)}, + }) + if advance.BilltapAdvanceResult.RefundCount != 1 || advance.BilltapAdvanceResult.SettledRefunds[0].Status != "succeeded" { + t.Fatalf("refund clock advance = %#v, want one settled refund", advance) + } + updatedDispute := postForm[struct { + ID string `json:"id"` + EvidenceDetails map[string]any `json:"evidence_details"` + }](t, handler, "/v1/disputes/dp_fixture_surface", url.Values{ + "evidence[product_description]": {"Local test product"}, + }) + if updatedDispute.ID != "dp_fixture_surface" || updatedDispute.EvidenceDetails["has_evidence"] != true { + t.Fatalf("updated dispute = %#v, want submitted evidence details", updatedDispute) + } + events := getJSON[struct { + Data []webhooks.Event `json:"data"` + }](t, handler, "/v1/events") + eventTypes := map[string]bool{} + for _, event := range events.Data { + eventTypes[event.Type] = true + } + for _, eventType := range []string{"charge.refund.updated", "credit_note.voided", "charge.dispute.created", "charge.dispute.updated"} { + if !eventTypes[eventType] { + t.Fatalf("events missing %s in %#v", eventType, eventTypes) + } + } +} + func TestWebhookReplaySimulateAppFailureThenDeliver(t *testing.T) { calls := 0 receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/local_evidence.go b/internal/api/local_evidence.go index 71f9a9c..d2aff6d 100644 --- a/internal/api/local_evidence.go +++ b/internal/api/local_evidence.go @@ -547,6 +547,40 @@ func (h *Handler) handleDispute(w http.ResponseWriter, r *http.Request) { h.methodNotAllowed(w, r, "GET, POST") return } + if r.Method == http.MethodPost { + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + evidence := map[string]any{} + if current, ok := dispute["evidence"].(map[string]any); ok { + for key, value := range current { + evidence[key] = value + } + } + for key, value := range p.values { + if strings.HasPrefix(key, "evidence[") && strings.HasSuffix(key, "]") { + evidence[strings.TrimSuffix(strings.TrimPrefix(key, "evidence["), "]")] = value + } + } + if status := p.string("status"); status != "" { + dispute["status"] = status + } + if metadata := p.metadata(); metadata != nil { + dispute["metadata"] = nonNilMap(metadata) + } + dispute["evidence"] = evidence + dispute["evidence_details"] = map[string]any{ + "has_evidence": len(evidence) > 0, + "submission_count": 1, + "past_due": false, + } + h.local.mu.Lock() + h.local.disputes[id] = dispute + h.local.mu.Unlock() + h.emitGenericWebhook(r, "charge.dispute.updated", id, dispute, webhooks.SourceAPI) + } writeJSON(w, http.StatusOK, cloneEvidence(dispute)) return } diff --git a/internal/api/validation.go b/internal/api/validation.go index 3f7b0a9..0f7f12f 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -34,6 +34,7 @@ var ( couponAppliesToParamRE = regexp.MustCompile(`^applies_to(\[[^\]]+\])+$`) promotionRestrictionParamRE = regexp.MustCompile(`^restrictions(\[[^\]]+\])+$`) schedulePhaseParamRE = regexp.MustCompile(`^phases\[\d+\]\[(start_date|end_date|iterations|items|plans)\].*$`) + invoicePreviewItemParamRE = regexp.MustCompile(`^((subscription_details|subscriptionDetails)\[items\]\[\d+\]\[(id|price|price_id|quantity)\]|(subscription_items|items)\[\d+\]\[(id|price|price_id|quantity)\])$`) ) var stripePaymentMethodTypes = []string{ @@ -765,19 +766,23 @@ func validatePaymentIntentCreate(p params) error { "deferred_outcome", "payment_intent_outcome", "description", + "return_url", "receipt_email", "setup_future_usage", "off_session", + "use_stripe_sdk", + "billtap_next_action_type", "automatic_payment_methods[enabled]", }, AllowedRegex: []*regexp.Regexp{paymentMethodTypesRE, paymentMethodOptionsRE}, Required: []string{"amount", "currency"}, Int64Params: []string{"amount"}, Positive: []string{"amount"}, - BoolParams: []string{"confirm", "off_session", "automatic_payment_methods[enabled]"}, + BoolParams: []string{"confirm", "off_session", "use_stripe_sdk", "automatic_payment_methods[enabled]"}, EnumParams: map[string][]string{ - "capture_method": {"automatic", "automatic_async", "manual"}, - "setup_future_usage": {"on_session", "off_session"}, + "capture_method": {"automatic", "automatic_async", "manual"}, + "setup_future_usage": {"on_session", "off_session"}, + "billtap_next_action_type": {"use_stripe_sdk", "redirect_to_url"}, }, AllowMetadata: true, }); err != nil { @@ -803,6 +808,33 @@ func validateInvoicePay(p params) error { }) } +func validateInvoicePreview(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "customer", + "subscription", + "schedule", + "currency", + "price", + "proration_behavior", + "proration_date", + "subscription_details[proration_behavior]", + "subscription_details[proration_date]", + "subscriptionDetails[prorationBehavior]", + "subscriptionDetails[prorationDate]", + "preview_mode", + }, + AllowedRegex: []*regexp.Regexp{invoicePreviewItemParamRE, expandParamRE}, + Int64Params: []string{"proration_date", "subscription_details[proration_date]", "subscriptionDetails[prorationDate]"}, + EnumParams: map[string][]string{ + "proration_behavior": {"always_invoice", "create_prorations", "none"}, + "subscription_details[proration_behavior]": {"always_invoice", "create_prorations", "none"}, + "subscriptionDetails[prorationBehavior]": {"always_invoice", "create_prorations", "none"}, + "preview_mode": {"next", "recurring"}, + }, + }) +} + func validateRefundCreate(p params) error { if err := p.validate(paramSpec{ Allowed: []string{ @@ -814,10 +846,14 @@ func validateRefundCreate(p params) error { "amount", "currency", "reason", + "status", + }, + RequiredAny: [][]string{{"charge", "payment_intent", "invoice"}}, + Int64Params: []string{"amount"}, + Positive: []string{"amount"}, + EnumParams: map[string][]string{ + "status": {"pending", "succeeded", "failed", "canceled"}, }, - RequiredAny: [][]string{{"charge", "payment_intent", "invoice"}}, - Int64Params: []string{"amount"}, - Positive: []string{"amount"}, AllowMetadata: true, }); err != nil { return err @@ -828,12 +864,28 @@ func validateRefundCreate(p params) error { return nil } +func validateRefundUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "metadata", + "status", + }, + EnumParams: map[string][]string{ + "status": {"pending", "succeeded", "failed", "canceled"}, + }, + AllowMetadata: true, + }) +} + func validateCreditNoteCreate(p params) error { return p.validate(paramSpec{ - Allowed: []string{"id", "invoice", "customer", "amount", "currency", "reason"}, - Required: []string{"invoice", "amount"}, - Int64Params: []string{"amount"}, - Positive: []string{"amount"}, + Allowed: []string{"id", "invoice", "customer", "amount", "currency", "reason", "status"}, + Required: []string{"invoice", "amount"}, + Int64Params: []string{"amount"}, + Positive: []string{"amount"}, + EnumParams: map[string][]string{ + "status": {"issued", "void"}, + }, AllowMetadata: true, }) } @@ -848,11 +900,13 @@ func validatePaymentIntentConfirm(p params) error { "setup_future_usage", "off_session", "use_stripe_sdk", + "billtap_next_action_type", }, AllowedRegex: []*regexp.Regexp{paymentMethodOptionsRE}, BoolParams: []string{"off_session", "use_stripe_sdk"}, EnumParams: map[string][]string{ - "setup_future_usage": {"on_session", "off_session"}, + "setup_future_usage": {"on_session", "off_session"}, + "billtap_next_action_type": {"use_stripe_sdk", "redirect_to_url"}, }, }) } diff --git a/internal/billing/models.go b/internal/billing/models.go index df03dc5..5a21356 100644 --- a/internal/billing/models.go +++ b/internal/billing/models.go @@ -35,6 +35,7 @@ const ( MetadataPaymentMethodIDs = "billtap_payment_method_ids" MetadataPaymentIntentOutcome = "billtap_payment_intent_outcome" MetadataDefaultPaymentIntentOutcome = "billtap_default_payment_intent_outcome" + MetadataDefaultInvoiceOutcome = "billtap_default_invoice_outcome" PaymentMethodsFixtureEmpty = "empty" PaymentMethodsFixtureExplicit = "explicit" @@ -150,12 +151,14 @@ type ClockAdvanceResult struct { Renewals []InvoicePaymentResult `json:"renewals,omitempty"` Canceled []Subscription `json:"canceled,omitempty"` Scheduled []Subscription `json:"scheduled,omitempty"` + SettledRefunds []Refund `json:"settled_refunds,omitempty"` Skipped []string `json:"skipped,omitempty"` Processed int `json:"processed"` ActivatedCount int `json:"activated_count"` Renewed int `json:"renewed"` CanceledCount int `json:"canceled_count"` ScheduledCount int `json:"scheduled_count"` + RefundCount int `json:"refund_count"` } type PaymentIntent struct { diff --git a/internal/billing/service.go b/internal/billing/service.go index 56793f0..4ddb497 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strconv" "strings" "time" ) @@ -62,9 +63,11 @@ type Repository interface { CreateRefund(context.Context, Refund, []TimelineEntry) (Refund, error) GetRefund(context.Context, string) (Refund, error) ListRefundsFiltered(context.Context, RefundFilter) ([]Refund, error) + UpdateRefund(context.Context, Refund, []TimelineEntry) (Refund, error) CreateCreditNote(context.Context, CreditNote, []TimelineEntry) (CreditNote, error) GetCreditNote(context.Context, string) (CreditNote, error) ListCreditNotesFiltered(context.Context, CreditNoteFilter) ([]CreditNote, error) + UpdateCreditNote(context.Context, CreditNote, []TimelineEntry) (CreditNote, error) CreateAccount(context.Context, Account) (Account, error) GetAccount(context.Context, string) (Account, error) ListAccounts(context.Context) ([]Account, error) @@ -719,6 +722,13 @@ func (s *Service) advanceClock(ctx context.Context, at time.Time, testClockID st current = renewal.Subscription } } + settled, err := s.settlePendingRefunds(ctx, at, result.TestClockID) + if err != nil { + return result, err + } + result.SettledRefunds = settled + result.RefundCount = len(settled) + result.Processed += len(settled) return result, nil } @@ -849,6 +859,39 @@ func (s *Service) ListRefunds(ctx context.Context, filter RefundFilter) ([]Refun return s.repo.ListRefundsFiltered(ctx, filter) } +func (s *Service) UpdateRefundStatus(ctx context.Context, refundID string, status string, at time.Time) (Refund, error) { + refund, err := s.repo.GetRefund(ctx, refundID) + if err != nil { + return Refund{}, err + } + status = strings.ToLower(strings.TrimSpace(status)) + switch status { + case "pending", "succeeded", "failed", "canceled": + default: + return Refund{}, fmt.Errorf("%w: status must be pending, succeeded, failed, or canceled", ErrInvalidInput) + } + if at.IsZero() { + at = s.now() + } + refund.Status = status + refund.Metadata = copyMap(refund.Metadata) + refund.Metadata["billtap_last_status_update"] = at.Format(time.RFC3339Nano) + return s.repo.UpdateRefund(ctx, refund, []TimelineEntry{billingTimelineEntry( + "refund_status_"+refund.ID+"_"+status+"_"+at.Format(time.RFC3339Nano), + "charge.refund.updated", + "Refund "+status, + ObjectRefund, + refund.ID, + refund.CustomerID, + "", + "", + refund.InvoiceID, + refund.PaymentIntentID, + map[string]string{"source": "refund.update", "status": refund.Status, "charge": refund.ChargeID}, + at, + )}) +} + func (s *Service) CreateCreditNote(ctx context.Context, in CreditNote) (CreditNote, error) { if strings.TrimSpace(in.InvoiceID) == "" { return CreditNote{}, fmt.Errorf("%w: invoice is required", ErrInvalidInput) @@ -895,6 +938,37 @@ func (s *Service) ListCreditNotes(ctx context.Context, filter CreditNoteFilter) return s.repo.ListCreditNotesFiltered(ctx, filter) } +func (s *Service) VoidCreditNote(ctx context.Context, creditNoteID string) (CreditNote, error) { + note, err := s.repo.GetCreditNote(ctx, creditNoteID) + if err != nil { + return CreditNote{}, err + } + if note.Status == "void" { + return note, nil + } + if note.Status != "issued" { + return CreditNote{}, fmt.Errorf("%w: status must be issued", ErrInvalidInput) + } + now := s.now() + note.Status = "void" + note.Metadata = copyMap(note.Metadata) + note.Metadata["billtap_voided_at"] = now.Format(time.RFC3339Nano) + return s.repo.UpdateCreditNote(ctx, note, []TimelineEntry{billingTimelineEntry( + "credit_note_voided_"+note.ID+"_"+now.Format(time.RFC3339Nano), + "credit_note.voided", + "Credit note voided", + ObjectCreditNote, + note.ID, + note.CustomerID, + "", + "", + note.InvoiceID, + "", + map[string]string{"source": "credit_note.void", "status": note.Status}, + now, + )}) +} + func (s *Service) GetPaymentIntent(ctx context.Context, id string) (PaymentIntent, error) { return s.repo.GetPaymentIntent(ctx, id) } @@ -1515,6 +1589,11 @@ func (s *Service) renewSubscription(ctx context.Context, sub Subscription, at ti sub.Metadata["billtap_last_renewal_period_end"] = periodEnd.Format(time.RFC3339Nano) renewalOutcome := renewalOutcome(sub.Metadata) + if renewalOutcome == "" && sub.CustomerID != "" { + if customer, err := s.repo.GetCustomer(ctx, sub.CustomerID); err == nil { + renewalOutcome = CustomerDefaultInvoiceOutcome(customer.Metadata) + } + } renewalFailed := renewalOutcome != "" invoice := Invoice{ ID: id("in"), @@ -1538,6 +1617,7 @@ func (s *Service) renewSubscription(ctx context.Context, sub Subscription, at ti invoice.NextPaymentAttempt = &nextPaymentAttempt sub.Status = renewalFailureSubscriptionStatus(renewalOutcome) sub.Metadata["billtap_last_renewal_outcome"] = renewalOutcome + sub.Metadata["billtap_next_retry_at"] = nextPaymentAttempt.Format(time.RFC3339Nano) } intent := PaymentIntent{ ID: id("pi"), @@ -1614,21 +1694,111 @@ func (s *Service) subscriptionAttachedToClock(ctx context.Context, sub Subscript return false } +func (s *Service) refundAttachedToClock(ctx context.Context, refund Refund, clockID string) bool { + clockID = strings.TrimSpace(clockID) + if clockID == "" { + return true + } + if strings.TrimSpace(refund.Metadata["test_clock"]) == clockID || strings.TrimSpace(refund.Metadata["testClock"]) == clockID { + return true + } + if refund.CustomerID == "" { + return false + } + customer, err := s.repo.GetCustomer(ctx, refund.CustomerID) + if err != nil { + return false + } + return strings.TrimSpace(customer.Metadata["test_clock"]) == clockID || strings.TrimSpace(customer.Metadata["testClock"]) == clockID +} + +func (s *Service) settlePendingRefunds(ctx context.Context, at time.Time, testClockID string) ([]Refund, error) { + refunds, err := s.repo.ListRefundsFiltered(ctx, RefundFilter{}) + if err != nil { + return nil, err + } + var settled []Refund + for _, refund := range refunds { + if refund.Status != "pending" || !s.refundAttachedToClock(ctx, refund, testClockID) { + continue + } + settleAtRaw := firstNonEmpty(refund.Metadata["billtap_settle_at"], refund.Metadata["settle_at"], refund.Metadata["available_on"]) + if settleAtRaw == "" { + continue + } + settleAt, err := parseMetadataTime(settleAtRaw) + if err != nil { + return settled, err + } + if settleAt.After(at) { + continue + } + updated, err := s.UpdateRefundStatus(ctx, refund.ID, "succeeded", settleAt) + if err != nil { + return settled, err + } + settled = append(settled, updated) + } + return settled, nil +} + func renewalOutcome(metadata map[string]string) string { for _, key := range []string{"billtap_renewal_outcome", "renewal_outcome", "renewalOutcome"} { - value := strings.ToLower(strings.TrimSpace(metadata[key])) - switch value { - case "", "payment_succeeded", "succeeded", "success": - continue - case "payment_failed": - return "card_declined" - default: + if value := normalizeInvoiceOutcome(metadata[key]); value != "" { + return value + } + } + return "" +} + +// CustomerDefaultInvoiceOutcome returns the default renewal invoice outcome from customer metadata. +func CustomerDefaultInvoiceOutcome(metadata map[string]string) string { + for _, key := range []string{MetadataDefaultInvoiceOutcome, "billtap_default_renewal_outcome", "default_invoice_outcome", "default_renewal_outcome"} { + if value := normalizeInvoiceOutcome(metadata[key]); value != "" { return value } } return "" } +func IsSupportedInvoiceOutcome(outcome string) bool { + outcome = normalizeInvoiceOutcome(outcome) + if outcome == "" { + return true + } + return IsSupportedPaymentIntentOutcome(outcome) +} + +func normalizeInvoiceOutcome(outcome string) string { + value := strings.ToLower(strings.TrimSpace(outcome)) + switch value { + case "", "payment_succeeded", "succeeded", "success": + return "" + case "payment_failed": + return "card_declined" + default: + return value + } +} + +func parseMetadataTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("%w: time is required", ErrInvalidInput) + } + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Unix(seconds, 0).UTC(), nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + parsed, err = time.Parse(time.RFC3339Nano, value) + } + if err != nil { + return time.Time{}, fmt.Errorf("%w: invalid time %q", ErrInvalidInput, value) + } + return parsed.UTC(), nil +} + func renewalFailureSubscriptionStatus(outcome string) string { switch strings.ToLower(strings.TrimSpace(outcome)) { case "unpaid": @@ -1743,6 +1913,8 @@ type TimelineFilter struct { SubscriptionID string InvoiceID string PaymentIntentID string + ObjectType string + ObjectID string } func firstNonEmpty(values ...string) string { diff --git a/internal/fixtures/service.go b/internal/fixtures/service.go index fb032ae..f7d6031 100644 --- a/internal/fixtures/service.go +++ b/internal/fixtures/service.go @@ -65,6 +65,13 @@ func (s *Service) Apply(ctx context.Context, pack Pack) (ApplyResult, error) { } result.Prices = append(result.Prices, price) } + for _, fixture := range pack.ConnectedAccounts { + account, err := s.upsertAccount(ctx, pack, fixture) + if err != nil { + return result, err + } + result.ConnectedAccounts = append(result.ConnectedAccounts, account) + } for _, fixture := range pack.TestClocks { clock, err := s.upsertTestClock(ctx, fixture) if err != nil { @@ -98,14 +105,15 @@ func (s *Service) Apply(ctx context.Context, pack Pack) (ApplyResult, error) { } result.Summary = map[string]int{ - "customers": len(result.Customers), - "products": len(result.Products), - "prices": len(result.Prices), - "test_clocks": len(result.TestClocks), - "checkout_sessions": len(result.CheckoutSessions), - "subscriptions": len(result.Subscriptions), - "refunds": len(result.Refunds), - "credit_notes": len(result.CreditNotes), + "customers": len(result.Customers), + "products": len(result.Products), + "prices": len(result.Prices), + "connected_accounts": len(result.ConnectedAccounts), + "test_clocks": len(result.TestClocks), + "checkout_sessions": len(result.CheckoutSessions), + "subscriptions": len(result.Subscriptions), + "refunds": len(result.Refunds), + "credit_notes": len(result.CreditNotes), } if len(pack.Assertions) > 0 { @@ -123,6 +131,11 @@ func (s *Service) Apply(ctx context.Context, pack Pack) (ApplyResult, error) { return result, nil } +func (s *Service) Validate(pack Pack) error { + pack = normalizePack(pack) + return validatePack(pack) +} + func (s *Service) Snapshot(ctx context.Context, filter SnapshotFilter) (Snapshot, error) { if s.billing == nil { return Snapshot{}, fmt.Errorf("%w: billing service is required", ErrInvalidFixture) @@ -318,6 +331,7 @@ func (s *Service) upsertCustomer(ctx context.Context, pack Pack, fixture Custome return billing.Customer{}, err } metadata = applyCustomerDefaultPaymentIntentOutcome(metadata, fixture) + metadata = applyCustomerDefaultInvoiceOutcome(metadata, fixture) if fixture.ID != "" { current, err := s.billing.GetCustomer(ctx, fixture.ID) if err == nil { @@ -327,6 +341,7 @@ func (s *Service) upsertCustomer(ctx context.Context, pack Pack, fixture Custome return billing.Customer{}, err } metadata = applyCustomerDefaultPaymentIntentOutcome(metadata, fixture) + metadata = applyCustomerDefaultInvoiceOutcome(metadata, fixture) return s.billing.UpdateCustomer(ctx, fixture.ID, billing.Customer{ Email: fixture.Email, Name: fixture.Name, @@ -345,6 +360,43 @@ func (s *Service) upsertCustomer(ctx context.Context, pack Pack, fixture Custome }) } +func (s *Service) upsertAccount(ctx context.Context, pack Pack, fixture AccountFixture) (billing.Account, error) { + id := strings.TrimSpace(fixture.ID) + metadata := fixtureMetadata(fixture.Metadata, pack, id) + chargesEnabled := boolDefaultPtr(fixture.ChargesEnabled, true) + payoutsEnabled := boolDefaultPtr(fixture.PayoutsEnabled, true) + detailsSubmitted := boolDefaultPtr(fixture.DetailsSubmitted, true) + account := billing.Account{ + ID: id, + Type: firstFixtureNonEmpty(fixture.Type, "express"), + Country: strings.ToUpper(firstFixtureNonEmpty(fixture.Country, "US")), + Email: strings.TrimSpace(fixture.Email), + BusinessType: strings.TrimSpace(fixture.BusinessType), + DefaultCurrency: strings.ToLower(firstFixtureNonEmpty(fixture.DefaultCurrency, "usd")), + ChargesEnabled: chargesEnabled, + PayoutsEnabled: payoutsEnabled, + DetailsSubmitted: detailsSubmitted, + Capabilities: fixture.Capabilities, + Metadata: metadata, + } + if id != "" { + current, err := s.billing.GetAccount(ctx, id) + if err == nil { + if account.Capabilities == nil { + account.Capabilities = current.Capabilities + } + if metadata != nil { + account.Metadata = mergeStringMap(current.Metadata, metadata) + } + return s.billing.UpdateAccount(ctx, id, account) + } + if !errors.Is(err, billing.ErrNotFound) { + return billing.Account{}, err + } + } + return s.billing.CreateAccount(ctx, account) +} + func (s *Service) upsertProduct(ctx context.Context, pack Pack, fixture ProductFixture) (billing.Product, error) { active := true if fixture.Active != nil { @@ -559,6 +611,14 @@ func (s *Service) createRefund(ctx context.Context, pack Pack, fixture RefundFix } } metadata := fixtureMetadata(fixture.Metadata, pack, firstFixtureNonEmpty(fixture.ID, fixture.Charge, fixture.PaymentIntent, fixture.Invoice)) + if strings.TrimSpace(fixture.TestClock) != "" { + metadata = ensureStringMap(metadata) + metadata["test_clock"] = strings.TrimSpace(fixture.TestClock) + } + if settleAt := firstFixtureNonEmpty(fixture.SettleAt, fixture.AvailableOn); settleAt != "" { + metadata = ensureStringMap(metadata) + metadata["billtap_settle_at"] = settleAt + } return s.billing.CreateRefund(ctx, billing.Refund{ ID: strings.TrimSpace(fixture.ID), ChargeID: strings.TrimSpace(fixture.Charge), @@ -568,6 +628,7 @@ func (s *Service) createRefund(ctx context.Context, pack Pack, fixture RefundFix Amount: fixture.Amount, Currency: strings.TrimSpace(fixture.Currency), Reason: strings.TrimSpace(fixture.Reason), + Status: strings.TrimSpace(fixture.Status), Metadata: metadata, }) } @@ -590,6 +651,7 @@ func (s *Service) createCreditNote(ctx context.Context, pack Pack, fixture Credi Amount: fixture.Amount, Currency: strings.TrimSpace(fixture.Currency), Reason: strings.TrimSpace(fixture.Reason), + Status: strings.TrimSpace(fixture.Status), Metadata: metadata, }) } @@ -674,6 +736,14 @@ func validatePack(pack Pack) error { if outcome := customerDefaultPaymentIntentOutcomeFixture(customer); outcome != "" && !billing.IsSupportedPaymentIntentOutcome(outcome) { problems = append(problems, fmt.Sprintf("customers[%d].default_payment_intent_outcome is invalid", idx)) } + if outcome := customerDefaultInvoiceOutcomeFixture(customer); outcome != "" && !billing.IsSupportedInvoiceOutcome(outcome) { + problems = append(problems, fmt.Sprintf("customers[%d].default_invoice_outcome is invalid", idx)) + } + } + for idx, account := range pack.ConnectedAccounts { + if strings.TrimSpace(account.ID) == "" { + problems = append(problems, fmt.Sprintf("connected_accounts[%d].id is required", idx)) + } } for idx, product := range pack.Products { if strings.TrimSpace(product.Name) == "" { @@ -721,6 +791,16 @@ func validatePack(pack Pack) error { if refund.Amount <= 0 { problems = append(problems, fmt.Sprintf("refunds[%d].amount must be positive", idx)) } + if strings.TrimSpace(refund.SettleAt) != "" { + if _, err := parseFixtureTime(refund.SettleAt); err != nil { + problems = append(problems, fmt.Sprintf("refunds[%d].settle_at is invalid", idx)) + } + } + if strings.TrimSpace(refund.AvailableOn) != "" { + if _, err := parseFixtureTime(refund.AvailableOn); err != nil { + problems = append(problems, fmt.Sprintf("refunds[%d].available_on is invalid", idx)) + } + } } for idx, note := range pack.CreditNotes { if strings.TrimSpace(note.Invoice) == "" { @@ -730,6 +810,14 @@ func validatePack(pack Pack) error { problems = append(problems, fmt.Sprintf("credit_notes[%d].amount must be positive", idx)) } } + for idx, dispute := range pack.Disputes { + if strings.TrimSpace(dispute.Charge) == "" { + problems = append(problems, fmt.Sprintf("disputes[%d].charge is required", idx)) + } + if dispute.Amount < 0 { + problems = append(problems, fmt.Sprintf("disputes[%d].amount must be non-negative", idx)) + } + } if len(problems) > 0 { return fmt.Errorf("%w: %s", ErrInvalidFixture, strings.Join(problems, "; ")) } @@ -911,6 +999,18 @@ func applyCustomerDefaultPaymentIntentOutcome(metadata map[string]string, fixtur return metadata } +func applyCustomerDefaultInvoiceOutcome(metadata map[string]string, fixture CustomerFixture) map[string]string { + outcome := customerDefaultInvoiceOutcomeFixture(fixture) + if outcome == "" { + return metadata + } + if metadata == nil { + metadata = map[string]string{} + } + metadata[billing.MetadataDefaultInvoiceOutcome] = outcome + return metadata +} + func customerDefaultPaymentIntentOutcomeFixture(fixture CustomerFixture) string { return firstFixtureNonEmpty( fixture.DefaultPaymentIntentOutcome, @@ -920,6 +1020,19 @@ func customerDefaultPaymentIntentOutcomeFixture(fixture CustomerFixture) string ) } +func customerDefaultInvoiceOutcomeFixture(fixture CustomerFixture) string { + return firstFixtureNonEmpty( + fixture.DefaultInvoiceOutcome, + fixture.DefaultInvoiceOutcomeCamel, + fixture.DefaultRenewalOutcome, + fixture.DefaultRenewalOutcomeCamel, + fixture.Metadata[billing.MetadataDefaultInvoiceOutcome], + fixture.Metadata["billtap_default_renewal_outcome"], + fixture.Metadata["default_invoice_outcome"], + fixture.Metadata["default_renewal_outcome"], + ) +} + func customerPaymentMethodFixtureConfig(fixture CustomerFixture) (string, []string, string, bool, error) { mode := strings.ToLower(firstFixtureNonEmpty(fixture.PaymentMethodsFixture, fixture.PaymentMethodsFixtureCamel)) if mode != "" && mode != billing.PaymentMethodsFixtureEmpty && mode != billing.PaymentMethodsFixtureExplicit { @@ -1208,6 +1321,13 @@ func firstFixtureNonEmpty(values ...string) string { return "" } +func boolDefaultPtr(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + func uniqueFixtureStrings(values []string) []string { out := make([]string, 0, len(values)) seen := map[string]bool{} diff --git a/internal/fixtures/types.go b/internal/fixtures/types.go index 4e3f9bd..97ddd3e 100644 --- a/internal/fixtures/types.go +++ b/internal/fixtures/types.go @@ -14,18 +14,20 @@ const ( ) type Pack struct { - Name string `json:"name" yaml:"name"` - RunID string `json:"runId" yaml:"runId"` - Namespace string `json:"namespace" yaml:"namespace"` - Customers []CustomerFixture `json:"customers" yaml:"customers"` - Catalog CatalogFixture `json:"catalog" yaml:"catalog"` - Products []ProductFixture `json:"products" yaml:"products"` - Prices []PriceFixture `json:"prices" yaml:"prices"` - TestClocks []TestClockFixture `json:"test_clocks" yaml:"test_clocks"` - Subscriptions []SubscriptionFixture `json:"subscriptions" yaml:"subscriptions"` - Refunds []RefundFixture `json:"refunds" yaml:"refunds"` - CreditNotes []CreditNoteFixture `json:"credit_notes" yaml:"credit_notes"` - Assertions []Expectation `json:"assertions" yaml:"assertions"` + Name string `json:"name" yaml:"name"` + RunID string `json:"runId" yaml:"runId"` + Namespace string `json:"namespace" yaml:"namespace"` + Customers []CustomerFixture `json:"customers" yaml:"customers"` + Catalog CatalogFixture `json:"catalog" yaml:"catalog"` + Products []ProductFixture `json:"products" yaml:"products"` + Prices []PriceFixture `json:"prices" yaml:"prices"` + ConnectedAccounts []AccountFixture `json:"connected_accounts" yaml:"connected_accounts"` + TestClocks []TestClockFixture `json:"test_clocks" yaml:"test_clocks"` + Subscriptions []SubscriptionFixture `json:"subscriptions" yaml:"subscriptions"` + Refunds []RefundFixture `json:"refunds" yaml:"refunds"` + CreditNotes []CreditNoteFixture `json:"credit_notes" yaml:"credit_notes"` + Disputes []DisputeFixture `json:"disputes" yaml:"disputes"` + Assertions []Expectation `json:"assertions" yaml:"assertions"` } type CatalogFixture struct { @@ -45,9 +47,27 @@ type CustomerFixture struct { PaymentMethodsCamel []PaymentMethodFixture `json:"paymentMethods" yaml:"paymentMethods"` DefaultPaymentIntentOutcome string `json:"default_payment_intent_outcome" yaml:"default_payment_intent_outcome"` DefaultPIOutcomeCamel string `json:"defaultPaymentIntentOutcome" yaml:"defaultPaymentIntentOutcome"` + DefaultInvoiceOutcome string `json:"default_invoice_outcome" yaml:"default_invoice_outcome"` + DefaultInvoiceOutcomeCamel string `json:"defaultInvoiceOutcome" yaml:"defaultInvoiceOutcome"` + DefaultRenewalOutcome string `json:"default_renewal_outcome" yaml:"default_renewal_outcome"` + DefaultRenewalOutcomeCamel string `json:"defaultRenewalOutcome" yaml:"defaultRenewalOutcome"` Metadata map[string]string `json:"metadata" yaml:"metadata"` } +type AccountFixture struct { + ID string `json:"id" yaml:"id"` + Type string `json:"type" yaml:"type"` + Country string `json:"country" yaml:"country"` + Email string `json:"email" yaml:"email"` + BusinessType string `json:"business_type" yaml:"business_type"` + DefaultCurrency string `json:"default_currency" yaml:"default_currency"` + ChargesEnabled *bool `json:"charges_enabled" yaml:"charges_enabled"` + PayoutsEnabled *bool `json:"payouts_enabled" yaml:"payouts_enabled"` + DetailsSubmitted *bool `json:"details_submitted" yaml:"details_submitted"` + Capabilities map[string]string `json:"capabilities" yaml:"capabilities"` + Metadata map[string]string `json:"metadata" yaml:"metadata"` +} + type PaymentMethodFixture struct { ID string `json:"id" yaml:"id"` Type string `json:"type" yaml:"type"` @@ -128,6 +148,10 @@ type RefundFixture struct { Amount int64 `json:"amount" yaml:"amount"` Currency string `json:"currency" yaml:"currency"` Reason string `json:"reason" yaml:"reason"` + Status string `json:"status" yaml:"status"` + TestClock string `json:"test_clock" yaml:"test_clock"` + SettleAt string `json:"settle_at" yaml:"settle_at"` + AvailableOn string `json:"available_on" yaml:"available_on"` Metadata map[string]string `json:"metadata" yaml:"metadata"` } @@ -138,26 +162,39 @@ type CreditNoteFixture struct { Amount int64 `json:"amount" yaml:"amount"` Currency string `json:"currency" yaml:"currency"` Reason string `json:"reason" yaml:"reason"` + Status string `json:"status" yaml:"status"` + Metadata map[string]string `json:"metadata" yaml:"metadata"` +} + +type DisputeFixture struct { + ID string `json:"id" yaml:"id"` + Charge string `json:"charge" yaml:"charge"` + Amount int64 `json:"amount" yaml:"amount"` + Currency string `json:"currency" yaml:"currency"` + Reason string `json:"reason" yaml:"reason"` + Status string `json:"status" yaml:"status"` Metadata map[string]string `json:"metadata" yaml:"metadata"` } type ApplyResult struct { - ID string `json:"id"` - Object string `json:"object"` - Name string `json:"name"` - RunID string `json:"runId,omitempty"` - Namespace string `json:"namespace,omitempty"` - AppliedAt time.Time `json:"appliedAt"` - Customers []billing.Customer `json:"customers,omitempty"` - Products []billing.Product `json:"products,omitempty"` - Prices []billing.Price `json:"prices,omitempty"` - CheckoutSessions []billing.CheckoutSession `json:"checkoutSessions,omitempty"` - Subscriptions []billing.Subscription `json:"subscriptions,omitempty"` - TestClocks []billing.TestClock `json:"testClocks,omitempty"` - Refunds []billing.Refund `json:"refunds,omitempty"` - CreditNotes []billing.CreditNote `json:"creditNotes,omitempty"` - Assertions *AssertionReport `json:"assertions,omitempty"` - Summary map[string]int `json:"summary"` + ID string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` + RunID string `json:"runId,omitempty"` + Namespace string `json:"namespace,omitempty"` + AppliedAt time.Time `json:"appliedAt"` + Customers []billing.Customer `json:"customers,omitempty"` + Products []billing.Product `json:"products,omitempty"` + Prices []billing.Price `json:"prices,omitempty"` + ConnectedAccounts []billing.Account `json:"connectedAccounts,omitempty"` + CheckoutSessions []billing.CheckoutSession `json:"checkoutSessions,omitempty"` + Subscriptions []billing.Subscription `json:"subscriptions,omitempty"` + TestClocks []billing.TestClock `json:"testClocks,omitempty"` + Refunds []billing.Refund `json:"refunds,omitempty"` + CreditNotes []billing.CreditNote `json:"creditNotes,omitempty"` + Disputes []map[string]any `json:"disputes,omitempty"` + Assertions *AssertionReport `json:"assertions,omitempty"` + Summary map[string]int `json:"summary"` } type ResolveFilter struct { diff --git a/internal/storage/billing.go b/internal/storage/billing.go index 2aabd9c..6643a3a 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -247,6 +247,12 @@ func (s *SQLiteStore) UpdateAccount(ctx context.Context, id string, in billing.A if in.Email != "" { current.Email = in.Email } + if in.Type != "" { + current.Type = in.Type + } + if in.Country != "" { + current.Country = strings.ToUpper(in.Country) + } if in.BusinessType != "" { current.BusinessType = in.BusinessType } @@ -1063,6 +1069,35 @@ func (s *SQLiteStore) ListRefundsFiltered(ctx context.Context, filter billing.Re return out, rows.Err() } +func (s *SQLiteStore) UpdateRefund(ctx context.Context, refund billing.Refund, timeline []billing.TimelineEntry) (billing.Refund, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Refund{}, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE refunds + SET charge_id = ?, payment_intent_id = ?, invoice_id = ?, customer_id = ?, amount = ?, currency = ?, reason = ?, status = ?, metadata = ? + WHERE id = ?`, + refund.ChargeID, refund.PaymentIntentID, refund.InvoiceID, refund.CustomerID, refund.Amount, refund.Currency, refund.Reason, refund.Status, encodeMap(refund.Metadata), refund.ID) + if err != nil { + return billing.Refund{}, err + } + if changed, err := result.RowsAffected(); err != nil { + return billing.Refund{}, err + } else if changed == 0 { + return billing.Refund{}, billing.ErrNotFound + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Refund{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Refund{}, err + } + return s.GetRefund(ctx, refund.ID) +} + func (s *SQLiteStore) CreateCreditNote(ctx context.Context, note billing.CreditNote, timeline []billing.TimelineEntry) (billing.CreditNote, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -1122,6 +1157,35 @@ func (s *SQLiteStore) ListCreditNotesFiltered(ctx context.Context, filter billin return out, rows.Err() } +func (s *SQLiteStore) UpdateCreditNote(ctx context.Context, note billing.CreditNote, timeline []billing.TimelineEntry) (billing.CreditNote, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.CreditNote{}, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE credit_notes + SET invoice_id = ?, customer_id = ?, amount = ?, currency = ?, reason = ?, status = ?, metadata = ? + WHERE id = ?`, + note.InvoiceID, note.CustomerID, note.Amount, note.Currency, note.Reason, note.Status, encodeMap(note.Metadata), note.ID) + if err != nil { + return billing.CreditNote{}, err + } + if changed, err := result.RowsAffected(); err != nil { + return billing.CreditNote{}, err + } else if changed == 0 { + return billing.CreditNote{}, billing.ErrNotFound + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.CreditNote{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.CreditNote{}, err + } + return s.GetCreditNote(ctx, note.ID) +} + func (s *SQLiteStore) Timeline(ctx context.Context, filter billing.TimelineFilter) ([]billing.TimelineEntry, error) { clauses := []string{"1=1"} args := []any{} @@ -1145,6 +1209,14 @@ func (s *SQLiteStore) Timeline(ctx context.Context, filter billing.TimelineFilte clauses = append(clauses, "payment_intent_id = ?") args = append(args, filter.PaymentIntentID) } + if filter.ObjectType != "" { + clauses = append(clauses, "object_type = ?") + args = append(args, filter.ObjectType) + } + if filter.ObjectID != "" { + clauses = append(clauses, "object_id = ?") + args = append(args, filter.ObjectID) + } rows, err := s.db.QueryContext(ctx, `SELECT id, action, message, object_type, object_id, customer_id, checkout_session_id, subscription_id, invoice_id, payment_intent_id, data, created_at FROM timeline_entries WHERE `+strings.Join(clauses, " AND ")+` ORDER BY created_at ASC, id ASC`, args...) if err != nil { diff --git a/internal/stripecompat/registry.go b/internal/stripecompat/registry.go index 9a54b96..bc926ec 100644 --- a/internal/stripecompat/registry.go +++ b/internal/stripecompat/registry.go @@ -233,13 +233,18 @@ func DefaultClaims() []Claim { add(http.MethodGet, "/v1/invoices", statefulL3) add(http.MethodGet, "/v1/invoices/{id}", statefulL3) add(http.MethodPost, "/v1/invoices/{id}/pay", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.pay.failed_invoice_succeeds", "invoices.pay.failed_invoice_declines_again"}, WebhookEvents: []string{"payment_intent.succeeded", "payment_intent.payment_failed", "invoice.payment_succeeded", "invoice.payment_failed", "invoice.paid", "customer.subscription.updated"}, Risks: []string{"local retry/payment mutation only; finalize, send, void, collection, and dunning automation are not modeled"}}) - add(http.MethodPost, "/v1/invoices/create_preview", Claim{Level: "L2", Risks: []string{"zero-value local smoke-test invoice; no full proration model"}}) - add(http.MethodGet, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and disputes are not modeled"}}) - add(http.MethodPost, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and disputes are not modeled"}}) + add(http.MethodPost, "/v1/invoices/create_preview", Claim{Level: "L3", Stateful: true, Risks: []string{"local subscription-update proration subset; taxes, discounts, pending invoice items, and full invoice preview parity are not modeled"}}) + add(http.MethodGet, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"Stripe-compatible upcoming preview alias backed by the same local proration subset"}}) + add(http.MethodPost, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"local form-compatible upcoming preview convenience"}}) + add(http.MethodGet, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) + add(http.MethodPost, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) add(http.MethodGet, "/v1/refunds/{id}", Claim{Level: "L3", Stateful: true}) - add(http.MethodGet, "/v1/credit_notes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"credit_note.created"}, 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"}, Risks: []string{"local credit note evidence only; line/tax/customer-balance math is not modeled"}}) + 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.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"}}) add(http.MethodGet, "/v1/payment_intents", statefulL3) add(http.MethodPost, "/v1/payment_intents", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"payment_intents.create.confirm.succeeds", "payment_intents.confirm.card_decline"}, Risks: []string{"local state machine only; no card processing or full PaymentIntent parameter parity"}}) @@ -273,9 +278,9 @@ func DefaultClaims() []Claim { add(http.MethodGet, "/v1/customers/{id}/cash_balance_transactions", Claim{Level: "L2", Stateful: true}) add(http.MethodGet, "/v1/customers/{id}/cash_balance_transactions/{id}", Claim{Level: "L2", Stateful: true}) add(http.MethodPost, "/v1/test_helpers/customers/{id}/fund_cash_balance", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"payment_intent.succeeded"}, Risks: []string{"local test-helper funding only; banking rails are not modeled"}}) - add(http.MethodGet, "/v1/disputes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"charge.dispute.created", "charge.dispute.closed"}, Risks: []string{"local dispute evidence only; representment workflow is not modeled"}}) + add(http.MethodGet, "/v1/disputes", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"charge.dispute.created", "charge.dispute.updated", "charge.dispute.funds_withdrawn", "charge.dispute.closed"}, Risks: []string{"local dispute evidence only; representment workflow and balance movements are not modeled"}}) add(http.MethodGet, "/v1/disputes/{id}", Claim{Level: "L2", Stateful: true}) - add(http.MethodPost, "/v1/disputes/{id}", Claim{Level: "L2", Stateful: true}) + add(http.MethodPost, "/v1/disputes/{id}", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"charge.dispute.updated"}}) add(http.MethodPost, "/v1/disputes/{id}/close", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"charge.dispute.closed"}}) add(http.MethodGet, "/v1/charges/{id}/dispute", Claim{Level: "L2", Stateful: true}) add(http.MethodPost, "/v1/charges/{id}/dispute", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"charge.dispute.created"}}) diff --git a/internal/stripecompat/registry_test.go b/internal/stripecompat/registry_test.go index 9df932e..4d1b901 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) != 140 { - t.Fatalf("default claims = %d, want 140", len(claims)) + if len(claims) != 145 { + t.Fatalf("default claims = %d, want 145", len(claims)) } checkout, ok := registry.Lookup(http.MethodPost, "/v1/checkout/sessions") diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index ce31ae8..a5a26fb 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -108,6 +108,8 @@ invoicing, and full schedule lifecycle parity are not modeled. - `GET /v1/invoices` - `POST /v1/invoices/{id}/pay` - `POST /v1/invoices/create_preview` +- `GET /v1/invoices/upcoming` +- `POST /v1/invoices/upcoming` Direct invoice `pay` is a local retry mutation for open invoices created by Billtap checkout and scenarios. It accepts deterministic sandbox @@ -116,6 +118,15 @@ Billtap checkout and scenarios. It accepts deterministic sandbox create, finalize, send, void, line mutation, collection, and full dunning automation are not part of the current release-compatible subset. +Preview endpoints accept Stripe SDK-style `subscription`, +`subscription_details[items][0][price]`, +`subscription_details[items][0][quantity]`, +`subscription_details[proration_date]`, and +`subscription_details[proration_behavior]`. Billtap calculates a local +subscription-update proration line from the current period bounds and old/new +price totals. Taxes, discounts, pending invoice items, and collection behavior +are outside the modeled subset. + ### Payment Intents - `GET /v1/payment_intents/{id}` @@ -178,22 +189,28 @@ processing bank-transfer PaymentIntents for the customer. - `POST /v1/refunds` - `GET /v1/refunds/{id}` - `GET /v1/refunds` +- `POST /v1/refunds/{id}` +- `POST /v1/refunds/{id}/cancel` Refunds are local payment-history evidence. Creation accepts `charge`, `payment_intent`, or `invoice`, plus `amount`, optional `reason`, and metadata. -It emits local `charge.refunded` and `charge.refund.updated` events, but does -not model settlement, balance transactions, disputes, or failed refund -processing. +It emits local `charge.refunded` and `charge.refund.updated` events. A fixture +or API call can keep a refund `pending`; when attached to a test clock with +`settle_at`/`available_on`, clock advance marks it `succeeded` and emits +`charge.refund.updated`. Balance transactions and processor settlement are +outside the modeled subset. ### Credit Notes - `POST /v1/credit_notes` - `GET /v1/credit_notes/{id}` - `GET /v1/credit_notes` +- `POST /v1/credit_notes/{id}/void` Credit notes are local invoice-history evidence. Creation accepts `invoice`, -`amount`, optional `reason`, and metadata. It emits `credit_note.created`, but -does not model line-level tax/discount accounting, PDF rendering, or voiding. +`amount`, optional `reason`, and metadata. It emits `credit_note.created`; void +emits `credit_note.voided`. Line-level tax/discount accounting, PDF rendering, +and customer-balance math are outside the modeled subset. ### Disputes @@ -205,9 +222,10 @@ does not model line-level tax/discount accounting, PDF rendering, or voiding. - `POST /v1/charges/{id}/dispute` Disputes are local chargeback-style evidence. Creating one emits -`charge.dispute.created`; closing one emits `charge.dispute.closed`. -Representment, evidence upload, deadlines, balance movement, and processor -outcomes are not modeled. +`charge.dispute.created`; updating evidence emits `charge.dispute.updated`; +fixture statuses can also emit `charge.dispute.funds_withdrawn`; closing one +emits `charge.dispute.closed`. Representment deadlines, balance movement, and +processor outcomes are outside the modeled subset. ### Test Clocks @@ -217,9 +235,10 @@ outcomes are not modeled. - `POST /v1/test_helpers/test_clocks/{id}/advance` Test clocks are persisted local clocks for deterministic lifecycle simulation. -Customers and subscriptions can be attached through `test_clock`. Advancing a -clock processes due trials, renewals, configured renewal failures, and -period-end cancellations for attached objects. +Customers, subscriptions, and pending refunds can be attached through +`test_clock`. Advancing a clock processes due trials, renewals, configured +renewal failures, period-end cancellations, and refund settlement for attached +objects. ### Billing Portal Sessions @@ -430,15 +449,23 @@ Create a debug bundle. Apply a developer-test fixture pack. Request body may be JSON or YAML. +### `POST /api/fixtures/validate` + +Validate a fixture pack without mutating local billing state. Request body may +be JSON or YAML. The response contains `valid: true` plus counts for supported +sections when schema and local semantic checks pass. + Supported fixture sections: - `customers` +- `connected_accounts` - `catalog.products` - `catalog.prices` - `test_clocks` - `subscriptions` - `refunds` - `credit_notes` +- `disputes` - `assertions` Customer fixtures can opt out of Billtap's default sandbox card projection with