From 0ac5c093cb7e7a31b5e69a09e021e85056d3d1ce Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 12 May 2026 15:25:12 +0900 Subject: [PATCH 1/2] Document simulation capacity backlog --- docs/README.md | 2 + docs/ROADMAP.md | 2 + docs/SIMULATION_CAPACITY_BACKLOG.md | 275 +++++++++++++++++++++++ docs/STRIPE_API_COMPATIBILITY_ROADMAP.md | 5 +- specs/000-product/tasks.md | 1 + 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 docs/SIMULATION_CAPACITY_BACKLOG.md diff --git a/docs/README.md b/docs/README.md index 4d12e87..2f62853 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ This directory contains the public product, architecture, testing, and release n - `COMPATIBILITY.md`: supported and unsupported Stripe-like behavior - `STRIPE_API_COMPATIBILITY_ROADMAP.md`: roadmap for expanding from the current subscription-focused subset toward measured broader Stripe API compatibility +- `SIMULATION_CAPACITY_BACKLOG.md`: public, product-neutral backlog for + regression-driven fixture and simulation capacity expansion - `API_VALIDATION_AND_ERROR_SIMULATION.md`: Stripe-like validation and deterministic error-simulation target - `PUBLIC_RELEASE_READINESS.md`: public release readiness bar, current evidence, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f3dcb2d..6f9cec2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -148,6 +148,8 @@ through `STRIPE_API_COMPATIBILITY_ROADMAP.md`: - OpenAPI-backed validation and fixture-shaped responses - deeper subscription, invoice, payment-intent, setup-intent, refund, dispute, entitlement, metering, and Connect simulation +- regression-driven simulation capacity tracked in + `SIMULATION_CAPACITY_BACKLOG.md` - official Stripe SDK smoke lanes and optional `stripe-mock`/live-testmode oracle jobs diff --git a/docs/SIMULATION_CAPACITY_BACKLOG.md b/docs/SIMULATION_CAPACITY_BACKLOG.md new file mode 100644 index 0000000..1634575 --- /dev/null +++ b/docs/SIMULATION_CAPACITY_BACKLOG.md @@ -0,0 +1,275 @@ +# Simulation Capacity Backlog + +Status date: 2026-05-12 + +This backlog turns production-regression learnings into public, product-neutral +Billtap capability work. It is intentionally broader than the current +implementation. A capability should move from backlog to compatibility claim +only after it has tests, fixture examples, documented boundaries, and, where +useful, SDK or app-style smoke evidence. + +Do not commit private customer names, service names, internal issue IDs, or +incident evidence here. Adoption repositories can map private incidents to +these public capability rows in their own private docs. + +## Priority Model + +| Tier | Meaning | Merge rule | +| --- | --- | --- | +| P0 | Blocks high-value billing regression automation or closes a known class of payment/subscription production bugs. | Implement with API or scenario tests plus docs. | +| P1 | Expands near-term billing matrix depth for subscriptions, invoices, payment methods, webhooks, or history. | Implement as one bounded endpoint/scenario cluster per PR. | +| P2 | Useful for broader product coverage, tax/compliance, multi-region, or hosted UI parity. | Implement only with a clear adoption scenario. | +| P3 | Observability, performance, or developer-experience support. | Prefer additive diagnostics and generated evidence. | + +## Current Baseline + +Billtap currently has a stateful Stripe-like local subset with: + +- customers, products, prices, price search, coupons, promotion codes, 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 +- fixture apply/snapshot/assert APIs with stable fixture IDs and fixture refs +- local checkout and portal UI flows +- 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: `144 / 587` implemented operations, `24.5%` + +## P0 Regression-Paired Backlog + +These are the highest-value next chunks because they make app-side billing +regression specs easier to close with deterministic Billtap evidence. + +| Capability | Current state | Next implementation shape | Verification | +| --- | --- | --- | --- | +| Trial-used customer fixture | Trialing subscriptions, canceled subscriptions, stable fixture IDs, and test-clock trial activation exist. A dedicated "trial history only" customer fixture pattern is not documented or asserted. | Add fixture examples and assertions for a customer with canceled trial history but no active subscription. Preserve customer metadata and subscription history lookup behavior. | API tests for seeded history, scenario using fresh checkout after trial history, docs fixture snippet. | +| Trial to cancel history | Immediate cancellation and period-end cancellation exist; trial cancellation history needs a direct scenario contract. | Add scenario action/fixture pattern that creates trialing subscription, cancels it before activation, and leaves retrievable canceled history. | Scenario report and webhook sequence assertions. | +| Proration invariant matrix | Subscription-update invoice preview calculates bounded proration and Stripe SDK-friendly invoice fields. | Add a matrix of small positive, large positive, negative/downgrade, zero, and full-credit preview cases. | API tests for exact `amount_due`, `subtotal`, line periods, and discount/tax-neutral totals. | +| Renewal failure depth | Renewal failure can move subscriptions to `past_due` with invoice/payment-intent evidence. | Add configurable retry/dunning phases: first failure, retry failure, unpaid, grace window, and final cancellation. | Test-clock scenario reports and event ordering assertions. | +| Customer subscription history filters | Top-level subscription listing exists; customer nested subscription routes remain inventory-only. | Add `/v1/customers/{customer}/subscriptions` list with `status` filter coverage for `trialing`, `active`, `past_due`, `canceled`, `incomplete`, `unpaid`, and `all`. | Stripe-shaped list response tests and fixture history scenarios. | + +## Capability Matrix + +### 1. Subscription Lifecycle + +| Capability | Current state | Priority | +| --- | --- | --- | +| trial to active | Implemented with test clocks and fixture dates. | Done | +| trial to cancel history | Partial through cancellation APIs; needs dedicated scenario pattern. | P0 | +| past_due and unpaid transitions | Partial renewal failure; needs retry/dunning phases. | P0 | +| pause and resume API | Not modeled. | P1 | +| subscription schedule plan change | One due phase can replace items; full scheduled downgrade/proration remains bounded. | P1 | +| proration behavior `always_invoice`, `create_prorations`, `none` | Preview supports the three values; invoice creation side effects remain bounded. | P0 | +| cancel at period end vs immediate cancel | Implemented through API/portal/test-clock paths. | Done | +| grace period simulation | Not modeled as a separate state. | P1 | + +### 2. Customer History And Metadata + +| Capability | Current state | Priority | +| --- | --- | --- | +| customer subscription list status filter | Nested route inventory exists; implementation needed. | P0 | +| arbitrary customer metadata seed | Implemented through fixtures and customer APIs. | Done | +| customer search by email/metadata | Inventory-only. | P1 | +| customer delete and recreate history policy | Not modeled. | P1 | +| cross-merchant customer migration | Connect evidence exists; migration policy not modeled. | P2 | +| tax IDs, shipping, preferred locale | Partially represented in validation inventory; stateful coverage needed. | P2 | + +### 3. Invoice Correctness + +| Capability | Current state | Priority | +| --- | --- | --- | +| proration exactness matrix | Bounded proration exists; matrix coverage needed. | P0 | +| invoice discounts and coupons | Bounded single-discount subset implemented. | Done | +| automatic tax and regional rates | Response fields exist; tax calculation not modeled. | P2 | +| invoice line period and quantity diversity | Basic proration lines exist; broader cases needed. | P1 | +| amount due/paid/remaining consistency | Implemented for checkout, retry, renewal, and preview paths. | Done | +| zero-dollar invoice | Needs explicit full-credit scenario. | P1 | +| negative balance and ending balance | Not modeled beyond default fields. | P1 | +| hosted invoice URL and receipt | Placeholder fields exist; hosted receipt UI not modeled. | P2 | +| invoice payment settings diversity | Default response shape exists; behavior breadth needed. | P2 | + +### 4. Payment Intent And Payment Method + +| Capability | Current state | Priority | +| --- | --- | --- | +| 3D Secure / requires_action | Direct PaymentIntent and SetupIntent require-action simulation exists. | Done | +| saved card vs new card | Customer payment-method fixtures and portal save exist; create/attach/detach breadth remains. | P1 | +| wallet token simulation | Not modeled. | P2 | +| decline reason breadth | Common aliases exist; complete card-decline catalog remains. | P1 | +| provider outage mode | Not modeled. | P2 | +| mandate flows | Not modeled beyond shape fields. | P2 | + +### 5. Webhook Reliability + +| Capability | Current state | Priority | +| --- | --- | --- | +| retry after delivery failure | Implemented through replay and simulated failure evidence. | Done | +| duplicate delivery | Implemented. | Done | +| out-of-order delivery | Implemented. | Done | +| signature timestamp tolerance cases | Signature evidence exists; explicit tolerance matrix needed. | P1 | +| API version variation | Configurable webhook API version exists; compatibility matrix needed. | P1 | +| dead-letter simulation | Not modeled. | P2 | +| historical replay automation | Explicit historical replay exists; automatic policy remains bounded. | P1 | + +### 6. Test Clocks + +| Capability | Current state | Priority | +| --- | --- | --- | +| multi-clock customer simulation | Implemented through customer/subscription test-clock metadata. | Done | +| past period replay | Partial through fixture dates and clock advance. | P1 | +| mid-cycle proration on clock advance | Not modeled as a clock side effect. | P1 | +| trial expiry auto advance | Implemented. | Done | + +### 7. Connect And Multi-Account + +| Capability | Current state | Priority | +| --- | --- | --- | +| connected accounts | Implemented for local evidence and request traces. | Done | +| transfers, payouts, application fees | Implemented as local evidence. | Done | +| application fee reversal | Implemented for fee refunds/reversals evidence. | Done | +| platform vs connected customer separation | Request trace and Connect evidence exist; deeper isolation scenarios needed. | P1 | + +### 8. Coupon And Promotion Code + +| Capability | Current state | Priority | +| --- | --- | --- | +| duration once/repeating/forever | Shape exists; redemption lifecycle not modeled. | P1 | +| coupon stacking | Not modeled; single effective discount only. | P2 | +| promotion redemption count and max redemptions | Not modeled. | P1 | +| coupon currency restrictions | Amount-off currency guard exists; broader restrictions needed. | P1 | +| coupon expiration | Not modeled. | P1 | + +### 9. Refund And Credit Note + +| Capability | Current state | Priority | +| --- | --- | --- | +| partial refund and remaining amount | Implemented for local refund evidence; richer remaining-balance assertions needed. | P1 | +| refund reason classification | Implemented for common local reasons; UI/payment-history scenarios needed. | P1 | +| credit note simulation | Implemented for create/retrieve/void local evidence. | Done | +| credit note vs refund branch | Needs scenario matrix. | P1 | +| bank-transfer refund | Pending/settlement simulation exists for refunds; bank-transfer-specific behavior remains. | P2 | +| refund webhooks | Implemented for local refund paths; expand event matrix as needed. | P1 | + +### 10. Charge And Dispute + +| Capability | Current state | Priority | +| --- | --- | --- | +| dispute creation | Implemented as local evidence. | Done | +| evidence submission | Basic update evidence exists; structured evidence fields need expansion. | P1 | +| dispute outcome won/lost/warning closed | Not fully modeled. | P1 | +| dispute fees and adjustments | Not modeled. | P2 | + +### 11. Tax, VAT, And Compliance + +| Capability | Current state | Priority | +| --- | --- | --- | +| tax ID collection | Inventory-visible; local stateful flow needed. | P2 | +| automatic tax option | Invoice response shape exists; calculation not modeled. | P2 | +| regional tax rates | Not modeled. | P2 | +| invoice receipt PDF/HTML | Not modeled beyond placeholder fields. | P2 | +| VAT refund cases | Not modeled. | P2 | + +### 12. SetupIntent And Card Registration + +| Capability | Current state | Priority | +| --- | --- | --- | +| SetupIntent simulation | Implemented for create/list/retrieve/confirm/cancel. | Done | +| card add/delete/default switch | Portal save exists; PaymentMethod attach/detach/update breadth needed. | P1 | +| card expiry notification | Not modeled. | P2 | +| payment method attach/detach | Inventory-visible; stateful breadth needed. | P1 | +| off-session setup for renewal | Partial through default payment methods; mandate/off-session detail needed. | P2 | + +### 13. Multi-Region + +| Capability | Current state | Priority | +| --- | --- | --- | +| region-specific webhook routing | Connect/webhook evidence exists; explicit region router simulation needed. | P2 | +| cross-region customer migration | Not modeled. | P2 | +| region-specific currency | Multi-currency price fields exist; scenario matrix needed. | P2 | +| region-specific tax rate | Not modeled. | P2 | +| wrong-region forwarding | Not modeled. | P3 | + +### 14. Bank Transfer And Payment Diversity + +| Capability | Current state | Priority | +| --- | --- | --- | +| ACH credit transfer | Cash-balance funding and bank-transfer PaymentIntent processing exist in bounded form. | P1 | +| SEPA debit | Not modeled. | P2 | +| local virtual account rails | Not modeled. | P3 | +| invoice `payment_method_types` diversity | Shape exists; behavior breadth needed. | P2 | +| manual vs automatic confirmation | PaymentIntent confirmation modes need broader coverage. | P1 | + +### 15. Subscription, Price, And Product Metadata + +| Capability | Current state | Priority | +| --- | --- | --- | +| subscription metadata seed | Implemented through fixtures and APIs. | Done | +| price lookup key collision | Search exists; collision validation needed. | P1 | +| product statement descriptor | Shape/metadata coverage needed. | P2 | +| tax behavior inclusive/exclusive | Inventory-visible; invoice math not modeled. | P2 | +| tiered pricing | Not modeled. | P2 | + +### 16. Replay And Simulation Tools + +| Capability | Current state | Priority | +| --- | --- | --- | +| specific event replay | Implemented. | Done | +| bulk seed/dump | Fixture apply/snapshot exists; dump/export format can be expanded. | P1 | +| diagnostic snapshots | Implemented; JSON schema standardization needed. | P3 | +| time-travel rewind | Not modeled. | P3 | +| cross-test isolation | Fixture namespace/ref metadata exists; runner isolation policy can improve. | P1 | + +### 17. Edge Cases + +| Capability | Current state | Priority | +| --- | --- | --- | +| partial payment and remaining balance | Limited invoice retry evidence; partial payment not modeled. | P1 | +| subscription quantity zero invalid | Validation exists for supported quantity paths. | Done | +| promotion plus proration | Bounded single-discount proration preview exists. | Done | +| zero-dollar invoice | Needs explicit scenario. | P1 | +| negative balance | Not modeled. | P1 | +| concurrent checkout and cancel | Not modeled. | P2 | +| webhook before sync API race | Can be simulated manually with out-of-order replay; scenario helper needed. | P1 | + +### 18. Hosted Checkout And Portal UI + +| Capability | Current state | Priority | +| --- | --- | --- | +| hosted checkout UI selector guard | Local UI exists; Stripe selector parity is not claimed. | P2 | +| billing portal manage/cancel/payment method | Local portal actions exist. | Done | +| invoice download UI | Not modeled. | P2 | +| customer portal plan change | Local portal plan change exists; deeper scenario coverage needed. | P1 | + +### 19. Rate Limit And API Error Simulation + +| Capability | Current state | Priority | +| --- | --- | --- | +| rate-limited 429 | Not modeled. | P1 | +| API connection/auth/API errors | Stripe-shaped errors exist; injectable outage/error modes needed. | P1 | +| idempotency key consistency | Implemented for same-process POST replay and mismatch. | Done | +| partial outage mode | Not modeled. | P2 | + +### 20. Observability And Diagnostics + +| Capability | Current state | Priority | +| --- | --- | --- | +| request log and replay timeline | Request traces and timelines exist. | Done | +| webhook delivery dashboard | Dashboard and delivery attempts exist; retry summary UX can improve. | P2 | +| diagnostic export JSON schema | Export exists; schema standardization needed. | P3 | +| fixture seed assertions | Fixture assertion API exists; more domain assertions needed. | P1 | +| latency/performance metrics | Not modeled. | P3 | + +## Execution Rules + +1. Keep broad capability tracking in this file, but implement behavior in + bounded PRs that each name the compatibility family and verification. +2. Do not raise `internal/stripecompat` claim levels until tests and docs prove + the new behavior. +3. Any change affecting billing state, webhook order, signature handling, + idempotency, or request validation must include tests. +4. Any simulation that deliberately diverges from Stripe must be explicit in + `docs/COMPATIBILITY.md`. +5. Avoid private adoption names, internal issue keys, and company-specific + terms in public docs, code, fixtures, and PR descriptions. diff --git a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md index 5b98699..f91afdc 100644 --- a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md +++ b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md @@ -380,8 +380,9 @@ Before a family moves beyond inventory: Keep the broad Stripe surface as the long-term direction, but promote endpoints only when they have a measured compatibility level and a documented boundary. -After the current inventory/protocol baseline, the highest-value implementation -order is: +`SIMULATION_CAPACITY_BACKLOG.md` tracks the broader regression-driven fixture +and simulation queue. After the current inventory/protocol baseline, the +highest-value implementation order is: 1. Coupons, discounts, credit notes, refunds, and payment history. 2. Connect SDK/adoption smoke and connected-account webhook routing. diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index 8db3529..b54b07e 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -266,6 +266,7 @@ Gate: - [x] T141 Add account self/delete and people/person fixtures for remaining Connect inventory routes - [ ] T142 Add official Stripe SDK matrix for Node, Go, Java, Python, and Ruby - [x] T143 Add optional scheduled/manual Stripe OpenAPI inventory workflow artifacts +- [x] T144 Capture the public simulation capacity backlog for regression-driven fixture and scenario expansion Suggested agents: From 220933f983343cbde69ca14afa84e7996f15b68f Mon Sep 17 00:00:00 2001 From: midagedev Date: Tue, 12 May 2026 16:20:51 +0900 Subject: [PATCH 2/2] Expand simulation API coverage --- README.md | 2 +- docs/COMPATIBILITY.md | 10 +- docs/COMPATIBILITY_TRACKING.md | 2 +- docs/GATE_STATUS.md | 17 +- docs/SIMULATION_CAPACITY_BACKLOG.md | 17 +- docs/STRIPE_API_COMPATIBILITY_ROADMAP.md | 8 +- docs/STRIPE_COMPATIBILITY_90_TARGET.md | 16 +- internal/api/api.go | 681 ++++++++++++++++++++++- internal/api/api_test.go | 199 ++++++- internal/api/validation.go | 80 ++- internal/stripecompat/registry.go | 16 + internal/stripecompat/registry_test.go | 20 +- specs/000-product/tasks.md | 1 + 13 files changed, 1012 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2005a52..eeddefd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ curl -fsS "http://localhost:8080/api/diagnostics?limit=100" \ | 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 | `144 / 587` operations, `24.5%` 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 | `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` | Detailed compatibility matrix: `docs/COMPATIBILITY.md`. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index d1644bc..ca4c7a1 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -91,7 +91,7 @@ Base path: `/v1` | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Customers | `POST /v1/customers`, `GET /v1/customers`, `GET /v1/customers/{id}`, `POST /v1/customers/{id}` | Supported | Create, list, retrieve, and update `email`, `name`, and metadata. List supports `email` and `limit` filters. | +| 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. | | 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`. | @@ -100,13 +100,13 @@ Base path: `/v1` | Checkout sessions | `POST /v1/checkout/sessions`, `GET /v1/checkout/sessions`, `GET /v1/checkout/sessions/{id}` | Supported | Creates subscription-mode sandbox checkout sessions from request line items and hosted Billtap URLs. The Stripe-style session response leaves `line_items` unexpanded. Accepts Stripe SDK form params `allow_promotion_codes`, `discounts[0][coupon]`, `discounts[0][promotion_code]`, and `subscription_data[trial_period_days]`; trial checkout creates local `trialing` subscription evidence. Hosted URLs use the request host by default, or `BILLTAP_PUBLIC_BASE_URL` when configured for container-to-host browser flows. | | Checkout completion | `POST /v1/checkout/sessions/{id}/complete`, `POST /api/checkout/sessions/{id}/complete` | Billtap-specific | Completes a sandbox checkout and creates subscription, invoice, payment intent, timeline, and checkout webhook evidence. Supports success plus deterministic failure aliases such as `card_declined`, `insufficient_funds`, `expired_card`, `incorrect_cvc`, `processing_error`, `authentication_required`, `payment_pending`, `canceled`, and documented Stripe test PaymentMethod IDs such as `pm_card_visa_chargeDeclined`. | | Billing portal sessions | `POST /v1/billing_portal/sessions` | Partial | Returns a Stripe-like `billing_portal.session` object and Billtap portal URL for a known customer, including `flow`, `locale`, `on_behalf_of`, `return_url`, and `livemode` fields. Accepts `customer`, `return_url`, optional `configuration`, `locale`, `on_behalf_of`, and `flow_data`, with enum and required nested-field validation for the modeled portal flows. The hosted portal can simulate saved payment-method updates and subscription cancellation, then redirect to `return_url`; full Stripe-hosted portal configuration rendering is not modeled. | -| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, and `cancel_at_period_end`. List supports arbitrary metadata equality filters such as `metadata[billtap_fixture_ref]`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | +| Subscriptions | `POST /v1/subscriptions`, `GET /v1/subscriptions`, `GET /v1/subscriptions/search`, `GET /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}`, `DELETE /v1/subscriptions/{id}`, `POST /v1/subscriptions/{id}/resume`, nested `GET/POST/DELETE /v1/customers/{id}/subscriptions...`, `GET/DELETE /v1/subscriptions/{id}/discount` | Partial | Create/list/retrieve subscriptions through the local checkout-completion state path. Update supports item replacement, metadata merge, single coupon or promotion-code discount application, `test_clock`, `cancel_at_period_end`, and `pause_collection` evidence; resume clears local pause evidence and emits `customer.subscription.updated`. Top-level and nested customer lists support status and metadata equality filters such as `metadata[billtap_fixture_ref]`. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. Delete performs immediate sandbox cancellation. Test-clock and scenario clock advances can activate due trials, renew active periods with bounded discount math, fail configured renewals from subscription or customer metadata, and cancel period-end subscriptions in the local billing graph. | | Subscription schedules | `POST /v1/subscription_schedules`, `GET /v1/subscription_schedules`, `GET /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/cancel`, `POST /v1/subscription_schedules/{id}/release` | Partial | Local schedule evidence for one due phase on an existing subscription. Test-clock advance applies a due phase by replacing subscription items and emits `customer.subscription.updated`; complex multi-phase billing, proration, invoices, and schedule lifecycle parity are not modeled. | | Subscription items | `POST /v1/subscription_items`, `DELETE /v1/subscription_items/{id}` | Partial | Add or remove local subscription items for integration smoke paths. Billing proration and invoice recalculation are not modeled. | -| Invoices | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve invoices created by checkout. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`. Invoice and preview responses include the common Stripe Invoice default fields expected by generated SDK models, including status transitions, automatic tax state, tax totals, payment settings, and array-shaped `discounts`. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; taxes, pending invoice items, and full invoice collection lifecycle are outside the modeled subset. | -| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local payment intents. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | +| Invoices | `GET /v1/invoices`, `GET /v1/invoices/search`, `GET /v1/invoices/{id}`, `POST /v1/invoices/{id}/pay`, `POST /v1/invoices/create_preview`, `GET/POST /v1/invoices/upcoming` | Partial | List/retrieve/search invoices created by checkout. Search supports `id`, `customer`, `subscription`, and `status` clauses joined by `AND`. `pay` retries open local invoices with deterministic sandbox `payment_method` or `source` aliases, mutating invoice, subscription, payment-intent, timeline, and webhook evidence. Preview endpoints calculate a Stripe-shaped subscription-update proration line from current period bounds, old/new price totals, `subscription_details[items]`, `subscription_details[proration_date]`, `subscription_details[proration_behavior]`, and `subscription_details[billing_cycle_anchor]`. Invoice and preview responses include the common Stripe Invoice default fields expected by generated SDK models, including status transitions, automatic tax state, tax totals, payment settings, and array-shaped `discounts`. A bounded single-discount subset updates `subtotal`, `total`, and `total_discount_amounts`; taxes, pending invoice items, and full invoice collection lifecycle are outside the modeled subset. | +| Payment intents | `POST /v1/payment_intents`, `GET /v1/payment_intents`, `GET /v1/payment_intents/search`, `GET /v1/payment_intents/{id}`, `POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, `POST /v1/payment_intents/{id}/cancel` | Partial | Create/list/retrieve/search and mutate local payment intents. Search supports `id`, `customer`, `status`, and metadata equality clauses joined by `AND`. `confirm` supports deterministic sandbox PaymentMethod aliases such as `pm_card_visa`, `pm_card_visa_chargeDeclined`, `pm_card_threeDSecure2Required`, `requires_action`, and local bank-transfer processing. Manual capture moves through `requires_capture` before `capture` succeeds. Direct one-time intents emit `payment_intent.created` and a terminal event when confirmed. Deferred per-intent outcomes and customer defaults are supported; `requires_action` can return either `use_stripe_sdk` or local `redirect_to_url` next-action payloads. | | Setup intents | `POST /v1/setup_intents`, `GET /v1/setup_intents`, `GET /v1/setup_intents/{id}`, `POST /v1/setup_intents/{id}/confirm`, `POST /v1/setup_intents/{id}/cancel` | Partial | Create/list/retrieve and mutate local setup intents with deterministic success, decline, and authentication-required aliases. Mandates, bank-account verification, and full SCA behavior are not modeled. | -| Payment methods | `GET /v1/payment_methods?customer={id}&type=card`, `GET /v1/customers/{id}/payment_methods?type=card` | Partial | Returns deterministic sandbox card PaymentMethod projections for known customers, including SDK-friendly `billing_details`, `card.checks`, `country`, `fingerprint`, `funding`, `networks`, `three_d_secure_usage`, `metadata`, and `redaction` fields. Query validation covers `type`, `allow_redisplay`, `limit`, and unknown parameters; non-card valid types return an empty local list. Customer fixtures can opt into empty or explicit payment-method lists for no-card and multi-card local scenarios. Create, attach, detach, and general update are not supported. | +| 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. | diff --git a/docs/COMPATIBILITY_TRACKING.md b/docs/COMPATIBILITY_TRACKING.md index 7d1fe9b..0672349 100644 --- a/docs/COMPATIBILITY_TRACKING.md +++ b/docs/COMPATIBILITY_TRACKING.md @@ -84,7 +84,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: `144 / 587`, `24.5%` +- current baseline: `160 / 587`, `27.3%` - 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 f4e61a4..90d73a6 100644 --- a/docs/GATE_STATUS.md +++ b/docs/GATE_STATUS.md @@ -18,7 +18,7 @@ This is the public gate snapshot. Internal adoption evidence and raw handoff not | 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 | | 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 144/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 160/587 baseline, family thresholds, and chunk plan | ## Current Public Claim @@ -35,22 +35,21 @@ under Apache-2.0. - 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: `144 / 587`, `24.5%` +- OpenAPI operation baseline: `160 / 587`, `27.3%` - 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 `origin/main` after PR #50: +Verified on 2026-05-12 from branch `simulation-capacity-backlog`: - `go test ./... -count=1` -- `go run ./cmd/billtap compatibility scorecard --output-dir /tmp/billtap-scorecard-current` +- `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-current --source stripe-openapi-local` - - result: `operations=587 implemented=144 inventory_only=443 schema_validated=587 implemented_percent=24.5` -- PR #50 release gate passed on `main`, including Go tests, compatibility scorecard, frontend typecheck/build, sample app smoke, Stripe SDK smoke, web UI smoke, binary build, scenario smoke, and Docker build. -- `docker pull ghcr.io/midagedev/billtap:main` - - result: `ghcr.io/midagedev/billtap@sha256:66072bae9d5dfc346df8cf24099f1ca184711aed0d9a7eacaf19c9d381369136` +- `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. +- 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/SIMULATION_CAPACITY_BACKLOG.md b/docs/SIMULATION_CAPACITY_BACKLOG.md index 1634575..ca26ea9 100644 --- a/docs/SIMULATION_CAPACITY_BACKLOG.md +++ b/docs/SIMULATION_CAPACITY_BACKLOG.md @@ -25,8 +25,9 @@ these public capability rows in their own private docs. Billtap currently has a stateful Stripe-like local subset with: -- customers, products, prices, price search, coupons, promotion codes, checkout - sessions, subscriptions, subscription schedules, invoices, invoice preview, +- customers, products, prices, customer/product/price/subscription/invoice/ + payment-intent search, coupons, promotion codes, 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 @@ -35,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: `144 / 587` implemented operations, `24.5%` +- OpenAPI inventory baseline: `160 / 587` implemented operations, `27.3%` ## P0 Regression-Paired Backlog @@ -48,7 +49,7 @@ regression specs easier to close with deterministic Billtap evidence. | Trial to cancel history | Immediate cancellation and period-end cancellation exist; trial cancellation history needs a direct scenario contract. | Add scenario action/fixture pattern that creates trialing subscription, cancels it before activation, and leaves retrievable canceled history. | Scenario report and webhook sequence assertions. | | Proration invariant matrix | Subscription-update invoice preview calculates bounded proration and Stripe SDK-friendly invoice fields. | Add a matrix of small positive, large positive, negative/downgrade, zero, and full-credit preview cases. | API tests for exact `amount_due`, `subtotal`, line periods, and discount/tax-neutral totals. | | Renewal failure depth | Renewal failure can move subscriptions to `past_due` with invoice/payment-intent evidence. | Add configurable retry/dunning phases: first failure, retry failure, unpaid, grace window, and final cancellation. | Test-clock scenario reports and event ordering assertions. | -| Customer subscription history filters | Top-level subscription listing exists; customer nested subscription routes remain inventory-only. | Add `/v1/customers/{customer}/subscriptions` list with `status` filter coverage for `trialing`, `active`, `past_due`, `canceled`, `incomplete`, `unpaid`, and `all`. | Stripe-shaped list response tests and fixture history scenarios. | +| Customer subscription history filters | Implemented for nested customer subscription list/retrieve/update/delete routes with `status=...` and metadata equality filters. | Extend fixture examples to cover dedicated trial-history-only customers and mixed status catalogs. | Stripe-shaped list response tests exist; add scenario-level fixture history assertions. | ## Capability Matrix @@ -59,7 +60,7 @@ regression specs easier to close with deterministic Billtap evidence. | trial to active | Implemented with test clocks and fixture dates. | Done | | trial to cancel history | Partial through cancellation APIs; needs dedicated scenario pattern. | P0 | | past_due and unpaid transitions | Partial renewal failure; needs retry/dunning phases. | P0 | -| pause and resume API | Not modeled. | P1 | +| pause and resume API | Implemented as local `pause_collection` evidence plus `POST /v1/subscriptions/{id}/resume`; billing-cycle side effects remain bounded. | P1 | | subscription schedule plan change | One due phase can replace items; full scheduled downgrade/proration remains bounded. | P1 | | proration behavior `always_invoice`, `create_prorations`, `none` | Preview supports the three values; invoice creation side effects remain bounded. | P0 | | cancel at period end vs immediate cancel | Implemented through API/portal/test-clock paths. | Done | @@ -69,9 +70,9 @@ regression specs easier to close with deterministic Billtap evidence. | Capability | Current state | Priority | | --- | --- | --- | -| customer subscription list status filter | Nested route inventory exists; implementation needed. | P0 | +| customer subscription list status filter | Implemented for `/v1/customers/{customer}/subscriptions` and top-level subscription lists. | Done | | arbitrary customer metadata seed | Implemented through fixtures and customer APIs. | Done | -| customer search by email/metadata | Inventory-only. | P1 | +| customer search by email/metadata | Implemented for `id`, `email`, `name`, and metadata equality clauses joined by `AND`. | Done | | customer delete and recreate history policy | Not modeled. | P1 | | cross-merchant customer migration | Connect evidence exists; migration policy not modeled. | P2 | | tax IDs, shipping, preferred locale | Partially represented in validation inventory; stateful coverage needed. | P2 | @@ -95,7 +96,7 @@ regression specs easier to close with deterministic Billtap evidence. | Capability | Current state | Priority | | --- | --- | --- | | 3D Secure / requires_action | Direct PaymentIntent and SetupIntent require-action simulation exists. | Done | -| saved card vs new card | Customer payment-method fixtures and portal save exist; create/attach/detach breadth remains. | P1 | +| saved card vs new card | Customer payment-method fixtures, portal save, and local create/attach/detach projection are implemented. | Done | | wallet token simulation | Not modeled. | P2 | | decline reason breadth | Common aliases exist; complete card-decline catalog remains. | P1 | | provider outage mode | Not modeled. | P2 | diff --git a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md index f91afdc..e7f92cc 100644 --- a/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md +++ b/docs/STRIPE_API_COMPATIBILITY_ROADMAP.md @@ -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 `144 / 587` baseline to at least `529 / 587` +- move from the current `160 / 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 @@ -277,9 +277,9 @@ questions quickly. "generated_at": "2026-05-12T00:00:00Z", "summary": { "total_operations": 587, - "implemented_operations": 144, - "inventory_only_operations": 443, - "implemented_percent": 24.5, + "implemented_operations": 160, + "inventory_only_operations": 427, + "implemented_percent": 27.3, "families": [ { "family": "connect", diff --git a/docs/STRIPE_COMPATIBILITY_90_TARGET.md b/docs/STRIPE_COMPATIBILITY_90_TARGET.md index 201d22c..47287b5 100644 --- a/docs/STRIPE_COMPATIBILITY_90_TARGET.md +++ b/docs/STRIPE_COMPATIBILITY_90_TARGET.md @@ -11,7 +11,7 @@ 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:** `144 / 587` operations, `24.5%`, using Stripe OpenAPI +- **Current baseline:** `160 / 587` operations, `27.3%`, using Stripe OpenAPI `2026-04-22.dahlia` from the local OpenAPI snapshot on 2026-05-12. - **Minimum target count:** `529 / 587` operations at `L1` or higher. - **Remaining inventory-only budget:** at most `58 / 587` operations at `L0`. @@ -116,16 +116,18 @@ This raises the generated inventory from `98 / 587` (`16.7%`) to `110 / 587` The post-Connect billing, catalog, customer, payments, and payment-history chunks raised the generated inventory from `110 / 587` (`18.7%`) to -`144 / 587` (`24.5%`). The most recent visible changes include coupon and -promotion-code application, price search, direct PaymentIntent outcomes, -invoice payment failure evidence, dispute/refund/credit-note coverage, -subscription-update proration previews, and Stripe SDK-friendly invoice preview -response shape. +`160 / 587` (`27.3%`). The most recent visible changes include coupon and +promotion-code application, customer/catalog/billing-object search, direct +PaymentIntent outcomes, invoice payment failure evidence, dispute/refund/ +credit-note coverage, subscription-update proration previews, +Stripe SDK-friendly invoice preview response shape, nested customer +subscription history routes, subscription pause/resume evidence, and +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 `+385` operations needed to move the current `144 / 587` baseline to +than the `+369` operations needed to move the current `160 / 587` baseline to the `529 / 587` target. ## Derived Gate Checks diff --git a/internal/api/api.go b/internal/api/api.go index b4e55b2..c6b61b3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -70,6 +70,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) routes() { h.mux.HandleFunc("/v1/customers", h.handleCustomers) + h.mux.HandleFunc("/v1/customers/search", h.handleCustomerSearch) h.mux.HandleFunc("/v1/customers/", h.handleCustomer) h.mux.HandleFunc("/v1/products", h.handleProducts) h.mux.HandleFunc("/v1/products/search", h.handleProductSearch) @@ -96,6 +97,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/checkout/sessions/", h.handleCheckoutSession) h.mux.HandleFunc("/v1/billing_portal/sessions", h.handleBillingPortalSessions) h.mux.HandleFunc("/v1/subscriptions", h.handleSubscriptions) + h.mux.HandleFunc("/v1/subscriptions/search", h.handleSubscriptionSearch) h.mux.HandleFunc("/v1/subscriptions/", h.handleSubscription) h.mux.HandleFunc("/v1/subscription_schedules", h.handleSubscriptionSchedules) h.mux.HandleFunc("/v1/subscription_schedules/", h.handleSubscriptionSchedule) @@ -104,12 +106,14 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/invoices/create_preview", h.handleInvoicePreview) h.mux.HandleFunc("/v1/invoices/upcoming", h.handleInvoicePreview) h.mux.HandleFunc("/v1/invoices", h.handleInvoices) + h.mux.HandleFunc("/v1/invoices/search", h.handleInvoiceSearch) h.mux.HandleFunc("/v1/invoices/", h.handleInvoice) h.mux.HandleFunc("/v1/refunds", h.handleRefunds) h.mux.HandleFunc("/v1/refunds/", h.handleRefund) h.mux.HandleFunc("/v1/credit_notes", h.handleCreditNotes) h.mux.HandleFunc("/v1/credit_notes/", h.handleCreditNote) h.mux.HandleFunc("/v1/payment_intents", h.handlePaymentIntents) + h.mux.HandleFunc("/v1/payment_intents/search", h.handlePaymentIntentSearch) h.mux.HandleFunc("/v1/payment_intents/", h.handlePaymentIntent) h.mux.HandleFunc("/v1/setup_intents", h.handleSetupIntents) h.mux.HandleFunc("/v1/setup_intents/", h.handleSetupIntent) @@ -120,6 +124,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/test_helpers/test_clocks", h.handleTestClocks) h.mux.HandleFunc("/v1/test_helpers/test_clocks/", h.handleTestClock) h.mux.HandleFunc("/v1/payment_methods", h.handlePaymentMethods) + h.mux.HandleFunc("/v1/payment_methods/", h.handlePaymentMethod) h.mux.HandleFunc("/v1/webhook_endpoints", h.handleWebhookEndpoints) h.mux.HandleFunc("/v1/webhook_endpoints/", h.handleWebhookEndpoint) h.mux.HandleFunc("/v1/events", h.handleEvents) @@ -263,6 +268,37 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handleCustomerSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + p := paramsFromValues(r.URL.Query()) + if err := validateCustomerSearch(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + criteria, err := parseCustomerSearchQuery(p.string("query")) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + customers, err := h.billing.ListCustomers(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterCustomerSearchResults(customers, criteria) + if limit := queryInt(r, "limit"); limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + data := make([]map[string]any, 0, len(filtered)) + for _, customer := range filtered { + data = append(data, stripeCustomer(customer)) + } + writeResult(w, stripeSearchResult(r.URL.Path, p.string("query"), data), nil) +} + func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/v1/customers/") id, subresource, hasSubresource := strings.Cut(rest, "/") @@ -332,6 +368,8 @@ func (h *Handler) handleCustomerSubresource(w http.ResponseWriter, r *http.Reque h.handleCustomerCashBalanceTransactions(w, r, customerID, "") case "payment_methods": h.handleCustomerPaymentMethods(w, r, customerID) + case "subscriptions": + h.handleCustomerSubscriptions(w, r, customerID) case "discount": h.handleCustomerDiscount(w, r, customerID) default: @@ -339,16 +377,75 @@ func (h *Handler) handleCustomerSubresource(w http.ResponseWriter, r *http.Reque h.handleCustomerCashBalanceTransactions(w, r, customerID, strings.TrimPrefix(subresource, "cash_balance_transactions/")) return } + if strings.HasPrefix(subresource, "payment_methods/") { + h.handleCustomerPaymentMethod(w, r, customerID, strings.TrimPrefix(subresource, "payment_methods/")) + return + } if strings.HasPrefix(subresource, "subscriptions/") { - rest := strings.TrimPrefix(subresource, "subscriptions/") - subscriptionID, nested, ok := strings.Cut(rest, "/") - if ok && nested == "discount" && subscriptionID != "" { - h.handleSubscriptionDiscount(w, r, subscriptionID) - return + h.handleCustomerSubscription(w, r, customerID, strings.TrimPrefix(subresource, "subscriptions/")) + return + } + h.notFound(w, r) + } +} + +func (h *Handler) handleCustomerSubscriptions(w http.ResponseWriter, r *http.Request, customerID string) { + if _, err := h.billing.GetCustomer(r.Context(), customerID); err != nil { + writeResult(w, nil, err) + return + } + switch r.Method { + case http.MethodGet: + items, err := h.billing.ListSubscriptions(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterSubscriptionsForCustomer(items, r, customerID) + data := make([]map[string]any, 0, len(filtered)) + for _, item := range filtered { + data = append(data, h.stripeSubscription(r, item)) + if limit := queryInt(r, "limit"); limit > 0 && len(data) >= limit { + break } } + writeResult(w, stripeList(r.URL.Path, data), nil) + case http.MethodPost: + subscription, err := h.createSubscriptionFromParamsWithCustomer(r, customerID) + writeResult(w, h.stripeSubscription(r, subscription), err) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func (h *Handler) handleCustomerSubscription(w http.ResponseWriter, r *http.Request, customerID string, rest string) { + subscriptionID, nested, hasNested := strings.Cut(strings.Trim(rest, "/"), "/") + if subscriptionID == "" { + h.notFound(w, r) + return + } + if hasNested { + if nested == "discount" { + h.handleSubscriptionDiscount(w, r, subscriptionID) + return + } h.notFound(w, r) + return + } + 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 } + r2 := r.Clone(r.Context()) + u := *r.URL + u.Path = "/v1/subscriptions/" + subscriptionID + r2.URL = &u + h.handleSubscription(w, r2) } func (h *Handler) handleProducts(w http.ResponseWriter, r *http.Request) { @@ -1667,11 +1764,49 @@ func (h *Handler) handleSubscriptions(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handleSubscriptionSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + p := paramsFromValues(r.URL.Query()) + if err := validateSearch(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + criteria, err := parseObjectSearchQuery(p.string("query"), true) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + items, err := h.billing.ListSubscriptions(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterSubscriptionSearchResults(items, criteria) + if limit := queryInt(r, "limit"); limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + data := make([]map[string]any, 0, len(filtered)) + for _, item := range filtered { + data = append(data, h.stripeSubscription(r, item)) + } + writeResult(w, stripeSearchResult(r.URL.Path, p.string("query"), data), nil) +} + func (h *Handler) createSubscriptionFromParams(r *http.Request) (billing.Subscription, error) { + return h.createSubscriptionFromParamsWithCustomer(r, "") +} + +func (h *Handler) createSubscriptionFromParamsWithCustomer(r *http.Request, defaultCustomerID string) (billing.Subscription, error) { p, err := parseParams(r) if err != nil { return billing.Subscription{}, err } + if defaultCustomerID != "" && p.first("customer", "customer_id") == "" { + p.values["customer"] = defaultCustomerID + } if err := validateSubscriptionCreate(p); err != nil { return billing.Subscription{}, err } @@ -1757,6 +1892,15 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { h.handleSubscriptionDiscount(w, r, subscriptionID) return } + if strings.HasSuffix(id, "/resume") { + subscriptionID := strings.TrimSuffix(id, "/resume") + if subscriptionID == "" || strings.Contains(subscriptionID, "/") { + h.notFound(w, r) + return + } + h.handleSubscriptionResume(w, r, subscriptionID) + return + } if id == "" || strings.Contains(id, "/") { h.notFound(w, r) return @@ -1818,6 +1962,36 @@ func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handleSubscriptionResume(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 := validateSubscriptionResume(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + metadata := map[string]string{ + "pause_collection_behavior": "", + "pause_collection_resumes_at": "", + } + subscription, err := h.billing.PatchSubscription(r.Context(), subscriptionID, billing.SubscriptionPatch{ + Metadata: metadata, + TimelineAction: "customer.subscription.resumed", + TimelineMessage: "Stripe-compatible subscription resumed", + TimelineSource: "stripe_compat_resume", + }) + if err == nil { + h.emitSubscriptionWebhook(r, "customer.subscription.updated", subscription, webhooks.SourceAPI) + } + writeResult(w, h.stripeSubscription(r, subscription), err) +} + func (h *Handler) handleSubscriptionItems(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { h.methodNotAllowed(w, r, "POST") @@ -2235,6 +2409,37 @@ func (h *Handler) handleInvoices(w http.ResponseWriter, r *http.Request) { writeResult(w, stripeList(r.URL.Path, data), nil) } +func (h *Handler) handleInvoiceSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + p := paramsFromValues(r.URL.Query()) + if err := validateSearch(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + criteria, err := parseObjectSearchQuery(p.string("query"), false) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + items, err := h.billing.ListInvoices(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterInvoiceSearchResults(items, criteria) + if limit := queryInt(r, "limit"); limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + data := make([]map[string]any, 0, len(filtered)) + for _, item := range filtered { + data = append(data, stripeInvoice(item)) + } + writeResult(w, stripeSearchResult(r.URL.Path, p.string("query"), data), nil) +} + func (h *Handler) handleInvoice(w http.ResponseWriter, r *http.Request) { rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/invoices/"), "/") if rest == "" { @@ -2516,6 +2721,37 @@ func (h *Handler) handlePaymentIntents(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) handlePaymentIntentSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + h.methodNotAllowed(w, r, "GET") + return + } + p := paramsFromValues(r.URL.Query()) + if err := validateSearch(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + criteria, err := parseObjectSearchQuery(p.string("query"), true) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + items, err := h.billing.ListPaymentIntents(r.Context()) + if err != nil { + writeResult(w, nil, err) + return + } + filtered := filterPaymentIntentSearchResults(items, criteria) + if limit := queryInt(r, "limit"); limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + data := make([]map[string]any, 0, len(filtered)) + for _, item := range filtered { + data = append(data, stripePaymentIntent(item)) + } + writeResult(w, stripeSearchResult(r.URL.Path, p.string("query"), data), nil) +} + func (h *Handler) handlePaymentIntent(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/v1/payment_intents/") id, action, hasAction := strings.Cut(rest, "/") @@ -2780,20 +3016,173 @@ func (h *Handler) handleTestClock(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handlePaymentMethods(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + customerID := r.URL.Query().Get("customer") + h.writeCustomerPaymentMethods(w, r, customerID) + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validatePaymentMethodCreate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + paymentMethodID := p.stringDefault("id", "pm_"+sanitizeID(strconv.FormatInt(time.Now().UTC().UnixNano(), 36))) + customerID := p.string("customer") + if customerID != "" { + customer, err := h.attachPaymentMethod(r.Context(), customerID, paymentMethodID) + if err != nil { + writeResult(w, nil, err) + return + } + result := stripePaymentMethod(customer.ID, paymentMethodID) + h.emitGenericWebhook(r, "payment_method.attached", paymentMethodID, result, webhooks.SourceAPI) + writeResult(w, result, nil) + return + } + writeResult(w, stripeDetachedPaymentMethod(paymentMethodID), nil) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func (h *Handler) handlePaymentMethod(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/v1/payment_methods/") + paymentMethodID, action, hasAction := strings.Cut(strings.Trim(rest, "/"), "/") + if paymentMethodID == "" { + h.notFound(w, r) + return + } + if hasAction { + switch action { + case "attach": + h.handlePaymentMethodAttach(w, r, paymentMethodID) + case "detach": + h.handlePaymentMethodDetach(w, r, paymentMethodID) + default: + h.notFound(w, r) + } + return + } + switch r.Method { + case http.MethodGet: + customer, ok, err := h.findPaymentMethodCustomer(r.Context(), paymentMethodID) + if err != nil { + writeResult(w, nil, err) + return + } + if ok { + writeResult(w, stripePaymentMethod(customer.ID, paymentMethodID), nil) + return + } + if strings.HasPrefix(paymentMethodID, "pm_") { + writeResult(w, stripeDetachedPaymentMethod(paymentMethodID), nil) + return + } + writeResult(w, nil, billing.ErrNotFound) + case http.MethodPost: + p, err := parseParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validatePaymentMethodUpdate(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + customer, ok, err := h.findPaymentMethodCustomer(r.Context(), paymentMethodID) + if err != nil { + writeResult(w, nil, err) + return + } + if ok { + writeResult(w, stripePaymentMethod(customer.ID, paymentMethodID), nil) + return + } + writeResult(w, stripeDetachedPaymentMethod(paymentMethodID), nil) + default: + h.methodNotAllowed(w, r, "GET, POST") + } +} + +func (h *Handler) handlePaymentMethodAttach(w http.ResponseWriter, r *http.Request, paymentMethodID 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 := validatePaymentMethodAttach(p); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + customer, err := h.attachPaymentMethod(r.Context(), p.string("customer"), paymentMethodID) + if err != nil { + writeResult(w, nil, err) + return + } + result := stripePaymentMethod(customer.ID, paymentMethodID) + h.emitGenericWebhook(r, "payment_method.attached", paymentMethodID, result, webhooks.SourceAPI) + writeResult(w, result, nil) +} + +func (h *Handler) handlePaymentMethodDetach(w http.ResponseWriter, r *http.Request, paymentMethodID string) { + if r.Method != http.MethodPost { + h.methodNotAllowed(w, r, "POST") + return + } + customer, ok, err := h.findPaymentMethodCustomer(r.Context(), paymentMethodID) + if err != nil { + writeResult(w, nil, err) + return + } + if !ok { + writeResult(w, nil, billing.ErrNotFound) + return + } + metadata := copyStringMap(customer.Metadata) + metadata[billing.MetadataPaymentMethodIDs] = strings.Join(removePaymentMethodID(splitPaymentMethodIDs(metadata[billing.MetadataPaymentMethodIDs]), paymentMethodID), ",") + if metadata[billing.MetadataDefaultPaymentMethod] == paymentMethodID { + delete(metadata, billing.MetadataDefaultPaymentMethod) + } + if _, err := h.billing.UpdateCustomer(r.Context(), customer.ID, billing.Customer{Metadata: metadata}); err != nil { + writeResult(w, nil, err) + return + } + writeResult(w, stripeDetachedPaymentMethod(paymentMethodID), nil) +} + +func (h *Handler) handleCustomerPaymentMethods(w http.ResponseWriter, r *http.Request, customerID string) { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") return } - customerID := r.URL.Query().Get("customer") h.writeCustomerPaymentMethods(w, r, customerID) } -func (h *Handler) handleCustomerPaymentMethods(w http.ResponseWriter, r *http.Request, customerID string) { +func (h *Handler) handleCustomerPaymentMethod(w http.ResponseWriter, r *http.Request, customerID string, paymentMethodID string) { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") return } - h.writeCustomerPaymentMethods(w, r, customerID) + customer, err := h.billing.GetCustomer(r.Context(), customerID) + if err != nil { + writeResult(w, nil, err) + return + } + for _, id := range customerPaymentMethodIDs(customer) { + if id == paymentMethodID { + writeResult(w, stripePaymentMethod(customer.ID, paymentMethodID), nil) + return + } + } + writeResult(w, nil, billing.ErrNotFound) } func (h *Handler) writeCustomerPaymentMethods(w http.ResponseWriter, r *http.Request, customerID string) { @@ -2818,6 +3207,33 @@ func (h *Handler) writeCustomerPaymentMethods(w http.ResponseWriter, r *http.Req writeResult(w, stripeList(r.URL.Path, stripePaymentMethods(customer)), nil) } +func (h *Handler) attachPaymentMethod(ctx context.Context, customerID string, paymentMethodID string) (billing.Customer, error) { + customer, err := h.billing.GetCustomer(ctx, customerID) + if err != nil { + return billing.Customer{}, err + } + metadata := copyStringMap(customer.Metadata) + ids := append(splitPaymentMethodIDs(metadata[billing.MetadataPaymentMethodIDs]), paymentMethodID) + metadata[billing.MetadataPaymentMethodIDs] = strings.Join(uniquePaymentMethodIDs(ids), ",") + metadata[billing.MetadataPaymentMethodsFixture] = billing.PaymentMethodsFixtureExplicit + return h.billing.UpdateCustomer(ctx, customer.ID, billing.Customer{Metadata: metadata}) +} + +func (h *Handler) findPaymentMethodCustomer(ctx context.Context, paymentMethodID string) (billing.Customer, bool, error) { + customers, err := h.billing.ListCustomers(ctx) + if err != nil { + return billing.Customer{}, false, err + } + for _, customer := range customers { + for _, id := range customerPaymentMethodIDs(customer) { + if id == paymentMethodID { + return customer, true, nil + } + } + } + return billing.Customer{}, false, nil +} + func (h *Handler) handleObjects(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { h.methodNotAllowed(w, r, "GET") @@ -4446,6 +4862,12 @@ func stripePaymentMethod(customerID string, paymentMethodID string) map[string]a } } +func stripeDetachedPaymentMethod(paymentMethodID string) map[string]any { + out := stripePaymentMethod("", paymentMethodID) + out["customer"] = nil + return out +} + func stripePaymentMethods(customer billing.Customer) []map[string]any { ids := customerPaymentMethodIDs(customer) data := make([]map[string]any, 0, len(ids)) @@ -4507,6 +4929,16 @@ func uniquePaymentMethodIDs(ids []string) []string { return out } +func removePaymentMethodID(ids []string, paymentMethodID string) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + if id != paymentMethodID { + out = append(out, id) + } + } + return out +} + func paymentMethodLast4(paymentMethodID string) string { switch strings.ToLower(strings.TrimSpace(paymentMethodID)) { case "pm_card_visa", "pm_card_visa_debit": @@ -4947,12 +5379,23 @@ func (h *Handler) stripeSubscription(r *http.Request, sub billing.Subscription) "billing_cycle_anchor": unix(sub.CurrentPeriodStart), "currency": "usd", "livemode": false, - "pause_collection": nil, + "pause_collection": subscriptionPauseCollection(sub), "pending_update": nil, "cancellation_details": subscriptionCancellationDetails(sub), } } +func subscriptionPauseCollection(sub billing.Subscription) any { + behavior := strings.TrimSpace(sub.Metadata["pause_collection_behavior"]) + if behavior == "" { + return nil + } + return map[string]any{ + "behavior": behavior, + "resumes_at": metadataUnix(sub.Metadata["pause_collection_resumes_at"]), + } +} + func subscriptionCancelAt(sub billing.Subscription) any { if !sub.CancelAtPeriodEnd { return nil @@ -5351,6 +5794,26 @@ func filterSubscriptions(items []billing.Subscription, r *http.Request) []billin return out } +func filterSubscriptionsForCustomer(items []billing.Subscription, r *http.Request, customerID string) []billing.Subscription { + query := r.URL.Query() + metadataFilters := queryMetadataFilters(query) + out := make([]billing.Subscription, 0, len(items)) + for _, item := range items { + if item.CustomerID != customerID { + continue + } + status := strings.ToLower(query.Get("status")) + if status != "" && status != "all" && item.Status != status { + continue + } + if !metadataMatches(item.Metadata, metadataFilters) { + continue + } + out = append(out, item) + } + return out +} + func queryMetadataFilters(query url.Values) map[string]string { out := map[string]string{} for key, values := range query { @@ -5405,14 +5868,186 @@ type priceSearchCriteria struct { Metadata map[string]string } +type customerSearchCriteria struct { + ID string + Email string + Name string + Metadata map[string]string +} + +type objectSearchCriteria struct { + ID string + CustomerID string + Subscription string + Status string + Metadata map[string]string +} + var ( - priceSearchActiveClause = regexp.MustCompile(`^active:'(true|false)'$`) - priceSearchTypeClause = regexp.MustCompile(`^type:'(one_time|recurring)'$`) - priceSearchLookupClause = regexp.MustCompile(`^lookup_key:'([^']*)'$`) - priceSearchMetadataClause = regexp.MustCompile(`^metadata\['([^']+)'\]:'([^']*)'$`) - priceSearchANDPattern = regexp.MustCompile(`(?i)\s+AND\s+`) + priceSearchActiveClause = regexp.MustCompile(`^active:'(true|false)'$`) + priceSearchTypeClause = regexp.MustCompile(`^type:'(one_time|recurring)'$`) + priceSearchLookupClause = regexp.MustCompile(`^lookup_key:'([^']*)'$`) + priceSearchMetadataClause = regexp.MustCompile(`^metadata\['([^']+)'\]:'([^']*)'$`) + customerSearchIDClause = regexp.MustCompile(`^id:'([^']*)'$`) + customerSearchEmailClause = regexp.MustCompile(`^email:'([^']*)'$`) + customerSearchNameClause = regexp.MustCompile(`^name:'([^']*)'$`) + objectSearchCustomerClause = regexp.MustCompile(`^customer:'([^']*)'$`) + objectSearchStatusClause = regexp.MustCompile(`^status:'([^']*)'$`) + objectSearchSubscriptionClause = regexp.MustCompile(`^subscription:'([^']*)'$`) + priceSearchANDPattern = regexp.MustCompile(`(?i)\s+AND\s+`) ) +func parseCustomerSearchQuery(query string) (customerSearchCriteria, error) { + query = strings.TrimSpace(query) + if query == "" { + return customerSearchCriteria{}, missingParam("query") + } + criteria := customerSearchCriteria{Metadata: map[string]string{}} + for _, rawClause := range priceSearchANDPattern.Split(query, -1) { + clause := strings.TrimSpace(rawClause) + if clause == "" { + return customerSearchCriteria{}, invalidParam("query", "Expected field:'value' clauses joined by AND.") + } + switch { + case customerSearchIDClause.MatchString(clause): + match := customerSearchIDClause.FindStringSubmatch(clause) + criteria.ID = match[1] + case customerSearchEmailClause.MatchString(clause): + match := customerSearchEmailClause.FindStringSubmatch(clause) + criteria.Email = match[1] + case customerSearchNameClause.MatchString(clause): + match := customerSearchNameClause.FindStringSubmatch(clause) + criteria.Name = match[1] + case priceSearchMetadataClause.MatchString(clause): + match := priceSearchMetadataClause.FindStringSubmatch(clause) + criteria.Metadata[match[1]] = match[2] + default: + return customerSearchCriteria{}, invalidParam("query", "Unsupported customers search clause: "+clause+".") + } + } + if len(criteria.Metadata) == 0 { + criteria.Metadata = nil + } + return criteria, nil +} + +func filterCustomerSearchResults(customers []billing.Customer, criteria customerSearchCriteria) []billing.Customer { + out := make([]billing.Customer, 0, len(customers)) + for _, customer := range customers { + if criteria.ID != "" && customer.ID != criteria.ID { + continue + } + if criteria.Email != "" && customer.Email != criteria.Email { + continue + } + if criteria.Name != "" && customer.Name != criteria.Name { + continue + } + if !metadataMatches(customer.Metadata, criteria.Metadata) { + continue + } + out = append(out, customer) + } + return out +} + +func parseObjectSearchQuery(query string, allowMetadata bool) (objectSearchCriteria, error) { + query = strings.TrimSpace(query) + if query == "" { + return objectSearchCriteria{}, missingParam("query") + } + criteria := objectSearchCriteria{Metadata: map[string]string{}} + for _, rawClause := range priceSearchANDPattern.Split(query, -1) { + clause := strings.TrimSpace(rawClause) + if clause == "" { + return objectSearchCriteria{}, invalidParam("query", "Expected field:'value' clauses joined by AND.") + } + switch { + case customerSearchIDClause.MatchString(clause): + match := customerSearchIDClause.FindStringSubmatch(clause) + criteria.ID = match[1] + case objectSearchCustomerClause.MatchString(clause): + match := objectSearchCustomerClause.FindStringSubmatch(clause) + criteria.CustomerID = match[1] + case objectSearchStatusClause.MatchString(clause): + match := objectSearchStatusClause.FindStringSubmatch(clause) + criteria.Status = match[1] + case objectSearchSubscriptionClause.MatchString(clause): + match := objectSearchSubscriptionClause.FindStringSubmatch(clause) + criteria.Subscription = match[1] + case allowMetadata && priceSearchMetadataClause.MatchString(clause): + match := priceSearchMetadataClause.FindStringSubmatch(clause) + criteria.Metadata[match[1]] = match[2] + default: + return objectSearchCriteria{}, invalidParam("query", "Unsupported search clause: "+clause+".") + } + } + if len(criteria.Metadata) == 0 { + criteria.Metadata = nil + } + return criteria, nil +} + +func filterSubscriptionSearchResults(items []billing.Subscription, criteria objectSearchCriteria) []billing.Subscription { + out := make([]billing.Subscription, 0, len(items)) + for _, item := range items { + if criteria.ID != "" && item.ID != criteria.ID { + continue + } + if criteria.CustomerID != "" && item.CustomerID != criteria.CustomerID { + continue + } + if criteria.Status != "" && item.Status != criteria.Status { + continue + } + if !metadataMatches(item.Metadata, criteria.Metadata) { + continue + } + out = append(out, item) + } + return out +} + +func filterInvoiceSearchResults(items []billing.Invoice, criteria objectSearchCriteria) []billing.Invoice { + out := make([]billing.Invoice, 0, len(items)) + for _, item := range items { + if criteria.ID != "" && item.ID != criteria.ID { + continue + } + if criteria.CustomerID != "" && item.CustomerID != criteria.CustomerID { + continue + } + if criteria.Subscription != "" && item.SubscriptionID != criteria.Subscription { + continue + } + if criteria.Status != "" && item.Status != criteria.Status { + continue + } + out = append(out, item) + } + return out +} + +func filterPaymentIntentSearchResults(items []billing.PaymentIntent, criteria objectSearchCriteria) []billing.PaymentIntent { + out := make([]billing.PaymentIntent, 0, len(items)) + for _, item := range items { + if criteria.ID != "" && item.ID != criteria.ID { + continue + } + if criteria.CustomerID != "" && item.CustomerID != criteria.CustomerID { + continue + } + if criteria.Status != "" && item.Status != criteria.Status { + continue + } + if !metadataMatches(item.Metadata, criteria.Metadata) { + continue + } + out = append(out, item) + } + return out +} + func parsePriceSearchQuery(query string) (priceSearchCriteria, error) { query = strings.TrimSpace(query) if query == "" { @@ -5599,6 +6234,21 @@ func subscriptionUpdateMetadata(p params) map[string]string { metadata[item.key] = value } } + if value := p.string("pause_collection[behavior]"); value != "" { + if metadata == nil { + metadata = map[string]string{} + } + metadata["pause_collection_behavior"] = value + if resumesAt := p.string("pause_collection[resumes_at]"); resumesAt != "" { + metadata["pause_collection_resumes_at"] = resumesAt + } + } else if _, ok := p.values["pause_collection"]; ok { + if metadata == nil { + metadata = map[string]string{} + } + metadata["pause_collection_behavior"] = "" + metadata["pause_collection_resumes_at"] = "" + } return metadata } @@ -5652,6 +6302,9 @@ func metadataUnix(value string) any { if strings.TrimSpace(value) == "" { return nil } + if seconds, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil { + return seconds + } parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil || parsed.IsZero() { return nil diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c4d4b55..4d4cc77 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1081,13 +1081,13 @@ func TestKnownStripeRouteUnsupportedFallback(t *testing.T) { t.Fatalf("message=%q, want normalized known Stripe route", errBody.Error.Message) } - searchReq := httptest.NewRequest(http.MethodGet, "/v1/customers/search?query=email:'buyer@example.test'", nil) + searchReq := httptest.NewRequest(http.MethodGet, "/v1/charges/search?query=metadata['case']:'known'", nil) searchReq.Header.Set("Request-Id", "req_known_search_unsupported") searchRec := httptest.NewRecorder() handler.ServeHTTP(searchRec, searchReq) searchErr := decodeErrorBody(t, searchRec.Body.String()) if searchRec.Code != http.StatusBadRequest || searchErr.Error.Code != "unsupported_endpoint" { - t.Fatalf("customer search status=%d error=%#v, want known-route unsupported", searchRec.Code, searchErr.Error) + t.Fatalf("charges search status=%d error=%#v, want known-route unsupported", searchRec.Code, searchErr.Error) } v2Status, v2Body := postFormStatusWithHeaders(t, handler, "/v2/core/accounts", url.Values{}, map[string]string{ @@ -1200,7 +1200,7 @@ func TestKnownStripeRouteUnsupportedFallback(t *testing.T) { if searchTraces.Object != "list" || len(searchTraces.Data) != 1 { t.Fatalf("search request traces = %#v, want unsupported request trace", searchTraces) } - if searchTraces.Data[0].Path != "/v1/customers/search" || searchTraces.Data[0].ErrorCode != "unsupported_endpoint" { + if searchTraces.Data[0].Path != "/v1/charges/search" || searchTraces.Data[0].ErrorCode != "unsupported_endpoint" { t.Fatalf("search trace = %#v, want unsupported search route evidence", searchTraces.Data[0]) } @@ -3621,6 +3621,199 @@ func TestSubscriptionUpdatePreservesItemsAndSupportsAdditiveSeatItems(t *testing } } +func TestCustomerSearchAndNestedSubscriptionRoutes(t *testing.T) { + handler := newTestHandler(t) + + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"history@example.test"}, + "name": {"History Tenant"}, + "metadata[segment]": {"history"}, + }) + other := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"other@example.test"}, + "metadata[segment]": {"other"}, + }) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"History Plan"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"2000"}, + "recurring[interval]": {"month"}, + }) + + type subscriptionResponse struct { + ID string `json:"id"` + Customer string `json:"customer"` + Status string `json:"status"` + LatestInvoice *string `json:"latest_invoice"` + } + active := postForm[subscriptionResponse](t, handler, "/v1/customers/"+customer.ID+"/subscriptions", url.Values{ + "items[0][price]": {price.ID}, + }) + if active.Customer != customer.ID || active.Status != "active" { + t.Fatalf("nested subscription = %#v, want active subscription for customer", active) + } + subscriptionSearchQuery := url.QueryEscape("customer:'" + customer.ID + "' AND status:'active'") + subscriptionSearch := getJSON[struct { + Object string `json:"object"` + Data []subscriptionResponse `json:"data"` + }](t, handler, "/v1/subscriptions/search?query="+subscriptionSearchQuery) + if subscriptionSearch.Object != "search_result" || len(subscriptionSearch.Data) != 1 || subscriptionSearch.Data[0].ID != active.ID { + t.Fatalf("subscription search = %#v, want active subscription", subscriptionSearch) + } + invoiceSearchQuery := url.QueryEscape("customer:'" + customer.ID + "' AND status:'paid'") + invoiceSearch := getJSON[struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + Customer string `json:"customer"` + Status string `json:"status"` + } `json:"data"` + }](t, handler, "/v1/invoices/search?query="+invoiceSearchQuery) + if invoiceSearch.Object != "search_result" || len(invoiceSearch.Data) != 1 || invoiceSearch.Data[0].Customer != customer.ID { + t.Fatalf("invoice search = %#v, want paid customer invoice", invoiceSearch) + } + intentSearchQuery := url.QueryEscape("customer:'" + customer.ID + "' AND status:'succeeded'") + intentSearch := getJSON[struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + Customer string `json:"customer"` + Status string `json:"status"` + } `json:"data"` + }](t, handler, "/v1/payment_intents/search?query="+intentSearchQuery) + if intentSearch.Object != "search_result" || len(intentSearch.Data) != 1 || intentSearch.Data[0].Customer != customer.ID { + t.Fatalf("payment_intent search = %#v, want succeeded customer intent", intentSearch) + } + otherSub := postForm[subscriptionResponse](t, handler, "/v1/customers/"+other.ID+"/subscriptions", url.Values{ + "items[0][price]": {price.ID}, + }) + _ = deleteJSON[subscriptionResponse](t, handler, "/v1/customers/"+customer.ID+"/subscriptions/"+active.ID) + + searchQuery := url.QueryEscape("email:'history@example.test' AND metadata['segment']:'history'") + search := getJSON[struct { + Object string `json:"object"` + Data []billing.Customer `json:"data"` + }](t, handler, "/v1/customers/search?query="+searchQuery) + if search.Object != "search_result" || len(search.Data) != 1 || search.Data[0].ID != customer.ID { + t.Fatalf("customer search = %#v, want only history customer", search) + } + + canceled := getJSON[struct { + Object string `json:"object"` + Data []subscriptionResponse `json:"data"` + }](t, handler, "/v1/customers/"+customer.ID+"/subscriptions?status=canceled") + if len(canceled.Data) != 1 || canceled.Data[0].ID != active.ID { + t.Fatalf("nested canceled subscriptions = %#v, want canceled history", canceled.Data) + } + all := getJSON[struct { + Data []subscriptionResponse `json:"data"` + }](t, handler, "/v1/customers/"+customer.ID+"/subscriptions?status=all") + if len(all.Data) != 1 || all.Data[0].Customer != customer.ID { + t.Fatalf("nested all subscriptions = %#v, want only requested customer", all.Data) + } + wrongStatus, _ := getStatus(t, handler, "/v1/customers/"+customer.ID+"/subscriptions/"+otherSub.ID) + if wrongStatus != http.StatusNotFound { + t.Fatalf("wrong nested customer status = %d, want 404", wrongStatus) + } +} + +func TestSubscriptionPauseCollectionAndResume(t *testing.T) { + handler := newTestHandler(t) + + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{"email": {"pause@example.test"}}) + product := postForm[billing.Product](t, handler, "/v1/products", url.Values{"name": {"Pause Plan"}}) + price := postForm[billing.Price](t, handler, "/v1/prices", url.Values{ + "product": {product.ID}, + "currency": {"usd"}, + "unit_amount": {"2000"}, + "recurring[interval]": {"month"}, + }) + subscription := postForm[struct { + ID string `json:"id"` + }](t, handler, "/v1/subscriptions", url.Values{ + "customer": {customer.ID}, + "items[0][price]": {price.ID}, + }) + resumesAt := strconv.FormatInt(time.Now().UTC().Add(24*time.Hour).Unix(), 10) + paused := postForm[struct { + PauseCollection map[string]any `json:"pause_collection"` + }](t, handler, "/v1/subscriptions/"+subscription.ID, url.Values{ + "pause_collection[behavior]": {"void"}, + "pause_collection[resumes_at]": {resumesAt}, + }) + if paused.PauseCollection["behavior"] != "void" || int64(paused.PauseCollection["resumes_at"].(float64)) == 0 { + t.Fatalf("pause_collection = %#v, want void with resumes_at", paused.PauseCollection) + } + + resumed := postForm[struct { + PauseCollection any `json:"pause_collection"` + }](t, handler, "/v1/subscriptions/"+subscription.ID+"/resume", url.Values{"billing_cycle_anchor": {"unchanged"}}) + if resumed.PauseCollection != nil { + t.Fatalf("resumed pause_collection = %#v, want nil", resumed.PauseCollection) + } +} + +func TestPaymentMethodAttachDetach(t *testing.T) { + handler := newTestHandler(t) + + customer := postForm[billing.Customer](t, handler, "/v1/customers", url.Values{ + "email": {"cards@example.test"}, + "metadata[" + billing.MetadataPaymentMethodsFixture + "]": {billing.PaymentMethodsFixtureEmpty}, + }) + initial := getJSON[struct { + Data []map[string]any `json:"data"` + }](t, handler, "/v1/payment_methods?customer="+customer.ID+"&type=card") + if len(initial.Data) != 0 { + t.Fatalf("initial payment methods = %#v, want empty fixture", initial.Data) + } + + attached := postForm[struct { + ID string `json:"id"` + Customer string `json:"customer"` + }](t, handler, "/v1/payment_methods/pm_card_visa/attach", url.Values{"customer": {customer.ID}}) + if attached.ID != "pm_card_visa" || attached.Customer != customer.ID { + t.Fatalf("attached payment method = %#v", attached) + } + listed := getJSON[struct { + Data []struct { + ID string `json:"id"` + Customer string `json:"customer"` + } `json:"data"` + }](t, handler, "/v1/customers/"+customer.ID+"/payment_methods?type=card") + if len(listed.Data) != 1 || listed.Data[0].ID != "pm_card_visa" { + t.Fatalf("listed payment methods = %#v, want attached card", listed.Data) + } + retrieved := getJSON[struct { + ID string `json:"id"` + Customer string `json:"customer"` + }](t, handler, "/v1/payment_methods/pm_card_visa") + if retrieved.Customer != customer.ID { + t.Fatalf("retrieved payment method = %#v, want attached customer", retrieved) + } + nestedRetrieved := getJSON[struct { + ID string `json:"id"` + Customer string `json:"customer"` + }](t, handler, "/v1/customers/"+customer.ID+"/payment_methods/pm_card_visa") + if nestedRetrieved.ID != "pm_card_visa" || nestedRetrieved.Customer != customer.ID { + t.Fatalf("nested payment method = %#v, want attached customer card", nestedRetrieved) + } + + detached := postForm[struct { + ID string `json:"id"` + Customer *string `json:"customer"` + }](t, handler, "/v1/payment_methods/pm_card_visa/detach", url.Values{}) + if detached.ID != "pm_card_visa" || detached.Customer != nil { + t.Fatalf("detached payment method = %#v, want null customer", detached) + } + after := getJSON[struct { + Data []map[string]any `json:"data"` + }](t, handler, "/v1/payment_methods?customer="+customer.ID+"&type=card") + if len(after.Data) != 0 { + t.Fatalf("after detach payment methods = %#v, want empty", after.Data) + } +} + func TestFixtureApplySnapshotAndAssertAPI(t *testing.T) { handler := newTestHandler(t) pack := map[string]any{ diff --git a/internal/api/validation.go b/internal/api/validation.go index 904d95d..b5f8fc8 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -349,6 +349,19 @@ func validateCustomerUpdate(p params) error { }) } +func validateCustomerSearch(p params) error { + return validateSearch(p) +} + +func validateSearch(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"query", "limit", "page"}, + Required: []string{"query"}, + Int64Params: []string{"limit"}, + Positive: []string{"limit"}, + }) +} + func validateProductCreate(p params) error { return p.validate(paramSpec{ Allowed: []string{"id", "name", "description", "active"}, @@ -712,12 +725,25 @@ func validateSubscriptionCreate(p params) error { func validateSubscriptionUpdate(p params) error { if err := p.validate(paramSpec{ - Allowed: []string{"cancel_at_period_end", "proration_behavior", "payment_behavior", "billing_cycle_anchor", "trial_end", "coupon", "promotion_code"}, + Allowed: []string{ + "cancel_at_period_end", + "pause_collection", + "pause_collection[behavior]", + "pause_collection[resumes_at]", + "proration_behavior", + "payment_behavior", + "billing_cycle_anchor", + "trial_end", + "coupon", + "promotion_code", + }, AllowedRegex: []*regexp.Regexp{subscriptionItemRE, cancellationDetailsRE, discountParamRE}, BoolParams: []string{"cancel_at_period_end"}, + Int64Params: []string{"pause_collection[resumes_at]"}, EnumParams: map[string][]string{ - "proration_behavior": {"none", "create_prorations", "always_invoice"}, - "payment_behavior": {"allow_incomplete", "error_if_incomplete", "pending_if_incomplete"}, + "pause_collection[behavior]": {"void", "keep_as_draft", "mark_uncollectible"}, + "proration_behavior": {"none", "create_prorations", "always_invoice"}, + "payment_behavior": {"allow_incomplete", "error_if_incomplete", "pending_if_incomplete"}, "cancellation_details[feedback]": { "customer_service", "low_quality", @@ -746,6 +772,21 @@ func validateSubscriptionUpdate(p params) error { return nil } +func validateSubscriptionResume(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "billing_cycle_anchor", + "proration_behavior", + "proration_date", + }, + Int64Params: []string{"proration_date"}, + EnumParams: map[string][]string{ + "billing_cycle_anchor": {"now", "unchanged"}, + "proration_behavior": {"none", "create_prorations", "always_invoice"}, + }, + }) +} + func validateSubscriptionItemCreate(p params) error { return p.validate(paramSpec{ Allowed: []string{"subscription", "price", "price_id", "quantity"}, @@ -1082,6 +1123,39 @@ func validatePaymentMethodList(p params) error { }) } +func validatePaymentMethodCreate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "type", + "customer", + "billing_details[email]", + "billing_details[name]", + "card[token]", + }, + EnumParams: map[string][]string{ + "type": {"card"}, + }, + AllowMetadata: true, + }) +} + +func validatePaymentMethodAttach(p params) error { + return p.validate(paramSpec{ + Allowed: []string{"customer"}, + Required: []string{"customer"}, + }) +} + +func validatePaymentMethodUpdate(p params) error { + return p.validate(paramSpec{ + Allowed: []string{ + "billing_details[email]", + "billing_details[name]", + }, + AllowMetadata: true, + }) +} + func validateCouponCreate(p params) error { if err := p.validate(paramSpec{ Allowed: []string{ diff --git a/internal/stripecompat/registry.go b/internal/stripecompat/registry.go index 23111b0..7065daa 100644 --- a/internal/stripecompat/registry.go +++ b/internal/stripecompat/registry.go @@ -162,6 +162,7 @@ func DefaultClaims() []Claim { add(method, "/v1/subscriptions", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted"}}) add(method, "/v1/subscriptions/{id}", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.updated", "customer.subscription.deleted"}}) } + add(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.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"}}) @@ -223,10 +224,17 @@ func DefaultClaims() []Claim { add(http.MethodDelete, "/v1/customers/{id}/discount", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.discount.deleted"}, Risks: []string{"bounded single-discount subset"}}) add(http.MethodGet, "/v1/subscriptions/{id}/discount", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.discount.deleted"}, Risks: []string{"bounded single-discount subset"}}) add(http.MethodDelete, "/v1/subscriptions/{id}/discount", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.discount.deleted", "customer.subscription.updated"}, Risks: []string{"bounded single-discount subset"}}) + add(http.MethodGet, "/v1/customers/{id}/subscriptions", Claim{Level: "L3", Stateful: true, Risks: []string{"legacy nested subscription route backed by the same local subscription graph and status filters as /v1/subscriptions"}}) + add(http.MethodPost, "/v1/customers/{id}/subscriptions", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"customer.subscription.created", "invoice.paid", "payment_intent.succeeded"}}) + 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.MethodPost, "/v1/subscription_items", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"subscription_items.create.invalid_quantity"}}) add(http.MethodDelete, "/v1/subscription_items/{id}", Claim{Level: "L3", Stateful: true}) 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"}}) add(http.MethodGet, "/v1/subscription_schedules", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"customer.subscription.updated"}, Risks: []string{"local phase evidence only; complex proration and phase billing are not modeled"}}) add(http.MethodPost, "/v1/subscription_schedules", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"customer.subscription.updated"}, Risks: []string{"local phase evidence only; complex proration and phase billing are not modeled"}}) add(http.MethodGet, "/v1/subscription_schedules/{id}", Claim{Level: "L2", Stateful: true}) @@ -240,6 +248,7 @@ func DefaultClaims() []Claim { add(http.MethodPost, "/v1/invoices/create_preview", Claim{Level: "L3", Stateful: true, Risks: []string{"local subscription-update proration and single-discount subset; taxes, pending invoice items, and full invoice preview parity are not modeled"}}) add(http.MethodGet, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"Stripe-compatible upcoming preview alias backed by the same local proration subset"}}) add(http.MethodPost, "/v1/invoices/upcoming", Claim{Level: "L3", Stateful: true, Risks: []string{"local form-compatible upcoming preview convenience"}}) + add(http.MethodGet, "/v1/invoices/search", Claim{Level: "L3", Stateful: true, Risks: []string{"supports a measured invoice search subset for id, customer, subscription, and status clauses joined by AND"}}) add(http.MethodGet, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) add(http.MethodPost, "/v1/refunds", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"charge.refunded", "charge.refund.updated"}, Risks: []string{"local refund evidence only; charges, balances, payouts, and processor accounting are not modeled"}}) add(http.MethodGet, "/v1/refunds/{id}", Claim{Level: "L3", Stateful: true}) @@ -256,13 +265,20 @@ 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.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"}}) add(http.MethodGet, "/v1/setup_intents/{id}", statefulL3) add(http.MethodPost, "/v1/setup_intents/{id}/confirm", Claim{Level: "L3", Stateful: true, ScorecardCases: []string{"setup_intents.create.confirm.succeeds"}, Risks: []string{"local deterministic outcome aliases only"}}) add(http.MethodPost, "/v1/setup_intents/{id}/cancel", Claim{Level: "L3", Stateful: true}) add(http.MethodGet, "/v1/payment_methods", Claim{Level: "L2", Risks: []string{"deterministic sandbox card projection only"}}) + add(http.MethodPost, "/v1/payment_methods", Claim{Level: "L2", Stateful: true, WebhookEvents: []string{"payment_method.attached"}, Risks: []string{"creates local sandbox card projection only; real card data is rejected"}}) + add(http.MethodGet, "/v1/payment_methods/{id}", Claim{Level: "L2", Stateful: true, Risks: []string{"retrieves local sandbox card projection only"}}) + add(http.MethodPost, "/v1/payment_methods/{id}", Claim{Level: "L2", Stateful: true, Risks: []string{"metadata/billing_details update is accepted as a compatibility no-op for projected local cards"}}) + add(http.MethodPost, "/v1/payment_methods/{id}/attach", Claim{Level: "L3", Stateful: true, WebhookEvents: []string{"payment_method.attached"}, Risks: []string{"attaches projected sandbox cards to local customer metadata"}}) + add(http.MethodPost, "/v1/payment_methods/{id}/detach", Claim{Level: "L3", Stateful: true, Risks: []string{"detaches projected sandbox cards from local customer metadata"}}) add(http.MethodGet, "/v1/customers/{customer}/payment_methods", Claim{Level: "L2", Risks: []string{"deterministic sandbox card projection only"}}) + add(http.MethodGet, "/v1/customers/{customer}/payment_methods/{id}", Claim{Level: "L2", Stateful: true, Risks: []string{"retrieves projected sandbox cards attached to the requested customer"}}) for _, method := range []string{http.MethodGet, http.MethodPost} { add(method, "/v1/webhook_endpoints", Claim{Level: "L5", Stateful: true, SDKSmoke: []string{"stripe-node"}}) diff --git a/internal/stripecompat/registry_test.go b/internal/stripecompat/registry_test.go index 0394795..f476d5c 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) != 149 { - t.Fatalf("default claims = %d, want 149", len(claims)) + if len(claims) != 165 { + t.Fatalf("default claims = %d, want 165", len(claims)) } checkout, ok := registry.Lookup(http.MethodPost, "/v1/checkout/sessions") @@ -29,6 +29,22 @@ func TestDefaultRegistryContainsCurrentPublicClaims(t *testing.T) { if !ok || concreteSubscription.Level != "L3" { t.Fatalf("subscription concrete lookup = %#v ok=%t, want L3", concreteSubscription, ok) } + customerSearch, ok := registry.Lookup(http.MethodGet, "/v1/customers/search") + if !ok || customerSearch.Level != "L3" || !customerSearch.Stateful { + t.Fatalf("customer search claim = %#v ok=%t, want L3 stateful", customerSearch, ok) + } + nestedSubscription, ok := registry.Lookup(http.MethodGet, "/v1/customers/cus_123/subscriptions") + if !ok || nestedSubscription.Level != "L3" || !nestedSubscription.Stateful { + t.Fatalf("nested subscription claim = %#v ok=%t, want L3 stateful", nestedSubscription, ok) + } + paymentMethodAttach, ok := registry.Lookup(http.MethodPost, "/v1/payment_methods/pm_123/attach") + if !ok || paymentMethodAttach.Level != "L3" || !paymentMethodAttach.Stateful { + t.Fatalf("payment method attach claim = %#v ok=%t, want L3 stateful", paymentMethodAttach, ok) + } + intentSearch, ok := registry.Lookup(http.MethodGet, "/v1/payment_intents/search") + if !ok || intentSearch.Level != "L3" || !intentSearch.Stateful { + t.Fatalf("payment intent search claim = %#v ok=%t, want L3 stateful", intentSearch, ok) + } account, ok := registry.Lookup(http.MethodGet, "/v1/accounts/{account}") if !ok || account.Level != "L3" || !account.Stateful { diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index b54b07e..cc3160e 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -267,6 +267,7 @@ Gate: - [ ] T142 Add official Stripe SDK matrix for Node, Go, Java, Python, and Ruby - [x] T143 Add optional scheduled/manual Stripe OpenAPI inventory workflow artifacts - [x] T144 Capture the public simulation capacity backlog for regression-driven fixture and scenario expansion +- [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes Suggested agents: