diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c6108f..ae5ddfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,70 @@ ## Unreleased +- The billing family is now full inventory (`39 / 39` OpenAPI operations): + all four P0 families (webhooks, checkout, billing, billing_portal) are at + 100%. +- `POST /v1/invoices/{id}/lines/{line_item_id}` updates one draft-invoice line + (`amount`, `description`, metadata) with the same totals recomputation as + `update_lines`. +- `POST /v1/invoices/{id}/attach_payment` records an attached PaymentIntent + (customer-matched) or payment record on a draft invoice as evidence; + collection still runs through `finalize`/`pay`. +- `POST /v1/subscriptions/{id}/migrate` records + `billing_mode[type]=flexible` (plus optional `proration_discounts`) as + subscription metadata evidence; flexible-billing proration recalculation is + not modeled. +- `POST /v1/payment_intents/{id}` updates intent metadata and evidenced + description; amount, currency, and status stay immutable. +- `GET /v1/credit_notes/{id}/lines` returns one line derived from the stored + credit-note amount and memo/reason. +- The OpenAPI inventory moves from `195 / 587` (`33.2%`) to `200 / 587` + (`34.1%`): billing `39 / 39`, payments `23 / 41`, payment_history `14 / 30`. +- `GET /v1/checkout/sessions/{id}/line_items` returns the session's line items + as expanded Stripe `item` objects (pre-discount amounts; per-line + discount/tax splits are not modeled). +- `POST /v1/checkout/sessions/{id}` updates an open session: metadata merge + plus `line_items[N][quantity]` overrides with immutable prices. Non-open + sessions, out-of-range indexes, and non-positive quantities are rejected. + Checkout sessions are now full inventory: `6 / 6` operations. +- `POST /v1/invoices/{id}` updates draft invoices (description, `days_until_due`, + `default_payment_method`, metadata merge) and `DELETE /v1/invoices/{id}` + deletes a draft together with its attached lines and timeline evidence. +- `POST /v1/invoices/{id}/add_lines`, `update_lines`, and `remove_lines` mutate + draft-invoice lines through the invoice-item path, recomputing + `subtotal`/`total`/`amount_due` per change. +- `DELETE /v1/products/{id}` removes local product evidence; existing prices + keep referencing the deleted product id. +- The OpenAPI inventory moves from `187 / 587` (`31.9%`) to `195 / 587` + (`33.2%`): checkout closes to `6 / 6`, billing moves to `36 / 39`, and + catalog to `28 / 54`. +- Added billing portal configurations: `GET/POST + /v1/billing_portal/configurations` and `GET/POST + /v1/billing_portal/configurations/{id}` store local portal-configuration + evidence with Stripe-shaped `business_profile`, `default_return_url`, + `login_page`, `features[...]`, and `metadata` fields, list filters for + `active`/`is_default`, and the first configuration in a run becoming the + default. The hosted portal does not render configuration features. +- `GET /v1/subscription_items` lists resolved items across subscriptions with + the `subscription` filter and the standard list envelope, and + `GET /v1/subscription_items/{id}` retrieves one item. +- `POST /v1/subscription_items/{id}` updates an item's `price` and `quantity` + (plus metadata and evidence-only `tax_rates`), routing + `proration_behavior=always_invoice/create_prorations` through the same + proration path as item create so a quantity bump with `always_invoice` + issues the prorated `subscription_update` invoice. +- `GET/DELETE /v1/customers/{id}/subscriptions/{id}/discount` now mirror the + top-level subscription discount routes and are customer-scoped: a mismatched + customer gets `404`. +- Fixed subscription discount deletion: `DELETE .../subscriptions/{id}/discount` + built a metadata copy with the discount keys removed, but subscription + metadata patches merge, so the stored discount survived its own deletion and + a following `GET` still returned it. The patch now zeroes the discount keys, + which is how merge-mode patches delete. +- Registered compat claims for already-tested routes: invoice `void`, + invoice `mark_uncollectible`, and checkout `expire`. The OpenAPI inventory + moves from `175 / 587` (`29.8%`) to `187 / 587` (`31.9%`): billing + `31 / 39`, billing_portal `5 / 5`, checkout `4 / 6`. - Local evidence objects — coupons, promotion codes, subscription schedules, disputes, tax rates, tax IDs and customer cash balances — are now stored in the run's own database instead of process memory. They were the only objects diff --git a/README.md b/README.md index 67723a2..e4e8654 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Billtap +[![CI](https://github.com/midagedev/billtap/actions/workflows/ci.yml/badge.svg)](https://github.com/midagedev/billtap/actions/workflows/ci.yml) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![GHCR image](https://img.shields.io/badge/ghcr-billtap-2496ED?logo=docker&logoColor=white)](https://github.com/midagedev/billtap/pkgs/container/billtap) + Full-stack Stripe-style billing sandbox for local development, CI scenarios, and controlled staging checks. @@ -19,7 +23,7 @@ behavior outside the published contract. | Surface | What it is for | | --- | --- | -| Stripe-like API | Local customers, products, prices, coupons, promotion codes, checkout sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, test clocks, webhook endpoints, and events for supported billing flows. | +| Stripe-like API | Local customers, products, prices, coupons, promotion codes, tax rates, checkout sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, test clocks, webhook endpoints, and events for supported billing flows. | | Hosted checkout | Browser-visible sandbox checkout for exercising app integration and deterministic payment outcomes. | | Billing portal | Local customer portal for plan changes, seats, cancellation, resume, and payment-method update flows. | | Developer dashboard | Billing objects, timeline, webhook delivery attempts, app responses, and debug bundle export in one place. | @@ -327,13 +331,13 @@ curl -fsS "http://localhost:8080/api/diagnostics?limit=100" \ | --- | --- | --- | | Runtime | Go server with SQLite local default | In-memory storage exists for tests | | Frontend | React checkout, portal, and dashboard apps | Built with Vite into `dist/app` | -| Stripe-like API | Practical local subset | Customers, catalog, checkout, portal sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, test clocks, webhook endpoints, events, search/list projections used by tests | +| Stripe-like API | Practical local subset | Customers, catalog, checkout, portal sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, tax rates, test clocks, webhook endpoints, events, search/list projections used by tests | | Webhooks | Signed delivery with reliability controls | Retry, duplicate, delay, out-of-order, grouped replay, endpoint attempts, delivery evidence, redaction | | Scenarios | YAML runner | Local clock, app assertions, JSON/Markdown reports, exit-code policy | | Fixtures | Apply/snapshot/assert APIs | JSON/YAML input, fixture metadata isolation, structured pass/fail reports | | SaaS profile | Generic workspace billing profile | Plans, seats, members, export quota, extra export, payment history, support bundle, platform/connect-style webhook evidence | | Release state | Source plus GHCR image | Local Docker image builds and GHCR image workflow; no package/Homebrew/signed binary yet | -| Stripe API inventory | `160 / 587` operations, `27.3%` L1+ | OpenAPI route inventory is schema-visible for all `587` operations; implemented coverage is tracked in `docs/STRIPE_COMPATIBILITY_90_TARGET.md` | +| Stripe API inventory | `200 / 587` operations, `34.1%` L1+ | OpenAPI route inventory is schema-visible for all `587` operations; implemented coverage is tracked in `docs/STRIPE_COMPATIBILITY_90_TARGET.md` | Detailed compatibility matrix: `docs/COMPATIBILITY.md`. diff --git a/docs/API_VALIDATION_AND_ERROR_SIMULATION.md b/docs/API_VALIDATION_AND_ERROR_SIMULATION.md index 0702445..8e25961 100644 --- a/docs/API_VALIDATION_AND_ERROR_SIMULATION.md +++ b/docs/API_VALIDATION_AND_ERROR_SIMULATION.md @@ -208,7 +208,7 @@ Current lane: - `go run ./cmd/billtap compatibility scorecard --output-dir dist/compatibility` runs the offline Billtap-owned corpus and writes JSON, Markdown, and replay bundle artifacts without requiring external Stripe services. -- `l3-public-readiness-v7` currently has 49 release-blocking cases covering the +- `l3-public-readiness-v7` currently has 50 release-blocking cases covering the public subset's request validation, protocol parameter acceptance, OpenAPI-backed fallback validation, idempotency mismatch, deterministic checkout payment-error aliases, and direct PaymentIntent/SetupIntent state diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 31784e2..1aa4a54 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -97,26 +97,29 @@ traffic can keep using the unprefixed service URL. | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Customers | `POST /v1/customers`, `GET /v1/customers`, `GET /v1/customers/search`, `GET /v1/customers/{id}`, `POST /v1/customers/{id}` | Supported | Create, list, retrieve, update, and search `email`, `name`, and metadata. List supports `email` and `limit` filters. Search supports a measured Stripe Search Query Language subset for `id`, `email`, `name`, and metadata equality clauses joined by `AND`. | -| Products | `POST /v1/products`, `GET /v1/products`, `GET /v1/products/{id}`, `POST /v1/products/{id}` | Supported | Create, list, retrieve, and update local service products with metadata. | +| Products | `POST /v1/products`, `GET /v1/products`, `GET /v1/products/{id}`, `POST /v1/products/{id}`, `DELETE /v1/products/{id}` | Supported | Create, list, retrieve, update, and delete local service products with metadata. Deletion removes the product; existing prices keep referencing the deleted product id. | | Product search | `GET /v1/products/search` | Partial | Supports metadata equality filters such as `metadata['tenantId']:'saas'` and `active:'true'`. This is not Stripe Search Query Language parity. | | Prices | `POST /v1/prices`, `GET /v1/prices`, `GET /v1/prices/{id}`, `POST /v1/prices/{id}`, `GET /v1/prices/search` | Supported | Create, list, retrieve, update, and search prices. Supports `product`, `currency`, `unit_amount`, `lookup_key`, recurring interval fields, `active`, and metadata. List supports `product`, `active`, `type=recurring`, and `type=one_time`. Search supports a measured Stripe Search Query Language subset for `active`, `type`, `lookup_key`, and metadata equality clauses joined by `AND`, including one-time price lookup by `lookup_key`. | | Coupons | `POST /v1/coupons`, `GET /v1/coupons`, `GET /v1/coupons/{id}`, `POST /v1/coupons/{id}`, `DELETE /v1/coupons/{id}` | Partial | Create/list/retrieve local coupon evidence for SDK and fixture smoke paths. Amount-off, percent-off, duration, metadata, and deletion markers are modeled. A bounded single-discount subset applies coupons to customer defaults, checkout sessions, subscriptions, invoice preview math, and renewal invoices; redemption limits and expiration analytics are not modeled. | | Promotion codes | `POST /v1/promotion_codes`, `GET /v1/promotion_codes`, `GET /v1/promotion_codes/{id}`, `POST /v1/promotion_codes/{id}` | Partial | Create/list/retrieve local promotion-code evidence linked to a local coupon. List supports `code`, `coupon`, `customer`, and `active` filters. Promotion codes can be applied through `discounts[0][promotion_code]`; minimum amounts, expiration rules, and promotion analytics are not modeled. | -| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, `subscription_data[trial_period_days]`, and `subscription_data[default_tax_rates]` (applied to session totals; exclusive/inclusive snapshots; mutually exclusive with `automatic_tax`); trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. `PUBLIC_BASE_PATH`, `BILLTAP_PUBLIC_BASE_PATH`, and `X-Forwarded-Prefix` are reflected in hosted browser URLs. | +| Tax rates | `GET/POST /v1/tax_rates`, `GET/POST /v1/tax_rates/{id}` | Partial | Create/list/retrieve/update local tax-rate evidence. Checkout `subscription_data[default_tax_rates][]` and subscription `default_tax_rates` resolve `txr_*` IDs and snapshot inclusive/exclusive rates onto sessions, subscriptions, invoices, renewal/proration math, and previews, applied after discounts; `automatic_tax` is mutually exclusive. Fixture packs can seed tax rates with explicit IDs. Jurisdiction and address-based calculation are not modeled; `automatic_tax` remains a customer-metadata `tax_percent` simulation. | +| Customer tax IDs | `GET/POST /v1/customers/{id}/tax_ids`, `GET/DELETE /v1/customers/{id}/tax_ids/{id}` | Partial | Local customer tax-ID evidence store backing checkout `tax_id_collection[enabled]` flows. Provider verification is not modeled. | +| Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}`, `POST /v1/checkout/sessions/{id}`, `POST /v1/checkout/sessions/{id}/expire`, `GET /v1/checkout/sessions/{id}/line_items` | Supported | Creates subscription- and payment-mode sandbox checkout sessions from request line items and hosted Billtap URLs; `setup` mode is rejected. The Stripe-style session response leaves `line_items` unexpanded; `GET .../line_items` returns them as expanded `item` objects with pre-discount amounts (per-line discount/tax splits are not modeled). Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, `subscription_data[trial_period_days]`, and `subscription_data[default_tax_rates]` (applied to session totals; exclusive/inclusive snapshots; mutually exclusive with `automatic_tax`); trial checkout creates local `trialing` subscription evidence. Payment-mode sessions accept inline `price_data` line items (creating local product/price evidence), `payment_intent_data[...]`, `client_reference_id`, session-level `metadata[...]`, `automatic_tax[enabled]`, and `tax_id_collection[enabled]` (tax snapshot from customer metadata `tax_percent`), and complete into a one-time PaymentIntent without subscription or invoice evidence. `POST .../sessions/{id}` updates an open session (metadata merge plus `line_items[N][quantity]` overrides with immutable prices); `expire` moves an open session to `expired` and emits `checkout.session.expired`; non-open sessions return `invalid_request_error`. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. `PUBLIC_BASE_PATH`, `BILLTAP_PUBLIC_BASE_PATH`, and `X-Forwarded-Prefix` are reflected in hosted browser URLs. | | Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete`, `POST/DELETE /api/checkout/sessions/{id}/promotion_code` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. Hosted checkout can apply or remove a promotion code on an open `allow_promotion_codes` session via `POST/DELETE /api/checkout/sessions/{id}/promotion_code` (form `promotion_code=`), refreshing session totals before completion. | | Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. | -| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, `cancel_at_period_end`, `pause_collection`, and accepted billing/proration evidence including `proration_date`, `proration_behavior`, `payment_behavior`, `billing_cycle_anchor`, and `trial_end`; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | +| Billing portal configurations | `GET/POST /v1/billing_portal/configurations`, `GET/POST /v1/billing_portal/configurations/{id}` | Partial | Local portal-configuration evidence with Stripe-shaped `business_profile`, `default_return_url`, `login_page`, `features[...]`, and `metadata`. Create applies Stripe-shaped feature defaults; update merges business profile, features, and metadata and bumps `updated`. List filters by `active` and `is_default`; the first configuration in a run becomes the default. The hosted portal does not render configuration features. | +| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, `POST /v1/subscriptions/{id}/migrate` (billing-mode evidence), nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, `cancel_at_period_end`, `pause_collection`, and accepted billing/proration evidence including `proration_date`, `proration_behavior`, `payment_behavior`, `billing_cycle_anchor`, and `trial_end`; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | | Subscription schedules | `POST /v1/subscription_schedules`, `GET /v1/subscription_schedules`, `GET /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/cancel`, `POST /v1/subscription_schedules/{id}/release` | Partial | Local schedule evidence for one due phase on an existing subscription. Test-clock advance applies a due phase by replacing subscription items and emits `customer.subscription.updated`; complex multi-phase billing, proration, invoices, and schedule lifecycle parity are not modeled. | -| Subscription items | `POST /v1/subscription_items`, `DELETE /v1/subscription_items/{id}` | Partial | Add or remove local subscription items for integration smoke paths. Billing proration and invoice recalculation are not modeled. | -| Invoices | `POST /v1/invoices`, `GET /v1/invoices`, `GET /v1/invoices/search`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/finalize`, `POST /v1/invoices/{id}/pay`, `GET /v1/invoices/{id}/lines`, `GET /v1/invoices/{id}/payments`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve/search invoices created by checkout and create a bounded manual one-time invoice flow. Search supports `id`, `customer`, `subscription`, and `status` clauses joined by `AND`. `pay` retries open checkout invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Manual invoices accept `customer`, `currency`, `collection_method`, `default_payment_method`, `description`, `auto_advance=false`, `pending_invoice_items_behavior`, `payment_settings[payment_method_types]`, and `metadata[...]`; `finalize` creates local PaymentIntent evidence, and `pay` applies per-intent or customer-level default outcomes including success, card decline, and `requires_action`. Invoice responses include `confirmation_secret`, local hosted/PDF paths, `payments.data.payment.payment_intent`, metadata, status transitions, automatic tax state, tax totals, payment settings, and array-shaped `discounts`. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; taxes, invoice rendering, automatic collection, and full dunning lifecycle are outside the modeled subset. | -| Invoice items | `POST /v1/invoiceitems`, `GET /v1/invoiceitems`, `GET /v1/invoiceitems/{id}` | Partial | Creates and lists local invoice item evidence for manual one-time invoices. `customer`, `invoice`, `amount`, `currency`, `description`, and `metadata[...]` are accepted. Tax, discount, pending invoice-item collection, and line mutation parity are not modeled. | -| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/search`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve/search and mutate local payment intents. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | +| Subscription items | `POST /v1/subscription_items`, `GET /v1/subscription_items`, `GET /v1/subscription_items/{id}`, `POST /v1/subscription_items/{id}`, `DELETE /v1/subscription_items/{id}` | Partial | List, retrieve, add, update, and remove local subscription items. List supports the `subscription` filter; update replaces `price`/`quantity` and echoes metadata and evidence-only `tax_rates`. Create, update, and delete accept `proration_behavior` (`none`/`create_prorations`/`always_invoice`) and `proration_date` through the same proration path as subscription update, applying the subscription's `default_tax_rates` after discounts: `always_invoice` issues a paid `subscription_update` invoice and repoints `latest_invoice`, and `create_prorations` defers the delta to the next renewal invoice. Deleting the last item is rejected. Item IDs are stored at creation so deletes no longer shift later IDs. Item-level `tax_rates` are evidence-only, and metered usage is not modeled. | +| Invoices | `POST /v1/invoices`, `GET /v1/invoices`, `GET /v1/invoices/search`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}`, `DELETE /v1/invoices/{id}`, `POST /v1/invoices/{id}/finalize`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/{id}/send`, `POST /v1/invoices/{id}/void`, `POST /v1/invoices/{id}/mark_uncollectible`, `POST /v1/invoices/{id}/attach_payment`, `POST /v1/invoices/{id}/add_lines`, `POST /v1/invoices/{id}/update_lines`, `POST /v1/invoices/{id}/remove_lines`, `POST /v1/invoices/{id}/lines/{line_item_id}`, `GET /v1/invoices/{id}/lines`, `GET /v1/invoices/{id}/payments`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve/search invoices created by checkout and create a bounded manual one-time invoice flow. Search supports `id`, `customer`, `subscription`, and `status` clauses joined by `AND`. `pay` retries open checkout invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Manual invoices accept `customer`, `currency`, `collection_method`, `default_payment_method`, `description`, `auto_advance=false`, `pending_invoice_items_behavior`, `payment_settings[payment_method_types]`, and `metadata[...]`; `pending_invoice_items_behavior=include` attaches the customer's same-currency pending invoice items once at create. Draft invoices can be updated in place (`POST /v1/invoices/{id}`: `description`, `days_until_due`, `default_payment_method`, metadata merge) and deleted with their attached lines and timeline evidence (`DELETE`); `add_lines`/`update_lines`/`remove_lines` (and the single-line `POST .../lines/{line_item_id}`) mutate draft lines through the invoice-item path and recompute `subtotal`/`total`/`amount_due`; `attach_payment` records a customer-matched PaymentIntent or payment record on a draft as evidence while collection still runs through `finalize`/`pay`. `finalize` creates local PaymentIntent evidence, and `pay` applies per-intent or customer-level default outcomes including success, card decline, and `requires_action`. `send` records local email evidence and emits `invoice.sent`; `void` moves an `open` invoice to `void` and emits `invoice.voided`; `mark_uncollectible` moves an `open` invoice to `uncollectible` and emits `invoice.marked_uncollectible`; non-open statuses return `invalid_request_error`. Invoice responses include `confirmation_secret`, local hosted/PDF paths, `payments.data.payment.payment_intent`, metadata, status transitions, automatic tax state, tax totals, payment settings, array-shaped `discounts`, `lines.data` serialized like the lines endpoint, and `post_payment_credit_notes_amount` on paid invoices. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`; previews without item overrides return the subscription's next billing cycle (items, discounts, and tax with `billing_reason: upcoming`) matching the next renewal invoice. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; invoice rendering, automatic collection, and full dunning lifecycle are outside the modeled subset. | +| Invoice items | `POST /v1/invoiceitems`, `GET /v1/invoiceitems`, `GET /v1/invoiceitems/{id}` | Partial | Creates, lists, and retrieves local invoice item evidence for manual one-time invoices. `customer`, `invoice`, `amount`, `currency`, `description`, and `metadata[...]` are accepted, or `pricing[price]` plus `quantity` instead of `amount` (line amount is the price's `unit_amount` times `quantity`, currency from the price). Omitting `invoice` stores the item as a pending customer item that `POST /v1/invoices` with `pending_invoice_items_behavior=include` attaches; `subscription` is accepted and echoed. Tax, discount, and full line mutation parity are not modeled. | +| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/search`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}` (metadata/description update), `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve/search and mutate local payment intents. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | | Setup intents | `POST /v1/setup_intents`, `GET /v1/setup_intents`, `GET /v1/setup_intents/{id}`, `POST /v1/setup_intents/{id}/confirm`, `POST /v1/setup_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local setup intents with deterministic success, decline, and authentication-required aliases. Mandates, bank-account verification, and full SCA behavior are not modeled. | | Payment methods | `GET/POST /v1/payment_methods`, `GET/POST /v1/payment_methods/{id}`, `POST /v1/payment_methods/{id}/attach`, `POST /v1/payment_methods/{id}/detach`, `GET /v1/customers/{id}/payment_methods?type=card`, `GET /v1/customers/{id}/payment_methods/{id}` | Partial | Returns deterministic sandbox card PaymentMethod projections for known customers, including SDK-friendly `billing_details`, `card.checks`, `country`, `fingerprint`, `funding`, `networks`, `three_d_secure_usage`, `metadata`, and `redaction` fields. Query validation covers `type`, `allow_redisplay`, `limit`, and unknown parameters; non-card valid types return an empty local list. Customer fixtures can opt into empty or explicit payment-method lists for no-card and multi-card local scenarios. Create/attach/detach mutate local customer payment-method metadata and reject real card data; general update is accepted as a compatibility no-op for projected local cards. | | Customer cash balance | `GET /v1/customers/{id}/cash_balance`, `POST /v1/customers/{id}/cash_balance`, `GET /v1/customers/{id}/cash_balance_transactions`, `GET /v1/customers/{id}/cash_balance_transactions/{id}`, `POST /v1/test_helpers/customers/{id}/fund_cash_balance` | Partial | Local cash-balance evidence for bank-transfer smoke tests. The test-helper funding endpoint records a cash-balance transaction and settles processing bank-transfer PaymentIntents for that customer. Banking rails, reconciliation, currency conversion, and customer-balance invoice application are not modeled. | | Connect platform evidence | `GET /v1/account`, `POST /v1/accounts`, `GET /v1/accounts`, `GET /v1/accounts/{id}`, `POST /v1/accounts/{id}`, `DELETE /v1/accounts/{id}`, `POST /v1/account_links`, `POST /v1/account_sessions`, account capabilities, people/persons, external accounts, transfers/reversals, payouts, application fees/refunds | Partial | Persist local connected-account profiles, capability status, person evidence, bank-account evidence, transfers, transfer reversals, payouts, and application-fee refunds. Account links, account sessions, and login links return local URLs/client secrets. Account deletion returns a local deletion marker. Request traces preserve `Stripe-Account` routing evidence, and local Connect evidence can emit `transfer.*`, `payout.*`, and `application_fee.refunded` webhooks. KYC, identity verification, bank verification, real onboarding, balance movement, account closure, and settlement behavior are not modeled. | -| Refunds | `POST /v1/refunds`, `GET /v1/refunds`, `GET/POST /v1/refunds/{id}`, `POST /v1/refunds/{id}/cancel` | Partial | Create/list/retrieve/update local refund evidence against an invoice, payment intent, or charge-like ID. Creation emits `charge.refunded` and `charge.refund.updated`; status updates and test-clock settlement emit `charge.refund.updated`. Refund fixtures can seed `pending` and settle on `settle_at`/`available_on`. Balance transactions and processor accounting are outside the modeled subset. | -| Credit notes | `POST /v1/credit_notes`, `GET /v1/credit_notes`, `GET /v1/credit_notes/{id}`, `POST /v1/credit_notes/{id}/void` | Partial | Create/list/retrieve/void local credit note evidence for an invoice and emit `credit_note.created` / `credit_note.voided`. Line-level tax, discount, PDF, and customer-balance math are outside the modeled subset. | +| 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. Omitting `amount` refunds the full remaining refundable balance on the invoice or payment intent; requested amounts cannot exceed that balance. 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}`, `GET /v1/credit_notes/{id}/lines`, `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`. `memo`, `out_of_band_amount`, and `refund_amount` are accepted, persisted, and echoed with derived `credit_amount`; `out_of_band_amount` is external settlement and does not change customer cash balance. `GET .../lines` returns one line derived from the stored amount and memo/reason. 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`. | diff --git a/docs/COMPATIBILITY_TRACKING.md b/docs/COMPATIBILITY_TRACKING.md index 0672349..8d47c8c 100644 --- a/docs/COMPATIBILITY_TRACKING.md +++ b/docs/COMPATIBILITY_TRACKING.md @@ -1,6 +1,6 @@ # Compatibility Tracking -Status date: 2026-05-12 +Status date: 2026-08-29 This document defines how Billtap tracks Stripe API compatibility as a measurable, long-running body of work. The public claim is the generated @@ -23,6 +23,10 @@ The command writes: - `stripe-api-inventory.json`: machine-readable source for agents and CI. - `stripe-api-inventory.md`: reviewable compatibility table for humans. +The current baseline was measured against Stripe OpenAPI `2026-04-22.dahlia` +(stripe/openapi tag `v2261`); use the same pinned tag when regenerating so +family deltas stay comparable across PRs. + The runtime also carries a generated known-route catalog from the same public Stripe OpenAPI lane. That catalog is diagnostic-only: matching a route there returns `unsupported_endpoint` for unimplemented operations, but it does not @@ -84,7 +88,7 @@ The long-running expansion goal is at least 90% OpenAPI operation coverage at The concrete target and chunk plan live in `docs/STRIPE_COMPATIBILITY_90_TARGET.md`. In short: -- current baseline: `160 / 587`, `27.3%` +- current baseline: `200 / 587`, `34.1%` - target: at least `529 / 587`, `90.0%` - maximum remaining `L0`: `58 / 587` - P0/P1 billing-lab flows still need deeper `L3-L6` evidence, not just broad diff --git a/docs/GATE_STATUS.md b/docs/GATE_STATUS.md index 90d73a6..33d83ad 100644 --- a/docs/GATE_STATUS.md +++ b/docs/GATE_STATUS.md @@ -16,9 +16,9 @@ This is the public gate snapshot. Internal adoption evidence and raw handoff not | G9 Release Candidate | Passed locally | Dockerfile, sample app, public examples, release checklist | | G10 Fixture Integration Smoke | Passed locally | Fixture apply/snapshot/assert APIs support deterministic integration setup | | G11 Assertion Ergonomics | Passed locally | Structured pass/fail fixture assertions and fixture-scoped snapshots | -| G12 Public Release Readiness | Passed locally | Public claims are tied to tests/scorecard cases; scorecard corpus has 49 release-blocking cases; Apache-2.0 `LICENSE` and `NOTICE` are present | +| G12 Public Release Readiness | Passed locally | Public claims are tied to tests/scorecard cases; scorecard corpus has 50 release-blocking cases; Apache-2.0 `LICENSE` and `NOTICE` are present | | G13 Stripe API Expansion | In progress | Roadmap defines compatibility levels and endpoint-family priorities; OpenAPI inventory generator and optional workflow write JSON/Markdown coverage artifacts | -| G14 Stripe API 90% Program | In progress | `docs/STRIPE_COMPATIBILITY_90_TARGET.md` defines 90% L1+ target, current 160/587 baseline, family thresholds, and chunk plan | +| G14 Stripe API 90% Program | In progress | `docs/STRIPE_COMPATIBILITY_90_TARGET.md` defines 90% L1+ target, current 200/587 baseline, family thresholds, and chunk plan | ## Current Public Claim @@ -30,25 +30,27 @@ under Apache-2.0. ## Current Compatibility Evidence - Scorecard version: `l3-public-readiness-v7` -- Release-blocking scorecard cases: 49 +- Release-blocking scorecard cases: 50 - Required scorecard release result: `mismatch=0`, `error=0`, `passed=true` - Coverage focus: request validation, protocol parameter acceptance, idempotency mismatch, deterministic checkout payment-error aliases, direct intent state machines, and billing lifecycle retry/renewal mutations -- OpenAPI operation baseline: `160 / 587`, `27.3%` +- OpenAPI operation baseline: `200 / 587`, `34.1%` - Long-running OpenAPI operation target: at least `529 / 587`, `90.0%`, at `L1+` with deeper P0/P1 behavior gates ## Last Local Code Verification -Verified on 2026-05-12 from branch `simulation-capacity-backlog`: +Verified on 2026-08-29 from branch `main`: - `go test ./... -count=1` -- `go run ./cmd/billtap compatibility scorecard --output-dir /tmp/billtap-scorecard-simulation-impl` - - result: `imported=49 skipped=1 unsupported=1 mismatch=0 error=0` -- `go run ./cmd/billtap compatibility inventory --openapi /tmp/stripe-openapi-spec3.json --output-dir /tmp/billtap-inventory-simulation-impl --source stripe-openapi-local` - - result: `operations=587 implemented=160 inventory_only=427 schema_validated=587 implemented_percent=27.3` -- Current branch verification covers Go tests, compatibility scorecard, inventory generation, whitespace checks, and forbidden internal keyword scan. +- `go run ./cmd/billtap compatibility scorecard --output-dir /tmp/billtap-compatibility` + - result: `imported=50 skipped=1 unsupported=1 mismatch=0 error=0` +- `go run ./cmd/billtap compatibility inventory --openapi --output-dir /tmp/billtap-inventory --source stripe/openapi-v2261-2026-04-22.dahlia` + - result: `operations=587 implemented=200 inventory_only=387 schema_validated=587 implemented_percent=34.1` +- Current branch verification covers Go tests, compatibility scorecard, and + inventory generation; `gofmt -l .` is clean. The forbidden internal keyword + scan runs in CI with the configured keyword list. - Container/image release gates run after merge through the existing release workflow. Release verification should still be rerun on the final release branch or tag. diff --git a/docs/PUBLIC_RELEASE_READINESS.md b/docs/PUBLIC_RELEASE_READINESS.md index a603962..23b56f2 100644 --- a/docs/PUBLIC_RELEASE_READINESS.md +++ b/docs/PUBLIC_RELEASE_READINESS.md @@ -1,6 +1,6 @@ # Public Release Readiness -Status date: 2026-05-12 +Status date: 2026-08-29 Billtap has a clear community-facing strength: it is a stateful local billing lab for deterministic subscription, fixture, scenario, and webhook reliability @@ -84,10 +84,10 @@ The scorecard evidence should state: - imported/skipped/unsupported/mismatch/error counts - whether `passed` is `true` -Current scorecard evidence on 2026-05-12: +Current scorecard evidence on 2026-08-29: - Scorecard version: `l3-public-readiness-v7` -- Scorecard result: `imported=49 skipped=1 unsupported=1 mismatch=0 error=0` +- Scorecard result: `imported=50 skipped=1 unsupported=1 mismatch=0 error=0` - Apache-2.0 `LICENSE`, `NOTICE`, `package.json`, and `package-lock.json` metadata are aligned. diff --git a/docs/SIMULATION_CAPACITY_BACKLOG.md b/docs/SIMULATION_CAPACITY_BACKLOG.md index ca26ea9..df74e2f 100644 --- a/docs/SIMULATION_CAPACITY_BACKLOG.md +++ b/docs/SIMULATION_CAPACITY_BACKLOG.md @@ -1,6 +1,6 @@ # Simulation Capacity Backlog -Status date: 2026-05-12 +Status date: 2026-08-29 This backlog turns production-regression learnings into public, product-neutral Billtap capability work. It is intentionally broader than the current @@ -26,8 +26,8 @@ these public capability rows in their own private docs. Billtap currently has a stateful Stripe-like local subset with: - customers, products, prices, customer/product/price/subscription/invoice/ - payment-intent search, coupons, promotion codes, checkout sessions, - subscriptions, subscription schedules, invoices, invoice preview, + payment-intent search, coupons, promotion codes, tax rates, checkout + sessions, subscriptions, subscription schedules, invoices, invoice preview, payment intents, setup intents, payment methods, refunds, credit notes, disputes, cash balance, test clocks, Connect evidence, webhook endpoints, and events @@ -36,7 +36,7 @@ Billtap currently has a stateful Stripe-like local subset with: - webhook retry, duplicate, delay, out-of-order, replay, historical replay, and delivery attempt evidence - diagnostic bundles, request traces, timeline evidence, and dashboard views -- OpenAPI inventory baseline: `160 / 587` implemented operations, `27.3%` +- OpenAPI inventory baseline: `200 / 587` implemented operations, `34.1%` ## P0 Regression-Paired Backlog diff --git a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md index e7f92cc..a42352d 100644 --- a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md +++ b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md @@ -1,6 +1,6 @@ # Stripe API Compatibility Roadmap -Status date: 2026-05-12 +Status date: 2026-08-29 This roadmap extends Billtap beyond one SaaS adoption path. The goal is to make Billtap progressively Stripe-compatible across a measured, versioned API @@ -63,7 +63,7 @@ The concrete expansion target is defined in `docs/STRIPE_COMPATIBILITY_90_TARGET.md`: - reach at least `90.0%` OpenAPI operation coverage at `L1+` -- move from the current `160 / 587` baseline to at least `529 / 587` +- move from the current `200 / 587` baseline to at least `529 / 587` - keep P0/P1 billing-lab families on deeper `L3-L6` gates where behavior matters - use broad `L1-L2` validation/fixture coverage for safe low-state and @@ -273,13 +273,13 @@ questions quickly. "inventory_version": "stripe-api-inventory-v2", "openapi_version": "3.0.0", "stripe_api_version": "2026-04-22.dahlia", - "source": "stripe/openapi master", - "generated_at": "2026-05-12T00:00:00Z", + "source": "stripe/openapi v2261", + "generated_at": "2026-08-29T00:00:00Z", "summary": { "total_operations": 587, - "implemented_operations": 160, - "inventory_only_operations": 427, - "implemented_percent": 27.3, + "implemented_operations": 200, + "inventory_only_operations": 387, + "implemented_percent": 34.1, "families": [ { "family": "connect", diff --git a/docs/STRIPE_COMPATIBILITY_90_TARGET.md b/docs/STRIPE_COMPATIBILITY_90_TARGET.md index 47287b5..c536309 100644 --- a/docs/STRIPE_COMPATIBILITY_90_TARGET.md +++ b/docs/STRIPE_COMPATIBILITY_90_TARGET.md @@ -1,6 +1,6 @@ # Stripe Compatibility 90% Target -Status date: 2026-05-12 +Status date: 2026-08-29 Billtap's long-running Stripe API compatibility target is measurable coverage of at least 90% of the public Stripe OpenAPI operation inventory, without @@ -11,8 +11,9 @@ claiming that every operation has deep payment-processing behavior. The 90% target is based on generated `stripe-api-inventory.json`: - **Overall target:** `summary.implemented_percent >= 90.0`. -- **Current baseline:** `160 / 587` operations, `27.3%`, using Stripe OpenAPI - `2026-04-22.dahlia` from the local OpenAPI snapshot on 2026-05-12. +- **Current baseline:** `200 / 587` operations, `34.1%`, using Stripe OpenAPI + `2026-04-22.dahlia` (stripe/openapi tag `v2261`), re-measured on 2026-08-29. + All four P0 families are at 100%. - **Minimum target count:** `529 / 587` operations at `L1` or higher. - **Remaining inventory-only budget:** at most `58 / 587` operations at `L0`. @@ -52,20 +53,21 @@ toward the 90% target after it has an explicit tested claim at `L1+`. ## Baseline By Family -Latest measured baseline from the local Stripe OpenAPI snapshot on 2026-05-12: +Latest measured baseline from the Stripe OpenAPI `2026-04-22.dahlia` snapshot +(stripe/openapi tag `v2261`), re-measured on 2026-08-29: | Priority | Family | Total | Implemented | Coverage | 90% target count | First target | | --- | --- | ---: | ---: | ---: | ---: | --- | | P0 | webhooks | 7 | 7 | 100.0% | 7 | Expand connected-account routing, thin event fixtures, and replay evidence. | -| P0 | checkout | 6 | 3 | 50.0% | 6 | Close checkout route gaps and SDK smoke. | -| P0 | billing | 39 | 12 | 30.8% | 36 | Add renewal, trial, dunning, subscription schedule, coupon, and credit-note scenarios. | -| P0 | billing_portal | 5 | 1 | 20.0% | 5 | Add portal configurations and session retrieval fixtures. | -| P1 | catalog | 54 | 19 | 35.2% | 49 | Add coupon, promotion code, tax-rate, and product/price search validation. | -| P1 | customers | 31 | 11 | 35.5% | 28 | Add OpenAPI-backed validation, search/list parity, and payment source fixtures. | -| P1 | payments | 41 | 15 | 36.6% | 37 | Add PaymentIntent and SetupIntent create/confirm/capture/cancel state machines. | +| P0 | checkout | 6 | 6 | 100.0% | 6 | Deepen SDK smoke and hosted-page parity evidence. | +| P0 | billing | 39 | 39 | 100.0% | 36 | Deepen dunning, schedule, and credit-note scenario evidence. | +| P0 | billing_portal | 5 | 5 | 100.0% | 5 | Deepen portal configuration fixtures and hosted portal rendering evidence. | +| P1 | catalog | 54 | 28 | 51.9% | 49 | Add coupon, promotion code, tax-rate, and product/price search validation. | +| P1 | customers | 31 | 12 | 38.7% | 28 | Add OpenAPI-backed validation, search/list parity, and payment source fixtures. | +| P1 | payments | 41 | 23 | 56.1% | 37 | Add PaymentIntent and SetupIntent create/confirm/capture/cancel state machines. | | P1 | connect | 53 | 53 | 100.0% | 48 | Deepen Connect SDK/adoption smoke, connected-account webhook routing, and v2 Core account inventory evidence. | -| P1 | payment_history | 30 | 13 | 43.3% | 27 | Add charge, refund, balance transaction, dispute, and payment history evidence. | -| P3 | auxiliary | 321 | 10 | 3.1% | 289 | Keep inventory visible and add schema/fixture smoke only when adoption requires it. | +| P1 | payment_history | 30 | 14 | 46.7% | 27 | Add charge, refund, balance transaction, dispute, and payment history evidence. | +| P3 | auxiliary | 321 | 13 | 4.0% | 289 | Keep inventory visible and add schema/fixture smoke only when adoption requires it. | ## PR Chunk Plan @@ -127,9 +129,40 @@ payment-method attach/detach. T10 also does not increase `summary.implemented_operations` by itself. It raises confidence and levels for already counted operations; new operation coverage must come from T3-T9. The planned T3-T9 delta is intentionally larger -than the `+369` operations needed to move the current `160 / 587` baseline to +than the `+329` operations needed to move the current `200 / 587` baseline to the `529 / 587` target. +The billing-completion wave on 2026-08-29 raised the generated inventory from +`195 / 587` (`33.2%`) to `200 / 587` (`34.1%`): single draft-invoice line +update, invoice `attach_payment`, subscription `migrate` (billing-mode +evidence), PaymentIntent metadata/description update, and credit-note lines. +Billing closes to `39 / 39`, so all four P0 families are at 100%; further +count growth now comes from P1 families and the auxiliary waves. + +The P0 checkout/invoice wave on 2026-08-29 raised the generated inventory from +`187 / 587` (`31.9%`) to `195 / 587` (`33.2%`): checkout session update and +line_items listing, draft invoice update/delete and add_lines/update_lines/ +remove_lines, and product deletion. Checkout closes to `6 / 6`, billing moves +to `36 / 39`, and catalog to `28 / 54`; three of the four P0 families are now +at 100%. + +The P0 portal/items wave on 2026-08-29 raised the generated inventory from +`175 / 587` (`29.8%`) to `187 / 587` (`31.9%`): billing portal configurations +create/list/retrieve/update, subscription item list/retrieve/update, nested +customer subscription discount retrieve/delete, and claims for the +already-tested invoice `void`/`mark_uncollectible` and checkout `expire` +routes. billing_portal closed to `5 / 5`, billing moved to `31 / 39`, and +checkout to `4 / 6`. + +The tax and invoice-depth waves landed between the 2026-05-12 and 2026-08-29 +measurements raised the generated inventory from `160 / 587` (`27.3%`) to +`175 / 587` (`29.8%`): tax-rate create/list/retrieve/update, customer tax-ID +create/list/retrieve/delete, `POST /v1/invoices/{id}/send`, and invoice-item +list/create/retrieve plus manual invoice create/finalize/lines claims. Billing +is now `24 / 39`, catalog `27 / 54`, and auxiliary `13 / 321`; the previous +baseline table had drifted behind its own summary, so the family rows above +moved further than these operation deltas alone suggest. + ## Derived Gate Checks The overall `summary.implemented_percent` check is necessary but not sufficient. diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png new file mode 100644 index 0000000..8b81a5e Binary files /dev/null and b/docs/assets/social-preview.png differ diff --git a/internal/api/api.go b/internal/api/api.go index ad97632..15e1dfc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -113,6 +113,8 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/checkout/sessions", h.handleCheckoutSessions) h.mux.HandleFunc("/v1/checkout/sessions/", h.handleCheckoutSession) h.mux.HandleFunc("/v1/billing_portal/sessions", h.handleBillingPortalSessions) + h.mux.HandleFunc("/v1/billing_portal/configurations", h.handleBillingPortalConfigurations) + h.mux.HandleFunc("/v1/billing_portal/configurations/", h.handleBillingPortalConfiguration) h.mux.HandleFunc("/v1/subscriptions", h.handleSubscriptions) h.mux.HandleFunc("/v1/subscriptions/search", h.handleSubscriptionSearch) h.mux.HandleFunc("/v1/subscriptions/", h.handleSubscription) @@ -210,25 +212,7 @@ func (h *Handler) methodNotAllowed(w http.ResponseWriter, r *http.Request, allow methodNotAllowed(w, allow) } -func implementedWithoutCompatClaim(method string, path string) bool { - if method != http.MethodPost { - return false - } - if strings.HasPrefix(path, "/v1/invoices/") && (strings.HasSuffix(path, "/void") || strings.HasSuffix(path, "/mark_uncollectible")) { - parts := strings.Split(strings.TrimPrefix(path, "/v1/invoices/"), "/") - return len(parts) == 2 && parts[0] != "" - } - if strings.HasPrefix(path, "/v1/checkout/sessions/") && strings.HasSuffix(path, "/expire") { - id := strings.TrimSuffix(strings.TrimPrefix(path, "/v1/checkout/sessions/"), "/expire") - return id != "" && !strings.Contains(id, "/") - } - return false -} - func (h *Handler) writeKnownUnsupportedRoute(w http.ResponseWriter, r *http.Request) bool { - if implementedWithoutCompatClaim(r.Method, r.URL.Path) { - return false - } route, ok := h.knownRoutes.Lookup(r.Method, r.URL.Path) if !ok { return false @@ -473,6 +457,15 @@ func (h *Handler) handleCustomerSubscription(w http.ResponseWriter, r *http.Requ } if hasNested { if nested == "discount" { + subscription, err := h.billing.GetSubscription(r.Context(), subscriptionID) + if err != nil { + writeResult(w, nil, err) + return + } + if subscription.CustomerID != customerID { + writeResult(w, nil, billing.ErrNotFound) + return + } h.handleSubscriptionDiscount(w, r, subscriptionID) return } @@ -565,8 +558,14 @@ func (h *Handler) handleProduct(w http.ResponseWriter, r *http.Request) { Metadata: p.metadata(), }) writeResult(w, stripeProduct(product), err) + case http.MethodDelete: + if err := h.billing.DeleteProduct(r.Context(), id); err != nil { + writeResult(w, nil, err) + return + } + writeJSON(w, http.StatusOK, stripeDeleted(id, "product")) default: - h.methodNotAllowed(w, r, "GET, POST") + h.methodNotAllowed(w, r, "GET, POST, DELETE") } } @@ -1689,12 +1688,29 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) h.expireCheckoutSession(w, r, id) return } + if strings.HasSuffix(rest, "/line_items") { + id := strings.TrimSuffix(rest, "/line_items") + if id == "" || strings.Contains(id, "/") { + h.notFound(w, r) + return + } + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + h.handleCheckoutSessionLineItems(w, r, id) + return + } if rest == "" || strings.Contains(rest, "/") { h.notFound(w, r) return } + if r.Method == http.MethodPost { + h.handleCheckoutSessionUpdate(w, r, rest) + return + } if r.Method != http.MethodGet { - h.methodNotAllowed(w, r, "GET") + h.methodNotAllowed(w, r, "GET, POST") return } session, err := h.billing.GetCheckoutSession(r.Context(), rest) @@ -1710,6 +1726,110 @@ func (h *Handler) handleCheckoutSession(w http.ResponseWriter, r *http.Request) writeResult(w, payload, err) } +// handleCheckoutSessionUpdate implements the bounded POST /v1/checkout/sessions/{id} +// subset: metadata merge plus line_items[N][quantity] overrides on open sessions. +func (h *Handler) handleCheckoutSessionUpdate(w http.ResponseWriter, r *http.Request, id string) { + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateCheckoutSessionUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + session, err := h.billing.GetCheckoutSession(r.Context(), id) + if err != nil { + writeResult(w, nil, err) + return + } + items := append([]billing.LineItem{}, session.LineItems...) + overridden := false + for idx := range items { + key := fmt.Sprintf("line_items[%d][quantity]", idx) + if !p.has(key) { + continue + } + quantity := p.int64(key) + if quantity <= 0 { + writeError(w, http.StatusBadRequest, invalidParam(key, "Must be at least 1.")) + return + } + items[idx].Quantity = quantity + overridden = true + } + for key := range p.values { + matches := checkoutSessionLineItemQuantityRE.FindStringSubmatch(key) + if matches == nil { + continue + } + index, parseErr := strconv.Atoi(matches[1]) + if parseErr != nil || index >= len(items) { + writeError(w, http.StatusBadRequest, invalidParam(key, "No such line item on this session.")) + return + } + } + var itemOverrides []billing.LineItem + if overridden { + itemOverrides = items + } + updated, err := h.billing.UpdateCheckoutSessionDetails(r.Context(), id, p.metadata(), itemOverrides) + if err == nil { + updated.URL = h.absoluteURL(r, updated.URL) + } + writeResult(w, h.stripeCheckoutSession(r, updated), err) +} + +// handleCheckoutSessionLineItems returns expanded line items for a session +// (GET /v1/checkout/sessions/{id}/line_items). Line amounts are pre-discount; +// per-line discount/tax splits are not modeled. +func (h *Handler) handleCheckoutSessionLineItems(w http.ResponseWriter, r *http.Request, id string) { + session, err := h.billing.GetCheckoutSession(r.Context(), id) + if err != nil { + writeResult(w, nil, err) + return + } + data := make([]map[string]any, 0, len(session.LineItems)) + for idx, item := range session.LineItems { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + amount := int64(0) + currency := "usd" + var priceObject map[string]any + if price, priceErr := h.billing.GetPrice(r.Context(), item.PriceID); priceErr == nil { + amount = price.UnitAmount * quantity + currency = price.Currency + priceObject = stripePrice(price) + } else { + priceObject = map[string]any{ + "id": item.PriceID, + "object": billing.ObjectPrice, + "currency": currency, + "unit_amount": 0, + "livemode": false, + } + } + data = append(data, map[string]any{ + "id": fmt.Sprintf("li_%s_%d", session.ID, idx), + "object": "item", + "currency": strings.ToLower(currency), + "quantity": quantity, + "amount_subtotal": amount, + "amount_total": amount, + "amount_discount": 0, + "amount_tax": 0, + "description": nil, + "price": priceObject, + "discounts": []map[string]any{}, + "taxes": []map[string]any{}, + "metadata": map[string]string{}, + }) + } + writeJSON(w, http.StatusOK, stripeListFromRequest(r, data)) +} + func (h *Handler) handleBillingPortalSessions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -2154,6 +2274,15 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { h.handleSubscriptionResume(w, r, subscriptionID) return } + if strings.HasSuffix(id, "/migrate") { + subscriptionID := strings.TrimSuffix(id, "/migrate") + if subscriptionID == "" || strings.Contains(subscriptionID, "/") { + h.notFound(w, r) + return + } + h.handleSubscriptionMigrate(w, r, subscriptionID) + return + } if id == "" || strings.Contains(id, "/") { h.notFound(w, r) return @@ -2322,6 +2451,41 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { } } +// handleSubscriptionMigrate records a billing-mode migration request +// (POST /v1/subscriptions/{id}/migrate) as subscription metadata evidence. +// Flexible-billing proration recalculation itself is not modeled. +func (h *Handler) handleSubscriptionMigrate(w http.ResponseWriter, r *http.Request, subscriptionID string) { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateSubscriptionMigrate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + subscription, err := h.billing.GetSubscription(r.Context(), subscriptionID) + if err != nil { + writeResult(w, nil, err) + return + } + metadata := map[string]string{"billtap_billing_mode": p.string("billing_mode[type]")} + if discounts := p.string("billing_mode[flexible][proration_discounts]"); discounts != "" { + metadata["billtap_proration_discounts"] = discounts + } + updated, err := h.billing.PatchSubscription(r.Context(), subscription.ID, billing.SubscriptionPatch{ + Metadata: metadata, + TimelineAction: "customer.subscription.migrated", + TimelineMessage: "Subscription billing-mode migration recorded", + TimelineSource: "stripe_compat_migrate", + }) + writeResult(w, h.stripeSubscription(r, updated), err) +} + func (h *Handler) handleSubscriptionResume(w http.ResponseWriter, r *http.Request, subscriptionID string) { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -2353,8 +2517,32 @@ func (h *Handler) handleSubscriptionResume(w http.ResponseWriter, r *http.Reques } func (h *Handler) handleSubscriptionItems(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + p := params{values: firstValues(r.URL.Query())} + if err := validateSubscriptionItemList(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + subscriptions, err := h.billing.ListSubscriptions(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filter := strings.TrimSpace(r.URL.Query().Get("subscription")) + data := make([]map[string]any, 0) + for _, subscription := range subscriptions { + if filter != "" && subscription.ID != filter { + continue + } + for idx, item := range subscription.Items { + data = append(data, h.stripeSubscriptionItem(r, subscription, item, idx)) + } + } + writeJSON(w, http.StatusOK, stripeListFromRequest(r, data)) + return + } if r.Method != http.MethodPost { - h.methodNotAllowed(w, r, "POST") + h.methodNotAllowed(w, r, "GET, POST") return } p, err := parseParams(r) @@ -2455,10 +2643,117 @@ func (h *Handler) handleSubscriptionItem(w http.ResponseWriter, r *http.Request) h.notFound(w, r) return } - if r.Method != http.MethodDelete { - h.methodNotAllowed(w, r, "DELETE") + switch r.Method { + case http.MethodGet: + subscription, idx, found, err := h.findSubscriptionItem(r, id) + if err != nil { + writeResult(w, nil, err) + return + } + if !found { + writeResult(w, nil, billing.ErrNotFound) + return + } + writeJSON(w, http.StatusOK, h.stripeSubscriptionItem(r, subscription, subscription.Items[idx], idx)) + case http.MethodPost: + h.handleSubscriptionItemUpdate(w, r, id) + case http.MethodDelete: + h.handleSubscriptionItemDelete(w, r, id) + default: + h.methodNotAllowed(w, r, "GET, POST, DELETE") + } +} + +func (h *Handler) handleSubscriptionItemUpdate(w http.ResponseWriter, r *http.Request, id string) { + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateSubscriptionItemUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + subscription, idx, found, err := h.findSubscriptionItem(r, id) + if err != nil { + writeResult(w, nil, err) + return + } + if !found { + writeResult(w, nil, billing.ErrNotFound) return } + // Resolve item tax_rates before mutation (evidence only; totals use subscription rates). + var itemTaxRates []billing.AppliedTaxRate + if p.hasDefaultTaxRatesParam("tax_rates") { + itemTaxRates, err = h.appliedTaxRatesFromParams(p, "tax_rates") + if err != nil { + writeResult(w, nil, err) + return + } + } + item := subscription.Items[idx] + if priceID := p.first("price", "price_id"); priceID != "" { + if err := validatePriceExists(h.billing.GetPrice(r.Context(), priceID)); err != nil { + writeResult(w, nil, err) + return + } + item.PriceID = priceID + } + if p.has("quantity") { + item.Quantity = p.int64Default("quantity", item.Quantity) + } + items := append([]billing.LineItem{}, subscription.Items...) + items[idx] = item + + prorationBehavior := p.string("proration_behavior") + var updated billing.Subscription + if prorationBehavior == "always_invoice" || prorationBehavior == "create_prorations" { + prorationDate := time.Time{} + if raw := p.string("proration_date"); raw != "" { + if seconds, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil { + prorationDate = time.Unix(seconds, 0).UTC() + } + } + result, err := h.billing.UpdateSubscriptionItemsWithProration(r.Context(), billing.SubscriptionProrationRequest{ + SubscriptionID: subscription.ID, + NewItems: items, + ProrationBehavior: prorationBehavior, + ProrationDate: prorationDate, + BillingCycleAnchor: "", + }) + if err != nil { + writeResult(w, nil, err) + return + } + if result.Invoice != nil && result.Invoice.ID != "" { + h.emitRenewalWebhooks(r, result.PaymentResult, "subscription_update") + } else { + h.emitSubscriptionWebhook(r, "customer.subscription.updated", result.Subscription, webhooks.SourceAPI) + } + updated = result.Subscription + } else { + // none / unspecified: item replacement only (legacy path; no proration invoice). + updated, err = h.billing.PatchSubscription(r.Context(), subscription.ID, billing.SubscriptionPatch{ + Items: items, + ReplaceItems: true, + }) + if err != nil { + writeResult(w, nil, err) + return + } + } + itemResp := h.stripeSubscriptionItem(r, updated, updated.Items[idx], idx) + if p.hasDefaultTaxRatesParam("tax_rates") { + itemResp["tax_rates"] = h.stripeTaxRateObjects(itemTaxRates) + } + if meta := p.metadata(); len(meta) > 0 { + itemResp["metadata"] = meta + } + writeJSON(w, http.StatusOK, itemResp) +} + +func (h *Handler) handleSubscriptionItemDelete(w http.ResponseWriter, r *http.Request, id string) { p, err := parseParamsAllowingDeleteBody(r) if err != nil { writeError(w, http.StatusBadRequest, err) @@ -3576,12 +3871,157 @@ func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { writeResult(w, stripeListFromRequest(r, payments), nil) return } + if len(parts) == 2 && parts[1] == "attach_payment" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceAttachPayment(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.AttachInvoicePayment(r.Context(), id, p.string("payment_intent"), p.string("payment_record")) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if len(parts) == 3 && parts[1] == "lines" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceLineItemUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + var amount *int64 + if p.has("amount") { + value := p.int64("amount") + amount = &value + } + var description *string + if p.has("description") { + value := p.string("description") + description = &value + } + invoice, err := h.billing.UpdateInvoiceLine(r.Context(), id, parts[2], amount, description, p.metadata()) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if len(parts) == 2 && parts[1] == "add_lines" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceAddLines(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + lines, err := invoiceLineAdditionsFromParams(p) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.AddInvoiceLines(r.Context(), id, lines) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if len(parts) == 2 && parts[1] == "update_lines" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceLineUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + patches := invoiceLinePatchesFromParams(p) + if len(patches) == 0 { + writeError(w, http.StatusBadRequest, missingParam("line_items[0][id]")) + return + } + var invoice billing.Invoice + for _, line := range patches { + var err error + invoice, err = h.billing.UpdateInvoiceLine(r.Context(), id, line.id, line.amount, line.description, nil) + if err != nil { + writeResult(w, nil, err) + return + } + } + writeResult(w, h.stripeInvoice(r.Context(), invoice), nil) + return + } + if len(parts) == 2 && parts[1] == "remove_lines" { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceLineRemove(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + lineIDs := invoiceLineIDsFromParams(p) + if len(lineIDs) == 0 { + writeError(w, http.StatusBadRequest, missingParam("line_items[0][id]")) + return + } + invoice, err := h.billing.RemoveInvoiceLines(r.Context(), id, lineIDs) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } if len(parts) != 1 { h.notFound(w, r) return } + if r.Method == http.MethodPost { + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateInvoiceUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + invoice, err := h.billing.UpdateInvoiceDetails(r.Context(), id, invoiceMetadataFromParams(p)) + writeResult(w, h.stripeInvoice(r.Context(), invoice), err) + return + } + if r.Method == http.MethodDelete { + if err := h.billing.DeleteInvoice(r.Context(), id); err != nil { + writeResult(w, nil, err) + return + } + writeJSON(w, http.StatusOK, stripeDeleted(id, billing.ObjectInvoice)) + return + } if r.Method != http.MethodGet { - h.methodNotAllowed(w, r, "GET") + h.methodNotAllowed(w, r, "GET, POST, DELETE") return } invoice, err := h.billing.GetInvoice(r.Context(), id) @@ -3737,6 +4177,31 @@ func (h *Handler) handleCreditNote(w http.ResponseWriter, r *http.Request) { return } id, action, hasAction := strings.Cut(rest, "/") + if hasAction && action == "lines" { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + note, err := h.billing.GetCreditNote(r.Context(), id) + if err != nil { + writeResult(w, nil, err) + return + } + description := firstNonEmptyString(note.Memo, note.Reason, "Credit note") + data := []map[string]any{{ + "id": "cnli_" + note.ID, + "object": "credit_note_line_item", + "amount": -note.Amount, + "description": description, + "credit_note": note.ID, + "invoice": note.InvoiceID, + "quantity": 1, + "unit_amount": -note.Amount, + "livemode": false, + }} + writeJSON(w, http.StatusOK, stripeListFromRequest(r, data)) + return + } if hasAction && action == "void" { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -3861,8 +4326,29 @@ func (h *Handler) handlePaymentIntent(w http.ResponseWriter, r *http.Request) { return } if !hasAction { + if r.Method == http.MethodPost { + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validatePaymentIntentUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + metadata := p.metadata() + if description := p.string("description"); description != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["billtap_description"] = description + } + intent, err := h.billing.UpdatePaymentIntentDetails(r.Context(), id, metadata) + writeResult(w, stripePaymentIntent(intent), err) + return + } if r.Method != http.MethodGet { - h.methodNotAllowed(w, r, "GET") + h.methodNotAllowed(w, r, "GET, POST") return } paymentIntent, err := h.billing.GetPaymentIntent(r.Context(), id) @@ -6105,6 +6591,96 @@ func copyPaymentSettingsMetadata(metadata map[string]string, p params) map[strin return metadata } +// invoiceLineIndexes collects the line_items[N] indexes present in the params. +func invoiceLineIndexes(p params) map[int]bool { + indexes := map[int]bool{} + for key := range p.values { + matches := invoiceLineItemParamRE.FindStringSubmatch(key) + if matches == nil { + continue + } + if index, err := strconv.Atoi(matches[1]); err == nil { + indexes[index] = true + } + } + return indexes +} + +func sortedInvoiceLineIndexes(p params) []int { + indexes := invoiceLineIndexes(p) + sorted := make([]int, 0, len(indexes)) + for index := range indexes { + sorted = append(sorted, index) + } + sort.Ints(sorted) + return sorted +} + +// invoiceLineAdditionsFromParams builds new draft-invoice lines from +// line_items[N][amount]/[description]/[currency] form params (add_lines). +func invoiceLineAdditionsFromParams(p params) ([]billing.InvoiceItem, error) { + sorted := sortedInvoiceLineIndexes(p) + if len(sorted) == 0 { + return nil, missingParam("line_items[0][amount]") + } + lines := make([]billing.InvoiceItem, 0, len(sorted)) + for _, index := range sorted { + amountKey := fmt.Sprintf("line_items[%d][amount]", index) + if !p.has(amountKey) { + return nil, missingParam(amountKey) + } + lines = append(lines, billing.InvoiceItem{ + Amount: p.int64(amountKey), + Currency: p.string(fmt.Sprintf("line_items[%d][currency]", index)), + Description: p.string(fmt.Sprintf("line_items[%d][description]", index)), + }) + } + return lines, nil +} + +type invoiceLinePatch struct { + id string + amount *int64 + description *string +} + +// invoiceLinePatchesFromParams builds per-line patches keyed by +// line_items[N][id] with optional amount/description (update_lines). +func invoiceLinePatchesFromParams(p params) []invoiceLinePatch { + patches := make([]invoiceLinePatch, 0) + for _, index := range sortedInvoiceLineIndexes(p) { + idKey := fmt.Sprintf("line_items[%d][id]", index) + if !p.has(idKey) { + continue + } + patch := invoiceLinePatch{id: p.string(idKey)} + amountKey := fmt.Sprintf("line_items[%d][amount]", index) + if p.has(amountKey) { + amount := p.int64(amountKey) + patch.amount = &amount + } + descriptionKey := fmt.Sprintf("line_items[%d][description]", index) + if p.has(descriptionKey) { + description := p.string(descriptionKey) + patch.description = &description + } + patches = append(patches, patch) + } + return patches +} + +// invoiceLineIDsFromParams collects line_items[N][id] values (remove_lines). +func invoiceLineIDsFromParams(p params) []string { + ids := make([]string, 0) + for _, index := range sortedInvoiceLineIndexes(p) { + idKey := fmt.Sprintf("line_items[%d][id]", index) + if p.has(idKey) { + ids = append(ids, p.string(idKey)) + } + } + return ids +} + func paymentIntentMetadata(p params) map[string]string { metadata := p.metadata() outcome := firstNonEmptyString( diff --git a/internal/api/billing_completion_test.go b/internal/api/billing_completion_test.go new file mode 100644 index 0000000..e1344c9 --- /dev/null +++ b/internal/api/billing_completion_test.go @@ -0,0 +1,249 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/hckim/billtap/internal/billing" +) + +func setupDraftInvoiceWithLine(t *testing.T, handler http.Handler) (customer billing.Customer, invoiceID, lineID string) { + t.Helper() + customer = postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"inv-final@example.test"}}) + invoice := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + }) + line := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"4000"}, + "currency": {"usd"}, + "description": {"Original"}, + }) + return customer, invoice.ID, line.ID +} + +func TestInvoiceSingleLineUpdate(t *testing.T) { + handler := newTestHandler(t) + _, invoiceID, lineID := setupDraftInvoiceWithLine(t, handler) + + // Unknown params are rejected. + status, body := postFormStatus(t, handler, "/v1/invoices/"+invoiceID+"/lines/"+lineID, url.Values{ + "quantity": {"2"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("unknown line update param status = %d body = %s, want 400", status, body) + } + // Missing lines are 404. + status, body = postFormStatus(t, handler, "/v1/invoices/"+invoiceID+"/lines/ii_missing", url.Values{ + "amount": {"1000"}, + }) + if status != http.StatusNotFound { + t.Fatalf("missing line update status = %d body = %s, want 404", status, body) + } + + updated := postForm[struct { + Subtotal int64 `json:"subtotal"` + }](t, handler, "/v1/invoices/"+invoiceID+"/lines/"+lineID, url.Values{ + "amount": {"2500"}, + "description": {"Revised"}, + "metadata[k]": {"v"}, + }) + if updated.Subtotal != 2500 { + t.Fatalf("single line update subtotal = %d, want 2500", updated.Subtotal) + } + lines := getJSON[struct { + Data []struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Description string `json:"description"` + } `json:"data"` + }](t, handler, "/v1/invoices/"+invoiceID+"/lines") + if len(lines.Data) != 1 || lines.Data[0].ID != lineID || lines.Data[0].Amount != 2500 || lines.Data[0].Description != "Revised" { + t.Fatalf("line after single update = %#v, want revised %s", lines.Data, lineID) + } + + // Non-draft invoices reject line edits. + _ = postJSON[map[string]json.RawMessage](t, handler, "/v1/invoices/"+invoiceID+"/finalize", map[string]string{}) + status, body = postFormStatus(t, handler, "/v1/invoices/"+invoiceID+"/lines/"+lineID, url.Values{ + "amount": {"1000"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("finalized invoice line update status = %d body = %s, want 400", status, body) + } +} + +func TestInvoiceAttachPayment(t *testing.T) { + handler := newTestHandler(t) + customer, invoiceID, _ := setupDraftInvoiceWithLine(t, handler) + + intent := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/payment_intents", url.Values{ + "customer": {customer.ID}, + "amount": {"4000"}, + "currency": {"usd"}, + }) + + // payment_intent or payment_record is required. + status, body := postFormStatus(t, handler, "/v1/invoices/"+invoiceID+"/attach_payment", url.Values{}) + if status != http.StatusBadRequest { + t.Fatalf("attach_payment without params status = %d body = %s, want 400", status, body) + } + // Unknown intents are 404. + status, body = postFormStatus(t, handler, "/v1/invoices/"+invoiceID+"/attach_payment", url.Values{ + "payment_intent": {"pi_missing"}, + }) + if status != http.StatusNotFound { + t.Fatalf("attach_payment unknown intent status = %d body = %s, want 404", status, body) + } + + attached := postForm[struct { + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/invoices/"+invoiceID+"/attach_payment", url.Values{ + "payment_intent": {intent.ID}, + }) + if attached.Metadata["billtap_attached_payment_intent"] != intent.ID { + t.Fatalf("attach_payment metadata = %#v, want recorded %s", attached.Metadata, intent.ID) + } +} + +func TestSubscriptionMigrate(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"migrate@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Migrate Plan"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"1900"}, + "recurring[interval]": {"month"}, + }) + subscription := postForm[prorationSubResponse](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + "items[0][quantity]": {"1"}, + }) + + // billing_mode[type] is required and enum-bound. + status, body := postFormStatus(t, handler, "/v1/subscriptions/"+subscription.ID+"/migrate", url.Values{}) + if status != http.StatusBadRequest { + t.Fatalf("migrate without billing_mode status = %d body = %s, want 400", status, body) + } + status, body = postFormStatus(t, handler, "/v1/subscriptions/"+subscription.ID+"/migrate", url.Values{ + "billing_mode[type]": {"legacy"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("migrate invalid billing_mode status = %d body = %s, want 400", status, body) + } + + migrated := postForm[struct { + ID string `json:"id"` + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/subscriptions/"+subscription.ID+"/migrate", url.Values{ + "billing_mode[type]": {"flexible"}, + }) + if migrated.ID != subscription.ID || migrated.Metadata["billtap_billing_mode"] != "flexible" { + t.Fatalf("migrated subscription = %#v, want billing_mode=flexible evidence", migrated) + } +} + +func TestPaymentIntentUpdate(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"pi-update@example.test"}}) + intent := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/payment_intents", url.Values{ + "customer": {customer.ID}, + "amount": {"1200"}, + "currency": {"usd"}, + }) + + // Unknown params are rejected. + status, body := postFormStatus(t, handler, "/v1/payment_intents/"+intent.ID, url.Values{ + "amount": {"9999"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("intent amount update status = %d body = %s, want 400", status, body) + } + + updated := postForm[struct { + Description string `json:"description"` + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/payment_intents/"+intent.ID, url.Values{ + "description": {"Widget order"}, + "metadata[env]": {"ci"}, + }) + if updated.Description != "Widget order" { + t.Fatalf("updated description = %q, want Widget order", updated.Description) + } + if updated.Metadata["env"] != "ci" { + t.Fatalf("updated metadata = %#v, want env=ci", updated.Metadata) + } + fetched := getJSON[struct { + Description string `json:"description"` + }](t, handler, "/v1/payment_intents/"+intent.ID) + if fetched.Description != "Widget order" { + t.Fatalf("persisted description = %q, want Widget order", fetched.Description) + } +} + +func TestCreditNoteLines(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"cn-lines@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"CN Plan"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"3000"}, + "recurring[interval]": {"month"}, + }) + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "line_items[0][quantity]": {"1"}, + "success_url": {"http://app.test/success"}, + "cancel_url": {"http://app.test/cancel"}, + }) + completion := postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + var completed billing.CheckoutSession + if err := json.Unmarshal(completion["session"], &completed); err != nil { + t.Fatalf("decode completed session: %v", err) + } + + note := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/credit_notes", url.Values{ + "invoice": {completed.InvoiceID}, + "amount": {"1000"}, + "memo": {"Partial refund credit"}, + }) + lines := getJSON[struct { + Data []struct { + ID string `json:"id"` + Object string `json:"object"` + Amount int64 `json:"amount"` + Description string `json:"description"` + } `json:"data"` + }](t, handler, "/v1/credit_notes/"+note.ID+"/lines") + if len(lines.Data) != 1 { + t.Fatalf("credit note lines = %d, want 1", len(lines.Data)) + } + line := lines.Data[0] + if line.Object != "credit_note_line_item" || line.Amount != -1000 || line.Description != "Partial refund credit" { + t.Fatalf("credit note line = %#v, want negative 1000 with memo description", line) + } + + missingReq := httptest.NewRequest(http.MethodGet, "/v1/credit_notes/cn_missing/lines", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusNotFound { + t.Fatalf("missing credit note lines status = %d body = %s, want 404", missingRec.Code, missingRec.Body.String()) + } +} diff --git a/internal/api/checkout_invoice_lines_test.go b/internal/api/checkout_invoice_lines_test.go new file mode 100644 index 0000000..1e15d69 --- /dev/null +++ b/internal/api/checkout_invoice_lines_test.go @@ -0,0 +1,331 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/hckim/billtap/internal/billing" +) + +func setupCheckoutLineFixtures(t *testing.T, handler http.Handler) (customer billing.Customer, price billing.Price) { + t.Helper() + customer = postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"cs-lines@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Line Item Plan"}}) + price = postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"2500"}, + "recurring[interval]": {"month"}, + }) + return customer, price +} + +func TestCheckoutSessionLineItems(t *testing.T) { + handler := newTestHandler(t) + customer, price := setupCheckoutLineFixtures(t, handler) + + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "line_items[0][quantity]": {"1"}, + "line_items[1][price]": {price.ID}, + "line_items[1][quantity]": {"2"}, + "success_url": {"http://app.test/success"}, + "cancel_url": {"http://app.test/cancel"}, + }) + + listed := getJSON[struct { + Data []struct { + ID string `json:"id"` + Object string `json:"object"` + Quantity int64 `json:"quantity"` + AmountTotal int64 `json:"amount_total"` + AmountSubtotal int64 `json:"amount_subtotal"` + Currency string `json:"currency"` + Price struct { + ID string `json:"id"` + } `json:"price"` + } `json:"data"` + }](t, handler, "/v1/checkout/sessions/"+session.ID+"/line_items") + if len(listed.Data) != 2 { + t.Fatalf("session line_items = %d, want 2", len(listed.Data)) + } + for idx, item := range listed.Data { + if item.Object != "item" || item.Price.ID != price.ID { + t.Fatalf("line item %d = %#v, want expanded price %s", idx, item, price.ID) + } + } + if listed.Data[0].Quantity != 1 || listed.Data[0].AmountTotal != 2500 || listed.Data[0].AmountSubtotal != 2500 { + t.Fatalf("first line item = %#v, want quantity 1 amount 2500", listed.Data[0]) + } + if listed.Data[1].Quantity != 2 || listed.Data[1].AmountTotal != 5000 { + t.Fatalf("second line item = %#v, want quantity 2 amount 5000", listed.Data[1]) + } + + missingReq := httptest.NewRequest(http.MethodGet, "/v1/checkout/sessions/cs_missing/line_items", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusNotFound { + t.Fatalf("missing session line_items status = %d body = %s, want 404", missingRec.Code, missingRec.Body.String()) + } +} + +func TestCheckoutSessionUpdateMetadataAndQuantities(t *testing.T) { + handler := newTestHandler(t) + customer, price := setupCheckoutLineFixtures(t, handler) + + session := postForm[billing.CheckoutSession](t, handler, "/v1/checkout/sessions", url.Values{ + "customer": {customer.ID}, + "line_items[0][price]": {price.ID}, + "line_items[0][quantity]": {"1"}, + "line_items[1][price]": {price.ID}, + "line_items[1][quantity]": {"1"}, + "success_url": {"http://app.test/success"}, + "cancel_url": {"http://app.test/cancel"}, + }) + + // Unknown update params are rejected. + status, body := postFormStatus(t, handler, "/v1/checkout/sessions/"+session.ID, url.Values{ + "shipping_options[0][shipping_rate]": {"shr_1"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("unknown update param status = %d body = %s, want 400", status, body) + } + // Out-of-range line index is rejected. + status, body = postFormStatus(t, handler, "/v1/checkout/sessions/"+session.ID, url.Values{ + "line_items[5][quantity]": {"2"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("out-of-range line_items status = %d body = %s, want 400", status, body) + } + // Quantities must stay positive. + status, body = postFormStatus(t, handler, "/v1/checkout/sessions/"+session.ID, url.Values{ + "line_items[0][quantity]": {"0"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("zero quantity status = %d body = %s, want 400", status, body) + } + + updated := postForm[struct { + Metadata map[string]string `json:"metadata"` + AmountTotal int64 `json:"amount_total"` + }](t, handler, "/v1/checkout/sessions/"+session.ID, url.Values{ + "metadata[env]": {"ci"}, + "line_items[1][quantity]": {"3"}, + }) + if updated.Metadata["env"] != "ci" { + t.Fatalf("updated metadata = %#v, want env=ci", updated.Metadata) + } + if updated.AmountTotal != 2500*4 { + t.Fatalf("updated amount_total = %d, want %d", updated.AmountTotal, 2500*4) + } + + // Non-open sessions reject updates. + completion := postJSON[map[string]json.RawMessage](t, handler, "/api/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + if completion["session"] == nil { + t.Fatalf("completion returned no session") + } + status, body = postFormStatus(t, handler, "/v1/checkout/sessions/"+session.ID, url.Values{ + "metadata[env]": {"done"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("completed session update status = %d body = %s, want 400", status, body) + } +} + +func TestInvoiceUpdateAndDelete(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"inv-update@example.test"}}) + invoice := postForm[struct { + ID string `json:"id"` + Created int64 `json:"created"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + }) + + // Unknown params are rejected. + status, body := postFormStatus(t, handler, "/v1/invoices/"+invoice.ID, url.Values{ + "auto_advance": {"false"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("unknown invoice update param status = %d body = %s, want 400", status, body) + } + + updated := postForm[struct { + Description string `json:"description"` + DueDate int64 `json:"due_date"` + Metadata map[string]string `json:"metadata"` + }](t, handler, "/v1/invoices/"+invoice.ID, url.Values{ + "description": {"Consulting"}, + "days_until_due": {"14"}, + "metadata[env]": {"ci"}, + }) + if updated.Description != "Consulting" { + t.Fatalf("updated description = %q, want Consulting", updated.Description) + } + if updated.Metadata["env"] != "ci" { + t.Fatalf("updated metadata = %#v, want env=ci", updated.Metadata) + } + if want := invoice.Created + 14*86400; updated.DueDate != want { + t.Fatalf("updated due_date = %d, want %d (created %d + 14d)", updated.DueDate, want, invoice.Created) + } + + // Attached lines are deleted with the draft invoice. + item := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"5000"}, + "currency": {"usd"}, + "description": {"Initial"}, + }) + deleted := deleteForm[struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }](t, handler, "/v1/invoices/"+invoice.ID, url.Values{}) + if !deleted.Deleted || deleted.ID != invoice.ID { + t.Fatalf("invoice delete = %#v, want deleted marker", deleted) + } + afterReq := httptest.NewRequest(http.MethodGet, "/v1/invoices/"+invoice.ID, nil) + afterRec := httptest.NewRecorder() + handler.ServeHTTP(afterRec, afterReq) + if afterRec.Code != http.StatusNotFound { + t.Fatalf("deleted invoice GET status = %d body = %s, want 404", afterRec.Code, afterRec.Body.String()) + } + itemReq := httptest.NewRequest(http.MethodGet, "/v1/invoiceitems/"+item.ID, nil) + itemRec := httptest.NewRecorder() + handler.ServeHTTP(itemRec, itemReq) + if itemRec.Code != http.StatusNotFound { + t.Fatalf("attached item after invoice delete status = %d, want 404", itemRec.Code) + } + + // Non-draft invoices cannot be deleted. + other := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + }) + _ = postJSON[map[string]json.RawMessage](t, handler, "/v1/invoices/"+other.ID+"/finalize", map[string]string{}) + status, body = deleteFormStatus(t, handler, "/v1/invoices/"+other.ID, url.Values{}) + if status != http.StatusBadRequest { + t.Fatalf("finalized invoice delete status = %d body = %s, want 400", status, body) + } +} + +func TestInvoiceLineMutation(t *testing.T) { + handler := newTestHandler(t) + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"inv-lines@example.test"}}) + invoice := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoices", url.Values{ + "customer": {customer.ID}, + }) + first := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/invoiceitems", url.Values{ + "customer": {customer.ID}, + "invoice": {invoice.ID}, + "amount": {"5000"}, + "currency": {"usd"}, + "description": {"First"}, + }) + + type invoiceState struct { + Subtotal int64 `json:"subtotal"` + Total int64 `json:"total"` + AmountDue int64 `json:"amount_due"` + } + + // add_lines appends attached lines and recomputes totals. + added := postForm[invoiceState](t, handler, "/v1/invoices/"+invoice.ID+"/add_lines", url.Values{ + "line_items[0][amount]": {"2000"}, + "line_items[0][description]": {"Second"}, + "line_items[0][currency]": {"usd"}, + }) + if added.Subtotal != 7000 || added.Total != 7000 || added.AmountDue != 7000 { + t.Fatalf("after add_lines = %#v, want 7000", added) + } + // add_lines requires amounts. + status, body := postFormStatus(t, handler, "/v1/invoices/"+invoice.ID+"/add_lines", url.Values{ + "line_items[0][description]": {"No amount"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("add_lines without amount status = %d body = %s, want 400", status, body) + } + + // update_lines patches a line by id and recomputes totals. + updated := postForm[invoiceState](t, handler, "/v1/invoices/"+invoice.ID+"/update_lines", url.Values{ + "line_items[0][id]": {first.ID}, + "line_items[0][amount]": {"3000"}, + "line_items[0][description]": {"First (revised)"}, + }) + if updated.Subtotal != 5000 || updated.Total != 5000 || updated.AmountDue != 5000 { + t.Fatalf("after update_lines = %#v, want 5000", updated) + } + lines := getJSON[struct { + Data []struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Description string `json:"description"` + } `json:"data"` + }](t, handler, "/v1/invoices/"+invoice.ID+"/lines") + if len(lines.Data) != 2 { + t.Fatalf("invoice lines = %d, want 2", len(lines.Data)) + } + for _, line := range lines.Data { + if line.ID == first.ID && (line.Amount != 3000 || line.Description != "First (revised)") { + t.Fatalf("updated line = %#v, want amount 3000 revised description", line) + } + } + // update_lines without id is rejected. + status, body = postFormStatus(t, handler, "/v1/invoices/"+invoice.ID+"/update_lines", url.Values{ + "line_items[0][amount]": {"1000"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("update_lines without id status = %d body = %s, want 400", status, body) + } + + // remove_lines detaches a line and recomputes totals. + removed := postForm[invoiceState](t, handler, "/v1/invoices/"+invoice.ID+"/remove_lines", url.Values{ + "line_items[0][id]": {first.ID}, + }) + if removed.Subtotal != 2000 || removed.Total != 2000 || removed.AmountDue != 2000 { + t.Fatalf("after remove_lines = %#v, want 2000", removed) + } + // Line mutations require draft invoices. + _ = postJSON[map[string]json.RawMessage](t, handler, "/v1/invoices/"+invoice.ID+"/finalize", map[string]string{}) + status, body = postFormStatus(t, handler, "/v1/invoices/"+invoice.ID+"/add_lines", url.Values{ + "line_items[0][amount]": {"1000"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("add_lines on finalized invoice status = %d body = %s, want 400", status, body) + } +} + +func TestProductDelete(t *testing.T) { + handler := newTestHandler(t) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Doomed Plan"}}) + + deleted := deleteForm[struct { + ID string `json:"id"` + Object string `json:"object"` + Deleted bool `json:"deleted"` + }](t, handler, "/v1/products/"+product.ID, url.Values{}) + if !deleted.Deleted || deleted.Object != "product" || deleted.ID != product.ID { + t.Fatalf("product delete = %#v, want deleted product marker", deleted) + } + afterReq := httptest.NewRequest(http.MethodGet, "/v1/products/"+product.ID, nil) + afterRec := httptest.NewRecorder() + handler.ServeHTTP(afterRec, afterReq) + if afterRec.Code != http.StatusNotFound { + t.Fatalf("deleted product GET status = %d body = %s, want 404", afterRec.Code, afterRec.Body.String()) + } + status, body := deleteFormStatus(t, handler, "/v1/products/"+product.ID, url.Values{}) + if status != http.StatusNotFound { + t.Fatalf("re-delete product status = %d body = %s, want 404", status, body) + } +} diff --git a/internal/api/discounts.go b/internal/api/discounts.go index 9fc1904..5591945 100644 --- a/internal/api/discounts.go +++ b/internal/api/discounts.go @@ -223,7 +223,12 @@ func (h *Handler) handleSubscriptionDiscount(w http.ResponseWriter, r *http.Requ case http.MethodGet: writeJSON(w, http.StatusOK, h.stripeDiscount(discounts[0], subscription.CustomerID, subscription.ID, "")) case http.MethodDelete: - metadata := billing.ClearDiscountMetadata(copyStringMap(subscription.Metadata)) + // PatchSubscription merges metadata; zeroed keys are what actually + // remove the discount from the stored subscription. + metadata := copyStringMap(subscription.Metadata) + for key, value := range billing.ZeroDiscountMetadata() { + metadata[key] = value + } updated, err := h.billing.PatchSubscription(r.Context(), subscription.ID, billing.SubscriptionPatch{ Metadata: metadata, TimelineSource: "api", diff --git a/internal/api/local_evidence.go b/internal/api/local_evidence.go index 22c48cb..8bdb5c8 100644 --- a/internal/api/local_evidence.go +++ b/internal/api/local_evidence.go @@ -17,13 +17,14 @@ import ( // Evidence kinds. These are the persistence keys, so renaming one orphans the // rows already written under the old name. const ( - kindCoupon = "coupon" - kindPromotionCode = "promotion_code" - kindSchedule = "schedule" - kindDispute = "dispute" - kindTaxRate = "tax_rate" - kindTaxID = "tax_id" - kindCash = "cash" + kindCoupon = "coupon" + kindPromotionCode = "promotion_code" + kindSchedule = "schedule" + kindDispute = "dispute" + kindTaxRate = "tax_rate" + kindTaxID = "tax_id" + kindCash = "cash" + kindPortalConfiguration = "portal_configuration" ) // LocalEvidenceRepository persists evidence objects in the run's own store, so a @@ -35,31 +36,33 @@ type LocalEvidenceRepository interface { } type localEvidenceStore struct { - mu sync.Mutex - repo LocalEvidenceRepository - coupons map[string]map[string]any - promotionCodes map[string]map[string]any - schedules map[string]map[string]any - cashBalances map[string]int64 - cashTxs map[string][]map[string]any - disputes map[string]map[string]any - taxRates map[string]map[string]any - taxIDs map[string]map[string]any + mu sync.Mutex + repo LocalEvidenceRepository + coupons map[string]map[string]any + promotionCodes map[string]map[string]any + schedules map[string]map[string]any + cashBalances map[string]int64 + cashTxs map[string][]map[string]any + disputes map[string]map[string]any + taxRates map[string]map[string]any + taxIDs map[string]map[string]any + portalConfigurations map[string]map[string]any } // newLocalEvidenceStore returns an evidence store. A nil repo keeps everything in // memory, which is what callers without a store (scorecard runs, unit tests) want. func newLocalEvidenceStore(repo LocalEvidenceRepository) *localEvidenceStore { s := &localEvidenceStore{ - repo: repo, - coupons: map[string]map[string]any{}, - promotionCodes: map[string]map[string]any{}, - schedules: map[string]map[string]any{}, - cashBalances: map[string]int64{}, - cashTxs: map[string][]map[string]any{}, - disputes: map[string]map[string]any{}, - taxRates: map[string]map[string]any{}, - taxIDs: map[string]map[string]any{}, + repo: repo, + coupons: map[string]map[string]any{}, + promotionCodes: map[string]map[string]any{}, + schedules: map[string]map[string]any{}, + cashBalances: map[string]int64{}, + cashTxs: map[string][]map[string]any{}, + disputes: map[string]map[string]any{}, + taxRates: map[string]map[string]any{}, + taxIDs: map[string]map[string]any{}, + portalConfigurations: map[string]map[string]any{}, } s.restore() return s @@ -79,6 +82,8 @@ func (s *localEvidenceStore) mapFor(kind string) map[string]map[string]any { return s.taxRates case kindTaxID: return s.taxIDs + case kindPortalConfiguration: + return s.portalConfigurations } return nil } diff --git a/internal/api/portal_configurations.go b/internal/api/portal_configurations.go new file mode 100644 index 0000000..94c5246 --- /dev/null +++ b/internal/api/portal_configurations.go @@ -0,0 +1,280 @@ +package api + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/hckim/billtap/internal/billing" +) + +// defaultPortalFeatures mirrors the Stripe default portal feature set. The +// hosted portal itself is a local stub, so these drive evidence and echo only. +func defaultPortalFeatures() map[string]any { + return map[string]any{ + "customer_update": map[string]any{ + "enabled": true, + "allowed_updates": []string{"email", "name"}, + }, + "invoice_history": map[string]any{ + "enabled": true, + }, + "payment_method_update": map[string]any{ + "enabled": true, + }, + "subscription_cancel": map[string]any{ + "enabled": true, + "mode": "at_period_end", + "cancellation_reason": nil, + "proration_behavior": nil, + }, + "subscription_update": map[string]any{ + "enabled": true, + "proration_behavior": "none", + "default_allowed_updates": []string{"price", "quantity"}, + }, + } +} + +func portalFeaturesFromParams(p params, features map[string]any) map[string]any { + if features == nil { + features = defaultPortalFeatures() + } + feature := func(name string) map[string]any { + existing, _ := features[name].(map[string]any) + if existing == nil { + existing = map[string]any{} + features[name] = existing + } + return existing + } + if p.has("features[customer_update][enabled]") { + feature("customer_update")["enabled"] = p.boolDefault("features[customer_update][enabled]", true) + } + if allowed := p.list("features[customer_update][allowed_updates]"); len(allowed) > 0 { + feature("customer_update")["allowed_updates"] = allowed + } + if p.has("features[invoice_history][enabled]") { + feature("invoice_history")["enabled"] = p.boolDefault("features[invoice_history][enabled]", true) + } + if p.has("features[payment_method_update][enabled]") { + feature("payment_method_update")["enabled"] = p.boolDefault("features[payment_method_update][enabled]", true) + } + cancel := feature("subscription_cancel") + if p.has("features[subscription_cancel][enabled]") { + cancel["enabled"] = p.boolDefault("features[subscription_cancel][enabled]", true) + } + if p.has("features[subscription_cancel][mode]") { + cancel["mode"] = p.string("features[subscription_cancel][mode]") + } + if p.has("features[subscription_cancel][cancellation_reason]") { + cancel["cancellation_reason"] = emptyToNil(p.string("features[subscription_cancel][cancellation_reason]")) + } + if p.has("features[subscription_cancel][proration_behavior]") { + cancel["proration_behavior"] = emptyToNil(p.string("features[subscription_cancel][proration_behavior]")) + } + update := feature("subscription_update") + if p.has("features[subscription_update][enabled]") { + update["enabled"] = p.boolDefault("features[subscription_update][enabled]", true) + } + if p.has("features[subscription_update][proration_behavior]") { + update["proration_behavior"] = p.string("features[subscription_update][proration_behavior]") + } + if allowed := p.list("features[subscription_update][default_allowed_updates]"); len(allowed) > 0 { + update["default_allowed_updates"] = allowed + } + return features +} + +func (h *Handler) handleBillingPortalConfigurations(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateBillingPortalConfigurationCreate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + now := time.Now().UTC() + id := "bpc_" + strconv.FormatInt(now.UnixNano(), 36) + h.local.mu.Lock() + isDefault := len(h.local.portalConfigurations) == 0 + configuration := map[string]any{ + "id": id, + "object": "billing_portal.configuration", + "active": true, + "is_default": isDefault, + "business_profile": portalBusinessProfileFromParams(p, nil), + "default_return_url": emptyToNil(p.string("default_return_url")), + "features": portalFeaturesFromParams(p, nil), + "login_page": portalLoginPageFromParams(p, nil), + "metadata": nonNilMap(p.metadata()), + "created": now.Unix(), + "updated": now.Unix(), + "livemode": false, + } + err = h.local.saveLocked(kindPortalConfiguration, id, configuration) + h.local.mu.Unlock() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, cloneEvidence(configuration)) + case http.MethodGet: + h.local.mu.Lock() + data := evidenceList(h.local.portalConfigurations) + h.local.mu.Unlock() + // Evidence maps iterate in random order; sort so starting_after pagination is stable. + sort.Slice(data, func(i, j int) bool { + return fmt.Sprint(data[i]["id"]) < fmt.Sprint(data[j]["id"]) + }) + data = filterPortalConfigurations(data, r) + writeJSON(w, http.StatusOK, stripeListFromRequest(r, data)) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func (h *Handler) handleBillingPortalConfiguration(w http.ResponseWriter, r *http.Request) { + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/billing_portal/configurations/"), "/") + if id == "" || strings.Contains(id, "/") { + h.notFound(w, r) + return + } + h.local.mu.Lock() + configuration, ok := h.local.portalConfigurations[id] + h.local.mu.Unlock() + if !ok { + writeResult(w, nil, billing.ErrNotFound) + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, cloneEvidence(configuration)) + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validateBillingPortalConfigurationUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + h.local.mu.Lock() + current := h.local.portalConfigurations[id] + if p.has("active") { + current["active"] = p.boolDefault("active", true) + } + if p.has("business_profile[headline]") || p.has("business_profile[privacy_policy_url]") || p.has("business_profile[terms_of_service_url]") { + current["business_profile"] = portalBusinessProfileFromParams(p, current["business_profile"]) + } + if p.has("default_return_url") { + current["default_return_url"] = emptyToNil(p.string("default_return_url")) + } + if p.has("login_page[logo_url]") { + current["login_page"] = portalLoginPageFromParams(p, current["login_page"]) + } + if portalFeaturesTouched(p) { + features, _ := current["features"].(map[string]any) + current["features"] = portalFeaturesFromParams(p, features) + } + if metadata := p.metadata(); metadata != nil { + merged := map[string]string{} + if existing, ok := current["metadata"].(map[string]string); ok { + for key, value := range existing { + merged[key] = value + } + } + for key, value := range metadata { + merged[key] = value + } + current["metadata"] = nonNilMap(merged) + } + current["updated"] = time.Now().UTC().Unix() + err = h.local.saveLocked(kindPortalConfiguration, id, current) + h.local.mu.Unlock() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, cloneEvidence(current)) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func portalBusinessProfileFromParams(p params, existing any) map[string]any { + profile := map[string]any{"headline": nil, "privacy_policy_url": nil, "terms_of_service_url": nil} + if current, ok := existing.(map[string]any); ok { + for key := range profile { + profile[key] = current[key] + } + } + if p.has("business_profile[headline]") { + profile["headline"] = emptyToNil(p.string("business_profile[headline]")) + } + if p.has("business_profile[privacy_policy_url]") { + profile["privacy_policy_url"] = emptyToNil(p.string("business_profile[privacy_policy_url]")) + } + if p.has("business_profile[terms_of_service_url]") { + profile["terms_of_service_url"] = emptyToNil(p.string("business_profile[terms_of_service_url]")) + } + return profile +} + +func portalLoginPageFromParams(p params, existing any) map[string]any { + loginPage := map[string]any{"enabled": false, "logo_url": nil} + if current, ok := existing.(map[string]any); ok { + for key := range loginPage { + loginPage[key] = current[key] + } + } + if p.has("login_page[logo_url]") { + loginPage["logo_url"] = emptyToNil(p.string("login_page[logo_url]")) + } + return loginPage +} + +func portalFeaturesTouched(p params) bool { + for key := range p.values { + if strings.HasPrefix(key, "features[") { + return true + } + } + return false +} + +func filterPortalConfigurations(data []map[string]any, r *http.Request) []map[string]any { + query := r.URL.Query() + activeFilter := strings.TrimSpace(query.Get("active")) + defaultFilter := strings.TrimSpace(query.Get("is_default")) + if activeFilter == "" && defaultFilter == "" { + return data + } + out := make([]map[string]any, 0, len(data)) + for _, configuration := range data { + if activeFilter != "" { + wantActive := activeFilter == "true" || activeFilter == "1" + active, _ := configuration["active"].(bool) + if active != wantActive { + continue + } + } + if defaultFilter != "" { + wantDefault := defaultFilter == "true" || defaultFilter == "1" + isDefault, _ := configuration["is_default"].(bool) + if isDefault != wantDefault { + continue + } + } + out = append(out, configuration) + } + return out +} diff --git a/internal/api/portal_configurations_test.go b/internal/api/portal_configurations_test.go new file mode 100644 index 0000000..b2b0561 --- /dev/null +++ b/internal/api/portal_configurations_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestBillingPortalConfigurationsCRUD(t *testing.T) { + handler := newTestHandler(t) + + // Unknown param → 400 parameter_unknown. + status, body := postFormStatus(t, handler, "/v1/billing_portal/configurations", url.Values{ + "business_profile[name]": {"Nope"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("unknown business_profile key status = %d body = %s, want 400", status, body) + } + // Invalid feature enum → 400. + status, body = postFormStatus(t, handler, "/v1/billing_portal/configurations", url.Values{ + "features[subscription_cancel][mode]": {"whenever"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("invalid cancel mode status = %d body = %s, want 400", status, body) + } + // Invalid allowed_updates member → 400. + status, body = postFormStatus(t, handler, "/v1/billing_portal/configurations", url.Values{ + "features[customer_update][allowed_updates][0]": {"address"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("invalid allowed_updates status = %d body = %s, want 400", status, body) + } + + created := postForm[map[string]any](t, handler, "/v1/billing_portal/configurations", url.Values{ + "business_profile[headline]": {"Acme Billing"}, + "business_profile[privacy_policy_url]": {"https://acme.test/privacy"}, + "default_return_url": {"https://acme.test/return"}, + "login_page[logo_url]": {"https://acme.test/logo.png"}, + "features[customer_update][enabled]": {"true"}, + "features[customer_update][allowed_updates][0]": {"email"}, + "features[subscription_cancel][mode]": {"immediately"}, + "features[subscription_update][proration_behavior]": {"always_invoice"}, + "metadata[env]": {"ci"}, + }) + id := fmt.Sprint(created["id"]) + if id == "" || created["object"] != "billing_portal.configuration" { + t.Fatalf("created configuration = %#v, want billing_portal.configuration with id", created) + } + if created["active"] != true || created["is_default"] != true || created["livemode"] != false { + t.Fatalf("created flags = %#v, want active default non-live", created) + } + profile, _ := created["business_profile"].(map[string]any) + if profile["headline"] != "Acme Billing" || profile["privacy_policy_url"] != "https://acme.test/privacy" || profile["terms_of_service_url"] != nil { + t.Fatalf("created business_profile = %#v", profile) + } + features, _ := created["features"].(map[string]any) + cancel, _ := features["subscription_cancel"].(map[string]any) + if cancel["mode"] != "immediately" { + t.Fatalf("created subscription_cancel = %#v, want mode immediately", cancel) + } + customerUpdate, _ := features["customer_update"].(map[string]any) + allowed, _ := customerUpdate["allowed_updates"].([]any) + if customerUpdate["enabled"] != true || len(allowed) != 1 || fmt.Sprint(allowed[0]) != "email" { + t.Fatalf("created customer_update = %#v, want enabled with email only", customerUpdate) + } + update, _ := features["subscription_update"].(map[string]any) + if update["proration_behavior"] != "always_invoice" { + t.Fatalf("created subscription_update = %#v, want always_invoice", update) + } + if _, ok := features["invoice_history"]; !ok { + t.Fatalf("created features missing default invoice_history: %#v", features) + } + loginPage, _ := created["login_page"].(map[string]any) + if fmt.Sprint(loginPage["logo_url"]) != "https://acme.test/logo.png" { + t.Fatalf("created login_page = %#v", loginPage) + } + meta, _ := created["metadata"].(map[string]any) + if fmt.Sprint(meta["env"]) != "ci" { + t.Fatalf("created metadata = %#v, want env=ci", created["metadata"]) + } + + fetched := getJSON[map[string]any](t, handler, "/v1/billing_portal/configurations/"+id) + if fetched["id"] != id || fetched["default_return_url"] != "https://acme.test/return" { + t.Fatalf("GET configuration = %#v, want %s with return url", fetched, id) + } + + // Second configuration is not the default; list filters narrow by flag. + second := postForm[map[string]any](t, handler, "/v1/billing_portal/configurations", url.Values{ + "business_profile[headline]": {"Secondary"}, + }) + if second["is_default"] != false { + t.Fatalf("second configuration is_default = %#v, want false", second["is_default"]) + } + listed := getJSON[struct { + Data []map[string]any `json:"data"` + }](t, handler, "/v1/billing_portal/configurations") + if len(listed.Data) != 2 { + t.Fatalf("list configurations = %d items, want 2", len(listed.Data)) + } + defaults := getJSON[struct { + Data []map[string]any `json:"data"` + }](t, handler, "/v1/billing_portal/configurations?is_default=true") + if len(defaults.Data) != 1 || fmt.Sprint(defaults.Data[0]["id"]) != id { + t.Fatalf("is_default filter = %#v, want the first configuration only", defaults.Data) + } + + // Retrieve misses are 404. + missingReq := httptest.NewRequest(http.MethodGet, "/v1/billing_portal/configurations/bpc_missing", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusNotFound { + t.Fatalf("missing configuration status = %d body = %s, want 404", missingRec.Code, missingRec.Body.String()) + } + + updated := postForm[map[string]any](t, handler, "/v1/billing_portal/configurations/"+id, url.Values{ + "active": {"false"}, + "business_profile[headline]": {"Acme Billing v2"}, + "features[subscription_cancel][mode]": {"at_period_end"}, + "features[invoice_history][enabled]": {"false"}, + "metadata[env]": {"ci2"}, + }) + if updated["active"] != false { + t.Fatalf("updated active = %#v, want false", updated["active"]) + } + profile, _ = updated["business_profile"].(map[string]any) + if profile["headline"] != "Acme Billing v2" || profile["privacy_policy_url"] != "https://acme.test/privacy" { + t.Fatalf("updated business_profile = %#v, want new headline keeping privacy url", profile) + } + features, _ = updated["features"].(map[string]any) + cancel, _ = features["subscription_cancel"].(map[string]any) + if cancel["mode"] != "at_period_end" { + t.Fatalf("updated subscription_cancel = %#v, want at_period_end", cancel) + } + invoiceHistory, _ := features["invoice_history"].(map[string]any) + if invoiceHistory["enabled"] != false { + t.Fatalf("updated invoice_history = %#v, want disabled", invoiceHistory) + } + meta, _ = updated["metadata"].(map[string]any) + if fmt.Sprint(meta["env"]) != "ci2" { + t.Fatalf("updated metadata = %#v, want merged env=ci2", updated["metadata"]) + } + activeFiltered := getJSON[struct { + Data []map[string]any `json:"data"` + }](t, handler, "/v1/billing_portal/configurations?active=false") + if len(activeFiltered.Data) != 1 || fmt.Sprint(activeFiltered.Data[0]["id"]) != id { + t.Fatalf("active=false filter = %#v, want the updated configuration", activeFiltered.Data) + } +} diff --git a/internal/api/subscription_item_read_update_test.go b/internal/api/subscription_item_read_update_test.go new file mode 100644 index 0000000..e5ec740 --- /dev/null +++ b/internal/api/subscription_item_read_update_test.go @@ -0,0 +1,223 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/hckim/billtap/internal/billing" +) + +type itemReadResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Subscription string `json:"subscription"` + Quantity int64 `json:"quantity"` + Price struct { + ID string `json:"id"` + } `json:"price"` + Metadata map[string]string `json:"metadata"` +} + +func setupItemReadFixtures(t *testing.T, handler http.Handler) (customer billing.Customer, first, second billing.Price, subscription prorationSubResponse) { + t.Helper() + customer = postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"items-read@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Item Read Plan"}}) + first = postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"5000"}, + "recurring[interval]": {"month"}, + }) + second = postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"7000"}, + "recurring[interval]": {"month"}, + }) + subscription = postForm[prorationSubResponse](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {first.ID}, + "items[0][quantity]": {"1"}, + }) + return customer, first, second, subscription +} + +func TestSubscriptionItemListAndRetrieve(t *testing.T) { + handler := newTestHandler(t) + _, first, _, subscription := setupItemReadFixtures(t, handler) + secondItem := postForm[itemReadResponse](t, handler, "/v1/subscription_items", url.Values{ + "subscription": {subscription.ID}, + "price": {first.ID}, + "quantity": {"2"}, + }) + + listed := getJSON[struct { + Data []itemReadResponse `json:"data"` + }](t, handler, "/v1/subscription_items") + if len(listed.Data) != 2 { + t.Fatalf("list items = %d, want 2", len(listed.Data)) + } + for _, item := range listed.Data { + if item.Subscription != subscription.ID || item.Price.ID != first.ID { + t.Fatalf("listed item = %#v, want subscription %s price %s", item, subscription.ID, first.ID) + } + } + + filtered := getJSON[struct { + Data []itemReadResponse `json:"data"` + }](t, handler, "/v1/subscription_items?subscription=sub_other") + if len(filtered.Data) != 0 { + t.Fatalf("filtered items = %d, want 0 for other subscription", len(filtered.Data)) + } + + fetched := getJSON[itemReadResponse](t, handler, "/v1/subscription_items/"+secondItem.ID) + if fetched.ID != secondItem.ID || fetched.Quantity != 2 || fetched.Subscription != subscription.ID { + t.Fatalf("retrieved item = %#v, want created item", fetched) + } + + missingReq := httptest.NewRequest(http.MethodGet, "/v1/subscription_items/si_missing", nil) + missingRec := httptest.NewRecorder() + handler.ServeHTTP(missingRec, missingReq) + if missingRec.Code != http.StatusNotFound { + t.Fatalf("missing item status = %d body = %s, want 404", missingRec.Code, missingRec.Body.String()) + } + + // List validation: unknown filter params are rejected. + badReq := httptest.NewRequest(http.MethodGet, "/v1/subscription_items?price="+first.ID, nil) + badRec := httptest.NewRecorder() + handler.ServeHTTP(badRec, badReq) + if badRec.Code != http.StatusBadRequest { + t.Fatalf("unknown list filter status = %d body = %s, want 400", badRec.Code, badRec.Body.String()) + } +} + +func TestSubscriptionItemUpdateQuantityAndPrice(t *testing.T) { + handler := newTestHandler(t) + _, first, second, subscription := setupItemReadFixtures(t, handler) + itemID := subscription.Items.Data[0].ID + + // Unknown params are rejected. + status, body := postFormStatus(t, handler, "/v1/subscription_items/"+itemID, url.Values{ + "payment_behavior": {"pending_if_incomplete"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("unknown update param status = %d body = %s, want 400", status, body) + } + // quantity must stay positive. + status, body = postFormStatus(t, handler, "/v1/subscription_items/"+itemID, url.Values{ + "quantity": {"0"}, + }) + if status != http.StatusBadRequest { + t.Fatalf("zero quantity status = %d body = %s, want 400", status, body) + } + // Missing item is 404. + status, body = postFormStatus(t, handler, "/v1/subscription_items/si_missing", url.Values{ + "quantity": {"2"}, + }) + if status != http.StatusNotFound { + t.Fatalf("missing item update status = %d body = %s, want 404", status, body) + } + + updated := postForm[itemReadResponse](t, handler, "/v1/subscription_items/"+itemID, url.Values{ + "quantity": {"3"}, + "metadata[k]": {"v"}, + "proration_behavior": {"none"}, + }) + if updated.ID != itemID || updated.Quantity != 3 || updated.Price.ID != first.ID { + t.Fatalf("updated item = %#v, want same id quantity 3 price %s", updated, first.ID) + } + if updated.Metadata["k"] != "v" { + t.Fatalf("updated item metadata = %#v, want k=v", updated.Metadata) + } + + // Price swap keeps the item id and subscription shape. + swapped := postForm[itemReadResponse](t, handler, "/v1/subscription_items/"+itemID, url.Values{ + "price": {second.ID}, + }) + if swapped.ID != itemID || swapped.Price.ID != second.ID { + t.Fatalf("swapped item = %#v, want same id price %s", swapped, second.ID) + } + subscriptionAfter := getJSON[prorationSubResponse](t, handler, "/v1/subscriptions/"+subscription.ID) + if len(subscriptionAfter.Items.Data) != 1 || subscriptionAfter.Items.Data[0].ID != itemID { + t.Fatalf("subscription after item update = %#v, want single stable item", subscriptionAfter.Items.Data) + } + if subscriptionAfter.Items.Data[0].Price.ID != second.ID { + t.Fatalf("subscription item price after swap = %s, want %s", subscriptionAfter.Items.Data[0].Price.ID, second.ID) + } +} + +func TestSubscriptionItemUpdateAlwaysInvoiceBillsDelta(t *testing.T) { + handler := newTestHandler(t) + _, _, _, subscription := setupItemReadFixtures(t, handler) + itemID := subscription.Items.Data[0].ID + + updated := postForm[itemReadResponse](t, handler, "/v1/subscription_items/"+itemID, url.Values{ + "quantity": {"2"}, + "proration_behavior": {"always_invoice"}, + "proration_date": {fmt.Sprint(subscription.CurrentPeriodStart)}, + }) + if updated.Quantity != 2 || updated.ID != itemID { + t.Fatalf("always_invoice item update = %#v, want quantity 2 stable id", updated) + } + after := getJSON[prorationSubResponse](t, handler, "/v1/subscriptions/"+subscription.ID) + if after.LatestInvoice == subscription.LatestInvoice { + t.Fatalf("latest_invoice unchanged after always_invoice update: %s", after.LatestInvoice) + } +} + +func TestNestedCustomerSubscriptionDiscountRoutes(t *testing.T) { + handler := newTestHandler(t) + customer, first, _, _ := setupItemReadFixtures(t, handler) + coupon := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/coupons", url.Values{ + "percent_off": {"10"}, + "duration": {"forever"}, + }) + + discounted := postForm[prorationSubResponse](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {first.ID}, + "items[0][quantity]": {"1"}, + "discounts[0][coupon]": {coupon.ID}, + }) + nestedPath := "/v1/customers/" + customer.ID + "/subscriptions/" + discounted.ID + "/discount" + + discount := getJSON[struct { + Object string `json:"object"` + Coupon struct { + ID string `json:"id"` + } `json:"coupon"` + Subscription string `json:"subscription"` + }](t, handler, nestedPath) + if discount.Object != "discount" || discount.Coupon.ID != coupon.ID || discount.Subscription != discounted.ID { + t.Fatalf("nested discount = %#v, want %s coupon on %s", discount, coupon.ID, discounted.ID) + } + + // The nested route is customer-scoped: another customer sees 404. + other := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"other@example.test"}}) + scopedReq := httptest.NewRequest(http.MethodGet, "/v1/customers/"+other.ID+"/subscriptions/"+discounted.ID+"/discount", nil) + scopedRec := httptest.NewRecorder() + handler.ServeHTTP(scopedRec, scopedReq) + if scopedRec.Code != http.StatusNotFound { + t.Fatalf("other-customer nested discount status = %d body = %s, want 404", scopedRec.Code, scopedRec.Body.String()) + } + + deleted := deleteForm[struct { + ID string `json:"id"` + Object string `json:"object"` + Deleted bool `json:"deleted"` + }](t, handler, nestedPath, url.Values{}) + if !deleted.Deleted || deleted.Object != "discount" { + t.Fatalf("nested discount delete = %#v, want deleted discount", deleted) + } + afterReq := httptest.NewRequest(http.MethodGet, nestedPath, nil) + afterRec := httptest.NewRecorder() + handler.ServeHTTP(afterRec, afterReq) + if afterRec.Code != http.StatusNotFound { + t.Fatalf("nested discount after delete status = %d body = %s, want 404", afterRec.Code, afterRec.Body.String()) + } +} diff --git a/internal/api/validation.go b/internal/api/validation.go index f4e667a..3eaa80a 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -51,6 +51,13 @@ var ( schedulePhaseParamRE = regexp.MustCompile(`^phases\[\d+\]\[(start_date|end_date|iterations|items|plans)\].*$`) invoicePreviewItemParamRE = regexp.MustCompile(`^((subscription_details|subscriptionDetails)\[items\]\[\d+\]\[(id|price|price_id|quantity)\]|(subscription_items|items)\[\d+\]\[(id|price|price_id|quantity)\])$`) invoicePaymentSettingsRE = regexp.MustCompile(`^payment_settings(\[[^\]]+\])+$`) + portalFeatureParamRE = regexp.MustCompile(`^features\[(customer_update|invoice_history|payment_method_update|subscription_cancel|subscription_update)\]\[(enabled|mode|proration_behavior|cancellation_reason|allowed_updates|default_allowed_updates)\](\[\d*\])?$`) + // Session update line items are quantity-only overrides of existing items. + checkoutSessionLineItemQuantityRE = regexp.MustCompile(`^line_items\[(\d+)\]\[quantity\]$`) + // Draft-invoice line mutation params (add_lines/update_lines/remove_lines). + invoiceLineItemParamRE = regexp.MustCompile(`^line_items\[(\d+)\]\[(id|amount|description|currency)\]$`) + // Subscription migrate billing_mode params. + subscriptionMigrateParamRE = regexp.MustCompile(`^billing_mode\[(type|flexible)\](\[proration_discounts\])?$`) ) var stripePaymentMethodTypes = []string{ @@ -1525,6 +1532,178 @@ func validateBillingPortalSessionCreate(p params) error { return nil } +func validateBillingPortalConfigurationCreate(p params) error { + if err := p.validate(paramSpec{ + Allowed: []string{ + "business_profile[headline]", + "business_profile[privacy_policy_url]", + "business_profile[terms_of_service_url]", + "default_return_url", + "login_page[logo_url]", + }, + AllowedRegex: []*regexp.Regexp{portalFeatureParamRE}, + EnumParams: map[string][]string{ + "features[subscription_cancel][mode]": {"at_period_end", "immediately"}, + "features[subscription_cancel][proration_behavior]": {"always_invoice", "create_prorations", "none"}, + "features[subscription_update][proration_behavior]": {"always_invoice", "create_prorations", "none"}, + }, + AllowMetadata: true, + }); err != nil { + return err + } + return validatePortalFeatureUpdateLists(p) +} + +func validateBillingPortalConfigurationUpdate(p params) error { + if err := p.validate(paramSpec{ + Allowed: []string{ + "active", + "business_profile[headline]", + "business_profile[privacy_policy_url]", + "business_profile[terms_of_service_url]", + "default_return_url", + "login_page[logo_url]", + }, + AllowedRegex: []*regexp.Regexp{portalFeatureParamRE}, + BoolParams: []string{"active"}, + EnumParams: map[string][]string{ + "features[subscription_cancel][mode]": {"at_period_end", "immediately"}, + "features[subscription_cancel][proration_behavior]": {"always_invoice", "create_prorations", "none"}, + "features[subscription_update][proration_behavior]": {"always_invoice", "create_prorations", "none"}, + }, + AllowMetadata: true, + }); err != nil { + return err + } + return validatePortalFeatureUpdateLists(p) +} + +func validatePortalFeatureUpdateLists(p params) error { + for _, allowed := range p.list("features[customer_update][allowed_updates]") { + if allowed != "email" && allowed != "name" { + return invalidParam("features[customer_update][allowed_updates]", "Invalid allowed_updates value. Allowed values are email, name.") + } + } + for _, allowed := range p.list("features[subscription_update][default_allowed_updates]") { + switch allowed { + case "price", "quantity": + default: + return invalidParam("features[subscription_update][default_allowed_updates]", "Invalid default_allowed_updates value. Allowed values are price, quantity.") + } + } + return nil +} + +func validateSubscriptionItemList(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"ending_before", "expand", "limit", "starting_after", "subscription"}, + Int64Params: []string{"limit"}, + Positive: []string{"limit"}, + }) +} + +// validateCheckoutSessionUpdate allows the bounded POST /v1/checkout/sessions/{id} +// subset: metadata merge and line_items[N][quantity] overrides. +func validateCheckoutSessionUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"expand"}, + AllowedRegex: []*regexp.Regexp{checkoutSessionLineItemQuantityRE}, + AllowMetadata: true, + }) +} + +func validateInvoiceUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"description", "days_until_due", "default_payment_method"}, + AllowMetadata: true, + }) +} + +func validateInvoiceAttachPayment(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"payment_intent", "payment_record"}, + RequiredAny: [][]string{{"payment_intent", "payment_record"}}, + }) +} + +func validateInvoiceLineItemUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"amount", "description"}, + AllowMetadata: true, + }) +} + +func validateSubscriptionMigrate(p params) error { + return p.validate(paramSpec{ + AllowedRegex: []*regexp.Regexp{subscriptionMigrateParamRE}, + Required: []string{"billing_mode[type]"}, + EnumParams: map[string][]string{ + "billing_mode[type]": {"flexible"}, + "billing_mode[flexible][proration_discounts]": {"included", "itemized"}, + }, + }) +} + +func validatePaymentIntentUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"description"}, + AllowMetadata: true, + }) +} + +func validateInvoiceAddLines(p params) error { + return p.validate(paramSpec{ + AllowedRegex: []*regexp.Regexp{invoiceLineItemParamRE}, + }) +} + +func validateInvoiceLineUpdate(p params) error { + if err := p.validate(paramSpec{ + AllowedRegex: []*regexp.Regexp{invoiceLineItemParamRE}, + }); err != nil { + return err + } + for index := range invoiceLineIndexes(p) { + if !p.has(fmt.Sprintf("line_items[%d][id]", index)) { + return missingParam(fmt.Sprintf("line_items[%d][id]", index)) + } + } + return nil +} + +func validateInvoiceLineRemove(p params) error { + if err := p.validate(paramSpec{ + AllowedRegex: []*regexp.Regexp{invoiceLineItemParamRE}, + }); err != nil { + return err + } + for index := range invoiceLineIndexes(p) { + if !p.has(fmt.Sprintf("line_items[%d][id]", index)) { + return missingParam(fmt.Sprintf("line_items[%d][id]", index)) + } + } + return nil +} + +func validateSubscriptionItemUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "price", + "price_id", + "quantity", + "proration_behavior", + "proration_date", + }, + AllowedRegex: []*regexp.Regexp{taxRatesParamRE}, + EnumParams: map[string][]string{ + "proration_behavior": {"always_invoice", "create_prorations", "none"}, + }, + Int64Params: []string{"quantity"}, + Positive: []string{"quantity"}, + AllowMetadata: true, + }) +} + func validatePaymentMethodList(p params) error { return p.validate(paramSpec{ Allowed: []string{"allow_redisplay", "customer", "customer_account", "ending_before", "limit", "starting_after", "type"}, diff --git a/internal/billing/service.go b/internal/billing/service.go index c7d824c..d827ba1 100644 --- a/internal/billing/service.go +++ b/internal/billing/service.go @@ -72,6 +72,7 @@ type Repository interface { GetProduct(context.Context, string) (Product, error) ListProducts(context.Context) ([]Product, error) UpdateProduct(context.Context, string, Product) (Product, error) + DeleteProduct(context.Context, string) error CreatePrice(context.Context, Price) (Price, error) GetPrice(context.Context, string) (Price, error) @@ -94,7 +95,10 @@ type Repository interface { ListInvoices(context.Context) ([]Invoice, error) ListInvoicesFiltered(context.Context, InvoiceFilter) ([]Invoice, error) UpdateInvoice(context.Context, Invoice, []TimelineEntry) (Invoice, error) + DeleteInvoice(context.Context, string) error CreateInvoiceItem(context.Context, InvoiceItem, Invoice, []TimelineEntry) (InvoiceItem, Invoice, error) + UpdateInvoiceItem(context.Context, InvoiceItem, Invoice, []TimelineEntry) (InvoiceItem, Invoice, error) + DeleteInvoiceItem(context.Context, string, Invoice, []TimelineEntry) (Invoice, error) AttachInvoiceItems(context.Context, Invoice, []string, []TimelineEntry) (Invoice, error) ListInvoiceItemsFiltered(context.Context, InvoiceItemFilter) ([]InvoiceItem, error) FinalizeInvoice(context.Context, Invoice, PaymentIntent, []TimelineEntry) (Invoice, PaymentIntent, error) @@ -202,6 +206,15 @@ func (s *Service) UpdateProduct(ctx context.Context, id string, in Product) (Pro return s.repo.UpdateProduct(ctx, id, in) } +// DeleteProduct removes local product evidence (DELETE /v1/products/{id}). +// Existing prices keep referencing the deleted product id. +func (s *Service) DeleteProduct(ctx context.Context, id string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("%w: product is required", ErrInvalidInput) + } + return s.repo.DeleteProduct(ctx, id) +} + func (s *Service) CreatePrice(ctx context.Context, in Price) (Price, error) { if strings.TrimSpace(in.ProductID) == "" { return Price{}, fmt.Errorf("%w: product is required", ErrInvalidInput) @@ -450,6 +463,52 @@ func (s *Service) ExpireCheckoutSession(ctx context.Context, sessionID string) ( )}) } +// UpdateCheckoutSessionDetails merges metadata and replaces line-item +// quantities on an open session (POST /v1/checkout/sessions/{id}). +func (s *Service) UpdateCheckoutSessionDetails(ctx context.Context, sessionID string, metadata map[string]string, items []LineItem) (CheckoutSession, error) { + if strings.TrimSpace(sessionID) == "" { + return CheckoutSession{}, fmt.Errorf("%w: session is required", ErrInvalidInput) + } + session, err := s.repo.GetCheckoutSession(ctx, sessionID) + if err != nil { + return CheckoutSession{}, err + } + if strings.ToLower(strings.TrimSpace(session.Status)) != "open" { + return CheckoutSession{}, fmt.Errorf("%w: status must be open", ErrInvalidInput) + } + at := s.now() + if metadata != nil { + session.Metadata = copyMap(session.Metadata) + if session.Metadata == nil { + session.Metadata = map[string]string{} + } + for key, value := range metadata { + if value == "" { + delete(session.Metadata, key) + } else { + session.Metadata[key] = value + } + } + } + if items != nil { + session.LineItems = items + } + return s.repo.UpdateCheckoutSession(ctx, session, []TimelineEntry{billingTimelineEntry( + "checkout_session_updated_"+session.ID+"_"+at.Format(time.RFC3339Nano), + "checkout.session.updated", + "Checkout session updated", + ObjectCheckoutSession, + session.ID, + session.CustomerID, + session.ID, + session.SubscriptionID, + session.InvoiceID, + session.PaymentIntentID, + map[string]string{"source": "checkout.session.update"}, + at, + )}) +} + func (s *Service) CompleteCheckout(ctx context.Context, sessionID string, outcome string) (CheckoutSession, error) { return s.completeCheckout(ctx, sessionID, outcome, CheckoutCompletionOptions{}) } @@ -886,6 +945,265 @@ func (s *Service) CreateInvoice(ctx context.Context, in Invoice) (Invoice, error return s.attachPendingInvoiceItems(ctx, created) } +// requireDraftInvoice loads an invoice and rejects anything that already left +// the draft state; Stripe line/detail edits apply to drafts only. +func (s *Service) requireDraftInvoice(ctx context.Context, invoiceID string) (Invoice, error) { + invoice, err := s.repo.GetInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if invoice.Status != "draft" { + return Invoice{}, fmt.Errorf("%w: invoice status must be draft", ErrInvalidInput) + } + return invoice, nil +} + +// UpdateInvoiceDetails applies the bounded draft-invoice update subset +// (POST /v1/invoices/{id}): metadata merge plus the evidenced description, +// days_until_due, and default_payment_method keys. +func (s *Service) UpdateInvoiceDetails(ctx context.Context, invoiceID string, metadata map[string]string) (Invoice, error) { + invoice, err := s.requireDraftInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + at := s.now() + if metadata != nil { + invoice.Metadata = copyMap(invoice.Metadata) + if invoice.Metadata == nil { + invoice.Metadata = map[string]string{} + } + for key, value := range metadata { + if value == "" { + delete(invoice.Metadata, key) + } else { + invoice.Metadata[key] = value + } + } + } + return s.repo.UpdateInvoice(ctx, invoice, []TimelineEntry{billingTimelineEntry( + "invoice_updated_"+invoice.ID+"_"+at.Format(time.RFC3339Nano), + "invoice.updated", + "Invoice updated", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.update"}, + at, + )}) +} + +// DeleteInvoice removes a draft invoice together with its attached lines and +// timeline evidence (DELETE /v1/invoices/{id}). +func (s *Service) DeleteInvoice(ctx context.Context, invoiceID string) error { + if _, err := s.requireDraftInvoice(ctx, invoiceID); err != nil { + return err + } + return s.repo.DeleteInvoice(ctx, invoiceID) +} + +// AddInvoiceLines attaches new lines to a draft invoice +// (POST /v1/invoices/{id}/add_lines), reusing the invoice-item path so +// subtotal/total/amount_due and timeline evidence stay consistent. +func (s *Service) AddInvoiceLines(ctx context.Context, invoiceID string, lines []InvoiceItem) (Invoice, error) { + invoice, err := s.requireDraftInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if len(lines) == 0 { + return Invoice{}, fmt.Errorf("%w: line_items are required", ErrInvalidInput) + } + for _, line := range lines { + if line.Amount == 0 { + return Invoice{}, fmt.Errorf("%w: line_items amount is required", ErrInvalidInput) + } + line.CustomerID = invoice.CustomerID + line.InvoiceID = invoice.ID + line.Currency = firstNonEmpty(strings.TrimSpace(line.Currency), invoice.Currency) + _, updated, err := s.CreateInvoiceItem(ctx, line) + if err != nil { + return Invoice{}, err + } + invoice = updated + } + return invoice, nil +} + +// findInvoiceLine locates one attached line on a draft invoice. +func (s *Service) findInvoiceLine(ctx context.Context, invoice Invoice, lineID string) (InvoiceItem, error) { + items, err := s.repo.ListInvoiceItemsFiltered(ctx, InvoiceItemFilter{InvoiceID: invoice.ID}) + if err != nil { + return InvoiceItem{}, err + } + for _, item := range items { + if item.ID == lineID { + return item, nil + } + } + return InvoiceItem{}, ErrNotFound +} + +// UpdateInvoiceLine patches one attached draft-invoice line +// (POST /v1/invoices/{id}/update_lines and /lines/{line_item_id}): +// amount, description, and metadata updates recompute +// subtotal/total/amount_due. +func (s *Service) UpdateInvoiceLine(ctx context.Context, invoiceID string, lineID string, amount *int64, description *string, metadata map[string]string) (Invoice, error) { + invoice, err := s.requireDraftInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + item, err := s.findInvoiceLine(ctx, invoice, lineID) + if err != nil { + return Invoice{}, err + } + at := s.now() + if amount != nil && *amount != item.Amount { + addInvoiceItemAmount(&invoice, *amount-item.Amount, item.Currency) + item.Amount = *amount + } + if description != nil { + item.Description = *description + } + if metadata != nil { + item.Metadata = copyMap(item.Metadata) + if item.Metadata == nil { + item.Metadata = map[string]string{} + } + for key, value := range metadata { + if value == "" { + delete(item.Metadata, key) + } else { + item.Metadata[key] = value + } + } + } + _, updated, err := s.repo.UpdateInvoiceItem(ctx, item, invoice, []TimelineEntry{billingTimelineEntry( + "invoiceline_updated_"+item.ID+"_"+at.Format(time.RFC3339Nano), + "invoice.updated", + "Invoice line updated", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.update_lines", "line": item.ID}, + at, + )}) + return updated, err +} + +// AttachInvoicePayment records a PaymentIntent (or payment record) attachment +// on a draft invoice (POST /v1/invoices/{id}/attach_payment) as evidence; +// collection itself still runs through finalize/pay. +func (s *Service) AttachInvoicePayment(ctx context.Context, invoiceID string, paymentIntentID string, paymentRecord string) (Invoice, error) { + invoice, err := s.requireDraftInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if strings.TrimSpace(paymentIntentID) != "" { + intent, err := s.repo.GetPaymentIntent(ctx, paymentIntentID) + if err != nil { + return Invoice{}, err + } + if intent.CustomerID != invoice.CustomerID { + return Invoice{}, fmt.Errorf("%w: payment intent customer must match invoice customer", ErrInvalidInput) + } + } + at := s.now() + invoice.Metadata = copyMap(invoice.Metadata) + if invoice.Metadata == nil { + invoice.Metadata = map[string]string{} + } + if strings.TrimSpace(paymentIntentID) != "" { + invoice.Metadata["billtap_attached_payment_intent"] = strings.TrimSpace(paymentIntentID) + } + if strings.TrimSpace(paymentRecord) != "" { + invoice.Metadata["billtap_attached_payment_record"] = strings.TrimSpace(paymentRecord) + } + return s.repo.UpdateInvoice(ctx, invoice, []TimelineEntry{billingTimelineEntry( + "invoice_payment_attached_"+invoice.ID+"_"+at.Format(time.RFC3339Nano), + "invoice.updated", + "Invoice payment attached", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.attach_payment"}, + at, + )}) +} + +// UpdatePaymentIntentDetails merges metadata on a PaymentIntent +// (POST /v1/payment_intents/{id}); amount and status stay immutable. +func (s *Service) UpdatePaymentIntentDetails(ctx context.Context, intentID string, metadata map[string]string) (PaymentIntent, error) { + intent, err := s.repo.GetPaymentIntent(ctx, intentID) + if err != nil { + return PaymentIntent{}, err + } + if metadata == nil { + return intent, nil + } + intent.Metadata = copyMap(intent.Metadata) + if intent.Metadata == nil { + intent.Metadata = map[string]string{} + } + for key, value := range metadata { + if value == "" { + delete(intent.Metadata, key) + } else { + intent.Metadata[key] = value + } + } + return s.repo.UpdatePaymentIntent(ctx, intent, nil) +} + +// RemoveInvoiceLines detaches lines from a draft invoice +// (POST /v1/invoices/{id}/remove_lines), recomputing totals per removed line. +func (s *Service) RemoveInvoiceLines(ctx context.Context, invoiceID string, lineIDs []string) (Invoice, error) { + invoice, err := s.requireDraftInvoice(ctx, invoiceID) + if err != nil { + return Invoice{}, err + } + if len(lineIDs) == 0 { + return Invoice{}, fmt.Errorf("%w: line_items are required", ErrInvalidInput) + } + for _, lineID := range lineIDs { + item, err := s.findInvoiceLine(ctx, invoice, lineID) + if err != nil { + return Invoice{}, err + } + at := s.now() + addInvoiceItemAmount(&invoice, -item.Amount, item.Currency) + updated, err := s.repo.DeleteInvoiceItem(ctx, item.ID, invoice, []TimelineEntry{billingTimelineEntry( + "invoiceline_removed_"+item.ID+"_"+at.Format(time.RFC3339Nano), + "invoice.updated", + "Invoice line removed", + ObjectInvoice, + invoice.ID, + invoice.CustomerID, + "", + invoice.SubscriptionID, + invoice.ID, + invoice.PaymentIntentID, + map[string]string{"source": "invoice.remove_lines", "line": item.ID}, + at, + )}) + if err != nil { + return Invoice{}, err + } + invoice = updated + } + return invoice, nil +} + func (s *Service) ListInvoices(ctx context.Context) ([]Invoice, error) { return s.repo.ListInvoices(ctx) } @@ -3260,7 +3578,26 @@ func ClearDiscountMetadata(metadata map[string]string) map[string]string { if metadata == nil { return metadata } - for _, key := range []string{ + for _, key := range DiscountMetadataKeys() { + delete(metadata, key) + } + return metadata +} + +// ZeroDiscountMetadata returns a metadata patch that clears the local discount +// keys. PatchSubscription merges metadata and deletes keys whose patch value is +// the empty string, so subscription patches need zeroed keys rather than a +// copy with the keys removed. +func ZeroDiscountMetadata() map[string]string { + patch := map[string]string{} + for _, key := range DiscountMetadataKeys() { + patch[key] = "" + } + return patch +} + +func DiscountMetadataKeys() []string { + return []string{ MetadataDiscountCouponID, MetadataDiscountPromotionCodeID, MetadataDiscountPercentOff, @@ -3269,10 +3606,7 @@ func ClearDiscountMetadata(metadata map[string]string) map[string]string { MetadataDiscountDuration, MetadataDiscountCreated, MetadataDiscountAppliesTo, - } { - delete(metadata, key) } - return metadata } func DiscountsFromMetadata(metadata map[string]string) []Discount { diff --git a/internal/storage/billing.go b/internal/storage/billing.go index 128db0c..f1650f3 100644 --- a/internal/storage/billing.go +++ b/internal/storage/billing.go @@ -129,6 +129,23 @@ func (s *SQLiteStore) UpdateProduct(ctx context.Context, id string, in billing.P return s.GetProduct(ctx, id) } +// DeleteProduct removes local product evidence. Prices keep referencing the +// deleted product id, matching Stripe's product-deletion behavior. +func (s *SQLiteStore) DeleteProduct(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM products WHERE id = ?`, id) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + return billing.ErrNotFound + } + return nil +} + func (s *SQLiteStore) CreatePrice(ctx context.Context, p billing.Price) (billing.Price, error) { if p.CreatedAt.IsZero() { p.CreatedAt = time.Now().UTC() @@ -479,7 +496,7 @@ func (s *SQLiteStore) UpdateCheckoutSession(ctx context.Context, cs billing.Chec return billing.CheckoutSession{}, err } defer tx.Rollback() - result, err := tx.ExecContext(ctx, `UPDATE checkout_sessions SET status = ?, metadata = ? WHERE id = ?`, cs.Status, encodeMap(cs.Metadata), cs.ID) + result, err := tx.ExecContext(ctx, `UPDATE checkout_sessions SET status = ?, metadata = ?, line_items = ? WHERE id = ?`, cs.Status, encodeMap(cs.Metadata), encodeLineItems(cs.LineItems), cs.ID) if err != nil { return billing.CheckoutSession{}, err } @@ -869,6 +886,117 @@ func (s *SQLiteStore) CreateInvoiceItem(ctx context.Context, item billing.Invoic return createdItem, updatedInvoice, nil } +// UpdateInvoiceItem saves an edited draft-invoice line together with the +// recomputed invoice totals in one transaction. +func (s *SQLiteStore) UpdateInvoiceItem(ctx context.Context, item billing.InvoiceItem, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.InvoiceItem, billing.Invoice, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE invoice_items SET amount = ?, description = ?, metadata = ? WHERE id = ?`, item.Amount, item.Description, encodeMap(item.Metadata), item.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + changed, err := result.RowsAffected() + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + if changed == 0 { + return billing.InvoiceItem{}, billing.Invoice{}, billing.ErrNotFound + } + if invoice.ID != "" { + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + updatedItem, err := s.GetInvoiceItem(ctx, item.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + if invoice.ID == "" { + return updatedItem, billing.Invoice{}, nil + } + updatedInvoice, err := s.GetInvoice(ctx, invoice.ID) + if err != nil { + return billing.InvoiceItem{}, billing.Invoice{}, err + } + return updatedItem, updatedInvoice, nil +} + +// DeleteInvoiceItem removes one attached invoice line and saves the recomputed +// invoice totals in the same transaction. +func (s *SQLiteStore) DeleteInvoiceItem(ctx context.Context, itemID string, invoice billing.Invoice, timeline []billing.TimelineEntry) (billing.Invoice, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return billing.Invoice{}, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `DELETE FROM invoice_items WHERE id = ?`, itemID) + if err != nil { + return billing.Invoice{}, err + } + changed, err := result.RowsAffected() + if err != nil { + return billing.Invoice{}, err + } + if changed == 0 { + return billing.Invoice{}, billing.ErrNotFound + } + if invoice.ID != "" { + if err := updateInvoiceTx(ctx, tx, invoice); err != nil { + return billing.Invoice{}, err + } + } + for _, entry := range timeline { + if err := s.insertTimeline(ctx, tx, entry); err != nil { + return billing.Invoice{}, err + } + } + if err := tx.Commit(); err != nil { + return billing.Invoice{}, err + } + if invoice.ID == "" { + return billing.Invoice{}, nil + } + return s.GetInvoice(ctx, invoice.ID) +} + +// DeleteInvoice removes a draft invoice together with its attached lines and +// timeline evidence. Children go first so invoice foreign keys stay satisfied. +func (s *SQLiteStore) DeleteInvoice(ctx context.Context, id string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM invoices WHERE id = ?`, id).Scan(&exists); err != nil { + return err + } + if exists == 0 { + return billing.ErrNotFound + } + if _, err := tx.ExecContext(ctx, `DELETE FROM invoice_items WHERE invoice_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM timeline_entries WHERE invoice_id = ?`, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM invoices WHERE id = ?`, id); err != nil { + return err + } + return tx.Commit() +} + func (s *SQLiteStore) AttachInvoiceItems(ctx context.Context, invoice billing.Invoice, itemIDs []string, timeline []billing.TimelineEntry) (billing.Invoice, error) { if invoice.ID == "" { return billing.Invoice{}, billing.ErrInvalidInput diff --git a/internal/stripecompat/registry.go b/internal/stripecompat/registry.go index 73e029f..5cc68ca 100644 --- a/internal/stripecompat/registry.go +++ b/internal/stripecompat/registry.go @@ -164,6 +164,7 @@ func DefaultClaims() []Claim { } add(http.MethodGet, "/v1/customers/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured customer search subset for id, email, name, and metadata equality clauses joined by AND"}}) add(http.MethodGet, "/v1/products/search", Claim{Level: "L2", Risks: []string{"metadata equality filters only; no Stripe Search Query Language parity"}}) + add(http.MethodDelete, "/v1/products/{id}", Claim{Level: "L3", Stateful: true, Risks: []string{"removes local product evidence; existing prices keep referencing the deleted product id"}}) add(http.MethodGet, "/v1/prices/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured prices search subset for active, type, lookup_key, and metadata equality clauses joined by AND"}}) for _, method := range []string{http.MethodGet, http.MethodPost} { @@ -209,7 +210,15 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/checkout/sessions", Claim{Level: "L4", Stateful: true, ScorecardCases: []string{"checkout.sessions.create.java_sdk_optional_params"}, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)", "automatic_tax is a metadata-driven simulation (customer metadata tax_percent); no jurisdiction or address-based calculation", "subscription_data[default_tax_rates] snapshots tax rates onto checkout/subscription totals (exclusive/inclusive math); mutually exclusive with automatic_tax"}}) add(http.MethodGet, "/v1/checkout/sessions", Claim{Level: "L4", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)"}}) add(http.MethodGet, "/v1/checkout/sessions/{id}", Claim{Level: "L4", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"subscription and payment modes only (setup unsupported)"}}) + add(http.MethodPost, "/v1/checkout/sessions/{id}/expire", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"checkout.session.expired"}, Risks: []string{"expires open sessions only; non-open sessions are rejected and payment recovery is not modeled"}}) + add(http.MethodGet, "/v1/checkout/sessions/{id}/line_items", Claim{Level: "L3", Stateful: true, Risks: []string{"line amounts are pre-discount; per-line discount and tax splits are not modeled"}}) + add(http.MethodPost, "/v1/checkout/sessions/{id}", Claim{Level: "L3", Stateful: true, Risks: []string{"open sessions only; metadata merge plus line_items[N][quantity] overrides with immutable prices; other Stripe update params are rejected"}}) add(http.MethodPost, "/v1/billing_portal/sessions", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated", "customer.subscription.deleted", "payment_method.attached", "customer.updated"}, Risks: []string{"hosted portal is a local stub; portal configuration rendering and full Stripe-hosted portal behavior are not modeled"}}) + portalConfigurationRisk := []string{"local portal configuration evidence only; the hosted portal does not render configuration features"} + add(http.MethodGet, "/v1/billing_portal/configurations", Claim{Level: "L2", Stateful: true, Risks: portalConfigurationRisk}) + add(http.MethodPost, "/v1/billing_portal/configurations", Claim{Level: "L3", Stateful: true, Risks: portalConfigurationRisk}) + add(http.MethodGet, "/v1/billing_portal/configurations/{id}", Claim{Level: "L2", Stateful: true, Risks: portalConfigurationRisk}) + add(http.MethodPost, "/v1/billing_portal/configurations/{id}", Claim{Level: "L3", Stateful: true, Risks: portalConfigurationRisk}) taxRateRisk := []string{"local tax-rate evidence; default_tax_rates snapshots apply rates to checkout/subscription/invoice/renewal totals (inclusive+exclusive math); automatic_tax remains a separate customer-metadata simulation"} add(http.MethodGet, "/v1/tax_rates", Claim{Level: "L2", Stateful: true, Risks: taxRateRisk}) @@ -240,9 +249,14 @@ func DefaultClaims() []Claim { add(http.MethodGet, "/v1/customers/{id}/subscriptions/{id}", Claim{Level: "L3", Stateful: true}) add(http.MethodPost, "/v1/customers/{id}/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated"}}) add(http.MethodDelete, "/v1/customers/{id}/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.deleted"}}) + add(http.MethodGet, "/v1/customers/{id}/subscriptions/{id}/discount", Claim{Level: "L3", Stateful: true, Risks: []string{"returns the subscription's single local discount only"}}) + add(http.MethodDelete, "/v1/customers/{id}/subscriptions/{id}/discount", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.discount.deleted", "customer.subscription.updated"}, Risks: []string{"clears the subscription's single local discount only"}}) - add(http.MethodPost, "/v1/subscription_items", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"subscription_items.create.invalid_quantity"}, SDKSmoke: []string{"stripe-node"}, Risks: []string{"proration_behavior always_invoice/create_prorations reuses subscription-update proration (subscription default_tax_rates only; item tax_rates evidence-only)", "item IDs are index-based (si__) and shift when a middle item is deleted"}}) - add(http.MethodDelete, "/v1/subscription_items/{id}", Claim{Level: "L3", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"proration_behavior supported; last item delete rejected; clear_usage evidence-only (metered usage not modeled)", "item IDs are index-based and shift after middle-item delete"}}) + add(http.MethodPost, "/v1/subscription_items", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"subscription_items.create.invalid_quantity"}, SDKSmoke: []string{"stripe-node"}, Risks: []string{"proration_behavior always_invoice/create_prorations reuses subscription-update proration (subscription default_tax_rates only; item tax_rates evidence-only)", "item IDs keep the si__ shape but are stored at creation, so deletes no longer shift later item IDs; subscriptions stored before that change are backfilled with their position-derived IDs on next write"}}) + add(http.MethodGet, "/v1/subscription_items", Claim{Level: "L3", Stateful: true, Risks: []string{"lists resolved items across subscriptions; supports the subscription filter and standard list envelope only"}}) + add(http.MethodGet, "/v1/subscription_items/{id}", Claim{Level: "L3", Stateful: true}) + add(http.MethodPost, "/v1/subscription_items/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated"}, Risks: []string{"price/quantity replacement with the same proration subset as item create (always_invoice/create_prorations); payment_behavior and discounts are not modeled", "item tax_rates are evidence on the response only; totals use subscription default_tax_rates"}}) + add(http.MethodDelete, "/v1/subscription_items/{id}", Claim{Level: "L3", Stateful: true, SDKSmoke: []string{"stripe-node"}, Risks: []string{"proration_behavior supported; last item delete rejected; clear_usage evidence-only (metered usage not modeled)", "stored item IDs stay stable after a middle-item delete; a later add reuses the lowest unused index"}}) add(http.MethodDelete, "/v1/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.deleted"}}) add(http.MethodPost, "/v1/subscriptions/{id}/resume", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated"}, Risks: []string{"clears local pause_collection evidence; billing-cycle and proration side effects are not modeled"}}) add(http.MethodGet, "/v1/subscriptions/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured subscription search subset for id, customer, status, and metadata equality clauses joined by AND"}}) @@ -259,6 +273,16 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/invoices/{id}/finalize", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.one_time_invoice_flow.succeeds"}, WebhookEvents: []string{"invoice.finalized", "payment_intent.created", "invoice.sent"}, Risks: []string{"manual one-time invoice finalization only; automatic collection and full invoice lifecycle automation are not modeled"}}) add(http.MethodPost, "/v1/invoices/{id}/send", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.sent"}, Risks: []string{"local email evidence only (metadata + invoice.sent event); no real email delivery", "charge_automatically invoices also allowed as evidence-only send", "paid invoices can be re-sent (evidence only)"}}) add(http.MethodPost, "/v1/invoices/{id}/pay", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"invoices.pay.failed_invoice_succeeds", "invoices.pay.failed_invoice_declines_again", "invoices.one_time_invoice_flow.succeeds"}, WebhookEvents: []string{"payment_intent.succeeded", "payment_intent.payment_failed", "payment_intent.requires_action", "invoice.payment_succeeded", "invoice.payment_failed", "invoice.paid", "customer.subscription.updated"}, Risks: []string{"local retry/payment mutation only; send, void, collection, and dunning automation are not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/void", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.voided"}, Risks: []string{"moves open invoices to void with local evidence only; unvoiding is not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/mark_uncollectible", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"invoice.marked_uncollectible"}, Risks: []string{"moves open invoices to uncollectible with local evidence only; reopening is not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; bounded subset (description, days_until_due, default_payment_method, metadata merge)"}}) + add(http.MethodDelete, "/v1/invoices/{id}", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; removes attached lines and timeline evidence with the invoice"}}) + add(http.MethodPost, "/v1/invoices/{id}/add_lines", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; amount/description/currency lines through the invoice-item path; price_data and tax lines are not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/update_lines", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; amount/description updates recompute subtotal/total/amount_due"}}) + add(http.MethodPost, "/v1/invoices/{id}/remove_lines", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; removing every line leaves a zero-amount draft"}}) + add(http.MethodPost, "/v1/invoices/{id}/lines/{line_item_id}", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; amount/description/metadata updates recompute subtotal/total/amount_due; pricing and tax line edits are not modeled"}}) + add(http.MethodPost, "/v1/invoices/{id}/attach_payment", Claim{Level: "L3", Stateful: true, Risks: []string{"draft invoices only; records the attached PaymentIntent or payment record as invoice evidence; collection still runs through finalize/pay"}}) + add(http.MethodPost, "/v1/subscriptions/{id}/migrate", Claim{Level: "L3", Stateful: true, Risks: []string{"records billing_mode=flexible as subscription metadata evidence; flexible-billing proration recalculation is not modeled"}}) add(http.MethodGet, "/v1/invoices/{id}/lines", Claim{Level: "L2", Stateful: true, Risks: []string{"returns local invoice items for manual one-time invoices only"}}) add(http.MethodGet, "/v1/invoices/{id}/payments", Claim{Level: "L2", Stateful: true, Risks: []string{"returns local invoice payment evidence only"}}) add(http.MethodPost, "/v1/invoices/create_preview", Claim{Level: "L3", Stateful: true, Risks: []string{"local next-period upcoming when no item overrides (with pending create_prorations + default_tax_rates/automatic_tax); item overrides keep subscription-update proration; pending invoice items and full multi-item Stripe parity are not modeled"}}) @@ -277,6 +301,7 @@ func DefaultClaims() []Claim { 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/credit_notes/{id}/lines", Claim{Level: "L2", Stateful: true, Risks: []string{"returns one synthesized line derived from the stored credit note amount and memo/reason; stored line-item breakdowns are not modeled"}}) 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"}}) @@ -284,6 +309,7 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/payment_intents/{id}/confirm", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"payment_intents.confirm.card_decline"}, Risks: []string{"local deterministic outcome aliases only"}}) add(http.MethodPost, "/v1/payment_intents/{id}/capture", Claim{Level: "L3", Stateful: true, Risks: []string{"local capture marks the intent succeeded; partial capture accounting is not modeled"}}) add(http.MethodPost, "/v1/payment_intents/{id}/cancel", Claim{Level: "L3", Stateful: true}) + add(http.MethodPost, "/v1/payment_intents/{id}", Claim{Level: "L3", Stateful: true, Risks: []string{"metadata merge plus evidenced description only; amount, currency, and status stay immutable"}}) add(http.MethodGet, "/v1/payment_intents/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured PaymentIntent search subset for id, customer, status, and metadata equality clauses joined by AND"}}) add(http.MethodGet, "/v1/setup_intents", statefulL3) add(http.MethodPost, "/v1/setup_intents", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"setup_intents.create.confirm.succeeds"}, Risks: []string{"local state machine only; mandates and full SCA flows are not modeled"}}) diff --git a/internal/stripecompat/registry_test.go b/internal/stripecompat/registry_test.go index 451aaaf..471dd3d 100644 --- a/internal/stripecompat/registry_test.go +++ b/internal/stripecompat/registry_test.go @@ -8,8 +8,8 @@ import ( func TestDefaultRegistryContainsCurrentPublicClaims(t *testing.T) { registry := DefaultRegistry() claims := registry.Claims() - if len(claims) != 181 { - t.Fatalf("default claims = %d, want 181", len(claims)) + if len(claims) != 206 { + t.Fatalf("default claims = %d, want 206", len(claims)) } checkout, ok := registry.Lookup(http.MethodPost, "/v1/checkout/sessions") @@ -103,6 +103,22 @@ func TestDefaultRegistryContainsCurrentPublicClaims(t *testing.T) { if !ok || portal.Level != "L3" || len(portal.WebhookEvents) < 2 { t.Fatalf("portal claim = %#v ok=%t, want webhook-backed L3 portal session", portal, ok) } + portalConfiguration, ok := registry.Lookup(http.MethodPost, "/v1/billing_portal/configurations/bpc_123") + if !ok || portalConfiguration.Level != "L3" || !portalConfiguration.Stateful { + t.Fatalf("portal configuration claim = %#v ok=%t, want L3 stateful", portalConfiguration, ok) + } + invoiceVoid, ok := registry.Lookup(http.MethodPost, "/v1/invoices/in_123/void") + if !ok || invoiceVoid.Level != "L3" || len(invoiceVoid.WebhookEvents) != 1 { + t.Fatalf("invoice void claim = %#v ok=%t, want L3 with invoice.voided", invoiceVoid, ok) + } + itemUpdate, ok := registry.Lookup(http.MethodPost, "/v1/subscription_items/si_123") + if !ok || itemUpdate.Level != "L3" || !itemUpdate.Stateful { + t.Fatalf("subscription item update claim = %#v ok=%t, want L3 stateful", itemUpdate, ok) + } + nestedDiscount, ok := registry.Lookup(http.MethodDelete, "/v1/customers/cus_123/subscriptions/sub_123/discount") + if !ok || nestedDiscount.Level != "L3" || !nestedDiscount.Stateful { + t.Fatalf("nested customer subscription discount claim = %#v ok=%t, want L3 stateful", nestedDiscount, ok) + } schedule, ok := registry.Lookup(http.MethodPost, "/v1/subscription_schedules/sub_sched_123/release") if !ok || schedule.Level != "L2" || !schedule.Stateful { t.Fatalf("subscription schedule release claim = %#v ok=%t, want L2 stateful", schedule, ok)