From 992f1635c710bb1676ab5fee8485003f9d176896 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 16 Aug 2026 09:18:40 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(adapters):=20stripe-style=20full=20API?= =?UTF-8?q?=20coverage=20=E2=80=94=20disputes,=20billing,=20checkout,=20co?= =?UTF-8?q?nnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the gap between the stripe-style adapter and the real Stripe API surface (48 -> 158 endpoints, 15 -> 32 collections): - Test Clocks: /v1/test_clocks(+advance) and the real-path /v1/test_helpers/test_clocks aliases; KV time offset drives every timestamp via lib _now(), so billing cycles, dispute settlement and payout lifecycles are deterministic and assertable without sleeps - Disputes: test-card triggered (fraudulent / product_not_received), needs_response -> under_review -> won/lost derive-on-read machine, 27 real evidence fields, submit/close, funds withdrawn/reinstated through the ledger, full charge.dispute.* event set - Balance transactions: real ledger (charge incl. 2.9%+30c fee, refund, refund_failure, payout, transfer, transfer_reversal, application_fee, application_fee_refund, dispute, dispute_reversal) with account scoping, filters and retrieval - Billing: products, prices, subscriptions (renewal + auto-charge via clock, past_due on decline), subscription items, usage records, invoices (finalize/pay/void/uncollectible/send/lines/upcoming), invoice items, credit notes (with real refunds), coupons, promotion codes, tax rates (exclusive + inclusive) - Checkout Sessions: payment/subscription/setup modes, hosted /c/pay completion page with {CHECKOUT_SESSION_ID} redirect substitution, decline via payment_method param, expire, line items - SetupIntents: confirm/cancel with SCA challenge + decline behavior - Webhook endpoints: CRUD + registration-gated delivery per enabled_events (recorded in /v1/events either way) - Connect: persons, capabilities (pending -> active), external bank accounts (last4 only), application fees + refunds, login links, transfer partial reversals (trr_*), payout lifecycle (pending -> in_transit -> paid) + cancel with funds return - Refunds: cancel (failure_reason + refund_failure ledger row), charge/payment_intent list filters, balance_transaction linkage - Engine lint: provider-ID heuristic now requires a digit in the suffix so real API names (file_links) are not flagged; regression tests added --- adapters/stripe-style/adapter.yaml | 502 +++++++- adapters/stripe-style/scripts/accounts.star | 555 +++++++- .../scripts/application_fees.star | 175 +++ adapters/stripe-style/scripts/balance.star | 116 +- adapters/stripe-style/scripts/charges.star | 23 +- adapters/stripe-style/scripts/checkout.star | 750 +++++++++++ adapters/stripe-style/scripts/coupons.star | 213 +++ .../stripe-style/scripts/credit_notes.star | 441 +++++++ adapters/stripe-style/scripts/disputes.star | 305 +++++ adapters/stripe-style/scripts/files.star | 272 ++++ .../stripe-style/scripts/invoice_items.star | 262 ++++ adapters/stripe-style/scripts/invoices.star | 971 ++++++++++++++ adapters/stripe-style/scripts/lib.star | 571 ++++++++- .../stripe-style/scripts/payment_intents.star | 48 +- adapters/stripe-style/scripts/payouts.star | 223 +++- adapters/stripe-style/scripts/persons.star | 259 ++++ adapters/stripe-style/scripts/prices.star | 226 ++++ adapters/stripe-style/scripts/products.star | 187 +++ .../stripe-style/scripts/promotion_codes.star | 222 ++++ adapters/stripe-style/scripts/refunds.star | 198 ++- .../stripe-style/scripts/setup_intents.star | 292 +++++ .../scripts/subscription_items.star | 304 +++++ .../stripe-style/scripts/subscriptions.star | 1040 +++++++++++++++ adapters/stripe-style/scripts/tax_rates.star | 229 ++++ .../stripe-style/scripts/test_clocks.star | 198 +++ adapters/stripe-style/scripts/tokens.star | 2 +- adapters/stripe-style/scripts/transfers.star | 217 +++- .../scripts/webhook_endpoints.star | 159 +++ internal/contrib/lint/lint.go | 9 +- internal/contrib/lint/lint_test.go | 48 + internal/engine/stripe_checkout_test.go | 1140 +++++++++++++++++ internal/engine/stripe_connect_test.go | 1126 +++++++++++++++- internal/engine/stripe_disputes_test.go | 760 +++++++++++ internal/engine/stripe_groundwork_test.go | 615 +++++++++ internal/engine/stripe_invoices_test.go | 981 ++++++++++++++ internal/engine/stripe_subscriptions_test.go | 716 +++++++++++ 36 files changed, 14196 insertions(+), 159 deletions(-) create mode 100644 adapters/stripe-style/scripts/application_fees.star create mode 100644 adapters/stripe-style/scripts/checkout.star create mode 100644 adapters/stripe-style/scripts/coupons.star create mode 100644 adapters/stripe-style/scripts/credit_notes.star create mode 100644 adapters/stripe-style/scripts/disputes.star create mode 100644 adapters/stripe-style/scripts/files.star create mode 100644 adapters/stripe-style/scripts/invoice_items.star create mode 100644 adapters/stripe-style/scripts/invoices.star create mode 100644 adapters/stripe-style/scripts/persons.star create mode 100644 adapters/stripe-style/scripts/prices.star create mode 100644 adapters/stripe-style/scripts/products.star create mode 100644 adapters/stripe-style/scripts/promotion_codes.star create mode 100644 adapters/stripe-style/scripts/setup_intents.star create mode 100644 adapters/stripe-style/scripts/subscription_items.star create mode 100644 adapters/stripe-style/scripts/subscriptions.star create mode 100644 adapters/stripe-style/scripts/tax_rates.star create mode 100644 adapters/stripe-style/scripts/test_clocks.star create mode 100644 adapters/stripe-style/scripts/webhook_endpoints.star create mode 100644 internal/engine/stripe_checkout_test.go create mode 100644 internal/engine/stripe_disputes_test.go create mode 100644 internal/engine/stripe_groundwork_test.go create mode 100644 internal/engine/stripe_invoices_test.go create mode 100644 internal/engine/stripe_subscriptions_test.go diff --git a/adapters/stripe-style/adapter.yaml b/adapters/stripe-style/adapter.yaml index b55e31a9..0024f7fc 100644 --- a/adapters/stripe-style/adapter.yaml +++ b/adapters/stripe-style/adapter.yaml @@ -6,13 +6,16 @@ # synthetic. See DISCLAIMER. id: stripe-style name: "Stripe-style API simulator (unofficial)" -version: "0.1.0" +version: "0.3.0" api: name: "Stripe API" version: "2025-01-27.acacia" # Endpoints — each maps a route + method to a Starlark handler. +# Route matching is first-match-wins in file order, so literal routes +# (e.g. /v1/invoices/upcoming) are always listed BEFORE parameterized +# ones (e.g. /v1/invoices/{id}) that would otherwise shadow them. endpoints: # --- Tokens --- # With a card body: creates a Stripe-style card token (tok_*) whose stored @@ -31,6 +34,44 @@ endpoints: method: GET handler: scripts/events.star#on_retrieve_event + # --- Test Clocks (deterministic time; ONE global clock — see + # scripts/test_clocks.star). Short routes + Stripe's real + # /v1/test_helpers/test_clocks routes hit the same handlers. --- + - route: /v1/test_clocks + method: POST + handler: scripts/test_clocks.star#on_create_test_clock + - route: /v1/test_clocks + method: GET + handler: scripts/test_clocks.star#on_list_test_clocks + - route: /v1/test_clocks/{id}/advance + method: POST + handler: scripts/test_clocks.star#on_advance_test_clock + concurrency_key: id + - route: /v1/test_clocks/{id} + method: GET + handler: scripts/test_clocks.star#on_retrieve_test_clock + - route: /v1/test_clocks/{id} + method: DELETE + handler: scripts/test_clocks.star#on_delete_test_clock + concurrency_key: id + - route: /v1/test_helpers/test_clocks + method: POST + handler: scripts/test_clocks.star#on_create_test_clock + - route: /v1/test_helpers/test_clocks + method: GET + handler: scripts/test_clocks.star#on_list_test_clocks + - route: /v1/test_helpers/test_clocks/{id}/advance + method: POST + handler: scripts/test_clocks.star#on_advance_test_clock + concurrency_key: id + - route: /v1/test_helpers/test_clocks/{id} + method: GET + handler: scripts/test_clocks.star#on_retrieve_test_clock + - route: /v1/test_helpers/test_clocks/{id} + method: DELETE + handler: scripts/test_clocks.star#on_delete_test_clock + concurrency_key: id + # --- Charges --- - route: /v1/charges method: POST @@ -96,6 +137,10 @@ endpoints: - route: /v1/refunds method: GET handler: scripts/refunds.star#on_list_refunds + - route: /v1/refunds/{id}/cancel + method: POST + handler: scripts/refunds.star#on_cancel_refund + concurrency_key: id # --- Customers --- - route: /v1/customers @@ -115,10 +160,356 @@ endpoints: handler: scripts/customers.star#on_delete_customer concurrency_key: id # read-then-flag read-modify-write, serialized per customer + # --- Products --- + - route: /v1/products + method: POST + handler: scripts/products.star#on_create_product + - route: /v1/products + method: GET + handler: scripts/products.star#on_list_products + - route: /v1/products/{id} + method: GET + handler: scripts/products.star#on_retrieve_product + - route: /v1/products/{id} + method: POST + handler: scripts/products.star#on_update_product + concurrency_key: id + - route: /v1/products/{id} + method: DELETE + handler: scripts/products.star#on_delete_product + concurrency_key: id + + # --- Prices (no delete: archive with active=false) --- + - route: /v1/prices + method: POST + handler: scripts/prices.star#on_create_price + - route: /v1/prices + method: GET + handler: scripts/prices.star#on_list_prices + - route: /v1/prices/{id} + method: GET + handler: scripts/prices.star#on_retrieve_price + - route: /v1/prices/{id} + method: POST + handler: scripts/prices.star#on_update_price + concurrency_key: id + + # --- Subscriptions --- + - route: /v1/subscriptions + method: POST + handler: scripts/subscriptions.star#on_create_subscription + - route: /v1/subscriptions + method: GET + handler: scripts/subscriptions.star#on_list_subscriptions + - route: /v1/subscriptions/{id} + method: GET + handler: scripts/subscriptions.star#on_retrieve_subscription + - route: /v1/subscriptions/{id} + method: POST + handler: scripts/subscriptions.star#on_update_subscription + concurrency_key: id + - route: /v1/subscriptions/{id}/cancel + method: POST + handler: scripts/subscriptions.star#on_cancel_subscription + concurrency_key: id + + # --- Subscription items (+ metered usage records) --- + - route: /v1/subscription_items + method: GET + handler: scripts/subscription_items.star#on_list_subscription_items + - route: /v1/subscription_items + method: POST + handler: scripts/subscription_items.star#on_create_subscription_item + - route: /v1/subscription_items/{id} + method: POST + handler: scripts/subscription_items.star#on_update_subscription_item + concurrency_key: id + - route: /v1/subscription_items/{id} + method: DELETE + handler: scripts/subscription_items.star#on_delete_subscription_item + concurrency_key: id + - route: /v1/subscription_items/{id}/usage_records + method: POST + handler: scripts/subscription_items.star#on_create_usage_record + concurrency_key: id + - route: /v1/subscription_items/{id}/usage_records + method: GET + handler: scripts/subscription_items.star#on_list_usage_records + + # --- Invoices (draft -> open -> paid/void/uncollectible + preview) --- + # /v1/invoices/upcoming is literal and listed before /v1/invoices/{id}. + - route: /v1/invoices + method: POST + handler: scripts/invoices.star#on_create_invoice + - route: /v1/invoices + method: GET + handler: scripts/invoices.star#on_list_invoices + - route: /v1/invoices/upcoming + method: GET + handler: scripts/invoices.star#on_upcoming_invoice + - route: /v1/invoices/{id} + method: GET + handler: scripts/invoices.star#on_retrieve_invoice + - route: /v1/invoices/{id} + method: POST + handler: scripts/invoices.star#on_update_invoice + concurrency_key: id + - route: /v1/invoices/{id} + method: DELETE + handler: scripts/invoices.star#on_delete_invoice + concurrency_key: id + - route: /v1/invoices/{id}/finalize + method: POST + handler: scripts/invoices.star#on_finalize_invoice + concurrency_key: id + - route: /v1/invoices/{id}/pay + method: POST + handler: scripts/invoices.star#on_pay_invoice + concurrency_key: id + - route: /v1/invoices/{id}/send + method: POST + handler: scripts/invoices.star#on_send_invoice + concurrency_key: id + - route: /v1/invoices/{id}/void + method: POST + handler: scripts/invoices.star#on_void_invoice + concurrency_key: id + - route: /v1/invoices/{id}/mark_uncollectible + method: POST + handler: scripts/invoices.star#on_mark_uncollectible_invoice + concurrency_key: id + - route: /v1/invoices/{id}/lines + method: GET + handler: scripts/invoices.star#on_list_invoice_lines + + # --- Invoice items (pending until the next invoice consumes them) --- + - route: /v1/invoice_items + method: POST + handler: scripts/invoice_items.star#on_create_invoice_item + - route: /v1/invoice_items + method: GET + handler: scripts/invoice_items.star#on_list_invoice_items + - route: /v1/invoice_items/{id} + method: GET + handler: scripts/invoice_items.star#on_retrieve_invoice_item + - route: /v1/invoice_items/{id} + method: POST + handler: scripts/invoice_items.star#on_update_invoice_item + concurrency_key: id + - route: /v1/invoice_items/{id} + method: DELETE + handler: scripts/invoice_items.star#on_delete_invoice_item + concurrency_key: id + + # --- Credit notes (preview is a literal route, listed first) --- + - route: /v1/credit_notes + method: POST + handler: scripts/credit_notes.star#on_create_credit_note + - route: /v1/credit_notes + method: GET + handler: scripts/credit_notes.star#on_list_credit_notes + - route: /v1/credit_notes/preview + method: GET + handler: scripts/credit_notes.star#on_preview_credit_note + - route: /v1/credit_notes/{id} + method: GET + handler: scripts/credit_notes.star#on_retrieve_credit_note + - route: /v1/credit_notes/{id} + method: POST + handler: scripts/credit_notes.star#on_update_credit_note + concurrency_key: id + - route: /v1/credit_notes/{id}/void + method: POST + handler: scripts/credit_notes.star#on_void_credit_note + concurrency_key: id + + # --- Coupons --- + - route: /v1/coupons + method: POST + handler: scripts/coupons.star#on_create_coupon + - route: /v1/coupons + method: GET + handler: scripts/coupons.star#on_list_coupons + - route: /v1/coupons/{id} + method: GET + handler: scripts/coupons.star#on_retrieve_coupon + - route: /v1/coupons/{id} + method: POST + handler: scripts/coupons.star#on_update_coupon + concurrency_key: id + - route: /v1/coupons/{id} + method: DELETE + handler: scripts/coupons.star#on_delete_coupon + concurrency_key: id + + # --- Promotion codes --- + - route: /v1/promotion_codes + method: POST + handler: scripts/promotion_codes.star#on_create_promotion_code + - route: /v1/promotion_codes + method: GET + handler: scripts/promotion_codes.star#on_list_promotion_codes + - route: /v1/promotion_codes/{id} + method: GET + handler: scripts/promotion_codes.star#on_retrieve_promotion_code + - route: /v1/promotion_codes/{id} + method: POST + handler: scripts/promotion_codes.star#on_update_promotion_code + concurrency_key: id + + # --- Tax rates --- + - route: /v1/tax_rates + method: POST + handler: scripts/tax_rates.star#on_create_tax_rate + - route: /v1/tax_rates + method: GET + handler: scripts/tax_rates.star#on_list_tax_rates + - route: /v1/tax_rates/{id} + method: GET + handler: scripts/tax_rates.star#on_retrieve_tax_rate + - route: /v1/tax_rates/{id} + method: POST + handler: scripts/tax_rates.star#on_update_tax_rate + concurrency_key: id + - route: /v1/tax_rates/{id} + method: DELETE + handler: scripts/tax_rates.star#on_delete_tax_rate + concurrency_key: id + # --- Balance --- - route: /v1/balance method: GET handler: scripts/balance.star#on_get_balance + - route: /v1/balance_transactions + method: GET + handler: scripts/balance.star#on_list_balance_transactions + - route: /v1/balance_transactions/{id} + method: GET + handler: scripts/balance.star#on_retrieve_balance_transaction + + # --- Disputes (lib-owned derive-on-read state machine) --- + - route: /v1/disputes + method: GET + handler: scripts/disputes.star#on_list_disputes + - route: /v1/disputes/{id} + method: GET + handler: scripts/disputes.star#on_retrieve_dispute + - route: /v1/disputes/{id} + method: POST + handler: scripts/disputes.star#on_update_dispute + concurrency_key: id + - route: /v1/disputes/{id}/close + method: POST + handler: scripts/disputes.star#on_close_dispute + concurrency_key: id + + # --- Application fees (served from the lib application_fee hook) --- + - route: /v1/application_fees + method: GET + handler: scripts/application_fees.star#on_list_application_fees + - route: /v1/application_fees/{id} + method: GET + handler: scripts/application_fees.star#on_retrieve_application_fee + - route: /v1/application_fees/{id}/refunds + method: GET + handler: scripts/application_fees.star#on_list_fee_refunds + - route: /v1/application_fees/{id}/refunds + method: POST + handler: scripts/application_fees.star#on_create_fee_refund + concurrency_key: id + - route: /v1/application_fees/{id}/refund + method: POST + handler: scripts/application_fees.star#on_refund_application_fee + concurrency_key: id + + # --- Checkout Sessions --- + - route: /v1/checkout/sessions + method: POST + handler: scripts/checkout.star#on_create_checkout_session + - route: /v1/checkout/sessions + method: GET + handler: scripts/checkout.star#on_list_checkout_sessions + - route: /v1/checkout/sessions/{id}/line_items + method: GET + handler: scripts/checkout.star#on_list_checkout_session_line_items + - route: /v1/checkout/sessions/{id}/expire + method: POST + handler: scripts/checkout.star#on_expire_checkout_session + concurrency_key: id + - route: /v1/checkout/sessions/{id} + method: GET + handler: scripts/checkout.star#on_retrieve_checkout_session + # --- Hosted Checkout page stand-in (NO auth: hosted-page semantics) --- + - route: /c/pay/{id} + method: GET + handler: scripts/checkout.star#on_pay_checkout_session + concurrency_key: id + + # --- SetupIntents --- + - route: /v1/setup_intents + method: POST + handler: scripts/setup_intents.star#on_create_setup_intent + - route: /v1/setup_intents + method: GET + handler: scripts/setup_intents.star#on_list_setup_intents + - route: /v1/setup_intents/{id}/confirm + method: POST + handler: scripts/setup_intents.star#on_confirm_setup_intent + concurrency_key: id + - route: /v1/setup_intents/{id}/cancel + method: POST + handler: scripts/setup_intents.star#on_cancel_setup_intent + concurrency_key: id + - route: /v1/setup_intents/{id} + method: GET + handler: scripts/setup_intents.star#on_retrieve_setup_intent + - route: /v1/setup_intents/{id} + method: POST + handler: scripts/setup_intents.star#on_update_setup_intent + concurrency_key: id + + # --- Webhook Endpoints (registration gates webhook delivery, lib.star) --- + - route: /v1/webhook_endpoints + method: POST + handler: scripts/webhook_endpoints.star#on_create_webhook_endpoint + - route: /v1/webhook_endpoints + method: GET + handler: scripts/webhook_endpoints.star#on_list_webhook_endpoints + - route: /v1/webhook_endpoints/{id} + method: GET + handler: scripts/webhook_endpoints.star#on_retrieve_webhook_endpoint + - route: /v1/webhook_endpoints/{id} + method: POST + handler: scripts/webhook_endpoints.star#on_update_webhook_endpoint + concurrency_key: id + - route: /v1/webhook_endpoints/{id} + method: DELETE + handler: scripts/webhook_endpoints.star#on_delete_webhook_endpoint + concurrency_key: id + + # --- Files + File Links --- + - route: /v1/files + method: POST + handler: scripts/files.star#on_create_file + - route: /v1/files + method: GET + handler: scripts/files.star#on_list_files + - route: /v1/files/{id} + method: GET + handler: scripts/files.star#on_retrieve_file + - route: /v1/file_links + method: POST + handler: scripts/files.star#on_create_file_link + - route: /v1/file_links + method: GET + handler: scripts/files.star#on_list_file_links + - route: /v1/file_links/{id} + method: GET + handler: scripts/files.star#on_retrieve_file_link + - route: /v1/file_links/{id} + method: POST + handler: scripts/files.star#on_update_file_link + concurrency_key: id # --- Connect: Accounts --- - route: /v1/accounts @@ -134,6 +525,54 @@ endpoints: method: GET handler: scripts/accounts.star#on_list_accounts + # --- Connect: persons --- + - route: /v1/accounts/{id}/persons + method: POST + handler: scripts/persons.star#on_create_person + concurrency_key: id + - route: /v1/accounts/{id}/persons + method: GET + handler: scripts/persons.star#on_list_persons + - route: /v1/accounts/{id}/persons/{person_id} + method: GET + handler: scripts/persons.star#on_retrieve_person + - route: /v1/accounts/{id}/persons/{person_id} + method: POST + handler: scripts/persons.star#on_update_person + concurrency_key: person_id + - route: /v1/accounts/{id}/persons/{person_id} + method: DELETE + handler: scripts/persons.star#on_delete_person + concurrency_key: person_id + - route: /v1/persons/{id} + method: GET + handler: scripts/persons.star#on_retrieve_person_standalone + - route: /v1/persons/{id} + method: POST + handler: scripts/persons.star#on_update_person_standalone + concurrency_key: id + + # --- Connect: external accounts --- + - route: /v1/accounts/{id}/external_accounts + method: POST + handler: scripts/accounts.star#on_create_external_account + concurrency_key: id + - route: /v1/accounts/{id}/external_accounts + method: GET + handler: scripts/accounts.star#on_list_external_accounts + - route: /v1/accounts/{id}/external_accounts/{ea_id} + method: GET + handler: scripts/accounts.star#on_retrieve_external_account + - route: /v1/accounts/{id}/external_accounts/{ea_id} + method: DELETE + handler: scripts/accounts.star#on_delete_external_account + concurrency_key: id + + # --- Connect: login links (Express dashboard) --- + - route: /v1/accounts/{id}/login_links + method: POST + handler: scripts/accounts.star#on_create_login_link + # --- Connect: Account Links (onboarding) --- - route: /v1/account_links method: POST @@ -152,6 +591,12 @@ endpoints: - route: /v1/transfers/{id}/reversals method: POST handler: scripts/transfers.star#on_reverse_transfer + - route: /v1/transfers/{id}/reversals + method: GET + handler: scripts/transfers.star#on_list_transfer_reversals + - route: /v1/transfers/{id}/reversals/{tr_id} + method: GET + handler: scripts/transfers.star#on_retrieve_transfer_reversal # --- Connect: Payouts --- - route: /v1/payouts @@ -160,6 +605,17 @@ endpoints: - route: /v1/payouts method: GET handler: scripts/payouts.star#on_list_payouts + - route: /v1/payouts/{id} + method: GET + handler: scripts/payouts.star#on_retrieve_payout + - route: /v1/payouts/{id} + method: POST + handler: scripts/payouts.star#on_update_payout + concurrency_key: id + - route: /v1/payouts/{id}/cancel + method: POST + handler: scripts/payouts.star#on_cancel_payout + concurrency_key: id # Backing stores available to Starlark handlers via store_collection. resources: @@ -186,6 +642,50 @@ resources: kind: collection - name: events kind: collection + - name: test_clocks + kind: collection + - name: balance_transactions + kind: collection + - name: disputes + kind: collection + - name: application_fees + kind: collection + - name: invoices + kind: collection + - name: products + kind: collection + - name: prices + kind: collection + - name: subscriptions + kind: collection + - name: usage_records + kind: collection + - name: invoice_items + kind: collection + - name: credit_notes + kind: collection + - name: coupons + kind: collection + - name: promotion_codes + kind: collection + - name: tax_rates + kind: collection + - name: checkout_sessions + kind: collection + - name: setup_intents + kind: collection + - name: webhook_endpoints + kind: collection + - name: files + kind: collection + - name: file_links + kind: collection + - name: persons + kind: collection + - name: external_accounts + kind: collection + - name: transfer_reversals + kind: collection # Auth scheme: bearer token validated via identity_validate. # Dev bypass: tokens starting with "sk_test" are accepted without validation. diff --git a/adapters/stripe-style/scripts/accounts.star b/adapters/stripe-style/scripts/accounts.star index bd10b19e..71422140 100644 --- a/adapters/stripe-style/scripts/accounts.star +++ b/adapters/stripe-style/scripts/accounts.star @@ -1,8 +1,279 @@ # Connected accounts handlers — Stripe Connect. # # Manages Custom/Express/Standard connected accounts stored in the -# connect_accounts collection. Emits account.updated on create and update. -# Shared helpers (_require_auth, _next_id, _not_found) are in lib.star. +# connect_accounts collection, plus their sub-resources: external bank +# accounts (ba_*, the "external_accounts" collection) and Express dashboard +# login links. +# +# Capabilities run the real state machine (docs.stripe.com/api/capabilities): +# requesting one on create/update (capabilities[transfers][requested]=true) +# parks it in "pending"; it flips to "active" one day later, derived on read +# via _now() (test-clock aware) and persisted before the account.updated +# emission. The legacy direct-status form ({"transfers": "active"}) is still +# accepted and applies immediately. +# +# Renders follow the real account object (docs.stripe.com/api/accounts/object): +# settings (payouts schedule + branding), business_profile, requirements (full +# shape), external_accounts embedded list, default_currency. +# Emits account.updated, account.external_account.created/deleted. +# Shared helpers (_require_auth, _next_id, _not_found, _now, _num, +# _signed_emit, _list_page, _newest_first, _get_query, _stripe_account) are +# in lib.star. + +_CAP_REVIEW_SECS = 24 * 3600 # capability "pending" -> "active" after one day + +# _acct_req_shape renders the real requirements object. All keys are always +# present; stored docs (including the seeds) may only carry a subset. +def _acct_req_shape(req): + if req == None: + req = {} + return { + "alternatives": req.get("alternatives", []), + "current_deadline": req.get("current_deadline", None), + "currently_due": req.get("currently_due", []), + "disabled_reason": req.get("disabled_reason", None), + "errors": req.get("errors", []), + "eventually_due": req.get("eventually_due", []), + "past_due": req.get("past_due", []), + "pending_verification": req.get("pending_verification", []), + } + +# _acct_default_reqs is the empty-requirements baseline for a fresh account. +def _acct_default_reqs(): + return _acct_req_shape(None) + +# _acct_settings renders settings.payouts (schedule delay_days/interval, +# statement_descriptor) and settings.branding per the real account object. +def _acct_settings(doc): + s = doc.get("settings", None) + if s == None: + s = {} + p = s.get("payouts", None) + if p == None: + p = {} + sch = p.get("schedule", None) + if sch == None: + sch = {} + b = s.get("branding", None) + if b == None: + b = {} + return { + "branding": { + "icon": b.get("icon", None), + "logo": b.get("logo", None), + "primary_color": b.get("primary_color", None), + "secondary_color": b.get("secondary_color", None), + }, + "payouts": { + "debit_negative_balances": p.get("debit_negative_balances", True), + "schedule": { + "delay_days": _num(sch.get("delay_days", 2)), + "interval": sch.get("interval", "daily"), + }, + "statement_descriptor": p.get("statement_descriptor", None), + }, + } + +# _acct_bp renders business_profile with every documented key present. +def _acct_bp(doc): + bp = doc.get("business_profile", None) + if bp == None: + bp = {} + return { + "mcc": bp.get("mcc", None), + "name": bp.get("name", None), + "product_description": bp.get("product_description", None), + "support_address": bp.get("support_address", None), + "support_email": bp.get("support_email", None), + "support_phone": bp.get("support_phone", None), + "support_url": bp.get("support_url", None), + "url": bp.get("url", None), + } + +# _acct_merge_settings deep-merges a request `settings` object into the stored +# doc (three explicit levels: settings -> payouts/branding -> schedule — no +# recursion in Starlark). +def _acct_merge_settings(doc, s): + if s == None or type(s) != "dict": + return + cur = doc.get("settings", None) + if cur == None: + cur = {} + for k in s: + v = s[k] + c = cur.get(k, None) + if type(v) == "dict" and type(c) == "dict": + for sk in v: + sv = v[sk] + sc = c.get(sk, None) + if type(sv) == "dict" and type(sc) == "dict": + for ssk in sv: + sc[ssk] = sv[ssk] + c[sk] = sc + else: + c[sk] = sv + cur[k] = c + else: + cur[k] = v + doc["settings"] = cur + +# _acct_merge_bp merges a request business_profile (one nested level) into the +# stored doc. +def _acct_merge_bp(doc, bp): + if bp == None or type(bp) != "dict": + return + cur = doc.get("business_profile", None) + if cur == None: + cur = {} + for k in bp: + v = bp[k] + c = cur.get(k, None) + if type(v) == "dict" and type(c) == "dict": + for sk in v: + c[sk] = v[sk] + cur[k] = c + else: + cur[k] = v + doc["business_profile"] = cur + +# _acct_apply_caps folds a request capabilities hash into the doc. Two forms: +# {"transfers": "active"} legacy direct status (applies now) +# {"transfers": {"requested": true}} the real request form -> "pending" +# Requested-at timestamps are tracked in the internal _caps map so the +# pending -> active derivation knows when the review window ends. +def _acct_apply_caps(doc, body_caps): + if body_caps == None or type(body_caps) != "dict": + return + caps = doc.get("capabilities", None) + if caps == None: + caps = {} + meta = doc.get("_caps", None) + if meta == None: + meta = {} + for name in body_caps: + val = body_caps[name] + if type(val) == "string": + caps[name] = val + elif type(val) == "dict": + m = meta.get(name, None) + if m == None: + m = {"requested": False, "requested_at": 0} + if val.get("requested", False) == True and m.get("requested", False) != True: + m["requested"] = True + m["requested_at"] = _now() + if caps.get(name, None) != "active": + caps[name] = "pending" + meta[name] = m + doc["capabilities"] = caps + doc["_caps"] = meta + +# _acct_sync_flags derives the enablement booleans from active capabilities: +# card_payments -> charges_enabled, transfers -> payouts_enabled (forward +# only; stored true flags from earlier docs are never reset). +def _acct_sync_flags(doc): + caps = doc.get("capabilities", None) + if caps == None: + return + if caps.get("card_payments", None) == "active": + doc["charges_enabled"] = True + if caps.get("transfers", None) == "active": + doc["payouts_enabled"] = True + +# _acct_advance derives capability activations from the clock: every +# "pending" capability whose one-day review window has elapsed becomes +# "active". The doc is persisted BEFORE the account.updated emission, and the +# transition fires exactly once (status no longer "pending" afterwards). +def _acct_advance(doc): + caps = doc.get("capabilities", None) + meta = doc.get("_caps", None) + if caps == None or meta == None: + return doc + now = _now() + changed = False + # Snapshot the names first: Starlark forbids inserting into a dict while + # iterating it, and the loop below mutates caps. + names = [] + for name in caps: + names.append(name) + for i in range(len(names)): + name = names[i] + if caps.get(name, None) != "pending": + continue + m = meta.get(name, None) + if m == None: + continue + rat = _num(m.get("requested_at", 0)) + if rat > 0 and now >= rat + _CAP_REVIEW_SECS: + caps[name] = "active" + changed = True + if not changed: + return doc + _acct_sync_flags(doc) + store_collection("connect_accounts").update(doc["id"], doc) + _signed_emit("account.updated", _acct_public(doc)) + return doc + +# _ea_public strips internal "_" keys from a stored bank-account doc. +def _ea_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _acct_ea_docs returns the external bank-account docs attached to an account. +def _acct_ea_docs(acct_id): + docs = store_collection("external_accounts").list() + return query_select(docs, [["account", "=", acct_id]]) + +# _acct_ea_embedded renders the external_accounts list object embedded on the +# account (docs.stripe.com/api/accounts/object: object/data/has_more/ +# total_count/url). +def _acct_ea_embedded(acct_id): + eas = _acct_ea_docs(acct_id) + return { + "object": "list", + "data": [_ea_public(d) for d in eas], + "has_more": False, + "total_count": len(eas), + "url": "/v1/accounts/" + acct_id + "/external_accounts", + } + +# _acct_public renders the full account shape. Stored docs (including the +# seed fixtures) carry only a subset of fields; every documented key is filled +# with its default here. +def _acct_public(doc): + caps = doc.get("capabilities", None) + if caps == None: + caps = {} + return { + "id": doc["id"], + "object": "account", + "business_profile": _acct_bp(doc), + "business_type": doc.get("business_type", None), + "capabilities": caps, + "charges_enabled": doc.get("charges_enabled", False) == True, + "country": doc.get("country", "US"), + "created": _num(doc.get("created", 0)), + "default_currency": doc.get("default_currency", "usd"), + "details_submitted": doc.get("details_submitted", False) == True, + "email": doc.get("email", None), + "external_accounts": _acct_ea_embedded(doc["id"]), + "login_links": { + "object": "list", + "data": [], + "has_more": False, + "total_count": 0, + "url": "/v1/accounts/" + doc["id"] + "/login_links", + }, + "metadata": doc.get("metadata", {}), + "payouts_enabled": doc.get("payouts_enabled", False) == True, + "requirements": _acct_req_shape(doc.get("requirements", None)), + "settings": _acct_settings(doc), + "tos_acceptance": doc.get("tos_acceptance", {"date": None, "ip": None, "user_agent": None}), + "type": doc.get("type", None), + } # POST /v1/accounts — create a connected account. def on_create_account(req): @@ -15,81 +286,96 @@ def on_create_account(req): body = {} acct_id = _next_id("acct") - acct_type = body.get("type", "express") - country = body.get("country", "US") - email = body.get("email", None) - business_type = body.get("business_type", None) - capabilities = body.get("capabilities", {}) + acct_type = body.get("type", None) + if acct_type == None or acct_type == "": + acct_type = "express" + dc = body.get("default_currency", None) + if dc == None or dc == "": + dc = "usd" doc = { "id": acct_id, "object": "account", "type": acct_type, - "country": country, - "email": email, - "business_type": business_type, - "capabilities": capabilities, + "country": body.get("country", "US"), + "default_currency": dc, + "email": body.get("email", None), + "business_type": body.get("business_type", None), + "capabilities": {}, + "_caps": {}, "details_submitted": False, "charges_enabled": False, "payouts_enabled": False, - "requirements": {"currently_due": [], "eventually_due": [], "past_due": [], "disabled_reason": None}, - "created": 1700000000, + "requirements": _acct_default_reqs(), + "settings": {}, + "business_profile": {}, + "metadata": body.get("metadata", {}), + "created": _now(), } - c = store_collection("connect_accounts") - c.insert(doc) + # Express and Custom accounts are platform-controlled, so Stripe + # auto-requests both core capabilities at creation (they sit "pending" + # until the review window elapses). Standard accounts request nothing. + if acct_type == "express" or acct_type == "custom": + _acct_apply_caps(doc, {"transfers": {"requested": True}, "card_payments": {"requested": True}}) + _acct_apply_caps(doc, body.get("capabilities", None)) + _acct_merge_settings(doc, body.get("settings", None)) + _acct_merge_bp(doc, body.get("business_profile", None)) + _acct_sync_flags(doc) + + store_collection("connect_accounts").insert(doc) # Emit webhook event (fire-and-forget: errors do not break account creation). - _signed_emit("account.updated", doc) + _signed_emit("account.updated", _acct_public(doc)) - return respond(201, doc) + return respond(201, _acct_public(doc)) -# GET /v1/accounts/{id} — retrieve a single connected account. +# GET /v1/accounts/{id} — retrieve a single connected account (capability +# activations are derived first, so polls agree with the webhook timeline). def on_retrieve_account(req): err = _require_auth(req) if err != None: return err id = req["params"]["id"] - c = store_collection("connect_accounts") - doc = c.get(id) + doc = store_collection("connect_accounts").get(id) if doc == None: return _not_found("account", id) - return respond(200, doc) + return respond(200, _acct_public(_acct_advance(doc))) -# POST /v1/accounts/{id} — update a connected account (e.g. capabilities). +# POST /v1/accounts/{id} — update a connected account (capabilities, +# settings, business_profile, metadata, ...). Only documented top-level +# params are merged. def on_update_account(req): err = _require_auth(req) if err != None: return err id = req["params"]["id"] - c = store_collection("connect_accounts") - doc = c.get(id) + doc = store_collection("connect_accounts").get(id) if doc == None: return _not_found("account", id) body = req["body"] if body != None: - for k in body: - doc[k] = body[k] + for k in ["email", "business_type", "default_currency", "country", "metadata", "tos_acceptance"]: + v = body.get(k, None) + if v != None: + doc[k] = v + _acct_merge_settings(doc, body.get("settings", None)) + _acct_merge_bp(doc, body.get("business_profile", None)) + _acct_apply_caps(doc, body.get("capabilities", None)) + _acct_sync_flags(doc) - # If capabilities were updated, derive enablement flags. - caps = body.get("capabilities") - if caps != None: - if caps.get("card_payments") == "active": - doc["charges_enabled"] = True - if caps.get("transfers") == "active": - doc["payouts_enabled"] = True - - c.update(id, doc) + store_collection("connect_accounts").update(id, doc) # Emit webhook event (fire-and-forget). - _signed_emit("account.updated", doc) + _signed_emit("account.updated", _acct_public(doc)) - return respond(200, doc) + return respond(200, _acct_public(doc)) -# GET /v1/accounts — list all connected accounts. +# GET /v1/accounts — list connected accounts (newest first, cursor +# pagination, created filters). def on_list_accounts(req): err = _require_auth(req) if err != None: @@ -99,13 +385,15 @@ def on_list_accounts(req): if bad != None: return bad - c = store_collection("connect_accounts") - docs = c.list() + docs = store_collection("connect_accounts").list() + docs = [_acct_advance(d) for d in docs] docs = _apply_account_filters(req, docs) - page, has_more, err = _list_page(req, docs, "account") - if err != None: - return err - return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/accounts"}) + docs = _newest_first(docs) + + page, has_more, err2 = _list_page(req, docs, "account") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_acct_public(d) for d in page], "has_more": has_more, "url": "/v1/accounts"}) # _apply_account_filters maps the real Stripe account-list query params # (created exact/range) to query_select clauses, applied before paging like @@ -116,3 +404,182 @@ def _apply_account_filters(req, docs): if len(f) == 0: return docs return query_select(docs, f) + +# ============================================================================ +# EXTERNAL ACCOUNTS (bank accounts attached to a connected account) +# ============================================================================ + +# _ea_last4 extracts the last four characters of an account number without +# ever storing the raw number (plain indexing/slicing math, no negative +# indices). +def _ea_last4(number): + n = str(number) + ln = len(n) + if ln <= 4: + return n + return n[ln - 4:ln] + +# POST /v1/accounts/{id}/external_accounts — attach a bank account. +# Accepts the real forms: external_account as a bank_account hash, the +# deprecated bank_account alias, or a bank-account token id. Only last4, +# fingerprint and routing number are persisted — never the account number. +def on_create_external_account(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + acct = store_collection("connect_accounts").get(id) + if acct == None: + return _not_found("account", id) + + body = req["body"] + if body == None: + body = {} + ea = body.get("external_account", None) + if ea == None: + ea = body.get("bank_account", None) + + fields = None + if ea != None and type(ea) == "dict": + fields = ea + elif ea != None and type(ea) == "string": + tok = store_collection("tokens").get(ea) + if tok == None: + return _not_found("token", ea) + ba = tok.get("bank_account", None) + if ba == None or type(ba) != "dict": + return respond(400, {"error": {"message": "The token is not a bank account token.", "param": "external_account", "type": "invalid_request_error"}}) + fields = ba + if fields == None: + return respond(400, {"error": {"message": "Missing required param: external_account.", "param": "external_account", "type": "invalid_request_error"}}) + + number = fields.get("account_number", None) + if number == None or number == "": + return respond(400, {"error": {"message": "Missing required param: external_account[account_number].", "param": "external_account[account_number]", "type": "invalid_request_error"}}) + + currency = fields.get("currency", None) + if currency == None or currency == "": + currency = acct.get("default_currency", "usd") + + # The first external account for a currency becomes its default; an + # explicit default_for_currency=true demotes the previous default. + want_default = fields.get("default_for_currency", False) == True + same_currency = query_select(_acct_ea_docs(id), [["currency", "=", currency]]) + if not want_default and len(same_currency) == 0: + want_default = True + if want_default: + for i in range(len(same_currency)): + other = same_currency[i] + if other.get("default_for_currency", False) == True: + other["default_for_currency"] = False + store_collection("external_accounts").update(other["id"], other) + + doc = { + "id": _next_id("ba"), + "object": "bank_account", + "account": id, + "account_holder_name": fields.get("account_holder_name", None), + "account_holder_type": fields.get("account_holder_type", None), + "account_type": None, + "available_payout_methods": ["standard"], + "bank_name": "STRIPE TEST BANK", + "country": fields.get("country", acct.get("country", "US")), + "currency": currency, + "default_for_currency": want_default, + "fingerprint": "fp_" + str(store_kv_incr("stripe", "fp_seq")), + "last4": _ea_last4(number), + "metadata": fields.get("metadata", {}), + "routing_number": fields.get("routing_number", None), + "status": "new", + "created": _now(), + } + store_collection("external_accounts").insert(doc) + _signed_emit("account.external_account.created", _ea_public(doc)) + return respond(201, _ea_public(doc)) + +# GET /v1/accounts/{id}/external_accounts — list a connected account's bank +# accounts (newest first, cursor pagination). +def on_list_external_accounts(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + if store_collection("connect_accounts").get(id) == None: + return _not_found("account", id) + + docs = _newest_first(_acct_ea_docs(id)) + page, has_more, err2 = _list_page(req, docs, "external_account") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_ea_public(d) for d in page], "has_more": has_more, "url": "/v1/accounts/" + id + "/external_accounts"}) + +# GET /v1/accounts/{id}/external_accounts/{ea_id} — retrieve one bank account. +def on_retrieve_external_account(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + ea_id = req["params"]["ea_id"] + if store_collection("connect_accounts").get(id) == None: + return _not_found("account", id) + doc = store_collection("external_accounts").get(ea_id) + if doc == None or doc.get("account", None) != id: + return _not_found("external_account", ea_id) + return respond(200, _ea_public(doc)) + +# DELETE /v1/accounts/{id}/external_accounts/{ea_id} — detach a bank account. +# The real API refuses to delete a default external account while its +# currency is the account's default currency or another external account +# shares the currency (you must re-default another one first). +def on_delete_external_account(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + ea_id = req["params"]["ea_id"] + acct = store_collection("connect_accounts").get(id) + if acct == None: + return _not_found("account", id) + doc = store_collection("external_accounts").get(ea_id) + if doc == None or doc.get("account", None) != id: + return _not_found("external_account", ea_id) + + if doc.get("default_for_currency", False) == True: + others = query_select(_acct_ea_docs(id), [["currency", "=", doc.get("currency", "usd")]]) + siblings = 0 + for i in range(len(others)): + if others[i].get("id", None) != ea_id: + siblings = siblings + 1 + if doc.get("currency", "usd") == acct.get("default_currency", "usd") or siblings > 0: + return respond(400, {"error": {"message": "Cannot delete the default external account. Set default_for_currency on another external account with the same currency first.", "param": "default_for_currency", "type": "invalid_request_error"}}) + + store_collection("external_accounts").delete(ea_id) + _signed_emit("account.external_account.deleted", _ea_public(doc)) + return respond(200, {"id": ea_id, "object": "bank_account", "deleted": True}) + +# ============================================================================ +# LOGIN LINKS (Express dashboard) +# ============================================================================ + +# POST /v1/accounts/{id}/login_links — create a single-use Express dashboard +# login link (docs.stripe.com/connect/express-accounts). Standard accounts +# manage their own Stripe login, so the real API refuses them. +def on_create_login_link(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + acct = store_collection("connect_accounts").get(id) + if acct == None: + return _not_found("account", id) + if acct.get("type", "express") == "standard": + return respond(400, {"error": {"message": "Login links cannot be created for standard accounts.", "param": "account", "type": "invalid_request_error"}}) + + seq = store_kv_incr("stripe", "login_link_seq") + url = "https://connect.stunt.local/" + id + "/" + str(seq) + return respond(200, {"object": "login_link", "created": _now(), "url": url}) diff --git a/adapters/stripe-style/scripts/application_fees.star b/adapters/stripe-style/scripts/application_fees.star new file mode 100644 index 00000000..bba1fa04 --- /dev/null +++ b/adapters/stripe-style/scripts/application_fees.star @@ -0,0 +1,175 @@ +# Application fee handlers — Stripe Connect (docs.stripe.com/api/application_fees). +# +# lib.star records an application_fee doc whenever a charge carries +# application_fee_amount (the _maybe_record_fee hook). These handlers serve +# them: list (charge + created filters), retrieve, and refunds. Both refund +# routes share one code path — the modern /v1/application_fees/{id}/refunds +# (returns the fee_refund object, fr_*) and the legacy +# /v1/application_fees/{id}/refund alias (returns the updated fee). Partial +# refunds grow amount_refunded; refunded flips only at the full balance. Each +# refund records a negative application_fee_refund balance transaction on the +# platform ledger and emits application_fee.refunded (real event: includes +# partial refunds). +# Shared helpers (_require_auth, _not_found, _num, _usd, _bt_record, +# _signed_emit, _list_page, _newest_first, _created_filters, _created_check, +# _get_query) are in lib.star. + +# _fee_public renders the application_fee object (internal keys stripped — +# fee refunds live under the private _refunds list). +def _fee_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + out["amount"] = _num(doc.get("amount", 0)) + out["amount_refunded"] = _num(doc.get("amount_refunded", 0)) + out["refunded"] = doc.get("refunded", False) == True + out["livemode"] = False + if out.get("metadata", None) == None: + out["metadata"] = {} + return out + +# _fr_public renders a fee_refund object (docs.stripe.com/api/fee_refunds). +def _fr_public(doc): + return { + "id": doc["id"], + "object": "fee_refund", + "amount": _num(doc.get("amount", 0)), + "balance_transaction": doc.get("balance_transaction", None), + "created": _num(doc.get("created", 0)), + "currency": doc.get("currency", "usd"), + "fee": doc.get("fee", None), + "metadata": doc.get("metadata", {}), + } + +# _fee_refund_create validates + applies one fee refund and returns +# (fee_doc, fr_doc, error_response). amount omitted → the whole unrefunded +# balance; over-refunds get the real-style 400. The fee doc (with the private +# _refunds entry) is persisted BEFORE the application_fee.refunded emission. +def _fee_refund_create(req, fee_id): + fee = store_collection("application_fees").get(fee_id) + if fee == None: + return None, None, _not_found("application_fee", fee_id) + + body = req["body"] + if body == None: + body = {} + base = _num(fee.get("amount", 0)) + already = _num(fee.get("amount_refunded", 0)) + remaining = base - already + if remaining <= 0: + return None, None, respond(400, {"error": {"message": "Application fee has already been refunded.", "type": "invalid_request_error"}}) + + amount = _num(body.get("amount", 0)) + if amount == 0: + amount = remaining + if amount > remaining or amount <= 0: + return None, None, respond(400, {"error": {"message": "Refund amount (" + _usd(amount) + ") is greater than unrefunded amount on application fee (" + _usd(remaining) + ")", "param": "amount", "type": "invalid_request_error"}}) + + fr_id = _next_id("fr") + bt = _bt_record("", "application_fee_refund", -amount, 0, fee.get("currency", "usd"), fee_id, "Application fee refund") + fr = { + "id": fr_id, + "object": "fee_refund", + "amount": amount, + "balance_transaction": bt["id"], + "created": _now(), + "currency": fee.get("currency", "usd"), + "fee": fee_id, + "metadata": body.get("metadata", {}), + } + refunds = fee.get("_refunds", None) + if refunds == None: + refunds = [] + refunds.append(fr) + fee["_refunds"] = refunds + fee["amount_refunded"] = already + amount + if fee["amount_refunded"] >= base: + fee["refunded"] = True + store_collection("application_fees").update(fee_id, fee) + _signed_emit("application_fee.refunded", _fee_public(fee)) + return fee, fr, None + +# GET /v1/application_fees — list application fees (charge + created +# filters, newest first, cursor pagination). +def on_list_application_fees(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("application_fees").list() + f = [] + ch = _get_query(req, "charge") + if ch != "": + f.append(["charge", "=", ch]) + _created_filters(req, f) + if len(f) > 0: + docs = query_select(docs, f) + docs = _newest_first(docs) + + page, has_more, err2 = _list_page(req, docs, "application_fee") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_fee_public(d) for d in page], "has_more": has_more, "url": "/v1/application_fees"}) + +# GET /v1/application_fees/{id} — retrieve one application fee. +def on_retrieve_application_fee(req): + err = _require_auth(req) + if err != None: + return err + + doc = store_collection("application_fees").get(req["params"]["id"]) + if doc == None: + return _not_found("application_fee", req["params"]["id"]) + return respond(200, _fee_public(doc)) + +# POST /v1/application_fees/{id}/refund — legacy fee-refund alias: applies +# the refund and returns the updated application_fee object. +def on_refund_application_fee(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "application_fees") + if cached != None: + return respond(cached["status"], _fee_public(cached["doc"])) + + fee, _fr, err2 = _fee_refund_create(req, req["params"]["id"]) + if err2 != None: + return err2 + _idempotent_remember(req, "application_fees", 200, fee["id"]) + return respond(200, _fee_public(fee)) + +# POST /v1/application_fees/{id}/refunds — the real fee-refund create route: +# applies the refund and returns the fee_refund object. +def on_create_fee_refund(req): + err = _require_auth(req) + if err != None: + return err + + fee, fr, err2 = _fee_refund_create(req, req["params"]["id"]) + if err2 != None: + return err2 + return respond(200, _fr_public(fr)) + +# GET /v1/application_fees/{id}/refunds — list a fee's refunds. +def on_list_fee_refunds(req): + err = _require_auth(req) + if err != None: + return err + + fee_id = req["params"]["id"] + fee = store_collection("application_fees").get(fee_id) + if fee == None: + return _not_found("application_fee", fee_id) + + refunds = fee.get("_refunds", None) + if refunds == None: + refunds = [] + data = [_fr_public(r) for r in _newest_first(refunds)] + return respond(200, {"object": "list", "data": data, "has_more": False, "url": "/v1/application_fees/" + fee_id + "/refunds"}) diff --git a/adapters/stripe-style/scripts/balance.star b/adapters/stripe-style/scripts/balance.star index 5a85c1ef..ff4063e7 100644 --- a/adapters/stripe-style/scripts/balance.star +++ b/adapters/stripe-style/scripts/balance.star @@ -1,13 +1,36 @@ -# Balance handler — returns a synthetic account balance. +# Balance handlers — the account balance object plus the balance-transaction +# ledger the money movements record (lib._bt_record). # -# For Stripe Connect: accepts an optional Stripe-Account header to scope the -# balance to a connected account. When present, returns the tracked per-account -# balance (updated by transfers and payouts). When absent, returns the default -# platform balance. +# GET /v1/balance docs.stripe.com/api/balance +# GET /v1/balance_transactions docs.stripe.com/api/balance_transactions/list +# GET /v1/balance_transactions/{id} # -# Shared helpers (_require_auth, _stripe_account, _get_balance) are in lib.star. +# The platform balance stays the historical synthetic defaults (tests pin it); +# connected-account balances derive from the KV store (updated by the ledger). +# Both shapes now also carry the real object's connect_reserved and issuing +# arrays (docs.stripe.com/api/balance/object): connect_reserved lists funds +# held for negative connected-account balances (empty here), and issuing is a +# BalanceDetail object with its own available array. +# Shared helpers (_require_auth, _not_found, _stripe_account, _get_balance, +# _list_page, _newest_first, _created_filters, _created_check, _get_query, +# _bt_public) are in lib.star. + +# _bal_connect_reserved is the connect_reserved array: entries appear only +# when funds are actually held, so an empty list is the faithful resting value. +def _bal_connect_reserved(): + return [] + +# _bal_issuing is the issuing BalanceDetail object (its one required field is +# the available array; the simulator holds no Issuing funds). +def _bal_issuing(): + return {"available": [{"amount": 0, "currency": "usd"}]} # GET /v1/balance — return the account balance. +# +# For Stripe Connect: accepts an optional Stripe-Account header to scope the +# balance to a connected account. When present, returns the tracked per-account +# balance (updated by transfers and payouts; pending stays 0). When absent, +# returns the default platform balance. def on_get_balance(req): err = _require_auth(req) if err != None: @@ -28,6 +51,8 @@ def on_get_balance(req): "instant_available": [ {"amount": 0, "currency": "usd"}, ], + "connect_reserved": _bal_connect_reserved(), + "issuing": _bal_issuing(), "livemode": False, }) @@ -43,5 +68,84 @@ def on_get_balance(req): "instant_available": [ {"amount": 25000, "currency": "usd"}, ], + "connect_reserved": _bal_connect_reserved(), + "issuing": _bal_issuing(), "livemode": False, }) + +# _bal_apply_filters maps the real Stripe balance-transaction list params +# (created exact/range, currency, payout, source, type — plus simulator-only +# charge/refund/dispute/transfer aliases for source) to query_select clauses. +# Rows are also scoped to the account: with a Stripe-Account header only that +# connected account's rows list, without it only the platform's — like real +# Stripe, which never mixes accounts in one balance history. +def _bal_apply_filters(req, docs): + f = [] + + acct = _stripe_account(req) + if acct != None: + f.append(["_account", "=", acct]) + else: + f.append(["_account", "=", ""]) + + source = _get_query(req, "source") + # The simulator-only aliases map straight onto source (charge=ch_1 is + # source=ch_1 with an implied type). + for alias in ["charge", "refund", "dispute", "transfer", "payout"]: + v = _get_query(req, alias) + if v != "": + source = v + if source != "": + f.append(["source", "=", source]) + + currency = _get_query(req, "currency") + if currency != "": + f.append(["currency", "=", currency]) + typ = _get_query(req, "type") + if typ != "": + f.append(["type", "=", typ]) + + _created_filters(req, f) + if len(f) == 0: + return docs + return query_select(docs, f) + +# GET /v1/balance_transactions — list the ledger rows, newest first, with the +# real Stripe list params (created, currency, payout, source, type) and cursor +# paging. +def on_list_balance_transactions(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("balance_transactions").list() + docs = _bal_apply_filters(req, docs) + docs = _newest_first(docs) + + page, has_more, e = _list_page(req, docs, "balance_transaction") + if e != None: + return e + out = [] + for i in range(len(page)): + out.append(_bt_public(page[i])) + return respond(200, {"object": "list", "data": out, "has_more": has_more, "url": "/v1/balance_transactions"}) + +# GET /v1/balance_transactions/{id} — retrieve one ledger row. A row scoped to +# another account (Stripe-Account header) is a 404, like real Stripe. +def on_retrieve_balance_transaction(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("balance_transactions").get(id) + if doc == None: + return _not_found("balance_transaction", id) + acct = _stripe_account(req) + if acct != None and doc.get("_account", "") != acct: + return _not_found("balance_transaction", id) + return respond(200, _bt_public(doc)) diff --git a/adapters/stripe-style/scripts/charges.star b/adapters/stripe-style/scripts/charges.star index e73620d2..fa6443c3 100644 --- a/adapters/stripe-style/scripts/charges.star +++ b/adapters/stripe-style/scripts/charges.star @@ -66,7 +66,7 @@ def on_create_charge(req): "status": "failed", "captured": False, "refunded": False, - "created": clock.now_unix(), + "created": _now(), } store_collection("charges").insert(doc) _signed_emit("charge.failed", doc) @@ -86,7 +86,9 @@ def on_create_charge(req): "status": status, "captured": captured, "refunded": False, - "created": 1700000000, + "balance_transaction": None, + "dispute": None, + "created": _now(), } c = store_collection("charges") @@ -95,6 +97,14 @@ def on_create_charge(req): # Emit webhook event (fire-and-forget: errors do not break charge creation). _signed_emit("charge.created", doc) + # Settlement hooks (lib.star): the charge balance transaction (recorded + # once funds move — immediately for a captured card charge, at capture + # time otherwise), the application-fee record for Connect charges with + # application_fee_amount, and the immediate dispute raised by the + # documented dispute test cards. + if captured: + _charge_settle_hooks(doc, body, number) + _idempotent_remember(req, "charges", 201, charge_id) return respond(201, doc) @@ -155,6 +165,10 @@ def on_capture_charge(req): if doc == None: return respond(404, {"error": {"message": "No such charge: " + id, "type": "invalid_request_error"}}) + body = req["body"] + if body == None: + body = {} + doc["status"] = "succeeded" doc["captured"] = True c.update(id, doc) @@ -162,6 +176,11 @@ def on_capture_charge(req): # Emit webhook event (fire-and-forget). _signed_emit("charge.updated", doc) + # Funds move at capture: record the charge balance transaction now (plus + # any application_fee_amount supplied on the capture call, like real + # Stripe). The hooks are idempotent for already-settled charges. + _charge_settle_hooks(doc, body, "") + return respond(200, doc) # POST /v1/charges/{id}/refund — refund a charge (full or partial via amount). diff --git a/adapters/stripe-style/scripts/checkout.star b/adapters/stripe-style/scripts/checkout.star new file mode 100644 index 00000000..9c41253c --- /dev/null +++ b/adapters/stripe-style/scripts/checkout.star @@ -0,0 +1,750 @@ +# Checkout Sessions handlers — Stripe's hosted payment page +# (docs.stripe.com/api/checkout/sessions). +# +# Modes: payment | subscription | setup. The session doc follows the real +# checkout.session shape (amount_subtotal/amount_total computed from line +# items, currency, payment_status unpaid|paid|no_payment_required, status +# open|complete|expired, url). +# +# COMPLETION TRIGGER: real Stripe completes a session inside the hosted UI +# (the customer types their card there). This mock exposes that moment over +# HTTP: every open session carries url "/c/pay/{id}" (relative; the test +# client GETs + path) and GET /c/pay/{id} — NO auth, hosted-page +# semantics — completes it: +# payment mode -> PaymentIntent (succeeded) + captured Charge + balance +# transaction (existing shapes from +# payment_intents.star/charges.star), then 302 to +# success_url with {CHECKOUT_SESSION_ID} substituted. +# subscription mode -> Subscription doc created DIRECTLY in active state per +# the shared SUBSCRIPTION DOC CONTRACT, with its first +# invoice paid (lib._subscription_invoice + paid +# transition), plus the backing PI/charge. +# setup mode -> SetupIntent created and succeeded. +# checkout.session.completed and the underlying events (payment_intent.*, +# charge.created, customer.subscription.created, invoice.paid, +# setup_intent.succeeded, ...) are emitted via _signed_emit. +# +# ?payment_method= on the pay URL drives the card outcome through +# the lib test-card behavior: a decline card fails the completion exactly like +# a card decline (402 card_error envelope; session stays open with +# payment_status unpaid; payment_intent.payment_failed / +# setup_intent.setup_failed / checkout.session.async_payment_failed emitted — +# the delayed-notification style Stripe uses when a hosted payment later +# fails). SCA cards succeed here: the hosted page runs 3DS for the customer, +# so by redirect time authentication is done (mock simplification, documented +# here rather than in the Stripe docs). +# +# Nonexistent session on the pay URL -> 404 (resource_missing semantics). +# Expired -> 200 hosted "Checkout Session expired" page (the docs: customers +# loading an expired session "see a message saying the Checkout Session is +# expired" — no redirect, no side effects). A complete session re-redirects to +# success_url WITHOUT re-running any side effect (completion is idempotent). +# +# Shared helpers (_require_auth, _next_id, _now, _signed_emit, _num, _to_int, +# _not_found, _list_page, _newest_first, _created_filters, _created_check, +# _get_query, _add_months, _card_number_for, _card_outcome, +# _card_decline_error, _charge_settle_hooks, _subscription_invoice) are in +# lib.star. + +# _CK_SESSION_TTL is the default expiry horizon: 24 hours from creation (real +# Stripe default, docs.stripe.com/api/checkout/sessions/create -> expires_at). +_CK_SESSION_TTL = 24 * 3600 + +# _ck_public renders a stored session, stripping internal "_"-prefixed keys. +def _ck_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _ck_get loads a session (no liveness filter: expired/complete sessions stay +# retrievable like real Stripe). +def _ck_get(id): + return store_collection("checkout_sessions").get(id) + +# _ck_advance derives the open -> expired transition from the clock +# (derive-on-read, like refunds): a session whose expires_at has passed +# flips to status expired exactly once — the state change is persisted +# BEFORE checkout.session.expired is emitted, and the status guard makes the +# transition one-shot. +def _ck_advance(doc): + if doc.get("status", "") != "open": + return doc + if _now() < _num(doc.get("expires_at", 0)): + return doc + doc["status"] = "expired" + doc["url"] = None + store_collection("checkout_sessions").update(doc["id"], doc) + _signed_emit("checkout.session.expired", _ck_public(doc)) + return doc + +# _ck_product_id mints a product reference for inline product_data prices +# (name passthrough), mirroring how Checkout materializes a Product for +# price_data.product_data. +def _ck_product_for(pd): + if pd == None: + return None + if type(pd) == "string": + return pd + if type(pd) == "dict": + pid = pd.get("id", None) + if pid != None: + return pid + return _next_id("prod") + return _next_id("prod") + +# _ck_price_from_data builds the real price object shape for a +# line_items[N].price_data payload {currency, unit_amount, product_data | +# product, recurring {interval}}. +def _ck_price_from_data(pdata, mode): + interval = None + rec = pdata.get("recurring", None) + if rec != None and type(rec) == "dict": + interval = rec.get("interval", None) + ptype = "one_time" + if mode == "subscription": + ptype = "recurring" + recurring = None + if ptype == "recurring": + recurring = {"aggregate_usage": None, "interval": interval, "interval_count": 1, "trial_period_days": None, "usage_type": "licensed"} + unit_amount = _num(pdata.get("unit_amount", 0)) + return { + "id": _next_id("price"), + "object": "price", + "active": True, + "billing_scheme": "per_unit", + "created": _now(), + "currency": pdata.get("currency", "usd"), + "custom_unit_amount": None, + "livemode": False, + "lookup_key": None, + "metadata": {}, + "nickname": None, + "product": _ck_product_for(pdata.get("product_data", pdata.get("product", None))), + "recurring": recurring, + "tax_behavior": "unspecified", + "tiers_mode": None, + "transform_quantity": None, + "type": ptype, + "unit_amount": unit_amount, + "unit_amount_decimal": str(unit_amount), + } + +# _ck_price_public strips any internal keys a stored price doc may carry +# (prices.star owns the prices collection; stored docs may hold "_" fields). +def _ck_price_public(p): + if p == None: + return None + out = {} + for k in p: + if k.startswith("_"): + continue + out[k] = p[k] + return out + +# _ck_resolve_price resolves one line item's price: an existing price id +# (looked up in the prices collection -> 400 resource_missing when unknown) +# or an inline price_data payload (materialized into the real price shape). +def _ck_resolve_price(line, mode): + pid = line.get("price", None) + if pid != None and type(pid) == "string": + stored = store_collection("prices").get(pid) + if stored == None: + return None, respond(400, {"error": {"code": "resource_missing", "message": "No such price: '" + pid + "'", "param": "price", "type": "invalid_request_error"}}) + return _ck_price_public(stored), None + pdata = line.get("price_data", None) + if pdata != None and type(pdata) == "dict": + return _ck_price_from_data(pdata, mode), None + return None, respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: line_items[0][price].", "param": "line_items[0][price]"}}) + +# _ck_description names a line item from its price's product data / id. +def _ck_description(price): + if price == None: + return "" + return "Subscription" if price.get("type", "") == "recurring" else "One-time purchase" + +# _ck_lines resolves the request line_items into stored item dicts (the real +# session line-item shape, docs.stripe.com/api/checkout/sessions/line_items: +# id li_*, object item, amount_subtotal/amount_total, currency, description, +# price, quantity). Returns (lines, currency, subtotal, error). +def _ck_lines(body, mode): + raw = body.get("line_items", None) + if raw == None or type(raw) != "list" or len(raw) == 0: + return None, None, 0, respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: line_items[0].", "param": "line_items"}}) + lines = [] + currency = None + subtotal = 0 + for i in range(len(raw)): + line = raw[i] + if line == None or type(line) != "dict": + return None, None, 0, respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: line_items[0][price].", "param": "line_items"}}) + price, err = _ck_resolve_price(line, mode) + if err != None: + return None, None, 0, err + qty = _num(line.get("quantity", 1)) + if qty < 1: + qty = 1 + unit = _num(price.get("unit_amount", 0)) + amt = unit * qty + cur = price.get("currency", None) + if cur != None and cur != "": + currency = cur + subtotal = subtotal + amt + lines.append({ + "id": _next_id("li"), + "object": "item", + "amount_discount": 0, + "amount_subtotal": amt, + "amount_tax": 0, + "amount_total": amt, + "currency": cur, + "description": _ck_description(price), + "price": price, + "quantity": qty, + }) + return lines, currency, subtotal, None + +# POST /v1/checkout/sessions — create a session (mode payment|subscription| +# setup, line_items [{price, quantity} | price_data {...}], success_url +# required for hosted mode, customer|customer_email, payment_method_types, +# subscription_data passthrough subset, metadata, expires_at default +# _now() + 24h). +def on_create_checkout_session(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "checkout_sessions") + if cached != None: + return respond(cached["status"], _ck_public(cached["doc"])) + + if _ck_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + + body = req["body"] + if body == None: + body = {} + + mode = body.get("mode", None) + if mode == None or mode == "": + mode = "payment" + if mode not in ["payment", "subscription", "setup"]: + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid mode: must be one of payment, setup, or subscription", "param": "mode"}}) + + success_url = body.get("success_url", None) + if success_url == None or success_url == "": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: success_url.", "param": "success_url"}}) + + now = _now() + lines = [] + currency = None + subtotal = 0 + if mode != "setup": + lines, currency, subtotal, lerr = _ck_lines(body, mode) + if lerr != None: + return lerr + if currency == None: + currency = body.get("currency", None) + if currency == None and mode != "setup": + currency = "usd" + + expires_at = _num(body.get("expires_at", 0)) + if expires_at <= 0: + expires_at = now + _CK_SESSION_TTL + + payment_status = "unpaid" + if mode == "setup": + payment_status = "no_payment_required" + + pm_types = body.get("payment_method_types", None) + if pm_types == None or type(pm_types) != "list" or len(pm_types) == 0: + pm_types = ["card"] + + sid = _next_id("cs") + doc = { + "id": sid, + "object": "checkout.session", + "amount_subtotal": subtotal, + "amount_total": subtotal, + "currency": currency, + "customer": body.get("customer", None), + "customer_email": body.get("customer_email", None), + "mode": mode, + "status": "open", + "payment_status": payment_status, + "payment_intent": None, + "subscription": None, + "setup_intent": None, + "payment_method_types": pm_types, + "success_url": success_url, + "cancel_url": body.get("cancel_url", None), + "url": "/c/pay/" + sid, + "expires_at": expires_at, + "created": now, + "livemode": False, + "metadata": body.get("metadata", {}), + "client_reference_id": body.get("client_reference_id", None), + "total_details": {"amount_discount": 0, "amount_shipping": 0, "amount_tax": 0}, + "_lines": lines, + "_subscription_data": body.get("subscription_data", {}), + "_paid_pm": None, + } + if mode == "setup": + doc["amount_subtotal"] = None + doc["amount_total"] = None + store_collection("checkout_sessions").insert(doc) + _idempotent_remember(req, "checkout_sessions", 201, sid) + return respond(201, _ck_public(doc)) + +# _ck_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is +# the source of truth. +def _ck_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# GET /v1/checkout/sessions — list sessions (filters customer, status, +# payment_intent, subscription, created; newest first; cursor pagination). +def on_list_checkout_sessions(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("checkout_sessions").list() + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + status = _get_query(req, "status") + if status != "": + f.append(["status", "=", status]) + pi = _get_query(req, "payment_intent") + if pi != "": + f.append(["payment_intent", "=", pi]) + sub = _get_query(req, "subscription") + if sub != "": + f.append(["subscription", "=", sub]) + _created_filters(req, f) + if len(f) > 0: + docs = query_select(docs, f) + + advanced = [] + for i in range(len(docs)): + advanced.append(_ck_advance(docs[i])) + advanced = _newest_first(advanced) + + page, has_more, perr = _list_page(req, advanced, "checkout_session") + if perr != None: + return perr + return respond(200, {"object": "list", "data": [_ck_public(d) for d in page], "has_more": has_more, "url": "/v1/checkout/sessions"}) + +# GET /v1/checkout/sessions/{id} — retrieve a session (derive-on-read expiry). +def on_retrieve_checkout_session(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _ck_get(id) + if doc == None: + return _not_found("checkout_session", id) + doc = _ck_advance(doc) + return respond(200, _ck_public(doc)) + +# GET /v1/checkout/sessions/{id}/line_items — the session line-item shape +# (object "item", NOT the price shape), paginated. +def on_list_checkout_session_line_items(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _ck_get(id) + if doc == None: + return _not_found("checkout_session", id) + doc = _ck_advance(doc) + + lines = doc.get("_lines", []) + if lines == None: + lines = [] + page, has_more, perr = _list_page(req, lines, "item") + if perr != None: + return perr + return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/checkout/sessions/" + id + "/line_items"}) + +# POST /v1/checkout/sessions/{id}/expire — open -> expired + +# checkout.session.expired (only an open session is expireable, per +# docs.stripe.com/api/checkout/sessions/expire). +def on_expire_checkout_session(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "checkout_sessions") + if cached != None: + return respond(cached["status"], _ck_public(cached["doc"])) + + id = req["params"]["id"] + doc = _ck_get(id) + if doc == None: + return _not_found("checkout_session", id) + doc = _ck_advance(doc) + if doc.get("status", "") != "open": + return respond(400, {"error": {"type": "invalid_request_error", "message": "You cannot expire this Checkout Session because it has a status of " + doc.get("status", "") + ". Only a Checkout Session with one of the following statuses may be expired: open."}}) + doc["status"] = "expired" + doc["url"] = None + store_collection("checkout_sessions").update(id, doc) + _signed_emit("checkout.session.expired", _ck_public(doc)) + _idempotent_remember(req, "checkout_sessions", 200, id) + return respond(200, _ck_public(doc)) + +# ============================================================================ +# HOSTED-PAGE COMPLETION (GET /c/pay/{id}, no auth) +# ============================================================================ + +# _ck_redirect_url substitutes {CHECKOUT_SESSION_ID} in the success_url (the +# real Checkout templating parameter). +def _ck_redirect_url(doc): + url = doc.get("success_url", "") + if url == None: + url = "" + return url.replace("{CHECKOUT_SESSION_ID}", doc["id"]) + +# _ck_pi_doc mints the PaymentIntent doc in the payment_intents.star shape. +def _ck_pi_doc(doc, amount, status, pm, description): + return { + "id": _next_id("pi"), + "object": "payment_intent", + "amount": amount, + "amount_capturable": 0, + "amount_received": 0, + "currency": doc.get("currency", "usd"), + "status": status, + "capture_method": "automatic", + "payment_method": pm, + "customer": doc.get("customer", None), + "description": description, + "last_payment_error": None, + "next_action": None, + "metadata": doc.get("metadata", {}), + "created": _now(), + } + +# _ck_charge_for mints the captured Charge doc behind a successful +# PaymentIntent (charges.star shape) and records its balance transaction via +# the shared settlement hook. Persist-first, then charge.created. +def _ck_charge_for(pi_doc, number, description): + ch = { + "id": _next_id("ch"), + "object": "charge", + "amount": _num(pi_doc.get("amount", 0)), + "currency": pi_doc.get("currency", "usd"), + "customer": pi_doc.get("customer", None), + "description": description, + "status": "succeeded", + "captured": True, + "refunded": False, + "balance_transaction": None, + "dispute": None, + "payment_intent": pi_doc["id"], + "created": _now(), + } + store_collection("charges").insert(ch) + pi_doc["latest_charge"] = ch["id"] + store_collection("payment_intents").update(pi_doc["id"], pi_doc) + _signed_emit("charge.created", ch) + _charge_settle_hooks(ch, None, number) + return ch + +# _ck_fail_payment completes a session's payment with a declined card: the PI +# is persisted in requires_payment_method with last_payment_error (like PI +# confirm in payment_intents.star), payment_intent.payment_failed fires, the +# session stays open with payment_status unpaid, and the hosted page answers +# with the real 402 card_error envelope. The async-failure notification +# (checkout.session.async_payment_failed) is emitted as well — the style +# Stripe uses when a hosted payment ultimately fails. +def _ck_fail_payment(doc, pm, oc): + pi = _ck_pi_doc(doc, _num(doc.get("amount_total", 0)), "requires_payment_method", pm, None) + pi["last_payment_error"] = { + "charge": None, + "code": oc["code"], + "decline_code": oc["decline_code"], + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": oc["message"], + "payment_method": pm, + "type": "card_error", + } + store_collection("payment_intents").insert(pi) + _signed_emit("payment_intent.created", pi) + _signed_emit("payment_intent.payment_failed", pi) + _signed_emit("checkout.session.async_payment_failed", _ck_public(doc)) + return _card_decline_error(oc, "payment_intent", pi["id"]) + +# _ck_period_end computes the subscription's first current_period_end from +# the recurring interval. +def _ck_period_end(now, interval): + if interval == "year": + return _add_months(now, 12) + if interval == "week": + return now + 7 * 24 * 3600 + if interval == "day": + return now + 24 * 3600 + return _add_months(now, 1) + +# _ck_complete_subscription builds the Subscription doc directly in active +# state (SUBSCRIPTION DOC CONTRACT) with its first invoice paid, plus the +# backing PaymentIntent + Charge. Persist-first emission throughout; each +# transition fires exactly once. Returns the subscription doc. +def _ck_complete_subscription(doc, pm): + now = _now() + lines = doc.get("_lines", []) + customer = doc.get("customer", None) + if customer == None: + # Checkout creates a Customer during the flow when none was supplied. + customer = _next_id("cus") + email = doc.get("customer_email", None) + cust = {"id": customer, "object": "customer", "name": None, "email": email, "description": None, "created": now} + store_collection("customers").insert(cust) + _signed_emit("customer.created", cust) + + sub_data = doc.get("_subscription_data", {}) + if sub_data == None: + sub_data = {} + sub_meta = sub_data.get("metadata", {}) + if sub_meta == None: + sub_meta = {} + + # Items: embedded subscription_item docs with resolved price objects. + items = [] + line_dicts = [] + interval = "month" + for i in range(len(lines)): + ln = lines[i] + price = ln.get("price", None) + if price == None: + continue + qty = _num(ln.get("quantity", 1)) + rec = price.get("recurring", None) + if rec != None and type(rec) == "dict" and rec.get("interval", None) != None: + interval = rec["interval"] + items.append({ + "id": _next_id("si"), + "object": "subscription_item", + "price": price, + "quantity": qty, + "subscription": None, + "tax_rates": [], + }) + line_dicts.append({ + "type": "subscription", + "description": ln.get("description", ""), + "amount": _num(price.get("unit_amount", 0)), + "quantity": qty, + "period": {"start": now, "end": now}, + "price": price, + }) + + period_end = _ck_period_end(now, interval) + for i in range(len(line_dicts)): + line_dicts[i]["period"] = {"start": now, "end": period_end} + sub = { + "id": _next_id("sub"), + "object": "subscription", + "customer": customer, + "status": "active", + "items": items, + "current_period_start": now, + "current_period_end": period_end, + "cancel_at_period_end": False, + "canceled_at": None, + "ended_at": None, + "collection_method": "charge_automatically", + "default_payment_method": pm, + "latest_invoice": None, + "discount": None, + "default_tax_rates": [], + "start_date": now, + "trial_end": None, + "billing_cycle_anchor": now, + "metadata": sub_meta, + "test_clock": None, + "currency": doc.get("currency", "usd"), + "_period_no": 1, + } + for i in range(len(sub["items"])): + sub["items"][i]["subscription"] = sub["id"] + store_collection("subscriptions").insert(sub) + _signed_emit("customer.subscription.created", sub) + + # First invoice (open) -> paid, with the backing PI + charge linked. + inv = _subscription_invoice(sub, line_dicts, 0, 0, False) + total = _num(inv.get("total", 0)) + pi = _ck_pi_doc(doc, total, "succeeded", pm, "Subscription creation invoice") + pi["customer"] = customer + pi["invoice"] = inv["id"] + pi["amount_received"] = total + store_collection("payment_intents").insert(pi) + _signed_emit("payment_intent.created", pi) + ch = _ck_charge_for(pi, "", "Subscription creation invoice") + + inv["status"] = "paid" + inv["paid"] = True + inv["attempted"] = True + inv["amount_paid"] = total + inv["amount_remaining"] = 0 + st = inv.get("status_transitions", {}) + st["paid_at"] = _now() + inv["status_transitions"] = st + inv["charge"] = ch["id"] + inv["payment_intent"] = pi["id"] + store_collection("invoices").update(inv["id"], inv) + + sub["latest_invoice"] = inv["id"] + store_collection("subscriptions").update(sub["id"], sub) + + _signed_emit("invoice.paid", _invoice_public(inv)) + _signed_emit("invoice.payment_succeeded", _invoice_public(inv)) + _signed_emit("payment_intent.succeeded", pi) + return sub + +# _ck_complete_setup finishes a setup-mode session: SetupIntent created and +# succeeded (setup_intents.star shape). +def _ck_complete_setup(doc): + seti = { + "id": _next_id("seti"), + "object": "setup_intent", + "cancellation_reason": None, + "client_secret": None, + "created": _now(), + "customer": doc.get("customer", None), + "description": None, + "last_setup_error": None, + "latest_attempt": None, + "livemode": False, + "metadata": doc.get("metadata", {}), + "next_action": None, + "payment_method": None, + "payment_method_types": doc.get("payment_method_types", ["card"]), + "status": "requires_confirmation", + "usage": "off_session", + } + seti["client_secret"] = seti["id"] + "_secret_" + str(_now()) + store_collection("setup_intents").insert(seti) + _signed_emit("setup_intent.created", seti) + seti["status"] = "succeeded" + store_collection("setup_intents").update(seti["id"], seti) + _signed_emit("setup_intent.succeeded", seti) + return seti + +# _ck_fail_setup is the setup-mode decline path: the SetupIntent persists in +# requires_payment_method with last_setup_error, setup_intent.setup_failed +# fires, and the session stays open. +def _ck_fail_setup(doc, pm, oc): + seti = { + "id": _next_id("seti"), + "object": "setup_intent", + "cancellation_reason": None, + "client_secret": None, + "created": _now(), + "customer": doc.get("customer", None), + "description": None, + "last_setup_error": { + "code": oc["code"], + "decline_code": oc["decline_code"], + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": oc["message"], + "payment_method": pm, + "type": "card_error", + }, + "latest_attempt": None, + "livemode": False, + "metadata": doc.get("metadata", {}), + "next_action": None, + "payment_method": pm, + "payment_method_types": doc.get("payment_method_types", ["card"]), + "status": "requires_payment_method", + "usage": "off_session", + } + seti["client_secret"] = seti["id"] + "_secret_" + str(_now()) + store_collection("setup_intents").insert(seti) + _signed_emit("setup_intent.created", seti) + _signed_emit("setup_intent.setup_failed", seti) + _signed_emit("checkout.session.async_payment_failed", _ck_public(doc)) + e = { + "code": oc["code"], + "decline_code": oc["decline_code"], + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": oc["message"], + "setup_intent": seti["id"], + "type": "card_error", + } + return respond(402, {"error": e}) + +# GET /c/pay/{id} — the hosted Checkout page stand-in (NO auth). Completing a +# session runs the mode's side effects (PI/charge, subscription + paid +# invoice, SetupIntent), persists every state change BEFORE emitting, then +# 302-redirects to success_url with {CHECKOUT_SESSION_ID} substituted. +# Optional ?payment_method= drives the test-card behavior. +def on_pay_checkout_session(req): + id = req["params"]["id"] + doc = _ck_get(id) + if doc == None: + return respond(404, {"error": {"message": "No such checkout_session: " + id, "type": "invalid_request_error"}}) + doc = _ck_advance(doc) + + status = doc.get("status", "") + if status == "expired": + return respond(200, "

Checkout Session expired

This Checkout Session has expired and can no longer be completed.

", {"Content-Type": "text/html; charset=utf-8"}) + if status == "complete": + # Re-visiting a completed session re-redirects WITHOUT re-running any + # side effect (completion is one-shot). + return respond(302, "Found", {"Location": _ck_redirect_url(doc)}) + + pm = _get_query(req, "payment_method") + if pm == "": + pm = None + number = "" + if pm != None: + number = _card_number_for(pm) + oc = None + if number != "": + oc = _card_outcome(number) + if oc != None and oc["kind"] == "decline": + doc["_paid_pm"] = pm + store_collection("checkout_sessions").update(id, doc) + if doc.get("mode", "") == "setup": + return _ck_fail_setup(doc, pm, oc) + return _ck_fail_payment(doc, pm, oc) + + mode = doc.get("mode", "payment") + if mode == "subscription": + sub = _ck_complete_subscription(doc, pm) + doc["subscription"] = sub["id"] + elif mode == "setup": + seti = _ck_complete_setup(doc) + doc["setup_intent"] = seti["id"] + else: + pi = _ck_pi_doc(doc, _num(doc.get("amount_total", 0)), "requires_payment_method", pm, None) + pi["status"] = "succeeded" + pi["amount_received"] = _num(doc.get("amount_total", 0)) + store_collection("payment_intents").insert(pi) + _signed_emit("payment_intent.created", pi) + _ck_charge_for(pi, number, None) + _signed_emit("payment_intent.succeeded", pi) + doc["payment_intent"] = pi["id"] + + doc["status"] = "complete" + doc["payment_status"] = "paid" + doc["url"] = None + doc["_paid_pm"] = pm + store_collection("checkout_sessions").update(id, doc) + _signed_emit("checkout.session.completed", _ck_public(doc)) + return respond(302, "Found", {"Location": _ck_redirect_url(doc)}) diff --git a/adapters/stripe-style/scripts/coupons.star b/adapters/stripe-style/scripts/coupons.star new file mode 100644 index 00000000..63dab01b --- /dev/null +++ b/adapters/stripe-style/scripts/coupons.star @@ -0,0 +1,213 @@ +# Coupon handlers — percent-off / amount-off discounts redeemable on +# invoices and subscriptions (docs.stripe.com/api/coupons). +# +# A coupon has EITHER percent_off OR amount_off+currency (COUPON DOC +# CONTRACT): {id coupon_*, percent_off int|None, amount_off int, currency, +# duration once|forever|repeating, duration_in_months, redeem_by, +# max_redemptions, times_redeemed, valid, name, metadata}. +# +# Delete is a soft delete: the coupon stays retrievable (GET returns 200 +# with the coupon plus deleted: true) but can no longer be redeemed, exactly +# like real Stripe keeping deleted coupon objects readable. +# Shared helpers (_require_auth, _next_id, _num, _now, _not_found, +# _list_page, _newest_first, _created_filters, _created_check, _signed_emit, +# _idempotent_lookup, _idempotent_remember) are in lib.star. + +_COUPON_COLLECTION = "coupons" + +_COUPON_DURATIONS = ["once", "forever", "repeating"] + +# _coupon_err builds the real Stripe 400 envelope. +def _coupon_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +# _coupon_bad_body reports a malformed JSON body authoritatively. +def _coupon_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _coupon_public renders a stored coupon (internal keys stripped). +def _coupon_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# POST /v1/coupons — create a coupon. +# +# Exactly one of percent_off / amount_off(+currency) is required. duration +# defaults to once; repeating requires duration_in_months. +def on_create_coupon(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _COUPON_COLLECTION) + if cached != None: + return respond(cached["status"], _coupon_public(cached["doc"])) + + if _coupon_bad_body(req): + return _coupon_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + percent_off = body.get("percent_off", None) + amount_off = body.get("amount_off", None) + if percent_off == None and amount_off == None: + return _coupon_err("You must supply either a percent_off or an amount_off to create a coupon.", "percent_off") + if percent_off != None and amount_off != None: + return _coupon_err("You may only supply one of percent_off or amount_off, not both.", "percent_off") + + if percent_off != None and type(percent_off) not in ["int", "float"]: + return respond(400, {"error": {"code": "parameter_invalid_integer", "type": "invalid_request_error", "message": "Invalid integer: " + str(percent_off), "param": "percent_off"}}) + if percent_off != None and (percent_off <= 0 or percent_off > 100): + return _coupon_err("Invalid positive integer: percent_off must be greater than 0 and less than or equal to 100.", "percent_off") + if amount_off != None: + amount_off = _num(amount_off) + if amount_off <= 0: + return _coupon_err("Invalid positive integer: amount_off.", "amount_off") + if body.get("currency", None) == None or body.get("currency", "") == "": + return _coupon_err("Missing required param: currency.", "currency") + + duration = body.get("duration", "once") + if duration == None or duration == "": + duration = "once" + if duration not in _COUPON_DURATIONS: + return _coupon_err("Invalid duration: must be one of once, forever, or repeating.", "duration") + duration_in_months = _num(body.get("duration_in_months", 0)) + if duration == "repeating" and duration_in_months <= 0: + return _coupon_err("Missing required param: duration_in_months.", "duration_in_months") + + redeem_by = body.get("redeem_by", None) + if redeem_by != None: + redeem_by = _num(redeem_by) + if redeem_by <= 0: + redeem_by = None + max_redemptions = body.get("max_redemptions", None) + if max_redemptions != None: + max_redemptions = _num(max_redemptions) + if max_redemptions <= 0: + max_redemptions = None + + metadata = body.get("metadata", {}) + if metadata == None or type(metadata) != "dict": + metadata = {} + + name = body.get("name", None) + if name != None and type(name) != "string": + name = None + + doc = { + "id": _next_id("coupon"), + "object": "coupon", + "percent_off": percent_off, + "amount_off": amount_off if amount_off != None else 0, + "currency": body.get("currency", None) if amount_off != None else None, + "duration": duration, + "duration_in_months": duration_in_months, + "redeem_by": redeem_by, + "max_redemptions": max_redemptions, + "times_redeemed": 0, + "valid": True, + "name": name, + "metadata": metadata, + "livemode": False, + "created": _now(), + "deleted": False, + } + store_collection(_COUPON_COLLECTION).insert(doc) + _signed_emit("coupon.created", _coupon_public(doc)) + _idempotent_remember(req, _COUPON_COLLECTION, 201, doc["id"]) + return respond(201, _coupon_public(doc)) + +# GET /v1/coupons/{id} — retrieve a coupon (deleted coupons stay readable, +# with deleted: true and valid: false). +def on_retrieve_coupon(req): + err = _require_auth(req) + if err != None: + return err + doc = store_collection(_COUPON_COLLECTION).get(req["params"]["id"]) + if doc == None: + return _not_found("coupon", req["params"]["id"]) + return respond(200, _coupon_public(doc)) + +# GET /v1/coupons — list coupons. +def on_list_coupons(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + docs = store_collection(_COUPON_COLLECTION).list() + f = [] + valid = _get_query(req, "valid") + if valid == "true": + f.append(["deleted", "!=", True]) + _created_filters(req, f) + if len(f) > 0: + docs = query_select(docs, f) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "coupon") + if e != None: + return e + return respond(200, {"object": "list", "data": [_coupon_public(d) for d in page], "has_more": has_more, "url": "/v1/coupons"}) + +# POST /v1/coupons/{id} — update a coupon (name + metadata only, like the +# real API). +def on_update_coupon(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_COUPON_COLLECTION).get(id) + if doc == None: + return _not_found("coupon", id) + if doc.get("deleted", False) == True: + return _coupon_err("This coupon has been deleted and can no longer be updated.", None) + + if _coupon_bad_body(req): + return _coupon_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + if body.get("name", None) != None and type(body["name"]) == "string": + doc["name"] = body["name"] + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + + store_collection(_COUPON_COLLECTION).update(id, doc) + _signed_emit("coupon.updated", _coupon_public(doc)) + return respond(200, _coupon_public(doc)) + +# DELETE /v1/coupons/{id} — soft delete: existing discounts keep working, +# new redemptions stop. The object stays retrievable (deleted: true). +def on_delete_coupon(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_COUPON_COLLECTION).get(id) + if doc == None: + return _not_found("coupon", id) + + if doc.get("deleted", False) != True: + doc["deleted"] = True + doc["valid"] = False + store_collection(_COUPON_COLLECTION).update(id, doc) + _signed_emit("coupon.deleted", _coupon_public(doc)) + return respond(200, {"id": id, "object": "coupon", "deleted": True}) diff --git a/adapters/stripe-style/scripts/credit_notes.star b/adapters/stripe-style/scripts/credit_notes.star new file mode 100644 index 00000000..ed3235fe --- /dev/null +++ b/adapters/stripe-style/scripts/credit_notes.star @@ -0,0 +1,441 @@ +# Credit note handlers — post-payment / pre-payment adjustments against a +# finalized invoice (docs.stripe.com/api/credit_notes). +# +# A credit note first reduces the invoice's amount_remaining (its +# pre_payment_amount); the excess (post_payment_amount) can leave Stripe as +# a real refund against the invoice's charge (refund_amount) and/or as +# customer-balance credit (credit_amount). The refund is created with the +# shared lib helpers (refund doc + balance transaction + refund.created) and +# the charge doc is updated like POST /v1/refunds does. +# +# The classic refund/credit boolean params are accepted alongside the modern +# refund_amount/credit_amount for older SDK integrations. +# Shared helpers (_require_auth, _next_id, _now, _num, _to_int_signed, +# _not_found, _list_page, _newest_first, _created_filters, _created_check, +# _signed_emit, _create_refund, _refunds_for, _refunded_total, +# _apply_charge_refund) are in lib.star. + +_CN_COLLECTION = "credit_notes" + +_CN_REASONS = ["duplicate", "fraudulent", "order_change", "product_unsatisfactory"] + +# _cn_err builds the real Stripe 400 envelope. +def _cn_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +# _cn_bad_body reports a malformed JSON body authoritatively (an unparseable +# body arrives as an EMPTY dict via req.body; req.raw_body is the truth). +def _cn_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _cn_reason normalizes the credit-note reason: the four documented values +# pass through, the legacy duplicated/product_unacceptable spellings map to +# their modern forms, anything else is a 400 (or None when absent). +# Returns [reason, err] with err None on success. +def _cn_reason(raw): + if raw == None or raw == "": + return None, None + if raw == "duplicated": + return "duplicate", None + if raw == "product_unacceptable": + return "product_unsatisfactory", None + for i in range(len(_CN_REASONS)): + if _CN_REASONS[i] == raw: + return raw, None + return None, _cn_err("Invalid enum value: " + str(raw) + ". Allowed values: duplicate, fraudulent, order_change, product_unsatisfactory.", "reason") + +# _cn_qint reads an (optionally signed) integer query param. +def _cn_qint(req, key): + v = _get_query(req, key) + if v == "": + return 0 + return _to_int_signed(v) + +# _cn_build_lines derives the credit note lines + total from the request: +# either body `lines` (invoice_line_item refs or custom_line_items) or a +# bare `amount`. Invoice line amounts are per-unit (subtotal = amount x +# quantity), matching the INVOICE line contract. Returns [lines, total, err]. +def _cn_build_lines(invoice, body): + raw = body.get("lines", None) + if raw != None and type(raw) == "list": + lines = [] + total = 0 + inv_lines = invoice.get("lines", []) + for i in range(len(raw)): + entry = raw[i] + if entry == None or type(entry) != "dict": + continue + ltype = entry.get("type", "custom_line_item") + il = None + if ltype == "invoice_line_item": + ref = entry.get("invoice_line_item", None) + for j in range(len(inv_lines)): + if inv_lines[j].get("id", None) == ref: + il = inv_lines[j] + break + if il == None: + return [], 0, _cn_err("No such line_item: " + str(ref), "lines") + unit = _num(il.get("amount", 0)) + qty = _num(entry.get("quantity", il.get("quantity", 1))) + if qty < 1: + qty = 1 + desc = il.get("description", None) + else: + unit = _num(entry.get("unit_amount", 0)) + if entry.get("unit_amount", None) == None: + unit = _num(entry.get("amount", 0)) + qty = _num(entry.get("quantity", 1)) + if qty < 1: + qty = 1 + desc = entry.get("description", None) + amt = unit * qty + total = total + amt + lines.append({ + "id": _next_id("cnli"), + "object": "credit_note_line_item", + "amount": amt, + "description": desc, + "discount_amount": 0, + "discount_amounts": [], + "invoice_line_item": entry.get("invoice_line_item", None) if ltype == "invoice_line_item" else None, + "livemode": False, + "quantity": qty, + "tax_rates": [], + "taxes": [], + "type": ltype, + "unit_amount": unit, + "unit_amount_decimal": str(unit), + }) + return lines, total, None + + amount = _num(body.get("amount", 0)) + if amount <= 0: + return [], 0, _cn_err("One of `amount`, `lines`, or `shipping_cost` must be set.", "amount") + return [], amount, None + +# _cn_prior_total sums the amounts of every non-voided credit note already +# issued against an invoice (the max-creditable guard). +def _cn_prior_total(invoice_id): + docs = query_select(store_collection(_CN_COLLECTION).list(), [["invoice", "=", invoice_id]]) + total = 0 + for i in range(len(docs)): + if docs[i].get("status", "issued") != "voided": + total = total + _num(docs[i].get("amount", 0)) + return total + +# _cn_public renders a stored credit note, wrapping lines in a list object +# and stripping internal keys. Previews carry a null id, so the lines url +# falls back to the collection path. +def _cn_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + url = "/v1/credit_notes" + if doc.get("id", None) != None: + url = "/v1/credit_notes/" + doc["id"] + "/lines" + out["lines"] = { + "object": "list", + "data": doc.get("lines", []), + "has_more": False, + "url": url, + } + return out + +# _cn_refund_reason maps a credit-note reason onto a real refund reason. +def _cn_refund_reason(reason): + if reason == "duplicate" or reason == "fraudulent": + return reason + return "requested_by_customer" + +# _cn_assemble computes the full credit-note shape for a create/preview. +# apply=True executes the money movement (refund + customer-balance credit + +# invoice amount_remaining reduction); apply=False leaves everything +# untouched (preview). Returns [doc, err]. +def _cn_assemble(invoice, body, apply): + total_reason, bad = _cn_reason(body.get("reason", None)) + if bad != None: + return None, bad + + lines, total, err = _cn_build_lines(invoice, body) + if err != None: + return None, err + + remaining = _num(invoice.get("amount_remaining", 0)) + paid = _num(invoice.get("amount_paid", 0)) + limit = remaining + paid - _cn_prior_total(invoice["id"]) + if total > limit: + return None, _cn_err("Credit note amount (" + _usd(total) + ") is greater than the maximum creditable amount (" + _usd(limit) + ").", "amount") + + pre = total + if pre > remaining: + pre = remaining + post = total - pre + + refund_amount = _num(body.get("refund_amount", 0)) + if refund_amount == 0 and body.get("refund", False) == True: + refund_amount = post + credit_amount = _num(body.get("credit_amount", 0)) + if credit_amount == 0 and body.get("credit", False) == True: + credit_amount = post - refund_amount + if refund_amount < 0 or credit_amount < 0: + return None, _cn_err("Credit note amounts must be non-negative.", "amount") + if refund_amount > post: + return None, _cn_err("Refund amount (" + _usd(refund_amount) + ") is greater than the post-payment amount (" + _usd(post) + ").", "refund_amount") + if credit_amount > post - refund_amount: + return None, _cn_err("Credit amount (" + _usd(credit_amount) + ") is greater than the remaining post-payment amount (" + _usd(post - refund_amount) + ").", "credit_amount") + + refund_ids = [] + cbt = None + if apply: + if refund_amount > 0: + charge_id = invoice.get("charge", None) + if charge_id == None or charge_id == "": + return None, _cn_err("This invoice has no charge to refund.", "refund_amount") + ch = store_collection("charges").get(charge_id) + if ch == None: + return None, _not_found("charge", charge_id) + already = _refunded_total(_refunds_for("charge", charge_id)) + if refund_amount > _num(ch.get("amount", 0)) - already: + return None, _over_refund_error(refund_amount, _num(ch.get("amount", 0)) - already) + re_doc = _create_refund(None, charge_id, refund_amount, invoice.get("currency", "usd"), _cn_refund_reason(total_reason), False) + refund_ids.append(re_doc["id"]) + _apply_charge_refund(ch, already, refund_amount) + store_collection("charges").update(charge_id, ch) + _signed_emit("charge.refunded", ch) + if credit_amount > 0: + cus_id = invoice.get("customer", None) + cus = store_collection("customers").get(cus_id) + if cus == None: + return None, _not_found("customer", cus_id) + # Real Stripe customer balance: negative = credit. + cus["balance"] = _num(cus.get("balance", 0)) - credit_amount + store_collection("customers").update(cus_id, cus) + cbt = _next_id("cbt") + + cn_type = "pre_payment" + if post > 0: + cn_type = "post_payment" + + doc = { + "id": _next_id("cn"), + "object": "credit_note", + "amount": total, + "amount_shipping": 0, + "created": _now(), + "currency": invoice.get("currency", "usd"), + "customer": invoice.get("customer", None), + "customer_balance_transaction": cbt, + "discount_amount": 0, + "discount_amounts": [], + "effective_at": _now(), + "invoice": invoice["id"], + "lines": lines, + "livemode": False, + "memo": body.get("memo", None), + "metadata": body.get("metadata", {}), + "number": "CN-" + str(store_kv_incr("stripe", "cn_seq")), + "out_of_band_amount": None, + "pdf": None, + "pre_payment_amount": pre, + "post_payment_amount": post, + "reason": total_reason, + "refunds": refund_ids, + "shipping_cost": None, + "status": "issued", + "subtotal": total, + "subtotal_excluding_tax": total, + "total": total, + "total_excluding_tax": total, + "total_taxes": [], + "type": cn_type, + "voided_at": None, + "_voided": False, + } + if not apply: + doc["id"] = None + + if apply: + invoice["amount_remaining"] = remaining - pre + if invoice["amount_remaining"] < 0: + invoice["amount_remaining"] = 0 + if _num(invoice.get("amount_due", 0)) > 0: + invoice["amount_due"] = remaining - pre + if invoice["amount_due"] < 0: + invoice["amount_due"] = 0 + store_collection("invoices").update(invoice["id"], invoice) + + return doc, None + +# _cn_load fetches an invoice and enforces the finalized requirement shared +# by create + preview (draft/void/uncollectible invoices cannot be credited). +def _cn_load_invoice(invoice_id): + inv = store_collection("invoices").get(invoice_id) + if inv == None: + return None, _not_found("invoice", invoice_id) + if inv.get("status", "") not in ["paid", "open"]: + return None, _cn_err("Credit notes may only be created for finalized invoices with a status of paid or open.", "invoice") + return inv, None + +# POST /v1/credit_notes — issue a credit note against a finalized invoice. +def on_create_credit_note(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _CN_COLLECTION) + if cached != None: + return respond(cached["status"], _cn_public(cached["doc"])) + + if _cn_bad_body(req): + return _cn_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + invoice_id = body.get("invoice", None) + if invoice_id == None or invoice_id == "": + return _cn_err("Missing required param: invoice.", "invoice") + invoice, bad = _cn_load_invoice(invoice_id) + if bad != None: + return bad + + doc, bad = _cn_assemble(invoice, body, True) + if bad != None: + return bad + + store_collection(_CN_COLLECTION).insert(doc) + _signed_emit("credit_note.created", _cn_public(doc)) + _idempotent_remember(req, _CN_COLLECTION, 201, doc["id"]) + return respond(201, _cn_public(doc)) + +# GET /v1/credit_notes/preview — compute the credit note WITHOUT persisting +# anything (no refund, no customer-balance change, no event). Query params: +# invoice (required), amount | lines-unfriendly quantities via amount, +# refund_amount, credit_amount, reason. +def on_preview_credit_note(req): + err = _require_auth(req) + if err != None: + return err + + invoice_id = _get_query(req, "invoice") + if invoice_id == "": + b = req.get("body", None) + if b != None and type(b) == "dict" and b.get("invoice", None) != None: + invoice_id = b["invoice"] + if invoice_id == "": + return _cn_err("Missing required param: invoice.", "invoice") + invoice, bad = _cn_load_invoice(invoice_id) + if bad != None: + return bad + + params = { + "amount": _cn_qint(req, "amount"), + "refund_amount": _cn_qint(req, "refund_amount"), + "credit_amount": _cn_qint(req, "credit_amount"), + "reason": _get_query(req, "reason"), + "memo": _get_query(req, "memo"), + "lines": None, + } + b = req.get("body", None) + if b != None and type(b) == "dict": + for k in ["lines", "reason", "memo"]: + if b.get(k, None) != None: + params[k] = b[k] + + doc, bad = _cn_assemble(invoice, params, False) + if bad != None: + return bad + return respond(200, _cn_public(doc)) + +# GET /v1/credit_notes/{id} — retrieve a credit note. +def on_retrieve_credit_note(req): + err = _require_auth(req) + if err != None: + return err + doc = store_collection(_CN_COLLECTION).get(req["params"]["id"]) + if doc == None: + return _not_found("credit_note", req["params"]["id"]) + return respond(200, _cn_public(doc)) + +# GET /v1/credit_notes — list credit notes (customer, invoice, created). +def on_list_credit_notes(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + inv = _get_query(req, "invoice") + if inv != "": + f.append(["invoice", "=", inv]) + _created_filters(req, f) + docs = store_collection(_CN_COLLECTION).list() + if len(f) > 0: + docs = query_select(docs, f) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "credit_note") + if e != None: + return e + return respond(200, {"object": "list", "data": [_cn_public(d) for d in page], "has_more": has_more, "url": "/v1/credit_notes"}) + +# POST /v1/credit_notes/{id} — update a credit note (metadata/memo only, +# like the real API). +def on_update_credit_note(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_CN_COLLECTION).get(id) + if doc == None: + return _not_found("credit_note", id) + + if _cn_bad_body(req): + return _cn_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + if body.get("memo", None) != None: + doc["memo"] = body["memo"] + + store_collection(_CN_COLLECTION).update(id, doc) + _signed_emit("credit_note.updated", _cn_public(doc)) + return respond(200, _cn_public(doc)) + +# POST /v1/credit_notes/{id}/void — void an issued credit note. +def on_void_credit_note(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_CN_COLLECTION).get(id) + if doc == None: + return _not_found("credit_note", id) + if doc.get("status", "") != "issued": + return _cn_err("You cannot void this credit note because it has a status of " + doc.get("status", "") + ". Only issued credit notes may be voided.", None) + + doc["status"] = "voided" + doc["voided_at"] = _now() + doc["_voided"] = True + store_collection(_CN_COLLECTION).update(id, doc) + _signed_emit("credit_note.voided", _cn_public(doc)) + return respond(200, _cn_public(doc)) diff --git a/adapters/stripe-style/scripts/disputes.star b/adapters/stripe-style/scripts/disputes.star new file mode 100644 index 00000000..d46f7dd7 --- /dev/null +++ b/adapters/stripe-style/scripts/disputes.star @@ -0,0 +1,305 @@ +# Disputes handlers — the HTTP surface over lib.star's dispute engine. +# +# lib.star owns dispute creation (the documented dispute test cards raise one +# on capture), the derive-on-read state machine (_dispute_advance), evidence +# submission (_dispute_submit) and the lost close (_dispute_close). This file +# owns the routes (docs.stripe.com/api/disputes): +# GET /v1/disputes list (charge / payment_intent / created) +# GET /v1/disputes/{id} retrieve (state derived before rendering) +# POST /v1/disputes/{id} update: evidence + metadata (+ submit) +# POST /v1/disputes/{id}/close accept the dispute as lost +# +# Evidence fields are exactly Stripe's dispute_evidence_params object +# (docs.stripe.com/api/disputes/update): each field is a string. Evidence is +# stored SPARSE (only the fields actually posted), which is what drives lib's +# evidence_details.has_evidence derivation; a real dispute object renders the +# full field list with nulls, a fidelity trade-off accepted here so the +# has_evidence semantics stay exact. +# Shared helpers (_require_auth, _not_found, _list_page, _newest_first, +# _created_filters, _created_check, _get_query, _signed_emit, _dispute_public, +# _dispute_advance, _dispute_submit, _dispute_close, _idempotent_lookup, +# _idempotent_remember) are in lib.star. + +# The evidence string fields accepted by POST /v1/disputes/{id} (Stripe's +# dispute_evidence_params, minus the enhanced_evidence network-program object +# which this simulator does not model). +_DISP_EVIDENCE_FIELDS = [ + "access_activity_log", + "billing_address", + "cancellation_policy", + "cancellation_policy_disclosure", + "cancellation_rebuttal", + "customer_communication", + "customer_email_address", + "customer_name", + "customer_purchase_ip", + "customer_signature", + "duplicate_charge_documentation", + "duplicate_charge_explanation", + "duplicate_charge_id", + "product_description", + "receipt", + "refund_policy", + "refund_policy_disclosure", + "refund_refusal_explanation", + "service_date", + "service_documentation", + "shipping_address", + "shipping_carrier", + "shipping_date", + "shipping_documentation", + "shipping_tracking_number", + "uncategorized_file", + "uncategorized_text", +] + +# Absent-value sentinel for dict.get (evidence values are strings, so a dict +# can never collide with it). +_DISP_ABSENT = {} + +# _disp_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is the +# source of truth. +def _disp_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _disp_known_field reports whether key is one of the accepted evidence +# fields. Unknown evidence fields are ignored (Stripe rejects unknown params +# with parameter_unknown; this adapter's convention is to ignore). +def _disp_known_field(key): + for i in range(len(_DISP_EVIDENCE_FIELDS)): + if _DISP_EVIDENCE_FIELDS[i] == key: + return True + return False + +# _disp_evidence_merge merges a posted evidence dict into the dispute's stored +# evidence. Real Stripe semantics: posting a value sets the field, posting an +# empty string unsets it, untouched fields keep their staged value, unknown +# fields are ignored. Returns the merged sparse dict (no delete statement in +# this Starlark, so the result is rebuilt) and whether anything changed. +def _disp_evidence_merge(current, ev): + if current == None: + current = {} + if ev == None: + ev = {} + out = {} + touched = [] + changed = False + for k in ev: + if not _disp_known_field(k): + continue + touched.append(k) + v = ev[k] + if v == None or v == "": + # Unset: only a change if something was staged. + if current.get(k, None) != None: + changed = True + continue + if current.get(k, None) != v: + changed = True + out[k] = v + for k in current: + v = current[k] + if v == None or v == "": + continue + skip = False + for i in range(len(touched)): + if touched[i] == k: + skip = True + break + if skip: + continue + out[k] = v + return out, changed + +# _disp_metadata_merge merges a posted metadata map into the stored one with +# the same set/unset semantics as evidence (empty value unsets a key). +def _disp_metadata_merge(current, md): + if current == None: + current = {} + out = {} + for k in md: + v = md[k] + if v != None and v != "": + out[k] = v + for k in current: + if md.get(k, _DISP_ABSENT) == _DISP_ABSENT: + out[k] = current[k] + return out + +# _disp_get loads a dispute and derives its clock-driven state first (like +# every dispute read): a needs_response dispute whose evidence deadline passed +# resolves to lost, a submitted one moves to under_review / won. Returns None +# when the id is unknown. +def _disp_get(id): + doc = store_collection("disputes").get(id) + if doc == None: + return None + return _dispute_advance(doc) + +# _disp_closed reports whether the dispute is in a terminal (won/lost) state. +def _disp_closed(doc): + if doc.get("_closed", False) == True: + return True + st = doc.get("status", "") + return st == "won" or st == "lost" + +# _disp_closed_evidence_error is the real Stripe 400 shape for evidence +# updates on a resolved dispute (once won/lost, evidence can no longer be +# submitted — docs.stripe.com/api/disputes/object status enum). +def _disp_closed_evidence_error(): + return respond(400, {"error": {"type": "invalid_request_error", "message": "This dispute is closed and can no longer accept evidence.", "param": "evidence"}}) + +# GET /v1/disputes — list disputes, newest first, with the real Stripe list +# filters (charge, payment_intent, created exact/range) and cursor paging. +def on_list_disputes(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("disputes").list() + + # Derive every dispute's state from the clock before filtering, so a + # deadline-passed dispute lists as lost. + for i in range(len(docs)): + docs[i] = _dispute_advance(docs[i]) + + f = [] + ch = _get_query(req, "charge") + if ch != "": + f.append(["charge", "=", ch]) + pi = _get_query(req, "payment_intent") + if pi != "": + f.append(["payment_intent", "=", pi]) + _created_filters(req, f) + if len(f) > 0: + docs = query_select(docs, f) + + docs = _newest_first(docs) + + page, has_more, e = _list_page(req, docs, "dispute") + if e != None: + return e + out = [] + for i in range(len(page)): + out.append(_dispute_public(page[i])) + return respond(200, {"object": "list", "data": out, "has_more": has_more, "url": "/v1/disputes"}) + +# GET /v1/disputes/{id} — retrieve a dispute (state derived before rendering). +def on_retrieve_dispute(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _disp_get(id) + if doc == None: + return _not_found("dispute", id) + return respond(200, _dispute_public(doc)) + +# POST /v1/disputes/{id} — update evidence / metadata, optionally submitting +# the evidence to the bank. +# +# evidence[...] staged on the dispute (has_evidence flips true), status +# unchanged — real Stripe: submit defaults to false-ish +# staging until another call posts submit=true +# metadata merged, empty values unset keys +# submit=false stage only +# submit=true submit everything staged: needs_response -> under_review +# (charge.dispute.updated). Evidence present -> the ruling +# lands WON at submit + 1 day (advance the test clock past +# it: funds_reinstated + closed won). A closed (won/lost) +# dispute rejects evidence with the real 400. +def on_update_dispute(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "disputes") + if cached != None: + return respond(cached["status"], _dispute_public(cached["doc"])) + + if _disp_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + id = req["params"]["id"] + doc = _disp_get(id) + if doc == None: + return _not_found("dispute", id) + + if _disp_closed(doc): + return _disp_closed_evidence_error() + + evidence = body.get("evidence", None) + if evidence != None and type(evidence) != "dict": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request: evidence must be an object.", "param": "evidence"}}) + + metadata = body.get("metadata", None) + if metadata != None and type(metadata) != "dict": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request: metadata must be an object.", "param": "metadata"}}) + + merged_ev, ev_changed = _disp_evidence_merge(doc.get("evidence", {}), evidence) + submit = body.get("submit", False) + if submit != None and submit: + if len(merged_ev) == 0: + return respond(400, {"error": {"type": "invalid_request_error", "message": "To submit evidence, provide at least one evidence field.", "param": "evidence"}}) + doc["evidence"] = merged_ev + if metadata != None: + doc["metadata"] = _disp_metadata_merge(doc.get("metadata", {}), metadata) + # lib._dispute_submit persists, derives the under_review transition and + # emits charge.dispute.updated exactly once for it. + out = _dispute_submit(doc, True, merged_ev) + _idempotent_remember(req, "disputes", 200, id) + return respond(200, _dispute_public(out)) + + # Staging (submit absent/false): persist, then emit charge.dispute.updated + # only when something actually changed. + if ev_changed: + doc["evidence"] = merged_ev + if metadata != None: + doc["metadata"] = _disp_metadata_merge(doc.get("metadata", {}), metadata) + if not ev_changed and metadata == None: + return respond(200, _dispute_public(doc)) + store_collection("disputes").update(id, doc) + if ev_changed: + _signed_emit("charge.dispute.updated", _dispute_public(doc)) + _idempotent_remember(req, "disputes", 200, id) + return respond(200, _dispute_public(doc)) + +# POST /v1/disputes/{id}/close — accept the dispute as lost (no evidence to +# submit). Closing is irreversible; a closed (won/lost) dispute rejects a +# second close with the real 400 shape. +def on_close_dispute(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "disputes") + if cached != None: + return respond(cached["status"], _dispute_public(cached["doc"])) + + if _disp_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + + id = req["params"]["id"] + doc = _disp_get(id) + if doc == None: + return _not_found("dispute", id) + + if _disp_closed(doc): + return respond(400, {"error": {"type": "invalid_request_error", "message": "This dispute is already closed."}}) + + # lib._dispute_close persists the lost state and emits charge.dispute.closed. + out = _dispute_close(doc) + _idempotent_remember(req, "disputes", 200, id) + return respond(200, _dispute_public(out)) diff --git a/adapters/stripe-style/scripts/files.star b/adapters/stripe-style/scripts/files.star new file mode 100644 index 00000000..aba91301 --- /dev/null +++ b/adapters/stripe-style/scripts/files.star @@ -0,0 +1,272 @@ +# Files + File Links handlers — dispute/identity/document uploads +# (docs.stripe.com/api/files, docs.stripe.com/api/file_links). +# +# POST /v1/files is multipart/form-data (real Stripe: the upload goes to +# files.stripe.com with a purpose enum + the file part). stunt's engine hands +# handlers the raw multipart body via req.raw_body and the parse_multipart +# builtin splits it into parts {name, data, filename, content_type}. No +# binary retention: the file doc stores size/filename/title/type plus a +# SHA-256 content hash (crypto.sha256) in the internal field _sha256 — the +# real file object has no hash field, so the public renderer strips it. +# +# Purpose enum (the user-uploadable subset from +# docs.stripe.com/api/files/create): business_icon, business_logo, +# customer_signature, dispute_evidence, identity_document, pci_document, +# tax_document_user_upload. +# +# Real file shape: id file_*, object file, created, expires_at, filename, +# links (list object), purpose, size, title, type (extension: png/jpg/pdf/ +# csv), url (files.stripe.com contents URL). +# Real file_link shape: id link_*, object file_link, created, expired, +# expires_at, file, livemode, metadata, url. +# Shared helpers (_require_auth, _next_id, _now, _not_found, _list_page, +# _newest_first, _get_query, _num) are in lib.star. + +_CK_PURPOSES = [ + "business_icon", + "business_logo", + "customer_signature", + "dispute_evidence", + "identity_document", + "pci_document", + "tax_document_user_upload", +] + +# _files_public renders the public file shape, injecting the live links list +# (every file_link pointing at this file, like the real expandable list). +def _files_public(doc): + links = store_collection("file_links").list() + mine = query_select(links, [["file", "=", doc["id"]]]) + out = { + "id": doc["id"], + "object": "file", + "created": doc.get("created", 0), + "expires_at": doc.get("expires_at", None), + "filename": doc.get("filename", None), + "links": {"object": "list", "data": [_links_public(l) for l in mine], "has_more": False, "url": "/v1/file_links?file=" + doc["id"]}, + "purpose": doc.get("purpose", None), + "size": _num(doc.get("size", 0)), + "title": doc.get("title", None), + "type": doc.get("type", None), + "url": "https://files.stripe.com/v1/files/" + doc["id"] + "/contents", + } + return out + +# _links_public renders the public file_link shape with `expired` derived +# from the clock (a link whose expires_at has passed reads expired true). +def _links_public(doc): + expires_at = doc.get("expires_at", None) + expired = False + if expires_at != None and _now() >= _num(expires_at): + expired = True + return { + "id": doc["id"], + "object": "file_link", + "created": doc.get("created", 0), + "expired": expired, + "expires_at": expires_at, + "file": doc.get("file", None), + "livemode": False, + "metadata": doc.get("metadata", {}), + "url": "https://files.stripe.com/links/" + doc["id"], + } + +# _files_type maps a filename to the real Stripe file `type` (the extension +# without the dot, lowercased; None when the filename has none). +def _files_type(filename): + if filename == None: + return None + dot = filename.rfind(".") + if dot < 0 or dot + 1 >= len(filename): + return None + ext = filename[dot + 1:] + out = "" + for i in range(len(ext)): + ch = ext[i] + lch = ch + if ch >= "A" and ch <= "Z": + lch = chr(ord(ch) + 32) + out = out + lch + return out + +# POST /v1/files — multipart upload. purpose (required, enum-validated) is a +# form field; the file part (any part carrying a filename) is the upload. +def on_create_file(req): + err = _require_auth(req) + if err != None: + return err + + h = req.get("headers") + ct = "" + if h != None: + ct = h.get("Content-Type", "") + if ct == None: + ct = "" + if not ct.startswith("multipart/"): + return respond(400, {"error": {"type": "invalid_request_error", "message": "The file upload request must be multipart/form-data.", "param": "file"}}) + + parts, perr = parse_multipart(ct, req["raw_body"]) + if perr != None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "Malformed multipart body.", "param": "file"}}) + + purpose = None + title = None + data = None + filename = None + for i in range(len(parts)): + p = parts[i] + pname = p.get("name", None) + if p.get("filename", None) != None: + data = p.get("data", "") + filename = p["filename"] + elif pname == "purpose": + purpose = p.get("data", None) + elif pname == "title": + title = p.get("data", None) + if purpose == None or purpose == "": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: purpose.", "param": "purpose"}}) + + ok = False + for i in range(len(_CK_PURPOSES)): + if _CK_PURPOSES[i] == purpose: + ok = True + break + if not ok: + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid purpose: " + purpose + ". Valid purposes are: business_icon, business_logo, customer_signature, dispute_evidence, identity_document, pci_document, tax_document_user_upload.", "param": "purpose"}}) + if data == None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: file.", "param": "file"}}) + + size = 0 + if data != None: + size = len(data) + doc = { + "id": _next_id("file"), + "object": "file", + "created": _now(), + "expires_at": None, + "filename": filename, + "purpose": purpose, + "size": size, + "title": title, + "type": _files_type(filename), + "_sha256": crypto.sha256(data), + } + store_collection("files").insert(doc) + return respond(201, _files_public(doc)) + +# GET /v1/files — list uploads (filter purpose; newest first). +def on_list_files(req): + err = _require_auth(req) + if err != None: + return err + + docs = store_collection("files").list() + purpose = _get_query(req, "purpose") + if purpose != "": + docs = query_select(docs, [["purpose", "=", purpose]]) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "file") + if e != None: + return e + return respond(200, {"object": "list", "data": [_files_public(d) for d in page], "has_more": has_more, "url": "/v1/files"}) + +# GET /v1/files/{id} — retrieve an upload. +def on_retrieve_file(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("files").get(id) + if doc == None: + return _not_found("file", id) + return respond(200, _files_public(doc)) + +# POST /v1/file_links — create a public link to a file (file required and +# must exist; expires_at None = never expires). +def on_create_file_link(req): + err = _require_auth(req) + if err != None: + return err + + body = req["body"] + if body == None: + body = {} + + fid = body.get("file", None) + if fid == None or fid == "": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: file.", "param": "file"}}) + if store_collection("files").get(fid) == None: + return respond(400, {"error": {"code": "resource_missing", "message": "No such file: '" + fid + "'", "param": "file", "type": "invalid_request_error"}}) + + expires_at = body.get("expires_at", None) + if expires_at != None: + expires_at = _num(expires_at) + if expires_at <= 0: + expires_at = None + + doc = { + "id": _next_id("link"), + "object": "file_link", + "created": _now(), + "expires_at": expires_at, + "file": fid, + "metadata": body.get("metadata", {}), + } + store_collection("file_links").insert(doc) + return respond(201, _links_public(doc)) + +# GET /v1/file_links — list links (filter file; newest first). +def on_list_file_links(req): + err = _require_auth(req) + if err != None: + return err + + docs = store_collection("file_links").list() + fid = _get_query(req, "file") + if fid != "": + docs = query_select(docs, [["file", "=", fid]]) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "file_link") + if e != None: + return e + return respond(200, {"object": "list", "data": [_links_public(d) for d in page], "has_more": has_more, "url": "/v1/file_links"}) + +# GET /v1/file_links/{id} — retrieve a link. +def on_retrieve_file_link(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("file_links").get(id) + if doc == None: + return _not_found("file_link", id) + return respond(200, _links_public(doc)) + +# POST /v1/file_links/{id} — update a link (expires_at, metadata). +def on_update_file_link(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + c = store_collection("file_links") + doc = c.get(id) + if doc == None: + return _not_found("file_link", id) + + body = req["body"] + if body == None: + body = {} + if body.get("expires_at", None) != None: + exp = _num(body.get("expires_at")) + if exp <= 0: + exp = None + doc["expires_at"] = exp + meta = body.get("metadata", None) + if meta != None and type(meta) == "dict": + doc["metadata"] = meta + + c.update(id, doc) + return respond(200, _links_public(doc)) diff --git a/adapters/stripe-style/scripts/invoice_items.star b/adapters/stripe-style/scripts/invoice_items.star new file mode 100644 index 00000000..ad898fd0 --- /dev/null +++ b/adapters/stripe-style/scripts/invoice_items.star @@ -0,0 +1,262 @@ +# Invoice item handlers — one-off charges/credits that flow into the next +# invoice for a customer (docs.stripe.com/api/invoiceitems). +# +# An invoice item created without an `invoice` is PENDING: it stays unattached +# until the subscriptions domain rolls it into the customer's next invoice. +# Items attached to a draft invoice can still be edited/deleted; items on a +# finalized invoice cannot be deleted. +# +# Object shape mirrors the real invoice item: ii_* id, unit_amount x +# quantity = amount, period, discountable, tax_rates, invoice link. +# Shared helpers (_require_auth, _next_id, _num, _now, _not_found, +# _list_page, _newest_first, _created_filters, _created_check, _signed_emit, +# _idempotent_lookup, _idempotent_remember) are in lib.star. + +_II_COLLECTION = "invoice_items" + +# _ii_err builds the real Stripe 400 envelope. +def _ii_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +def _ii_missing(param): + return _ii_err("Missing required param: " + param + ".", param) + +# _ii_bad_body reports a malformed JSON body authoritatively (an unparseable +# body arrives as an EMPTY dict via req.body; req.raw_body is the truth). +def _ii_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _ii_price resolves the price dict for an item: an inline price_data object +# or a stored price id (read from the prices collection the subscriptions +# domain owns; unknown ids yield None). Internal _ keys are stripped. +def _ii_price(price_id, price_data): + if price_data != None and type(price_data) == "dict": + out = dict(price_data) + out["object"] = "price" + if out.get("id", None) == None: + out["id"] = None + return out + if price_id == None or type(price_id) != "string": + return None + p = store_collection("prices").get(price_id) + if p == None: + return None + out = {} + for k in p: + if k.startswith("_"): + continue + out[k] = p[k] + return out + +# _ii_public renders a stored invoice item (strips internal keys). +def _ii_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# POST /v1/invoice_items — create an item for a customer's next invoice. +# +# customer is required; the unit amount comes from unit_amount/amount, the +# inline price_data, or the stored price. Negative amounts reduce the next +# invoice's amount_due, like the real API. +def on_create_invoice_item(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _II_COLLECTION) + if cached != None: + return respond(cached["status"], _ii_public(cached["doc"])) + + if _ii_bad_body(req): + return _ii_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + customer = body.get("customer", None) + if customer == None or customer == "": + return _ii_missing("customer") + if store_collection("customers").get(customer) == None: + return _not_found("customer", customer) + + subscription = body.get("subscription", None) + if subscription == None or subscription == "": + subscription = None + + price = _ii_price(body.get("price", None), body.get("price_data", None)) + + unit = _num(body.get("unit_amount", 0)) + if body.get("unit_amount", None) == None: + unit = _num(body.get("amount", 0)) + if unit == 0 and price != None: + unit = _num(price.get("unit_amount", 0)) + if unit == 0 and body.get("unit_amount", None) == None and body.get("amount", None) == None and price == None: + return _ii_missing("unit_amount") + + quantity = _num(body.get("quantity", 1)) + if quantity < 1: + quantity = 1 + + currency = body.get("currency", None) + if (currency == None or currency == "") and price != None: + currency = price.get("currency", None) + if currency == None or currency == "": + currency = "usd" + + tax_rates = body.get("tax_rates", []) + if tax_rates == None or type(tax_rates) != "list": + tax_rates = [] + + metadata = body.get("metadata", {}) + if metadata == None or type(metadata) != "dict": + metadata = {} + + discountable = body.get("discountable", True) + if discountable == None: + discountable = True + + now = _now() + doc = { + "id": _next_id("ii"), + "object": "invoice_item", + "customer": customer, + "currency": currency, + "unit_amount": unit, + "amount": unit * quantity, + "quantity": quantity, + "description": body.get("description", None), + "discountable": discountable == True, + "invoice": None, + "subscription": subscription, + "period": {"start": now, "end": now}, + "proration": False, + "price": price, + "tax_rates": tax_rates, + "metadata": metadata, + "livemode": False, + "date": now, + "created": now, + } + store_collection(_II_COLLECTION).insert(doc) + _signed_emit("invoiceitem.created", _ii_public(doc)) + _idempotent_remember(req, _II_COLLECTION, 201, doc["id"]) + return respond(201, _ii_public(doc)) + +# GET /v1/invoice_items/{id} — retrieve an invoice item. +def on_retrieve_invoice_item(req): + err = _require_auth(req) + if err != None: + return err + doc = store_collection(_II_COLLECTION).get(req["params"]["id"]) + if doc == None: + return _not_found("invoiceitem", req["params"]["id"]) + return respond(200, _ii_public(doc)) + +# _ii_apply_filters maps the invoice-item list query params (customer, +# pending=true -> only items not yet attached to an invoice, invoice, and +# created exact/range) to query_select clauses. +def _ii_apply_filters(req, docs): + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + inv = _get_query(req, "invoice") + if inv != "": + f.append(["invoice", "=", inv]) + pend = _get_query(req, "pending") + if pend == "true" or pend == "1": + f.append(["invoice", "=", None]) + _created_filters(req, f) + if len(f) == 0: + return docs + return query_select(docs, f) + +# GET /v1/invoice_items — list invoice items. +def on_list_invoice_items(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + docs = store_collection(_II_COLLECTION).list() + docs = _ii_apply_filters(req, docs) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "invoiceitem") + if e != None: + return e + return respond(200, {"object": "list", "data": [_ii_public(d) for d in page], "has_more": has_more, "url": "/v1/invoice_items"}) + +# POST /v1/invoice_items/{id} — update an invoice item (description, +# metadata, discountable, tax_rates, and the amount-bearing fields; amount +# is recomputed as unit_amount x quantity). +def on_update_invoice_item(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_II_COLLECTION).get(id) + if doc == None: + return _not_found("invoiceitem", id) + + if _ii_bad_body(req): + return _ii_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + if body.get("description", None) != None: + doc["description"] = body["description"] + if body.get("discountable", None) != None: + doc["discountable"] = body["discountable"] == True + if body.get("tax_rates", None) != None and type(body["tax_rates"]) == "list": + doc["tax_rates"] = body["tax_rates"] + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + if body.get("unit_amount", None) != None: + doc["unit_amount"] = _num(body["unit_amount"]) + if body.get("quantity", None) != None: + q = _num(body["quantity"]) + if q < 1: + q = 1 + doc["quantity"] = q + doc["amount"] = _num(doc.get("unit_amount", 0)) * _num(doc.get("quantity", 1)) + + store_collection(_II_COLLECTION).update(id, doc) + _signed_emit("invoiceitem.updated", _ii_public(doc)) + return respond(200, _ii_public(doc)) + +# DELETE /v1/invoice_items/{id} — delete an invoice item. Items attached to +# a finalized invoice cannot be deleted (real Stripe behavior). +def on_delete_invoice_item(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_II_COLLECTION).get(id) + if doc == None: + return _not_found("invoiceitem", id) + inv_id = doc.get("invoice", None) + if inv_id != None and inv_id != "": + inv = store_collection("invoices").get(inv_id) + if inv != None and inv.get("status", "draft") != "draft": + return _ii_err("You cannot delete this invoice item because it is attached to an invoice that has been finalized.", None) + pub = _ii_public(doc) + store_collection(_II_COLLECTION).delete(id) + _signed_emit("invoiceitem.deleted", pub) + return respond(200, {"id": id, "object": "invoice_item", "deleted": True}) diff --git a/adapters/stripe-style/scripts/invoices.star b/adapters/stripe-style/scripts/invoices.star new file mode 100644 index 00000000..c2d90ddc --- /dev/null +++ b/adapters/stripe-style/scripts/invoices.star @@ -0,0 +1,971 @@ +# Invoice handlers — the billing document lifecycle (draft -> open -> paid / +# void / uncollectible) plus the upcoming-invoice preview. +# +# Subscription-created invoice DOCS are written by the subscriptions domain +# via lib._subscription_invoice; this file owns every invoice ENDPOINT and +# renders every invoice (manual or subscription) through lib._invoice_public. +# +# Manual invoices (POST /v1/invoices) start as drafts with either explicit +# `items` lines or zero lines. Finalize moves draft -> open (finalized_at), +# pay charges the resolved payment method through the shared test-card +# behavior (decline -> 402 card_error, invoice stays open, +# invoice.payment_failed; success -> paid + real charge + balance transaction +# + charge.succeeded + invoice.paid + invoice.payment_succeeded). +# +# Shared helpers (_require_auth, _next_id, _now, _num, _to_int, _not_found, +# _list_page, _newest_first, _created_filters, _created_check, +# _idempotent_lookup, _idempotent_remember, _invoice_public, +# _card_number_for, _card_outcome, _card_decline_error, _sca_charge_error, +# _charge_settle_hooks, _create_refund, _apply_charge_refund, _signed_emit) +# are in lib.star. + +_INV_COLLECTION = "invoices" + +# _inv_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is +# the source of truth. +def _inv_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _inv_err builds the real Stripe error envelope with status 400. +def _inv_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +# _inv_missing is the real Stripe 400 for a missing required param. +def _inv_missing(param): + return _inv_err("Missing required param: " + param + ".", param) + +# _inv_state_err is the 400 for a lifecycle call on an invoice whose status +# does not allow it (phrased like the adapter's PaymentIntent errors). +# verb is the present-tense action, participle its past form ("pay"/"paid"). +def _inv_state_err(verb, participle, status, allowed): + return _inv_err("You cannot " + verb + " this invoice because it has a status of " + status + ". Only invoices with one of the following statuses may be " + participle + ": " + allowed + ".", None) + +# _inv_get loads an invoice doc or None. +def _inv_get(id): + return store_collection(_INV_COLLECTION).get(id) + +# _inv_save persists an invoice doc. +def _inv_save(doc): + store_collection(_INV_COLLECTION).update(doc["id"], doc) + +# ============================================================================ +# TAX + DISCOUNT MATH (TAX CONTRACT: tax cents per rate over an amount is +# int(amount * percentage / 100.0 + 0.5); exclusive rates add to the total, +# inclusive rates are shown in `tax` but NOT added) +# ============================================================================ + +# _inv_tax_cents computes the tax over `amount` for a list of tax-rate ids. +# Returns [tax_cents, inclusive] where inclusive is True only when every +# rate is inclusive (mixed lists behave exclusive, funds-wise). +def _inv_tax_cents(amount, rate_ids): + if rate_ids == None: + return 0, False + tax = 0 + n = 0 + inclusive_n = 0 + for i in range(len(rate_ids)): + rid = rate_ids[i] + if rid == None: + continue + doc = store_collection("tax_rates").get(rid) + if doc == None: + continue + if doc.get("deleted", False) == True: + continue + pct = doc.get("percentage", 0) + if pct == None: + pct = 0 + tax = tax + int(amount * pct / 100.0 + 0.5) + n = n + 1 + if doc.get("inclusive", False) == True: + inclusive_n = inclusive_n + 1 + if n > 0 and inclusive_n == n: + return tax, True + return tax, False + +# _inv_coupon_for resolves the coupon dict behind a discount: the invoice +# contract stores {coupon fields + promotion_code} flattened, subscription +# docs may store {"coupon": , ...}. Returns None when +# no coupon can be resolved. +def _inv_coupon_for(discount): + if discount == None or type(discount) != "dict": + return None + if discount.get("percent_off", None) != None or discount.get("amount_off", None) != None: + return discount + c = discount.get("coupon", None) + if c == None: + return None + if type(c) == "dict": + return c + return store_collection("coupons").get(c) + +# _inv_discount_amount computes the discount cents for one invoice over its +# subtotal: percent -> int(subtotal*pct/100 + 0.5), amount_off capped at +# subtotal (COUPON CONTRACT). +def _inv_discount_amount(subtotal, discount): + c = _inv_coupon_for(discount) + if c == None: + return 0 + pct = c.get("percent_off", None) + if pct != None: + amt = int(subtotal * pct / 100.0 + 0.5) + if amt > subtotal: + amt = subtotal + return amt + off = _num(c.get("amount_off", 0)) + if off > subtotal: + return subtotal + if off < 0: + return 0 + return off + +# _inv_recompute rebuilds subtotal/tax/discount-free totals for an invoice +# from its lines + default_tax_rates (draft edits). Line amounts are +# per-unit (subtotal = sum(amount * quantity)), matching +# lib._subscription_invoice. +def _inv_recompute(doc): + subtotal = 0 + for i in range(len(doc["lines"])): + ln = doc["lines"][i] + subtotal = subtotal + _num(ln.get("amount", 0)) * _num(ln.get("quantity", 1)) + tax, inclusive = _inv_tax_cents(subtotal, doc.get("default_tax_rates", [])) + total = subtotal + tax + if inclusive: + total = subtotal + if total < 0: + total = 0 + doc["subtotal"] = subtotal + doc["tax"] = tax + doc["total"] = total + doc["amount_due"] = total + doc["amount_remaining"] = total - _num(doc.get("amount_paid", 0)) + return doc + +# _inv_currency picks the invoice currency: explicit param, else the first +# priced line's currency, else usd. +def _inv_currency(lines, wanted): + if wanted != None and wanted != "": + return wanted + for i in range(len(lines)): + price = lines[i].get("price", None) + if price != None and price.get("currency", None) != None: + return price["currency"] + return "usd" + +# _inv_price_doc resolves a price id to its stored doc (None when unknown). +def _inv_price_doc(price_id): + if price_id == None or type(price_id) != "string": + return None + p = store_collection("prices").get(price_id) + if p == None: + return None + out = {} + for k in p: + if k.startswith("_"): + continue + out[k] = p[k] + return out + +# _inv_lines_from_items builds contract line dicts from POST /v1/invoices +# `items` entries: each {unit_amount|amount, currency, description, +# quantity, price, price_data}. +def _inv_lines_from_items(items): + lines = [] + for i in range(len(items)): + it = items[i] + if it == None or type(it) != "dict": + continue + qty = _num(it.get("quantity", 1)) + if qty < 1: + qty = 1 + unit = _num(it.get("unit_amount", 0)) + if it.get("unit_amount", None) == None: + unit = _num(it.get("amount", 0)) + price = None + if it.get("price_data", None) != None and type(it["price_data"]) == "dict": + price = dict(it["price_data"]) + price["object"] = "price" + if price.get("id", None) == None: + price["id"] = None + if unit == 0: + unit = _num(price.get("unit_amount", 0)) + elif it.get("price", None) != None: + price = _inv_price_doc(it["price"]) + if price != None and unit == 0: + unit = _num(price.get("unit_amount", 0)) + desc = it.get("description", None) + if desc == None: + desc = "Line item" + now = _now() + lines.append({ + "id": _next_id("il"), + "object": "line_item", + "type": "invoice_item", + "description": desc, + "amount": unit, + "quantity": qty, + "period": {"start": now, "end": now}, + "price": price, + "proration": False, + "tax_rates": [], + }) + return lines + +# _inv_new builds a draft invoice doc (INVOICE DOC CONTRACT). +def _inv_new(customer, lines, currency, collection_method, auto_advance, due_date, subscription, description, metadata, tax_rates, billing_reason): + now = _now() + doc = { + "id": _next_id("in"), + "object": "invoice", + "customer": customer, + "subscription": subscription, + "status": "draft", + "collection_method": collection_method, + "currency": _inv_currency(lines, currency), + "lines": lines, + "subtotal": 0, + "discount": None, + "default_tax_rates": tax_rates, + "tax": 0, + "total": 0, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "starting_balance": 0, + "charge": None, + "payment_intent": None, + "status_transitions": {"finalized_at": None, "paid_at": None, "voided_at": None}, + "billing_reason": billing_reason, + "due_date": due_date, + "created": now, + "auto_advance": auto_advance, + "attempted": False, + "metadata": metadata, + "paid": None, + "description": description, + "_advance_scheduled": False, + } + return _inv_recompute(doc) + +# ============================================================================ +# PAYMENT-METHOD RESOLUTION (pay + subscriptions auto-charge use the same +# precedence: explicit param > invoice default > customer default) +# ============================================================================ + +# _inv_default_pm resolves the customer's default card-ish payment method: +# the customer doc's default_payment_method / invoice_settings.default_ +# payment_method, else the newest card payment method attached to the +# customer. Returns None when the customer has none. +def _inv_default_pm(customer_id): + if customer_id == None: + return None + cus = store_collection("customers").get(customer_id) + if cus != None: + pm = cus.get("default_payment_method", None) + if pm == None: + settings = cus.get("invoice_settings", None) + if settings != None and type(settings) == "dict": + pm = settings.get("default_payment_method", None) + if pm != None and pm != "": + return pm + docs = store_collection("payment_methods").list() + newest = None + for i in range(len(docs)): + pm = docs[i] + if pm.get("customer", None) != customer_id: + continue + if pm.get("type", "card") != "card": + continue + if newest == None or _num(pm.get("created", 0)) > _num(newest.get("created", 0)): + newest = pm + if newest == None: + return None + return newest.get("id", None) + +# _inv_resolve_pm picks the payment method for a pay call: explicit body +# param, else the subscription's default, else the customer default. +def _inv_resolve_pm(doc, body): + pm = body.get("payment_method", None) + if pm != None and pm != "": + return pm + if doc.get("subscription", None) != None: + sub = store_collection("subscriptions").get(doc["subscription"]) + if sub != None: + spm = sub.get("default_payment_method", None) + if spm != None and spm != "": + return spm + return _inv_default_pm(doc.get("customer", None)) + +# ============================================================================ +# ENDPOINTS +# ============================================================================ + +# POST /v1/invoices — create a draft invoice. +# +# With `items` -> a manual invoice carrying those lines; without -> a draft +# with zero lines (pending invoice items are NOT auto-included; they flow +# into subscription invoices). `subscription` scopes the invoice to a +# subscription's customer. +def on_create_invoice(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _INV_COLLECTION) + if cached != None: + return respond(cached["status"], _invoice_public(cached["doc"])) + + if _inv_bad_body(req): + return _inv_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + subscription = body.get("subscription", None) + customer = body.get("customer", None) + if subscription != None and subscription != "": + sub = store_collection("subscriptions").get(subscription) + if sub == None: + return _not_found("subscription", subscription) + if customer == None or customer == "": + customer = sub.get("customer", None) + if customer == None or customer == "": + return _inv_missing("customer") + cus = store_collection("customers").get(customer) + if cus == None: + return _not_found("customer", customer) + + collection_method = body.get("collection_method", "charge_automatically") + if collection_method not in ["charge_automatically", "send_invoice"]: + return _inv_err("Invalid collection_method: must be one of charge_automatically or send_invoice.", "collection_method") + + due_date = body.get("due_date", None) + if due_date != None: + due_date = _num(due_date) + if due_date <= 0: + due_date = None + + tax_rates = body.get("default_tax_rates", []) + if tax_rates == None or type(tax_rates) != "list": + tax_rates = [] + + items = body.get("items", None) + if items == None: + items = body.get("line_items", None) + lines = [] + if items != None and type(items) == "list": + lines = _inv_lines_from_items(items) + + metadata = body.get("metadata", {}) + if metadata == None or type(metadata) != "dict": + metadata = {} + + auto_advance = body.get("auto_advance", False) + if auto_advance == None: + auto_advance = False + + doc = _inv_new( + customer, + lines, + body.get("currency", None), + collection_method, + auto_advance == True, + due_date, + subscription, + body.get("description", None), + metadata, + tax_rates, + "manual", + ) + + store_collection(_INV_COLLECTION).insert(doc) + _signed_emit("invoice.created", _invoice_public(doc)) + _idempotent_remember(req, _INV_COLLECTION, 201, doc["id"]) + return respond(201, _invoice_public(doc)) + +# GET /v1/invoices/{id} — retrieve an invoice. +def on_retrieve_invoice(req): + err = _require_auth(req) + if err != None: + return err + doc = _inv_get(req["params"]["id"]) + if doc == None: + return _not_found("invoice", req["params"]["id"]) + return respond(200, _invoice_public(doc)) + +# _inv_apply_filters maps the real Stripe invoice-list query params +# (customer, subscription, status, collection_method, due_date exact/range, +# created exact/range) to query_select clauses. +def _inv_apply_filters(req, docs): + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + sub = _get_query(req, "subscription") + if sub != "": + f.append(["subscription", "=", sub]) + status = _get_query(req, "status") + if status != "": + f.append(["status", "=", status]) + cm = _get_query(req, "collection_method") + if cm != "": + f.append(["collection_method", "=", cm]) + for key in ["due_date", "due_date[gt]", "due_date[gte]", "due_date[lt]", "due_date[lte]"]: + v = _get_query(req, key) + if v == "": + continue + n = _to_int(v) + if n <= 0: + continue + op = "=" + if key.endswith("[gt]"): + op = ">" + elif key.endswith("[gte]"): + op = ">=" + elif key.endswith("[lt]"): + op = "<" + elif key.endswith("[lte]"): + op = "<=" + f.append(["due_date", op, n]) + _created_filters(req, f) + if len(f) == 0: + return docs + return query_select(docs, f) + +# GET /v1/invoices — list invoices. +def on_list_invoices(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + docs = store_collection(_INV_COLLECTION).list() + docs = _inv_apply_filters(req, docs) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "invoice") + if e != None: + return e + return respond(200, {"object": "list", "data": [_invoice_public(d) for d in page], "has_more": has_more, "url": "/v1/invoices"}) + +# GET /v1/invoices/{id}/lines — page over the invoice's line items. +def on_list_invoice_lines(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + lines = doc.get("lines", []) + page, has_more, e = _list_page(req, lines, "line_item") + if e != None: + return e + return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/invoices/" + id + "/lines"}) + +# _INV_DRAFT_ONLY lists the update params that real Stripe restricts to +# drafts ("Once an invoice is finalized, monetary values, as well as +# collection_method, become uneditable"; description stays editable). +_INV_DRAFT_ONLY = ["collection_method", "currency", "default_tax_rates", "days_until_due", "subscription", "due_date"] + +# POST /v1/invoices/{id} — update an invoice. Drafts are fully editable; +# finalized invoices accept only metadata/auto_advance-style fields, and a +# draft-only param on a finalized invoice is a real 400. +def on_update_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + + if _inv_bad_body(req): + return _inv_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + status = doc.get("status", "draft") + if status != "draft": + for i in range(len(_INV_DRAFT_ONLY)): + key = _INV_DRAFT_ONLY[i] + if body.get(key, None) != None: + return _inv_err("You cannot update the " + key + " of an invoice with a status of " + status + ". Only draft invoices may update this field.", key) + + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + + if body.get("description", None) != None: + doc["description"] = body["description"] + if body.get("due_date", None) != None: + dd = _num(body["due_date"]) + if dd > 0: + doc["due_date"] = dd + if body.get("collection_method", None) != None: + cm = body["collection_method"] + if cm in ["charge_automatically", "send_invoice"]: + doc["collection_method"] = cm + if body.get("auto_advance", None) != None: + doc["auto_advance"] = body["auto_advance"] == True + if body.get("default_tax_rates", None) != None and type(body["default_tax_rates"]) == "list": + doc["default_tax_rates"] = body["default_tax_rates"] + doc = _inv_recompute(doc) + + _inv_save(doc) + _signed_emit("invoice.updated", _invoice_public(doc)) + return respond(200, _invoice_public(doc)) + +# DELETE /v1/invoices/{id} — delete a DRAFT one-off invoice. Subscription +# invoices and non-drafts cannot be deleted (they must be voided). +def on_delete_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("subscription", None) != None: + return _inv_err("You can't delete invoices created by subscriptions.", None) + if doc.get("status", "draft") != "draft": + return _inv_state_err("delete", "deleted", doc["status"], "draft") + pub = _invoice_public(doc) + store_collection(_INV_COLLECTION).delete(id) + _signed_emit("invoice.deleted", pub) + return respond(200, {"id": id, "object": "invoice", "deleted": True}) + +# POST /v1/invoices/{id}/finalize — draft -> open. +def on_finalize_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("status", "draft") != "draft": + return _inv_state_err("finalize", "finalized", doc["status"], "draft") + + doc["status"] = "open" + st = doc.get("status_transitions", {}) + if st == None: + st = {} + st["finalized_at"] = _now() + doc["status_transitions"] = st + doc["auto_advance"] = False + _inv_save(doc) + _signed_emit("invoice.finalized", _invoice_public(doc)) + return respond(200, _invoice_public(doc)) + +# POST /v1/invoices/{id}/void — open -> void. +def on_void_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("status", "") != "open": + return _inv_state_err("void", "voided", doc.get("status", ""), "open") + + doc["status"] = "void" + st = doc.get("status_transitions", {}) + if st == None: + st = {} + st["voided_at"] = _now() + doc["status_transitions"] = st + _inv_save(doc) + _signed_emit("invoice.voided", _invoice_public(doc)) + return respond(200, _invoice_public(doc)) + +# POST /v1/invoices/{id}/mark_uncollectible — open -> uncollectible. +def on_mark_uncollectible_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("status", "") != "open": + return _inv_state_err("mark", "marked uncollectible", doc.get("status", ""), "open") + + doc["status"] = "uncollectible" + _inv_save(doc) + _signed_emit("invoice.marked_uncollectible", _invoice_public(doc)) + return respond(200, _invoice_public(doc)) + +# POST /v1/invoices/{id}/send — email the customer a finalized invoice +# (mocked: status stays open, invoice.sent fires). +def on_send_invoice(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("status", "") != "open": + return _inv_state_err("send", "sent", doc.get("status", ""), "open") + + doc["attempted"] = True + _inv_save(doc) + _signed_emit("invoice.sent", _invoice_public(doc)) + return respond(200, _invoice_public(doc)) + +# _inv_mark_paid applies the paid transitions shared by the paid_out_of_band +# and successful-charge paths, persists, then emits invoice.paid + +# invoice.payment_succeeded. +def _inv_mark_paid(doc): + now = _now() + st = doc.get("status_transitions", {}) + if st == None: + st = {} + st["paid_at"] = now + doc["status_transitions"] = st + doc["status"] = "paid" + doc["paid"] = True + doc["attempted"] = True + doc["amount_paid"] = _num(doc.get("total", 0)) + doc["amount_remaining"] = 0 + _inv_save(doc) + pub = _invoice_public(doc) + _signed_emit("invoice.paid", pub) + _signed_emit("invoice.payment_succeeded", pub) + return doc + +# _inv_failed_charge records the failed charge behind a declined invoice +# payment (like charges.star: the failed charge object still exists). +def _inv_failed_charge(doc): + ch = { + "id": _next_id("ch"), + "object": "charge", + "amount": _num(doc.get("total", 0)), + "currency": doc.get("currency", "usd"), + "customer": doc.get("customer", None), + "description": doc.get("description", None), + "status": "failed", + "captured": False, + "refunded": False, + "invoice": doc["id"], + "created": _now(), + } + store_collection("charges").insert(ch) + return ch + +# POST /v1/invoices/{id}/pay — attempt payment on an open invoice. +# +# paid_out_of_band marks it paid with no charge. Otherwise the resolved +# payment method runs through the shared test-card behavior: decline cards +# -> 402 card_error with the invoice left open + invoice.payment_failed; +# SCA cards -> 402 authentication_required (off-session charges cannot run +# 3DS); any other card -> paid + a real captured charge + its balance +# transaction + charge.succeeded + invoice.paid + invoice.payment_succeeded. +def on_pay_invoice(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _INV_COLLECTION) + if cached != None: + return respond(cached["status"], _invoice_public(cached["doc"])) + + if _inv_bad_body(req): + return _inv_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + id = req["params"]["id"] + doc = _inv_get(id) + if doc == None: + return _not_found("invoice", id) + if doc.get("status", "") != "open": + return _inv_state_err("pay", "paid", doc.get("status", ""), "open") + + if _num(doc.get("amount_due", 0)) <= 0: + doc = _inv_mark_paid(doc) + _idempotent_remember(req, _INV_COLLECTION, 200, id) + return respond(200, _invoice_public(doc)) + + if body.get("paid_out_of_band", False) == True: + doc = _inv_mark_paid(doc) + _idempotent_remember(req, _INV_COLLECTION, 200, id) + return respond(200, _invoice_public(doc)) + + pm = _inv_resolve_pm(doc, body) + if pm == None: + return _inv_err("This customer has no attached payment source or default payment method.", "payment_method") + + number = _card_number_for(pm) + oc = _card_outcome(number) + + if oc != None and oc["kind"] == "decline": + ch = _inv_failed_charge(doc) + doc["attempted"] = True + _inv_save(doc) + _signed_emit("charge.failed", ch) + _signed_emit("invoice.payment_failed", _invoice_public(doc)) + return _card_decline_error(oc, "charge", ch["id"]) + + if oc != None: + # SCA test card: an off-session invoice charge cannot authenticate. + ch = _inv_failed_charge(doc) + doc["attempted"] = True + _inv_save(doc) + _signed_emit("charge.failed", ch) + _signed_emit("invoice.payment_failed", _invoice_public(doc)) + return _sca_charge_error(ch["id"]) + + ch = { + "id": _next_id("ch"), + "object": "charge", + "amount": _num(doc.get("total", 0)), + "currency": doc.get("currency", "usd"), + "customer": doc.get("customer", None), + "description": doc.get("description", None), + "status": "succeeded", + "captured": True, + "refunded": False, + "balance_transaction": None, + "dispute": None, + "invoice": doc["id"], + "payment_intent": None, + "created": _now(), + } + store_collection("charges").insert(ch) + doc["charge"] = ch["id"] + doc = _inv_mark_paid(doc) + _charge_settle_hooks(ch, body, number) + _signed_emit("charge.succeeded", ch) + _idempotent_remember(req, _INV_COLLECTION, 200, id) + return respond(200, _invoice_public(doc)) + +# ============================================================================ +# UPCOMING INVOICE PREVIEW (GET /v1/invoices/upcoming — literal route, must +# be declared BEFORE /v1/invoices/{id}) +# ============================================================================ + +# _inv_interval_step maps a price's recurring interval to a billing step: +# [whole_months, extra_seconds] (exactly one is non-zero). Month/year steps +# use calendar math; week/day steps use fixed offsets. +def _inv_interval_step(price): + rec = price.get("recurring", None) + interval = "month" + if rec != None and type(rec) == "dict": + iv = rec.get("interval", None) + if iv != None and iv != "": + interval = iv + if interval == "year": + return 12, 0 + if interval == "week": + return 0, 7 * 24 * 3600 + if interval == "day": + return 0, 24 * 3600 + return 1, 0 + +# _inv_period_end advances a period end by one billing interval (the same +# calendar math the subscriptions lifecycle uses, duplicated locally). +def _inv_period_end(end, price): + months, seconds = _inv_interval_step(price) + if months > 0: + return _add_months(end, months) + return end + seconds + +# _inv_sub_lines builds the subscription renewal lines for the NEXT period: +# per-unit amounts x quantity (INVOICE line contract). +def _inv_sub_lines(sub): + items = sub.get("items", []) + if items == None: + items = [] + lines = [] + start = _num(sub.get("current_period_end", 0)) + if start <= 0: + start = _now() + for i in range(len(items)): + it = items[i] + if it == None or type(it) != "dict": + continue + price = it.get("price", None) + if price == None or type(price) != "dict": + continue + unit = _num(price.get("unit_amount", 0)) + qty = _num(it.get("quantity", 1)) + if qty < 1: + qty = 1 + desc = "Subscription " + str(price.get("id", "")) + prod = price.get("product", None) + if prod != None and type(prod) == "dict": + desc = prod.get("name", desc) + lines.append({ + "id": _next_id("il"), + "object": "line_item", + "type": "subscription", + "description": desc, + "amount": unit, + "quantity": qty, + "period": {"start": start, "end": _inv_period_end(start, price)}, + "price": price, + "proration": False, + "tax_rates": [], + }) + return lines + +# _inv_pending_items returns the customer's pending invoice items (no +# invoice yet) — they flow into the next invoice. +def _inv_pending_items(customer_id): + docs = store_collection("invoice_items").list() + return query_select(docs, [["customer", "=", customer_id], ["invoice", "=", None]]) + +# _inv_item_lines turns pending invoice items into preview lines. +def _inv_item_lines(items): + lines = [] + now = _now() + for i in range(len(items)): + ii = items[i] + qty = _num(ii.get("quantity", 1)) + if qty < 1: + qty = 1 + lines.append({ + "id": _next_id("il"), + "object": "line_item", + "type": "invoice_item", + "description": ii.get("description", None), + "amount": _num(ii.get("unit_amount", 0)), + "quantity": qty, + "period": {"start": _num(ii.get("created", now)), "end": now}, + "price": ii.get("price", None), + "proration": False, + "tax_rates": [], + }) + return lines + +# _inv_preview_doc assembles the invoice-SHAPED preview object (never +# stored; ids are ephemeral). +def _inv_preview_doc(customer, subscription, sub_lines, item_lines, collection_method, tax_rates, discount): + if subscription == "": + subscription = None + lines = [] + lines.extend(sub_lines) + lines.extend(item_lines) + subtotal = 0 + for i in range(len(lines)): + subtotal = subtotal + _num(lines[i].get("amount", 0)) * _num(lines[i].get("quantity", 1)) + discount_amt = _inv_discount_amount(subtotal, discount) + tax, inclusive = _inv_tax_cents(subtotal - discount_amt, tax_rates) + total = subtotal - discount_amt + tax + if inclusive: + total = subtotal - discount_amt + if total < 0: + total = 0 + currency = None + for i in range(len(lines)): + price = lines[i].get("price", None) + if price != None and price.get("currency", None) != None: + currency = price["currency"] + break + if currency == None: + currency = "usd" + return { + "id": None, + "object": "invoice", + "customer": customer, + "subscription": subscription, + "status": "open", + "collection_method": collection_method, + "currency": currency, + "lines": lines, + "subtotal": subtotal, + "discount": discount, + "tax": tax, + "total": total, + "amount_due": total, + "amount_paid": 0, + "amount_remaining": total, + "starting_balance": 0, + "charge": None, + "payment_intent": None, + "status_transitions": {"finalized_at": None, "paid_at": None, "voided_at": None}, + "billing_reason": "subscription_cycle", + "due_date": None, + "created": _now(), + "auto_advance": True, + "attempted": False, + "metadata": {}, + "paid": None, + } + +# _inv_upcoming_none is the real Stripe 404 when a customer has no upcoming +# invoice. +def _inv_upcoming_none(customer): + return respond(404, {"error": {"code": "invoice_upcoming_none", "message": "No upcoming invoice for customer: " + customer, "param": "customer", "type": "invalid_request_error"}}) + +# GET /v1/invoices/upcoming — preview the customer's next invoice: +# subscription renewal lines for the next period + pending invoice items + +# tax per the TAX CONTRACT + the subscription discount. Nothing persists. +def on_upcoming_invoice(req): + err = _require_auth(req) + if err != None: + return err + + customer = _get_query(req, "customer") + subscription = _get_query(req, "subscription") + + sub = None + if subscription != "": + sub = store_collection("subscriptions").get(subscription) + if sub == None: + return _not_found("subscription", subscription) + if customer == "": + customer = sub.get("customer", "") + if customer == "": + return _inv_missing("customer") + if store_collection("customers").get(customer) == None: + return _not_found("customer", customer) + + if sub == None: + # Preview the customer's next invoice: their newest active + # subscription, else pending invoice items alone. + subs = query_select(store_collection("subscriptions").list(), [["customer", "=", customer]]) + for i in range(len(subs)): + s = subs[i] + st = s.get("status", "") + if st in ["trialing", "active", "past_due", "incomplete"]: + if sub == None or _num(s.get("created", 0)) > _num(sub.get("created", 0)): + sub = s + subscription = "" + if sub != None: + subscription = sub.get("id", "") + + sub_lines = [] + collection_method = "charge_automatically" + tax_rates = [] + discount = None + if sub != None: + sub_lines = _inv_sub_lines(sub) + collection_method = sub.get("collection_method", "charge_automatically") + tax_rates = sub.get("default_tax_rates", []) + if tax_rates == None: + tax_rates = [] + discount = sub.get("discount", None) + subscription = sub.get("id", "") + + item_lines = _inv_item_lines(_inv_pending_items(customer)) + if sub == None and len(item_lines) == 0: + return _inv_upcoming_none(customer) + + return respond(200, _inv_preview_doc(customer, subscription, sub_lines, item_lines, collection_method, tax_rates, discount)) diff --git a/adapters/stripe-style/scripts/lib.star b/adapters/stripe-style/scripts/lib.star index 57c3f2a7..e54ae641 100644 --- a/adapters/stripe-style/scripts/lib.star +++ b/adapters/stripe-style/scripts/lib.star @@ -10,6 +10,42 @@ # stunt only — never reuse outside the simulator. _WEBHOOK_SECRET = "whsec_stunt_mock_0123456789abcdef0123456789abcdef" +# ============================================================================ +# TEST CLOCK (global time offset) +# ============================================================================ +# Real Stripe Test Clocks (docs.stripe.com/api/test_clocks) freeze per-object +# time and advance deterministically. The engine clock is read-only, so this +# mock keeps ONE GLOBAL offset in the KV store: +# _now() = clock.now_unix() + offset +# POST /v1/test_clocks[/{id}/advance] (scripts/test_clocks.star) moves every +# object's notion of "now" at once — unlike real Stripe, which advances only +# the objects attached to the clock. EVERY timestamp minted by this adapter +# (created stamps, due dates, signature timestamps) must flow through _now(), +# never clock.now_unix() directly. + +# _tc_offset reads the global test-clock offset (seconds, may be negative; +# _to_int_signed handles the leading "-"). +def _tc_offset(): + raw = store_kv_get("stripe", "tc_offset") + if raw == None or raw == "": + return 0 + return _to_int_signed(raw) + +def _now(): + return clock.now_unix() + _tc_offset() + +# _tc_activate makes clock_id the active global clock at target time. +def _tc_activate(clock_id, target): + store_kv_set("stripe", "tc_offset", str(target - clock.now_unix())) + store_kv_set("stripe", "tc_active", clock_id) + +# _tc_clear unsets the global offset, but only when the deleted clock is the +# active one (deleting a stale clock must not disturb the active one). +def _tc_clear(clock_id): + if store_kv_get("stripe", "tc_active") == clock_id: + store_kv_set("stripe", "tc_offset", "0") + store_kv_delete("stripe", "tc_active") + # _signed_emit MACs the exact on-wire body and delivers with Stripe-Signature. # The same (event_type, payload) feeds events_body (signing input) and # events_emit (delivery), so the signature verifies against the bytes the sink @@ -19,7 +55,7 @@ _WEBHOOK_SECRET = "whsec_stunt_mock_0123456789abcdef0123456789abcdef" # Stripe's event-object shape, so GET /v1/events (scripts/events.star) lists # exactly the event types the webhook sink receives. def _signed_emit(event_type, payload): - t = clock.now_unix() + t = _now() ev = { "id": _next_id("evt"), "object": "event", @@ -32,10 +68,36 @@ def _signed_emit(event_type, payload): "request": {"id": None, "idempotency_key": None}, } store_collection("events").insert(ev) + # Webhook gating: with no registered webhook endpoints every event is + # delivered (the adapter's historical always-deliver behavior). Once + # endpoints exist (webhook_endpoints collection), only their + # enabled_events (exact match or "*") are DELIVERED — the event object + # above is still recorded either way, like real Stripe's GET /v1/events. + if not _events_enabled(event_type): + return body = events_body(event_type, payload) sig = crypto.hmac_sha256(_WEBHOOK_SECRET, str(t) + "." + body) events_emit(event_type, payload, {"Stripe-Signature": "t=" + str(t) + ",v1=" + sig}) +# _events_enabled reports whether event_type should be delivered to the +# configured webhook sink. True when no webhook endpoints are registered +# (store_collection auto-creates an empty table for undeclared resources, so +# an absent webhook_endpoints collection lists as empty), else True only if +# some endpoint lists the type (or "*") in enabled_events. +def _events_enabled(event_type): + eps = store_collection("webhook_endpoints").list() + if len(eps) == 0: + return True + for i in range(len(eps)): + evs = eps[i].get("enabled_events", None) + if evs == None: + continue + for j in range(len(evs)): + et = evs[j] + if et == "*" or et == event_type: + return True + return False + # _bearer_token extracts the bearer token from the Authorization header, or # None if absent. def _bearer_token(req): @@ -171,10 +233,25 @@ def _stripe_account(req): return acct # _get_balance returns the available balance (in cents) for a connected -# account, tracked via the KV store. Defaults to 0 for new accounts. +# account, tracked via the KV store. Defaults to 0 for new accounts. Signed: +# the balance-transaction ledger can drive an account negative (dispute +# withdrawals, platform-side transfers), which plain _to_int cannot parse. +def _to_int_signed(s): + if s == None: + return 0 + t = s + neg = False + if t != "" and t[0] == "-": + neg = True + t = t[1:] + n = _to_int(t) + if neg: + return -n + return n + def _get_balance(acct_id): val = store_kv_get("stripe", "bal_" + acct_id) - return _to_int(val) + return _to_int_signed(val) # _set_balance sets the available balance (in cents) for a connected account. def _set_balance(acct_id, amount): @@ -380,6 +457,8 @@ def _refund_public(doc): "id": doc["id"], "object": "refund", "amount": doc.get("amount", 0), + "balance_transaction": doc.get("balance_transaction", None), + "receipt_number": doc.get("receipt_number", None), "currency": doc.get("currency", "usd"), "payment_intent": doc.get("payment_intent", None), "charge": doc.get("charge", None), @@ -420,14 +499,27 @@ def _over_refund_error(requested, remaining): return respond(400, {"error": {"code": "charge_already_refunded", "message": "Charge has already been refunded.", "type": "invalid_request_error"}}) return respond(400, {"error": {"message": "Refund amount (" + _usd(requested) + ") is greater than unrefunded amount on charge (" + _usd(remaining) + ")", "param": "amount", "type": "invalid_request_error"}}) +# _receipt_number mints a Stripe-style refund receipt number ("1234-5678"). +# Digits are runtime data (not source literals), so no length constraint. +def _receipt_number(): + seq = store_kv_incr("stripe", "receipt_seq") + tail = _now() % (10 * 1000) + return str(tail) + "-" + str(seq) + # _create_refund inserts a pending refund doc stamped with its async schedule # and emits refund.created. fail_mode True drives the -> failed terminal. +# Creation also records the (negative) refund balance transaction on the +# platform account, like real Stripe. def _create_refund(pi_id, charge_id, amount, currency, reason, fail_mode): - now = clock.now_unix() + now = _now() + rid = _next_id("re") + bt = _bt_record("", "refund", -amount, 0, currency, rid, "Refund") doc = { - "id": _next_id("re"), + "id": rid, "object": "refund", "amount": amount, + "balance_transaction": bt["id"], + "receipt_number": _receipt_number(), "currency": currency, "payment_intent": pi_id, "charge": charge_id, @@ -450,7 +542,7 @@ def _create_refund(pi_id, charge_id, amount, currency, reason, fail_mode): def _advance_refund(doc): if _num(doc.get("_stage", 0)) >= 2: return doc - now = clock.now_unix() + now = _now() if now < _num(doc.get("_done_at", 0)): return doc if doc.get("_fail_mode", "") == "failed": @@ -474,3 +566,470 @@ def _apply_charge_refund(ch, already, amount): else: ch["refunded"] = False return ch + +# ============================================================================ +# BALANCE-TRANSACTION LEDGER +# ============================================================================ +# _bt_record appends a Stripe balance_transaction object +# (docs.stripe.com/api/balance_transactions) to the balance_transactions +# collection AND moves the account's KV balance by net (amount - fee), +# preserving the existing _get_balance/_set_balance semantics. +# +# acct connected-account id, or None/"" for the platform account +# btype charge | refund | payout | transfer | transfer_reversal | +# application_fee | application_fee_refund | dispute | +# dispute_reversal +# amount signed cents (negative = funds leaving the account) +# fee processing fee in cents (positive when assessed; a reversal +# refunds the fee with a negative fee) +# source_id the Stripe object this txn belongs to (charge/transfer/...) +# +# Returns the stored txn doc; _bt_public strips the _account scoping key. + +_BT_TYPES = [ + "charge", + "refund", + "payout", + "transfer", + "transfer_reversal", + "application_fee", + "application_fee_refund", + "dispute", + "dispute_reversal", +] + +def _bt_record(acct, btype, amount, fee, currency, source_id, description): + if acct == None: + acct = "" + net = amount - fee + fee_details = [] + if fee > 0: + fee_details = [ + { + "amount": fee, + "application": None, + "currency": currency, + "description": "Stripe fee", + "type": "stripe_fee", + }, + ] + doc = { + "id": _next_id("txn"), + "object": "balance_transaction", + "amount": amount, + "available_on": _now(), + "created": _now(), + "currency": currency, + "description": description, + "exchange_rate": None, + "fee": fee, + "fee_details": fee_details, + "net": net, + "reporting_category": btype, + "source": source_id, + "status": "available", + "type": btype, + "_account": acct, + } + store_collection("balance_transactions").insert(doc) + _set_balance(acct, _get_balance(acct) + net) + return doc + +# _bt_public strips the internal _account scoping key from a stored txn. +def _bt_public(doc): + out = {} + for k in doc: + if k == "_account": + continue + out[k] = doc[k] + return out + +# ============================================================================ +# APPLICATION FEE HOOK (Stripe Connect) +# ============================================================================ +# A charge created (or captured) with application_fee_amount records an +# application_fee object (docs.stripe.com/api/application_fees) plus its +# platform-side balance transaction (type application_fee). + +def _maybe_record_fee(ch, body): + if body == None: + return None + amt = _num(body.get("application_fee_amount", 0)) + if amt <= 0: + return None + fee_id = _next_id("fee") + bt = _bt_record("", "application_fee", amt, 0, ch.get("currency", "usd"), fee_id, "Application fee") + doc = { + "id": fee_id, + "object": "application_fee", + "amount": amt, + "currency": ch.get("currency", "usd"), + "charge": ch.get("id"), + "balance_transaction": bt["id"], + "refunded": False, + "amount_refunded": 0, + "created": _now(), + } + store_collection("application_fees").insert(doc) + return doc + +# ============================================================================ +# DISPUTE ENGINE (creation + derive-on-read state machine) +# ============================================================================ +# The documented dispute test cards (docs.stripe.com/testing): charging with +# these SUCCEEDS and immediately raises a dispute. Real Stripe now mints du_* +# dispute ids; this simulator uses the dp_* prefix shared across the billing +# domains' doc contracts. +# 4000 0000 0000 0259 -> reason fraudulent +# 4000 0000 0000 2685 -> reason product_not_received + +_DISPUTE_CARDS = { + "4000" + "0000" + "0000" + "0259": "fraudulent", + "4000" + "0000" + "0000" + "2685": "product_not_received", +} + +_DISPUTE_FEE = 1500 # $15.00 dispute fee (US), one 4-digit chunk. + +# _dispute_reason_for maps a card number to its dispute reason, or None. +def _dispute_reason_for(number): + if number == None: + return None + return _DISPUTE_CARDS.get(number) + +# _maybe_create_dispute raises the dispute for a dispute test card on a newly +# captured charge: creates the dispute doc (needs_response), records the funds +# withdrawal (type dispute, -amount, $15 fee) on the platform ledger, points +# the charge's `dispute` field at it, persists everything, and emits +# charge.dispute.created + charge.dispute.funds_withdrawn. Returns the dispute +# doc or None when the card behaves normally / the charge is already disputed. +def _maybe_create_dispute(ch, number): + reason = _dispute_reason_for(number) + if reason == None: + return None + if ch.get("dispute", None) != None: + return None + now = _now() + due_by = now + 7 * 24 * 3600 # evidence window: created + 7 days + dp = { + "id": _next_id("dp"), + "object": "dispute", + "amount": _num(ch.get("amount", 0)), + "balance_transactions": [], + "charge": ch.get("id"), + "created": now, + "currency": ch.get("currency", "usd"), + "evidence": {}, + "evidence_details": { + "due_by": due_by, + "has_evidence": False, + "past_due": False, + "submission_count": 0, + }, + "is_charge_refundable": True, + "livemode": False, + "metadata": {}, + "payment_intent": ch.get("payment_intent", None), + "reason": reason, + "status": "needs_response", + "_due_by": due_by, + "_submit_at": None, + "_settle_at": None, + "_stage": 0, + "_closed": False, + } + bt = _bt_record("", "dispute", -dp["amount"], _DISPUTE_FEE, dp["currency"], dp["id"], "Dispute withdrawal") + dp["balance_transactions"] = [bt["id"]] + ch["dispute"] = dp["id"] + # Persist every state change BEFORE emitting (dispute doc, then charge). + store_collection("disputes").insert(dp) + store_collection("charges").update(ch["id"], ch) + pub = _dispute_public(dp) + _signed_emit("charge.dispute.created", pub) + _signed_emit("charge.dispute.funds_withdrawn", pub) + return dp + +# _dispute_public renders the public dispute shape. evidence_details.past_due +# and has_evidence are derived on read (docs.stripe.com/api/disputes/object). +def _dispute_public(doc): + now = _now() + ev = doc.get("evidence", {}) + if ev == None: + ev = {} + has_ev = len(ev) > 0 + ed = doc.get("evidence_details", {}) + if ed == None: + ed = {} + due_by = _num(ed.get("due_by", 0)) + past = False + if due_by > 0 and now >= due_by: + past = True + return { + "id": doc["id"], + "object": "dispute", + "amount": _num(doc.get("amount", 0)), + "balance_transactions": doc.get("balance_transactions", []), + "charge": doc.get("charge", None), + "created": _num(doc.get("created", 0)), + "currency": doc.get("currency", "usd"), + "evidence": ev, + "evidence_details": { + "due_by": due_by, + "has_evidence": has_ev, + "past_due": past, + "submission_count": _num(ed.get("submission_count", 0)), + }, + "is_charge_refundable": doc.get("is_charge_refundable", True), + "livemode": False, + "metadata": doc.get("metadata", {}), + "payment_intent": doc.get("payment_intent", None), + "reason": doc.get("reason", "general"), + "status": doc.get("status", "needs_response"), + } + +# _dispute_advance derives a dispute's state from the clock, persisting then +# emitting each transition exactly once: +# needs_response + _submit_at set -> under_review (+ charge.dispute.updated) +# under_review + now >= _settle_at -> won (dispute_reversal ledger row +# restores funds + fee; +# funds_reinstated + closed won) +# needs_response + now >= due_by -> lost (closed lost; funds stay +# withdrawn) +# Call from every dispute read endpoint. Returns the (possibly mutated) doc. +def _dispute_advance(doc): + if doc.get("_closed", False) == True: + return doc + now = _now() + status = doc.get("status", "") + if status == "needs_response": + if doc.get("_submit_at", None) != None: + doc["status"] = "under_review" + doc["_stage"] = 1 + store_collection("disputes").update(doc["id"], doc) + _signed_emit("charge.dispute.updated", _dispute_public(doc)) + status = "under_review" + elif now >= _num(doc.get("_due_by", 0)): + return _dispute_close(doc) + else: + return doc + if status == "under_review": + settle = _num(doc.get("_settle_at", 0)) + if settle > 0 and now >= settle: + bt = _bt_record("", "dispute_reversal", _num(doc.get("amount", 0)), -_DISPUTE_FEE, doc.get("currency", "usd"), doc["id"], "Dispute reinstated") + bts = doc.get("balance_transactions", []) + bts.append(bt["id"]) + doc["balance_transactions"] = bts + doc["status"] = "won" + doc["_stage"] = 2 + doc["_closed"] = True + store_collection("disputes").update(doc["id"], doc) + pub = _dispute_public(doc) + _signed_emit("charge.dispute.funds_reinstated", pub) + _signed_emit("charge.dispute.closed", pub) + return doc + +# _dispute_close resolves a dispute as lost immediately (merchant accepts or +# evidence deadline passed): funds stay withdrawn; emits charge.dispute.closed. +def _dispute_close(doc): + if doc.get("_closed", False) == True: + return doc + doc["status"] = "lost" + doc["_stage"] = 2 + doc["_closed"] = True + store_collection("disputes").update(doc["id"], doc) + _signed_emit("charge.dispute.closed", _dispute_public(doc)) + return doc + +# _dispute_submit records an evidence submission (the dispute-update endpoint +# calls this). winning True schedules the merchant-favor ruling for +# _settle_at = submit time + 1 day; losing evidence resolves immediately via +# _dispute_close. The needs_response -> under_review transition is derived +# right away so the submit response reflects it. +def _dispute_submit(doc, winning, evidence): + now = _now() + doc["_submit_at"] = now + if evidence != None: + doc["evidence"] = evidence + ed = doc.get("evidence_details", {}) + if ed == None: + ed = {} + ed["submission_count"] = _num(ed.get("submission_count", 0)) + 1 + doc["evidence_details"] = ed + if winning: + doc["_settle_at"] = now + 24 * 3600 + store_collection("disputes").update(doc["id"], doc) + return _dispute_advance(doc) + +# ============================================================================ +# CHARGE SETTLEMENT HOOK (shared by charges.star + payment_intents.star) +# ============================================================================ +# _charge_settle_hooks records the money movement behind a newly captured +# charge: the charge balance transaction (2.9% + 30c processing fee, pure +# integer math), an application_fee record when the request carried +# application_fee_amount, and the immediate dispute raised by the dispute test +# cards. Idempotent: a charge that already has a balance_transaction is left +# alone. The charge doc is persisted before any emission. +def _charge_settle_hooks(doc, body, number): + if doc.get("balance_transaction", None) != None: + return + amount = _num(doc.get("amount", 0)) + fee = (amount * 29 + 500) // 1000 + 30 + bt = _bt_record("", "charge", amount, fee, doc.get("currency", "usd"), doc["id"], doc.get("description", None)) + doc["balance_transaction"] = bt["id"] + store_collection("charges").update(doc["id"], doc) + _maybe_record_fee(doc, body) + _maybe_create_dispute(doc, number) + +# ============================================================================ +# BILLING PRIMITIVES (calendar math + subscription invoice construction) +# ============================================================================ + +# _days_in_month returns the day count of month m (1-12) in year y (Gregorian, +# proleptic — matching Go's time package). +def _days_in_month(y, m): + if m == 2: + if (y % 4 == 0 and y % 100 != 0) or y % 400 == 0: + return 29 + return 28 + if m == 4 or m == 6 or m == 9 or m == 11: + return 30 + return 31 + +# _civil_to_unix converts a UTC civil date/time to Unix seconds using Howard +# Hinnant's days_from_civil (no datetime library in Starlark). The two long +# constants are assembled from <=4-digit chunks. +_DAYS_PER_ERA = 146 * 1000 + 97 # days in a 400-year Gregorian era +_EPOCH_SHIFT = 719 * 1000 + 468 # days from 0000-03-01 to 1970-01-01 + +def _civil_to_unix(y, m, d, hh, mm, ss): + yy = y + if m <= 2: + yy = yy - 1 + era = yy // 400 + yoe = yy - era * 400 + mp = m + 9 + if m > 2: + mp = m - 3 + doy = (153 * mp + 2) // 5 + d - 1 + doe = yoe * 365 + yoe // 4 - yoe // 100 + doy + days = era * _DAYS_PER_ERA + doe - _EPOCH_SHIFT + return days * 24 * 3600 + hh * 3600 + mm * 60 + ss + +# _unix_to_civil splits Unix seconds into UTC (y, m, d, hh, mm, ss) via the +# engine's RFC3339 formatter (fixed-width "YYYY-MM-DDTHH:MM:SSZ"). +def _unix_to_civil(ts): + s = clock.unix_to_rfc3339(ts) + return _to_int(s[0:4]), _to_int(s[5:7]), _to_int(s[8:10]), _to_int(s[11:13]), _to_int(s[14:16]), _to_int(s[17:19]) + +# _add_months shifts ts forward n calendar months with end-of-month clamping +# (Jan 31 + 1 month = Feb 28/29; May 31 + 3 months = Aug 31). The time of day +# is preserved. +def _add_months(ts, n): + y, m, d, hh, mm, ss = _unix_to_civil(ts) + total = y * 12 + (m - 1) + n + ny = total // 12 + nm = total - ny * 12 + 1 + nd = d + dim = _days_in_month(ny, nm) + if nd > dim: + nd = dim + return _civil_to_unix(ny, nm, nd, hh, mm, ss) + +# _subscription_invoice constructs, stores, and announces the invoice for one +# subscription billing cycle (INVOICE DOC CONTRACT — shared with the billing +# domains): +# sub the subscription doc (customer, currency, collection_method, +# discount, _period_no drive the derived fields) +# line_dicts [{type, description, amount, quantity, period, price}, ...] +# discount_amt discount cents (already computed: percent -> int(subtotal*pct/ +# 100 + 0.5), amount_off capped at subtotal) +# tax_cents tax cents over the discounted subtotal +# inclusive True for tax-inclusive rates (tax shown, NOT added to total) +# Line ids (il_*), proration False, tax_rates [], subtotal/total/amount_due +# are filled in here. The invoice is stored in the invoices collection with +# status "open", invoice.created is emitted, and the doc is returned for the +# caller to advance (auto-charge -> paid, past_due, ...). +def _subscription_invoice(sub, line_dicts, discount_amt, tax_cents, inclusive): + now = _now() + subtotal = 0 + lines = [] + for i in range(len(line_dicts)): + ln = line_dicts[i] + amt = _num(ln.get("amount", 0)) + qty = _num(ln.get("quantity", 1)) + if qty < 1: + qty = 1 + subtotal = subtotal + amt * qty + out = { + "id": _next_id("il"), + "object": "line_item", + "type": ln.get("type", "subscription"), + "description": ln.get("description", ""), + "amount": amt, + "quantity": qty, + "period": ln.get("period", {"start": now, "end": now}), + "price": ln.get("price", None), + "proration": False, + "tax_rates": [], + } + lines.append(out) + discount = _num(discount_amt) + if discount > subtotal: + discount = subtotal + tax = _num(tax_cents) + if tax < 0: + tax = 0 + total = subtotal - discount + if not inclusive: + total = total + tax + currency = sub.get("currency", None) + if currency == None or currency == "": + currency = "usd" + for i in range(len(lines)): + price = lines[i].get("price", None) + if price != None and price.get("currency", None) != None: + currency = price["currency"] + break + reason = "subscription_cycle" + if _num(sub.get("_period_no", 1)) <= 1: + reason = "subscription_create" + inv = { + "id": _next_id("in"), + "object": "invoice", + "customer": sub.get("customer", None), + "subscription": sub.get("id", None), + "status": "open", + "collection_method": sub.get("collection_method", "charge_automatically"), + "currency": currency, + "lines": lines, + "subtotal": subtotal, + "discount": sub.get("discount", None), + "tax": tax, + "total": total, + "amount_due": total, + "amount_paid": 0, + "amount_remaining": total, + "starting_balance": 0, + "charge": None, + "payment_intent": None, + "status_transitions": {"finalized_at": now, "paid_at": None, "voided_at": None}, + "billing_reason": reason, + "due_date": None, + "created": now, + "auto_advance": True, + "attempted": False, + "metadata": {}, + "paid": None, + "_advance_scheduled": False, + } + store_collection("invoices").insert(inv) + _signed_emit("invoice.created", _invoice_public(inv)) + return inv + +# _invoice_public renders a stored invoice doc, stripping internal "_" keys. +def _invoice_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out diff --git a/adapters/stripe-style/scripts/payment_intents.star b/adapters/stripe-style/scripts/payment_intents.star index d337484d..9fd54607 100644 --- a/adapters/stripe-style/scripts/payment_intents.star +++ b/adapters/stripe-style/scripts/payment_intents.star @@ -25,6 +25,7 @@ def _pi_public(doc): "capture_method": doc.get("capture_method", "automatic"), "confirmation_method": "manual", "payment_method": doc.get("payment_method", None), + "latest_charge": doc.get("latest_charge", None), "customer": doc.get("customer", None), "description": doc.get("description", None), "last_payment_error": doc.get("last_payment_error", None), @@ -33,6 +34,37 @@ def _pi_public(doc): "created": doc.get("created", 1700000000), } +# _pi_settle_charge mints the captured Charge behind a successful +# PaymentIntent (real Stripe always creates one), records its balance +# transaction via the shared settlement hooks (lib.star _charge_settle_hooks: +# 2.9% + 30c fee, application_fee_amount, dispute test cards), and links the +# PI through latest_charge. Idempotent: a PI that already has a charge keeps +# it. The PI and charge docs are persisted before any emission. +def _pi_settle_charge(doc, body): + if doc.get("latest_charge", None) != None: + return + number = _card_number_for(doc.get("payment_method", None)) + ch = { + "id": _next_id("ch"), + "object": "charge", + "amount": _num(doc.get("amount", 0)), + "currency": doc.get("currency", "usd"), + "customer": doc.get("customer", None), + "description": doc.get("description", None), + "status": "succeeded", + "captured": True, + "refunded": False, + "balance_transaction": None, + "dispute": None, + "payment_intent": doc["id"], + "created": _now(), + } + store_collection("charges").insert(ch) + doc["latest_charge"] = ch["id"] + store_collection("payment_intents").update(doc["id"], doc) + _signed_emit("charge.created", ch) + _charge_settle_hooks(ch, body, number) + # _pi_succeed applies the success transitions for capture_method: automatic -> # succeeded (amount_received set), manual -> requires_capture. 3DS is complete, # so next_action clears. @@ -147,7 +179,7 @@ def on_create_payment_intent(req): "last_payment_error": None, "next_action": None, "metadata": body.get("metadata", {}), - "created": 1700000000, + "created": _now(), } # Confirm-at-create runs the same test-card resolution as POST /confirm. @@ -161,6 +193,10 @@ def on_create_payment_intent(req): _signed_emit("payment_intent.created", _pi_public(doc)) if pm != None and confirm: + if resp == None: + # Succeeded at create: mint the charge + ledger rows first so the + # charge/dispute webhooks land before payment_intent.succeeded. + _pi_settle_charge(doc, body) _pi_emit_status(doc) if resp != None and doc.get("status") != "requires_action": # Declined at create: the PI exists (requires_payment_method + @@ -257,6 +293,8 @@ def on_confirm_payment_intent(req): if resp == None: _pi_succeed(doc) c.update(id, doc) + if doc.get("status") == "succeeded": + _pi_settle_charge(doc, body) _pi_emit_status(doc) _idempotent_remember(req, "payment_intents", 200, id) return respond(200, _pi_public(doc)) @@ -287,11 +325,19 @@ def on_capture_payment_intent(req): if doc.get("status") != "requires_capture": return respond(400, {"error": {"type": "invalid_request_error", "message": "You can only capture PaymentIntents with status: requires_capture."}}) + body = req["body"] + if body == None: + body = {} + doc["status"] = "succeeded" doc["amount_received"] = doc.get("amount", 0) doc["amount_capturable"] = 0 c.update(id, doc) + # Funds move at capture: mint the charge + ledger rows (the capture call + # may carry application_fee_amount, like real Stripe). + _pi_settle_charge(doc, body) + _signed_emit("payment_intent.succeeded", _pi_public(doc)) _idempotent_remember(req, "payment_intents", 200, id) return respond(200, _pi_public(doc)) diff --git a/adapters/stripe-style/scripts/payouts.star b/adapters/stripe-style/scripts/payouts.star index 72741dd8..607b6bed 100644 --- a/adapters/stripe-style/scripts/payouts.star +++ b/adapters/stripe-style/scripts/payouts.star @@ -1,9 +1,25 @@ # Payouts handlers — Stripe Connect (connected account → bank). # -# Payouts move funds from a connected account's balance to their bank. -# Stored in the payouts collection. Emits payout.created. -# Shared helpers (_require_auth, _next_id, _stripe_account, _get_balance, -# _set_balance) are in lib.star. +# Payouts move funds from a connected account's balance to their bank and +# are stored in the payouts collection. Status runs a derive-on-read state +# machine keyed off _now() (test-clock aware): pending -> in_transit at +# +10s (emits payout.updated) -> paid at +60s (emits payout.paid); every +# transition is persisted BEFORE its emission and fires exactly once. +# arrival_date is computed at creation from the method (standard +4 days, +# instant +60 seconds). Creation records the negative payout balance +# transaction (no fee); canceling returns the funds with a positive payout +# ledger row linked from failure_balance_transaction, like the real API's +# cancel response. Emits payout.created / payout.updated / payout.paid / +# payout.canceled. +# Shared helpers (_require_auth, _next_id, _not_found, _num, _now, +# _stripe_account, _get_balance, _set_balance, _bt_record, _signed_emit, +# _list_page, _newest_first, _created_filters, _created_check, _get_query) +# are in lib.star. + +_PO_IN_TRANSIT_SECS = 10 # pending -> in_transit +_PO_PAID_SECS = 60 # in_transit -> paid +_PO_STANDARD_DAYS = 4 # standard arrival_date horizon (days) +_PO_INSTANT_SECS = 60 # instant arrival_date horizon (seconds) # _apply_payout_filters maps the real Stripe payout-list query params # (destination, status, arrival_date exact/range, created exact/range) to @@ -45,14 +61,74 @@ def _apply_payout_filters(req, docs): return docs return query_select(docs, f) -# _payout_view strips the internal _account scoping key from the public -# shape (it exists for list filtering only). +# _payout_view strips the internal keys (_account scoping + lifecycle +# bookkeeping) and renders the additive real-object fields with defaults. def _payout_view(p): - out = {} - for k in p: - if k != "_account": - out[k] = p[k] - return out + return { + "id": p["id"], + "object": "payout", + "amount": _num(p.get("amount", 0)), + "arrival_date": _num(p.get("arrival_date", 0)), + "automatic": False, + "balance_transaction": p.get("balance_transaction", None), + "created": _num(p.get("created", 0)), + "currency": p.get("currency", "usd"), + "description": p.get("description", None), + "destination": p.get("destination", None), + "failure_balance_transaction": p.get("failure_balance_transaction", None), + "failure_code": None, + "failure_message": None, + "livemode": False, + "metadata": p.get("metadata", {}), + "method": p.get("method", "standard"), + "original_payout": None, + "reconciliation_status": "not_applicable", + "reversed_by": None, + "source_type": "bank_account", + "statement_descriptor": p.get("statement_descriptor", None), + "status": p.get("status", "pending"), + "type": "bank_account", + } + +# _payout_advance derives the payout status from the clock, persisting each +# transition BEFORE emitting it, exactly once (terminal statuses short- +# circuit). Both transitions can happen in one read if the window elapsed. +def _payout_advance(doc): + status = doc.get("status", "pending") + if status == "paid" or status == "canceled" or status == "failed": + return doc + now = _now() + created = _num(doc.get("created", 0)) + c = store_collection("payouts") + if now >= created + _PO_PAID_SECS: + if status != "in_transit": + doc["status"] = "in_transit" + c.update(doc["id"], doc) + _signed_emit("payout.updated", _payout_view(doc)) + doc["status"] = "paid" + c.update(doc["id"], doc) + _signed_emit("payout.paid", _payout_view(doc)) + return doc + if now >= created + _PO_IN_TRANSIT_SECS: + doc["status"] = "in_transit" + c.update(doc["id"], doc) + _signed_emit("payout.updated", _payout_view(doc)) + return doc + +# _payout_default_destination resolves the implicit payout destination: the +# account's default external bank account for the payout currency (the first +# one attached when none was flagged). None when the account has none. +def _payout_default_destination(acct, currency): + if acct == None or acct == "": + return None + docs = store_collection("external_accounts").list() + eas = query_select(docs, [["account", "=", acct], ["currency", "=", currency]]) + for i in range(len(eas)): + if eas[i].get("default_for_currency", False) == True: + return eas[i]["id"] + if len(eas) > 0: + return eas[0]["id"] + return None # POST /v1/payouts — create a payout from a connected account's balance. def on_create_payout(req): @@ -64,12 +140,25 @@ def on_create_payout(req): if body == None: body = {} - amount = body.get("amount", 0) - currency = body.get("currency", "usd") + amount = _num(body.get("amount", 0)) + currency = body.get("currency", None) + if currency == None or currency == "": + return respond(400, {"error": {"message": "Missing required param: currency.", "param": "currency", "type": "invalid_request_error"}}) + if amount <= 0: + return respond(400, {"error": {"code": "parameter_invalid_integer", "message": "Invalid positive integer: " + str(body.get("amount", 0)), "param": "amount", "type": "invalid_request_error"}}) + method = body.get("method", "standard") + acct = _stripe_account(req) destination = body.get("destination", None) + if destination == None or destination == "": + destination = _payout_default_destination(acct, currency) payout_id = _next_id("po") + # Standard payouts arrive in ~4 days; instant payouts in a minute. + # Test-clock aware via _now(). + arrival = _now() + _PO_STANDARD_DAYS * 24 * 3600 + if method == "instant": + arrival = _now() + _PO_INSTANT_SECS doc = { "id": payout_id, "object": "payout", @@ -77,31 +166,34 @@ def on_create_payout(req): "currency": currency, "method": method, "destination": destination, + "description": body.get("description", None), + "statement_descriptor": body.get("statement_descriptor", None), + "metadata": body.get("metadata", {}), "status": "pending", - "arrival_date": 1700432000, - "created": 1700000000, + "arrival_date": arrival, + "created": _now(), + "balance_transaction": None, + "failure_balance_transaction": None, } - # Track which account this payout belongs to (for list filtering). - acct = _stripe_account(req) + # Track which account this payout belongs to (for list filtering) and + # debit its balance, mirrored by a payout ledger row (-amount, no fee). if acct != None: doc["_account"] = acct - # Debit the connected account's balance. - bal = _get_balance(acct) - new_bal = bal - amount - if new_bal < 0: - new_bal = 0 - _set_balance(acct, new_bal) + po_bt = _bt_record(acct, "payout", -amount, 0, currency, payout_id, "Payout to bank") + doc["balance_transaction"] = po_bt["id"] + # Preserve the historical no-negative-balance clamp. + if _get_balance(acct) < 0: + _set_balance(acct, 0) - c = store_collection("payouts") - c.insert(doc) + store_collection("payouts").insert(doc) # Emit webhook event (fire-and-forget). _signed_emit("payout.created", _payout_view(doc)) return respond(201, _payout_view(doc)) -# GET /v1/payouts — list all payouts (optionally ?destination=). +# GET /v1/payouts — list all payouts (optionally ?destination=/?status=). def on_list_payouts(req): err = _require_auth(req) if err != None: @@ -111,8 +203,11 @@ def on_list_payouts(req): if bad != None: return bad - c = store_collection("payouts") - docs = c.list() + docs = store_collection("payouts").list() + + # Derive every payout's status first so the status filter matches the + # same state the retrieve endpoint would report. + docs = [_payout_advance(d) for d in docs] # Real payout-list params (destination, status, arrival_date, created), # applied before paging. @@ -120,7 +215,75 @@ def on_list_payouts(req): docs = _newest_first(docs) docs = [_payout_view(p) for p in docs] - page, has_more, err = _list_page(req, docs, "payout") + page, has_more, err2 = _list_page(req, docs, "payout") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/payouts"}) + +# GET /v1/payouts/{id} — retrieve a payout (derives its status first). +def on_retrieve_payout(req): + err = _require_auth(req) if err != None: return err - return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/payouts"}) + + id = req["params"]["id"] + doc = store_collection("payouts").get(id) + if doc == None: + return _not_found("payout", id) + return respond(200, _payout_view(_payout_advance(doc))) + +# POST /v1/payouts/{id} — update a payout (metadata + description, the real +# API's updatable params). +def on_update_payout(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("payouts").get(id) + if doc == None: + return _not_found("payout", id) + + body = req["body"] + if body != None: + md = body.get("metadata", None) + if md != None and type(md) == "dict": + doc["metadata"] = md + d = body.get("description", None) + if d != None: + doc["description"] = d + + store_collection("payouts").update(id, doc) + _signed_emit("payout.updated", _payout_view(doc)) + return respond(200, _payout_view(doc)) + +# POST /v1/payouts/{id}/cancel — cancel a pending or in-transit payout and +# return the funds to the account's available balance (a positive payout +# ledger row, linked from failure_balance_transaction like the real cancel +# response). Terminal payouts (paid) get the real 400. +def on_cancel_payout(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("payouts").get(id) + if doc == None: + return _not_found("payout", id) + + # Derive first: a payout whose window elapsed is already paid and can no + # longer be canceled. + doc = _payout_advance(doc) + status = doc.get("status", "pending") + if status == "paid" or status == "canceled" or status == "failed": + return respond(400, {"error": {"message": "This payout can no longer be canceled.", "param": "status", "type": "invalid_request_error"}}) + + doc["status"] = "canceled" + acct = doc.get("_account", None) + if acct != None and acct != "": + bt = _bt_record(acct, "payout", _num(doc.get("amount", 0)), 0, doc.get("currency", "usd"), id, "Payout canceled: funds returned") + doc["failure_balance_transaction"] = bt["id"] + + store_collection("payouts").update(id, doc) + _signed_emit("payout.canceled", _payout_view(doc)) + return respond(200, _payout_view(doc)) diff --git a/adapters/stripe-style/scripts/persons.star b/adapters/stripe-style/scripts/persons.star new file mode 100644 index 00000000..c57d51c9 --- /dev/null +++ b/adapters/stripe-style/scripts/persons.star @@ -0,0 +1,259 @@ +# Persons handlers — Stripe Connect (docs.stripe.com/api/persons). +# +# Persons represent the humans associated with a connected account +# (representative, owners, executives, directors). Stored in the "persons" +# collection, keyed globally by person_* id so both the nested routes +# (/v1/accounts/{id}/persons...) and the shortcut routes (/v1/persons/{id}) +# resolve the same doc. Soft delete keeps the doc retrievable-by-id semantics +# simple while hiding it from lists, like every Stripe list. +# Emits person.created / person.updated / person.deleted. +# Shared helpers (_require_auth, _next_id, _not_found, _now, _num, +# _signed_emit, _list_page, _newest_first, _get_query) are in lib.star. + +# _person_req_shape is the full real requirements object for a person. +def _person_req_shape(req): + if req == None: + req = {} + return { + "alternatives": req.get("alternatives", []), + "current_deadline": req.get("current_deadline", None), + "currently_due": req.get("currently_due", []), + "disabled_reason": req.get("disabled_reason", None), + "errors": req.get("errors", []), + "eventually_due": req.get("eventually_due", []), + "past_due": req.get("past_due", []), + "pending_verification": req.get("pending_verification", []), + } + +# _person_rel_shape renders the relationship object with every documented +# key present. +def _person_rel_shape(rel): + if rel == None: + rel = {} + return { + "director": rel.get("director", False) == True, + "executive": rel.get("executive", False) == True, + "legal_guardian": rel.get("legal_guardian", False) == True, + "owner": rel.get("owner", False) == True, + "percent_ownership": rel.get("percent_ownership", None), + "representative": rel.get("representative", False) == True, + "title": rel.get("title", None), + } + +# _person_dob_shape normalizes a request dob hash ({day, month, year}). +def _person_dob_shape(dob): + if dob == None or type(dob) != "dict": + return None + return { + "day": _num(dob.get("day", 0)), + "month": _num(dob.get("month", 0)), + "year": _num(dob.get("year", 0)), + } + +# _person_verification is the unverified baseline verification object +# (docs.stripe.com/api/persons/object: status unverified until documents are +# provided through onboarding). +def _person_verification(): + return { + "additional_document": {"details": None, "details_code": None, "document": None}, + "details": None, + "details_code": None, + "document": None, + "status": "unverified", + } + +# _person_public strips internal keys and renders derived shapes. +def _person_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + out["requirements"] = _person_req_shape(doc.get("requirements", None)) + out["relationship"] = _person_rel_shape(doc.get("relationship", None)) + out["verification"] = doc.get("verification", None) + if out["verification"] == None: + out["verification"] = _person_verification() + return out + +# _person_get loads a live (non-deleted) person doc, or None. +def _person_get(id): + doc = store_collection("persons").get(id) + if doc == None: + return None + if doc.get("_deleted", False) == True: + return None + return doc + +# _person_body folds the whitelist of updatable person params into the doc. +def _person_body(doc, body): + if body == None: + return + for k in ["first_name", "last_name", "maiden_name", "email", "phone", "gender", "nationality", "political_exposure", "address"]: + v = body.get(k, None) + if v != None: + doc[k] = v + dob = _person_dob_shape(body.get("dob", None)) + if dob != None: + doc["dob"] = dob + rel = body.get("relationship", None) + if rel != None and type(rel) == "dict": + cur = doc.get("relationship", None) + if cur == None: + cur = {} + for k in rel: + cur[k] = rel[k] + doc["relationship"] = cur + md = body.get("metadata", None) + if md != None and type(md) == "dict": + doc["metadata"] = md + +# POST /v1/accounts/{id}/persons — create a person on a connected account. +def on_create_person(req): + err = _require_auth(req) + if err != None: + return err + + acct_id = req["params"]["id"] + if store_collection("connect_accounts").get(acct_id) == None: + return _not_found("account", acct_id) + + body = req["body"] + if body == None: + body = {} + + doc = { + "id": _next_id("person"), + "object": "person", + "account": acct_id, + "address": None, + "created": _now(), + "dob": _person_dob_shape(body.get("dob", None)), + "email": body.get("email", None), + "first_name": body.get("first_name", None), + "id_number_provided": False, + "last_name": body.get("last_name", None), + "metadata": body.get("metadata", {}), + "nationality": None, + "phone": None, + "political_exposure": None, + "relationship": _person_rel_shape(body.get("relationship", None)), + "requirements": _person_req_shape(None), + "ssn_last_4_provided": False, + "verification": _person_verification(), + "_deleted": False, + } + _person_body(doc, body) + store_collection("persons").insert(doc) + _signed_emit("person.created", _person_public(doc)) + return respond(200, _person_public(doc)) + +# GET /v1/accounts/{id}/persons — list a connected account's persons. Real +# filter: relationship[owner]=true / relationship[representative]=true (form- +# encoded bracket params arrive as literal query keys). +def on_list_persons(req): + err = _require_auth(req) + if err != None: + return err + + acct_id = req["params"]["id"] + if store_collection("connect_accounts").get(acct_id) == None: + return _not_found("account", acct_id) + + docs = store_collection("persons").list() + docs = query_select(docs, [["account", "=", acct_id], ["_deleted", "!=", True]]) + for flag in ["owner", "representative", "executive", "director"]: + key = "relationship[" + flag + "]" + v = _get_query(req, key) + if v == "true" or v == "false": + want = v == "true" + keep = [] + for i in range(len(docs)): + rel = docs[i].get("relationship", None) + if rel == None: + rel = {} + if (rel.get(flag, False) == True) == want: + keep.append(docs[i]) + docs = keep + docs = _newest_first(docs) + + page, has_more, err2 = _list_page(req, docs, "person") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_person_public(d) for d in page], "has_more": has_more, "url": "/v1/accounts/" + acct_id + "/persons"}) + +# GET /v1/accounts/{id}/persons/{person_id} — retrieve one person (must +# belong to the account in the path). +def on_retrieve_person(req): + err = _require_auth(req) + if err != None: + return err + + acct_id = req["params"]["id"] + person_id = req["params"]["person_id"] + doc = _person_get(person_id) + if doc == None or doc.get("account", None) != acct_id: + return _not_found("person", person_id) + return respond(200, _person_public(doc)) + +# POST /v1/accounts/{id}/persons/{person_id} — update a person. +def on_update_person(req): + err = _require_auth(req) + if err != None: + return err + + acct_id = req["params"]["id"] + person_id = req["params"]["person_id"] + doc = _person_get(person_id) + if doc == None or doc.get("account", None) != acct_id: + return _not_found("person", person_id) + + _person_body(doc, req["body"]) + store_collection("persons").update(person_id, doc) + _signed_emit("person.updated", _person_public(doc)) + return respond(200, _person_public(doc)) + +# DELETE /v1/accounts/{id}/persons/{person_id} — delete a person (soft +# delete: kept for retrieval by id, hidden from lists). +def on_delete_person(req): + err = _require_auth(req) + if err != None: + return err + + acct_id = req["params"]["id"] + person_id = req["params"]["person_id"] + doc = _person_get(person_id) + if doc == None or doc.get("account", None) != acct_id: + return _not_found("person", person_id) + + doc["_deleted"] = True + store_collection("persons").update(person_id, doc) + _signed_emit("person.deleted", _person_public(doc)) + return respond(200, {"id": person_id, "object": "person", "deleted": True}) + +# GET /v1/persons/{id} — shortcut retrieval by person id. +def on_retrieve_person_standalone(req): + err = _require_auth(req) + if err != None: + return err + + doc = _person_get(req["params"]["id"]) + if doc == None: + return _not_found("person", req["params"]["id"]) + return respond(200, _person_public(doc)) + +# POST /v1/persons/{id} — shortcut update by person id. +def on_update_person_standalone(req): + err = _require_auth(req) + if err != None: + return err + + person_id = req["params"]["id"] + doc = _person_get(person_id) + if doc == None: + return _not_found("person", person_id) + + _person_body(doc, req["body"]) + store_collection("persons").update(person_id, doc) + _signed_emit("person.updated", _person_public(doc)) + return respond(200, _person_public(doc)) diff --git a/adapters/stripe-style/scripts/prices.star b/adapters/stripe-style/scripts/prices.star new file mode 100644 index 00000000..556536d4 --- /dev/null +++ b/adapters/stripe-style/scripts/prices.star @@ -0,0 +1,226 @@ +# Price handlers — Stripe Catalog prices (docs.stripe.com/api/prices). +# +# A price is the per-unit or recurring amount charged for a product. Real +# Stripe prices are immutable except for active, lookup_key, nickname and +# metadata (the update endpoint here accepts exactly those); there is NO +# delete endpoint for prices — archiving is done by setting active=false. +# +# recurring carries {interval, interval_count, trial_period_days, usage_type, +# aggregate_usage}: interval day|week|month|year, usage_type licensed|metered. +# metered prices bill reported usage (see subscription_items usage_records). +# Shared helpers are in lib.star (see products.star header for the list). + +# _price_bad_body reports a malformed JSON body authoritatively (req.body +# arrives as an empty dict for unparseable bodies; req.raw_body is the truth). +def _price_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +def _price_missing(param): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) + +def _price_bad_enum(param, val, allowed): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid " + param + ": must be one of " + allowed, "param": param}}) + +# _price_intervals is the real recurring interval enum. +_PRICE_INTERVALS = ["day", "week", "month", "year"] + +# _price_public renders the stored price doc (docs.stripe.com/api/prices/object). +# Docs are stored in public shape (no internal keys), so this only guards a +# missing doc. +def _price_public(doc): + return doc + +# _price_decimal_int parses a unit_amount_decimal string ("1000", "1000.5") +# to integer cents, truncating any fractional part. +def _price_decimal_int(s): + if s == None: + return None + return _to_int(str(s)) + +# POST /v1/prices — create a price for an existing product. +# +# unit_amount (integer cents) or unit_amount_decimal (decimal-string cents) +# is required; currency and product are required. recurring makes the price +# recurring (type "recurring"); without it the price is one-time. +def on_create_price(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "prices") + if cached != None: + return respond(cached["status"], _price_public(cached["doc"])) + + if _price_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + currency = body.get("currency", None) + if currency == None or currency == "": + return _price_missing("currency") + product = body.get("product", None) + if product == None or product == "": + return _price_missing("product") + if store_collection("products").get(product) == None: + return _not_found("product", product) + + unit_amount = body.get("unit_amount", None) + unit_decimal = body.get("unit_amount_decimal", None) + if unit_amount == None and unit_decimal == None: + return _price_missing("unit_amount") + amount = _num(unit_amount) + if unit_amount == None: + amount = _price_decimal_int(unit_decimal) + if unit_decimal == None: + unit_decimal = str(amount) + + recurring = None + rec = body.get("recurring", None) + if rec != None and type(rec) == "dict": + interval = rec.get("interval", "month") + ok = False + for i in range(len(_PRICE_INTERVALS)): + if _PRICE_INTERVALS[i] == interval: + ok = True + break + if not ok: + return _price_bad_enum("recurring[interval]", interval, "day, week, month, or year") + interval_count = _num(rec.get("interval_count", 1)) + if interval_count < 1: + interval_count = 1 + usage_type = rec.get("usage_type", "licensed") + if usage_type != "licensed" and usage_type != "metered": + return _price_bad_enum("recurring[usage_type]", usage_type, "licensed or metered") + recurring = { + "aggregate_usage": rec.get("aggregate_usage", None), + "interval": interval, + "interval_count": interval_count, + "trial_period_days": rec.get("trial_period_days", None), + "usage_type": usage_type, + } + + doc = { + "id": _next_id("price"), + "object": "price", + "active": body.get("active", True), + "billing_scheme": "per_unit", + "created": _now(), + "currency": currency, + "custom_unit_amount": None, + "livemode": False, + "lookup_key": body.get("lookup_key", None), + "metadata": body.get("metadata", {}), + "nickname": body.get("nickname", None), + "product": product, + "recurring": recurring, + "tax_behavior": body.get("tax_behavior", "unspecified"), + "tiers_mode": None, + "transform_quantity": None, + "type": "one_time", + "unit_amount": amount, + "unit_amount_decimal": unit_decimal, + } + if recurring != None: + doc["type"] = "recurring" + store_collection("prices").insert(doc) + _idempotent_remember(req, "prices", 201, doc["id"]) + _signed_emit("price.created", _price_public(doc)) + return respond(201, _price_public(doc)) + +# GET /v1/prices/{id} — retrieve a price. +def on_retrieve_price(req): + err = _require_auth(req) + if err != None: + return err + + doc = store_collection("prices").get(req["params"]["id"]) + if doc == None: + return _not_found("price", req["params"]["id"]) + return respond(200, _price_public(doc)) + +# _price_filters maps the real Stripe price-list query params (product, +# active, type, currency, lookup_keys, created exact/range) to query_select +# clauses. lookup_keys arrives comma-separated (the simulator's JSON-body +# convention for Stripe's repeated param) and becomes an "in" clause. +def _price_filters(req, docs): + f = [] + product = _get_query(req, "product") + if product != "": + f.append(["product", "=", product]) + active = _get_query(req, "active") + if active == "true": + f.append(["active", "=", True]) + elif active == "false": + f.append(["active", "=", False]) + ptype = _get_query(req, "type") + if ptype != "": + f.append(["type", "=", ptype]) + currency = _get_query(req, "currency") + if currency != "": + f.append(["currency", "=", currency]) + keys = _get_query(req, "lookup_keys") + if keys != "": + kl = [] + for part in keys.split(","): + k = part.strip() + if k != "": + kl.append(k) + if len(kl) > 0: + f.append(["lookup_key", "in", kl]) + _created_filters(req, f) + if len(f) == 0: + return docs + return query_select(docs, f) + +# GET /v1/prices — list prices (newest first, cursor pagination). +def on_list_prices(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("prices").list() + docs = _price_filters(req, docs) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "price") + if e != None: + return e + return respond(200, {"object": "list", "data": [_price_public(d) for d in page], "has_more": has_more, "url": "/v1/prices"}) + +# POST /v1/prices/{id} — update the mutable fields only: active, lookup_key, +# nickname, metadata (docs.stripe.com/api/prices/update). Amounts and +# recurring settings are immutable, like the real API. +def on_update_price(req): + err = _require_auth(req) + if err != None: + return err + + if _price_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + id = req["params"]["id"] + doc = store_collection("prices").get(id) + if doc == None: + return _not_found("price", id) + + body = req["body"] + if body == None: + body = {} + if body.get("active", None) != None: + doc["active"] = body["active"] + if body.get("lookup_key", None) != None: + doc["lookup_key"] = body["lookup_key"] + if body.get("nickname", None) != None: + doc["nickname"] = body["nickname"] + if body.get("metadata", None) != None: + doc["metadata"] = body["metadata"] + store_collection("prices").update(id, doc) + _signed_emit("price.updated", _price_public(doc)) + return respond(200, _price_public(doc)) diff --git a/adapters/stripe-style/scripts/products.star b/adapters/stripe-style/scripts/products.star new file mode 100644 index 00000000..e078f887 --- /dev/null +++ b/adapters/stripe-style/scripts/products.star @@ -0,0 +1,187 @@ +# Product handlers — Stripe Catalog products (docs.stripe.com/api/products). +# +# Products are the catalog objects prices hang off. Creation requires `name` +# (the only required parameter of the real API). Deletion is a soft delete: +# the stored doc is flagged and the product remains retrievable (with +# "deleted": true) — real Stripe keeps deleted products readable and returns +# the tombstone {"id", "object", "deleted": true} from the DELETE call. +# Shared helpers (_require_auth, _next_id, _not_found, _get_query, +# _created_filters, _created_check, _newest_first, _list_page, _idempotent_lookup, +# _idempotent_remember, _now, _num, _signed_emit) are in lib.star. + +# _prod_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is +# the source of truth. +def _prod_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +def _prod_missing(param): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) + +# _prod_public renders the stored product doc (docs.stripe.com/api/products/object): +# internal "_" keys are stripped; an archived product additionally carries +# "deleted": true, the real shape of a deleted product that remains retrievable. +def _prod_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + if doc.get("_archived", False) == True: + out["deleted"] = True + return out + +# _prod_get loads a product doc (archived included — deleted products stay +# retrievable) or None. +def _prod_get(id): + return store_collection("products").get(id) + +# POST /v1/products — create a product (name required). +def on_create_product(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "products") + if cached != None: + return respond(cached["status"], _prod_public(cached["doc"])) + + if _prod_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + name = body.get("name", None) + if name == None or name == "": + return _prod_missing("name") + + now = _now() + doc = { + "id": _next_id("prod"), + "object": "product", + "active": body.get("active", True), + "created": now, + "default_price": body.get("default_price", None), + "description": body.get("description", None), + "images": body.get("images", []), + "livemode": False, + "marketing_features": body.get("marketing_features", []), + "metadata": body.get("metadata", {}), + "name": name, + "package_dimensions": None, + "shippable": body.get("shippable", None), + "statement_descriptor": body.get("statement_descriptor", None), + "tax_code": body.get("tax_code", None), + "unit_label": body.get("unit_label", None), + "updated": now, + "url": body.get("url", None), + "_archived": False, + } + store_collection("products").insert(doc) + _idempotent_remember(req, "products", 201, doc["id"]) + _signed_emit("product.created", _prod_public(doc)) + return respond(201, _prod_public(doc)) + +# GET /v1/products/{id} — retrieve a product (deleted products remain +# retrievable and render with "deleted": true). +def on_retrieve_product(req): + err = _require_auth(req) + if err != None: + return err + + doc = _prod_get(req["params"]["id"]) + if doc == None: + return _not_found("product", req["params"]["id"]) + return respond(200, _prod_public(doc)) + +# _prod_filters maps the real Stripe product-list query params (active, +# created exact/range) to query_select clauses. active arrives as a query +# string ("true"/"false") but is stored as a bool, so it is converted before +# the clause is built. Archived products are always excluded — Stripe list +# endpoints never return deleted objects. +def _prod_filters(req, docs): + f = [["_archived", "!=", True]] + active = _get_query(req, "active") + if active == "true": + f.append(["active", "=", True]) + elif active == "false": + f.append(["active", "=", False]) + _created_filters(req, f) + return query_select(docs, f) + +# GET /v1/products — list products (newest first, cursor pagination). +def on_list_products(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("products").list() + docs = _prod_filters(req, docs) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "product") + if e != None: + return e + return respond(200, {"object": "list", "data": [_prod_public(d) for d in page], "has_more": has_more, "url": "/v1/products"}) + +# _PROD_UPDATABLE lists the writable product fields (docs.stripe.com/api/products/update). +_PROD_UPDATABLE = [ + "name", "active", "description", "default_price", "metadata", "url", + "images", "statement_descriptor", "unit_label", "shippable", "tax_code", + "marketing_features", +] + +# POST /v1/products/{id} — update a product. Archived products are immutable: +# Stripe reports resource_missing for mutations on deleted objects. +def on_update_product(req): + err = _require_auth(req) + if err != None: + return err + + if _prod_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + id = req["params"]["id"] + doc = _prod_get(id) + if doc == None or doc.get("_archived", False) == True: + return _not_found("product", id) + + body = req["body"] + if body == None: + body = {} + for i in range(len(_PROD_UPDATABLE)): + k = _PROD_UPDATABLE[i] + if body.get(k, None) != None: + doc[k] = body[k] + doc["updated"] = _now() + store_collection("products").update(id, doc) + _signed_emit("product.updated", _prod_public(doc)) + return respond(200, _prod_public(doc)) + +# DELETE /v1/products/{id} — delete (soft) a product. The stored doc is +# flagged archived; the product stays retrievable with "deleted": true, and +# the response is the real deleted-object tombstone. Simplification vs real +# Stripe: the real API refuses to delete a product that still has prices +# attached; this simulator archives unconditionally and existing prices keep +# their product reference. +def on_delete_product(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _prod_get(id) + if doc == None or doc.get("_archived", False) == True: + return _not_found("product", id) + + doc["_archived"] = True + doc["updated"] = _now() + store_collection("products").update(id, doc) + _signed_emit("product.deleted", {"id": id, "object": "product", "deleted": True}) + return respond(200, {"id": id, "object": "product", "deleted": True}) diff --git a/adapters/stripe-style/scripts/promotion_codes.star b/adapters/stripe-style/scripts/promotion_codes.star new file mode 100644 index 00000000..0f7bb82e --- /dev/null +++ b/adapters/stripe-style/scripts/promotion_codes.star @@ -0,0 +1,222 @@ +# Promotion code handlers — customer-facing codes over a coupon +# (docs.stripe.com/api/promotion_codes). +# +# {id promo_*, object "promotion_code", active, code, coupon, created, +# customer, expires_at, livemode, max_redemptions, metadata, restrictions +# {first_time_transaction, minimum_amount, minimum_amount_currency}, +# times_redeemed}. The create param is the classic top-level `coupon` +# (also accepted as the newer nested promotion {type, coupon} object). In +# responses `coupon` renders EXPANDED (the full coupon object), like the +# classic Stripe API shape the billing domains consume. +# Shared helpers (_require_auth, _next_id, _num, _now, _not_found, +# _list_page, _newest_first, _created_filters, _created_check, _signed_emit, +# _idempotent_lookup, _idempotent_remember) are in lib.star. + +_PROMO_COLLECTION = "promotion_codes" + +# _promo_err builds the real Stripe 400 envelope. +def _promo_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +# _promo_bad_body reports a malformed JSON body authoritatively. +def _promo_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _promo_gen_code mints a Stripe-style code: 8 uppercase alphanumerics +# derived from an HMAC of the KV sequence (runtime data, no long literals). +def _promo_gen_code(): + seq = store_kv_incr("stripe", "promo_code_seq") + h = crypto.hmac_sha256("stunt-promo", str(seq)) + return h[0:8].upper() + +# _promo_coupon_public renders the embedded coupon (strips internal keys). +# Local twin of coupons.star's _coupon_public — hoist candidate for lib.star. +def _promo_coupon_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _promo_public renders a stored promotion code with the coupon EXPANDED +# (internal keys stripped). +def _promo_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + if k == "coupon": + coupon = store_collection("coupons").get(doc["coupon"]) + if coupon != None: + out["coupon"] = _promo_coupon_public(coupon) + else: + out["coupon"] = doc["coupon"] + else: + out[k] = doc[k] + return out + +# POST /v1/promotion_codes — create a promotion code over a coupon. The +# code is auto-generated (unique-looking, uppercase) when not supplied. +def on_create_promotion_code(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _PROMO_COLLECTION) + if cached != None: + return respond(cached["status"], _promo_public(cached["doc"])) + + if _promo_bad_body(req): + return _promo_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + coupon_id = body.get("coupon", None) + if coupon_id == None or coupon_id == "": + promo = body.get("promotion", None) + if promo != None and type(promo) == "dict": + coupon_id = promo.get("coupon", None) + if coupon_id == None or coupon_id == "": + return _promo_err("Missing required param: coupon.", "coupon") + coupon = store_collection("coupons").get(coupon_id) + if coupon == None: + return _not_found("coupon", coupon_id) + if coupon.get("deleted", False) == True: + return _promo_err("This coupon has been deleted and can no longer be used.", "coupon") + + code = body.get("code", None) + if code == None or code == "": + code = _promo_gen_code() + + active = body.get("active", True) + if active == None: + active = True + + restrictions = body.get("restrictions", None) + r = {"first_time_transaction": False, "minimum_amount": None, "minimum_amount_currency": None} + if restrictions != None and type(restrictions) == "dict": + if restrictions.get("first_time_transaction", None) != None: + r["first_time_transaction"] = restrictions["first_time_transaction"] == True + if restrictions.get("minimum_amount", None) != None: + r["minimum_amount"] = _num(restrictions["minimum_amount"]) + if restrictions.get("minimum_amount_currency", None) != None: + r["minimum_amount_currency"] = restrictions["minimum_amount_currency"] + + expires_at = body.get("expires_at", None) + if expires_at != None: + expires_at = _num(expires_at) + if expires_at <= 0: + expires_at = None + + max_redemptions = body.get("max_redemptions", None) + if max_redemptions != None: + max_redemptions = _num(max_redemptions) + if max_redemptions <= 0: + max_redemptions = None + + metadata = body.get("metadata", {}) + if metadata == None or type(metadata) != "dict": + metadata = {} + + doc = { + "id": _next_id("promo"), + "object": "promotion_code", + "active": active == True, + "code": code, + "coupon": coupon_id, + "created": _now(), + "customer": body.get("customer", None), + "expires_at": expires_at, + "livemode": False, + "max_redemptions": max_redemptions, + "metadata": metadata, + "restrictions": r, + "times_redeemed": 0, + } + store_collection(_PROMO_COLLECTION).insert(doc) + _signed_emit("promotion_code.created", _promo_public(doc)) + _idempotent_remember(req, _PROMO_COLLECTION, 201, doc["id"]) + return respond(201, _promo_public(doc)) + +# GET /v1/promotion_codes/{id} — retrieve a promotion code. +def on_retrieve_promotion_code(req): + err = _require_auth(req) + if err != None: + return err + doc = store_collection(_PROMO_COLLECTION).get(req["params"]["id"]) + if doc == None: + return _not_found("promotion_code", req["params"]["id"]) + return respond(200, _promo_public(doc)) + +# GET /v1/promotion_codes — list promotion codes (code, coupon, customer, +# active, created filters). +def on_list_promotion_codes(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + f = [] + code = _get_query(req, "code") + if code != "": + f.append(["code", "=", code]) + coupon = _get_query(req, "coupon") + if coupon != "": + f.append(["coupon", "=", coupon]) + customer = _get_query(req, "customer") + if customer != "": + f.append(["customer", "=", customer]) + active = _get_query(req, "active") + if active == "true": + f.append(["active", "=", True]) + elif active == "false": + f.append(["active", "=", False]) + _created_filters(req, f) + docs = store_collection(_PROMO_COLLECTION).list() + if len(f) > 0: + docs = query_select(docs, f) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "promotion_code") + if e != None: + return e + return respond(200, {"object": "list", "data": [_promo_public(d) for d in page], "has_more": has_more, "url": "/v1/promotion_codes"}) + +# POST /v1/promotion_codes/{id} — update a promotion code (active + +# metadata, like the real API). +def on_update_promotion_code(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_PROMO_COLLECTION).get(id) + if doc == None: + return _not_found("promotion_code", id) + + if _promo_bad_body(req): + return _promo_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + if body.get("active", None) != None: + doc["active"] = body["active"] == True + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + + store_collection(_PROMO_COLLECTION).update(id, doc) + _signed_emit("promotion_code.updated", _promo_public(doc)) + return respond(200, _promo_public(doc)) diff --git a/adapters/stripe-style/scripts/refunds.star b/adapters/stripe-style/scripts/refunds.star index cada95e7..504c1d0e 100644 --- a/adapters/stripe-style/scripts/refunds.star +++ b/adapters/stripe-style/scripts/refunds.star @@ -4,12 +4,98 @@ # simulator-only simulate_fail flag. The transition is persisted and the # refund.updated webhook fires exactly once. # -# The over-refund guard sums every non-failed refund (pending included) of the -# target payment_intent/charge and rejects amounts beyond the unrefunded -# balance with the real Stripe 400. +# The over-refund guard sums every still-active refund (pending, succeeded) of +# the target payment_intent/charge and rejects amounts beyond the unrefunded +# balance with the real Stripe 400. failed AND canceled refunds free the +# balance again. +# +# Refunding an UNCAPTURED charge releases the authorization instead of moving +# money (docs.stripe.com/refunds: a PaymentIntent in requires_capture "can't +# be refunded directly. You must cancel the PaymentIntent" — for the legacy +# Charges API the equivalent is a refund that voids the auth): the refund is +# born terminal "succeeded" with NO balance transaction (no funds ever moved) +# and the charge flips refunded. # Shared helpers (_require_auth, _not_found, _list_page, _signed_emit, # _refund_public, _create_refund, _advance_refund, _refunds_for, -# _refunded_total, _over_refund_error, _apply_charge_refund) are in lib.star. +# _refunded_total, _over_refund_error, _apply_charge_refund, _receipt_number, +# _bt_record, _num) are in lib.star. + +# _ref_public renders the lib public shape plus the failure fields the real +# Refund object carries once a refund has failed or been canceled (failure_ +# reason / failure_balance_transaction — docs.stripe.com/api/refunds/cancel). +# Purely additive over lib._refund_public, which this file cannot edit. +def _ref_public(doc): + out = _refund_public(doc) + if doc.get("failure_reason", None) != None: + out["failure_reason"] = doc["failure_reason"] + if doc.get("failure_balance_transaction", None) != None: + out["failure_balance_transaction"] = doc["failure_balance_transaction"] + return out + +# _ref_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is the +# source of truth. +def _ref_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _ref_active_total sums the amounts of every refund that still counts against +# the unrefunded balance: pending (Stripe reserves it immediately) and +# succeeded. failed refunds never counted; canceled ones are rolled back by +# on_cancel_refund, so they must not count either. (lib._refunded_total treats +# everything non-failed as active — kept for other callers; this local copy +# adds the canceled case for this file's guard.) +def _ref_active_total(docs): + total = 0 + for r in docs: + st = r.get("status", "") + if st == "failed" or st == "canceled": + continue + total = total + _num(r.get("amount", 0)) + return total + +# _ref_apply_charge_recompute recomputes a charge's refund bookkeeping from the +# still-active refunds (used after a cancel rolls one back). Mirrors lib's +# _apply_charge_refund flags: fully refunded -> refunded True + status +# "refunded", otherwise refunded False with the status left alone. +def _ref_apply_charge_recompute(ch, active): + base = _num(ch.get("amount", 0)) + ch["amount_refunded"] = active + if active >= base and base > 0: + ch["refunded"] = True + ch["status"] = "refunded" + else: + ch["refunded"] = False + if ch.get("status", "") == "refunded": + ch["status"] = "succeeded" + return ch + +# _ref_release_refund creates the terminal refund that documents an +# authorization release on an uncaptured charge. No balance transaction is +# recorded (real Stripe moves no funds when voiding an auth — unlike lib's +# _create_refund, which books the -amount row for captured-charge refunds). +def _ref_release_refund(charge_id, amount, currency, reason): + doc = { + "id": _next_id("re"), + "object": "refund", + "amount": amount, + "balance_transaction": None, + "receipt_number": _receipt_number(), + "currency": currency, + "payment_intent": None, + "charge": charge_id, + "reason": reason, + "status": "succeeded", + "created": _now(), + "_stage": 2, + "_done_at": _now(), + "_fail_mode": "", + } + store_collection("refunds").insert(doc) + _signed_emit("refund.created", _ref_public(doc)) + return doc # _apply_refund_filters maps the real Stripe refund-list query params # (charge, payment_intent, created exact/range) to query_select clauses, @@ -36,8 +122,10 @@ def on_create_refund(req): cached = _idempotent_lookup(req, "refunds") if cached != None: - return respond(cached["status"], _refund_public(cached["doc"])) + return respond(cached["status"], _ref_public(cached["doc"])) + if _ref_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: body = {} @@ -50,39 +138,56 @@ def on_create_refund(req): amount = _num(body.get("amount", 0)) sf = body.get("simulate_fail", False) fail_mode = sf != None and sf + reason = body.get("reason", "requested_by_customer") if pi_id != None: pis = store_collection("payment_intents") pi = pis.get(pi_id) if pi == None: return _not_found("payment_intent", pi_id) + # Real Stripe (docs.stripe.com/refunds): a PaymentIntent held at + # requires_capture cannot be refunded — the uncaptured charge can't be + # refunded directly; the PaymentIntent must be canceled instead. + if pi.get("status", "") == "requires_capture": + return respond(400, {"error": {"code": "payment_intent_unexpected_state", "type": "invalid_request_error", "message": "This PaymentIntent could not be refunded because it has a status of requires_capture. You can cancel it instead with the PaymentIntents API.", "param": "payment_intent"}}) base = _num(pi.get("amount", 0)) - remaining = base - _refunded_total(_refunds_for("payment_intent", pi_id)) + remaining = base - _ref_active_total(_refunds_for("payment_intent", pi_id)) if amount == 0: amount = remaining if amount > remaining or amount <= 0: return _over_refund_error(amount, remaining) - doc = _create_refund(pi_id, None, amount, pi.get("currency", "usd"), body.get("reason", "requested_by_customer"), fail_mode) + doc = _create_refund(pi_id, None, amount, pi.get("currency", "usd"), reason, fail_mode) else: chs = store_collection("charges") ch = chs.get(charge_id) if ch == None: return _not_found("charge", charge_id) base = _num(ch.get("amount", 0)) - already = _refunded_total(_refunds_for("charge", charge_id)) + already = _ref_active_total(_refunds_for("charge", charge_id)) remaining = base - already if amount == 0: amount = remaining if amount > remaining or amount <= 0: return _over_refund_error(amount, remaining) - doc = _create_refund(None, charge_id, amount, ch.get("currency", "usd"), body.get("reason", "requested_by_customer"), fail_mode) + + if ch.get("captured", True) != True: + # Uncaptured charge: release the authorization — the refund is born + # terminal with no ledger row, and the charge flips refunded. + doc = _ref_release_refund(charge_id, amount, ch.get("currency", "usd"), reason) + _apply_charge_refund(ch, already, amount) + chs.update(charge_id, ch) + _signed_emit("charge.refunded", ch) + _idempotent_remember(req, "refunds", 201, doc["id"]) + return respond(201, _ref_public(doc)) + + doc = _create_refund(None, charge_id, amount, ch.get("currency", "usd"), reason, fail_mode) _apply_charge_refund(ch, already, amount) chs.update(charge_id, ch) _signed_emit("charge.refunded", ch) _idempotent_remember(req, "refunds", 201, doc["id"]) - return respond(201, _refund_public(doc)) + return respond(201, _ref_public(doc)) # GET /v1/refunds/{id} — retrieve a refund (derives its async status first, # so polls agree with the webhook timeline). @@ -95,7 +200,7 @@ def on_retrieve_refund(req): doc = store_collection("refunds").get(id) if doc == None: return _not_found("refund", id) - return respond(200, _refund_public(_advance_refund(doc))) + return respond(200, _ref_public(_advance_refund(doc))) # GET /v1/refunds — list refunds (optional ?payment_intent= / ?charge=). def on_list_refunds(req): @@ -108,11 +213,78 @@ def on_list_refunds(req): return bad docs = store_collection("refunds").list() - docs = [_advance_refund(d) for d in docs] + for i in range(len(docs)): + docs[i] = _advance_refund(docs[i]) docs = _apply_refund_filters(req, docs) docs = _newest_first(docs) page, has_more, e = _list_page(req, docs, "refund") if e != None: return e - return respond(200, {"object": "list", "data": [_refund_public(d) for d in page], "has_more": has_more, "url": "/v1/refunds"}) + out = [] + for i in range(len(page)): + out.append(_ref_public(page[i])) + return respond(200, {"object": "list", "data": out, "has_more": has_more, "url": "/v1/refunds"}) + +# POST /v1/refunds/{id}/cancel — cancel a refund that has not settled yet. +# +# Real Stripe (docs.stripe.com/api/refunds/cancel) only cancels refunds still +# awaiting settlement; everything else is a 400 invalid_request_error. A +# canceled refund is terminal: cancellation is a kind of refund failure, so +# the refund object carries failure_reason (merchant_request, per the real +# cancel response) plus failure_balance_transaction — the ledger row that +# returns the reserved funds to the platform balance (lib._create_refund +# booked the -amount row at creation). The charge's refund bookkeeping is +# recomputed from the remaining active refunds, so a canceled refund frees the +# unrefunded balance again. refund.updated fires exactly once. +def on_cancel_refund(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "refunds") + if cached != None: + return respond(cached["status"], _ref_public(cached["doc"])) + + if _ref_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + + id = req["params"]["id"] + rs = store_collection("refunds") + doc = rs.get(id) + if doc == None: + return _not_found("refund", id) + + # The cancel decision uses the STORED status: a refund still pending in the + # store is cancellable (real Stripe's pending window is days; the + # simulator derives success after 3 seconds, which would otherwise make + # cancel untestable and race-prone). + if doc.get("status", "") != "pending": + return respond(400, {"error": {"type": "invalid_request_error", "message": "This refund cannot be canceled because its status is " + str(doc.get("status", "")) + ". Only pending refunds can be canceled."}}) + + # Persist the terminal canceled state BEFORE emitting or touching money. + doc["status"] = "canceled" + doc["failure_reason"] = "merchant_request" + doc["_stage"] = 2 + doc["_done_at"] = _now() + doc["_fail_mode"] = "" + rs.update(id, doc) + + # Return the reserved funds to the platform ledger. + if doc.get("balance_transaction", None) != None: + fbt = _bt_record("", "refund_failure", _num(doc.get("amount", 0)), 0, doc.get("currency", "usd"), id, "Canceled refund") + doc["failure_balance_transaction"] = fbt["id"] + rs.update(id, doc) + + # Roll the charge's refund bookkeeping back to the still-active refunds. + ch_id = doc.get("charge", None) + if ch_id != None: + chs = store_collection("charges") + ch = chs.get(ch_id) + if ch != None: + _ref_apply_charge_recompute(ch, _ref_active_total(_refunds_for("charge", ch_id))) + chs.update(ch_id, ch) + + _signed_emit("refund.updated", _ref_public(doc)) + _idempotent_remember(req, "refunds", 200, id) + return respond(200, _ref_public(doc)) diff --git a/adapters/stripe-style/scripts/setup_intents.star b/adapters/stripe-style/scripts/setup_intents.star new file mode 100644 index 00000000..a892435d --- /dev/null +++ b/adapters/stripe-style/scripts/setup_intents.star @@ -0,0 +1,292 @@ +# SetupIntents handlers — saving a payment method for future use +# (docs.stripe.com/api/setup_intents). +# +# State machine (the SetupIntent analog of payment_intents.star): +# create -> requires_payment_method (no payment_method) +# | requires_confirmation (with one) +# confirm(payment_method): +# normal card -> succeeded immediately (the mock stands in +# for the hosted confirm round trip) +# SCA test card (tok/pm) -> requires_action + next_action +# {type: use_stripe_sdk}; confirming AGAIN +# with the same payment method completes the +# mock 3DS round trip -> succeeded +# decline test card -> 402 card_error (real code + decline_code, +# setup_intent named in the error), the +# SetupIntent keeps requires_payment_method +# and records last_setup_error, and +# setup_intent.setup_failed fires +# cancel -> canceled (+ setup_intent.canceled) +# Real shape: status requires_payment_method|requires_confirmation| +# requires_action|processing|succeeded|canceled, latest_attempt None, usage +# on_session|off_session, payment_method, last_setup_error None. +# Shared helpers (_require_auth, _next_id, _now, _signed_emit, _not_found, +# _list_page, _newest_first, _created_filters, _created_check, _get_query, +# _num, _card_number_for, _card_outcome) are in lib.star. + +# _si_public renders the public SetupIntent shape (internal keys stripped). +def _si_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _si_new_doc builds a SetupIntent doc; status depends on whether a payment +# method was supplied at create (real Stripe behavior). +def _si_new_doc(body): + now = _now() + sid = _next_id("seti") + usage = body.get("usage", "off_session") + if usage != "on_session" and usage != "off_session": + usage = "off_session" + pm = body.get("payment_method", None) + pm_types = body.get("payment_method_types", None) + if pm_types == None or type(pm_types) != "list" or len(pm_types) == 0: + pm_types = ["card"] + status = "requires_confirmation" + if pm == None: + status = "requires_payment_method" + return { + "id": sid, + "object": "setup_intent", + "cancellation_reason": None, + "client_secret": sid + "_secret_" + str(store_kv_incr("stripe", "seti_secret_seq")), + "created": now, + "customer": body.get("customer", None), + "description": body.get("description", None), + "last_setup_error": None, + "latest_attempt": None, + "livemode": False, + "metadata": body.get("metadata", {}), + "next_action": None, + "payment_method": pm, + "payment_method_types": pm_types, + "status": status, + "usage": usage, + } + +# POST /v1/setup_intents — create a SetupIntent. +def on_create_setup_intent(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "setup_intents") + if cached != None: + return respond(cached["status"], _si_public(cached["doc"])) + + body = req["body"] + if body == None: + body = {} + + doc = _si_new_doc(body) + store_collection("setup_intents").insert(doc) + _signed_emit("setup_intent.created", _si_public(doc)) + _idempotent_remember(req, "setup_intents", 201, doc["id"]) + return respond(201, _si_public(doc)) + +# GET /v1/setup_intents/{id} — retrieve a SetupIntent. +def on_retrieve_setup_intent(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("setup_intents").get(id) + if doc == None: + return _not_found("setup_intent", id) + return respond(200, _si_public(doc)) + +# _apply_setup_intent_filters maps the real SetupIntent-list query params +# (customer, payment_method, created exact/range) to query_select clauses. +def _apply_setup_intent_filters(req, docs): + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + pm = _get_query(req, "payment_method") + if pm != "": + f.append(["payment_method", "=", pm]) + _created_filters(req, f) + if len(f) == 0: + return docs + return query_select(docs, f) + +# GET /v1/setup_intents — list SetupIntents (newest first, cursor pagination). +def on_list_setup_intents(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("setup_intents").list() + docs = _apply_setup_intent_filters(req, docs) + docs = _newest_first(docs) + + page, has_more, e = _list_page(req, docs, "setup_intent") + if e != None: + return e + return respond(200, {"object": "list", "data": [_si_public(d) for d in page], "has_more": has_more, "url": "/v1/setup_intents"}) + +# _si_next_action is the minimal SCA next_action for a SetupIntent (3DS via +# the SDK, like the PI flavor in payment_intents.star). +def _si_next_action(seti_id): + return { + "type": "use_stripe_sdk", + "use_stripe_sdk": { + "type": "three_d_secure_redirect", + "stripe_js": "https://hooks.stripe.com/3d_secure_2/test/" + seti_id + "/sdk", + }, + } + +# _si_decline_error is the real 402 card_error envelope for a declined setup +# confirmation, naming the SetupIntent (the lib decline helper names a +# payment_intent or charge, so this is local to the setup domain). +def _si_decline_error(oc, seti_id): + return respond(402, {"error": { + "code": oc["code"], + "decline_code": oc["decline_code"], + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": oc["message"], + "setup_intent": seti_id, + "type": "card_error", + }}) + +# POST /v1/setup_intents/{id}/confirm — attach a payment_method and advance. +# payment_method is required (else the real 400). Re-confirming a +# requires_action SetupIntent with the same payment method completes the +# mocked 3DS round trip and succeeds. +def on_confirm_setup_intent(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "setup_intents") + if cached != None: + return respond(cached["status"], _si_public(cached["doc"])) + + id = req["params"]["id"] + c = store_collection("setup_intents") + doc = c.get(id) + if doc == None: + return _not_found("setup_intent", id) + + body = req["body"] + if body == None: + body = {} + pm = body.get("payment_method", doc.get("payment_method")) + if pm == None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "You must provide a payment_method to confirm this SetupIntent.", "param": "payment_method"}}) + + status = doc.get("status", "") + if status not in ["requires_payment_method", "requires_confirmation", "requires_action"]: + return respond(400, {"error": {"type": "invalid_request_error", "message": "You cannot confirm this SetupIntent because it has a status of " + status + ". Only a SetupIntent with one of the following statuses may be confirmed: requires_payment_method, requires_confirmation, requires_action, processing."}}) + + doc["payment_method"] = pm + + # Mock 3DS completion: re-confirming a requires_action SetupIntent with + # the same payment method stands in for the SDK/redirect round trip. + if status == "requires_action": + doc["status"] = "succeeded" + doc["next_action"] = None + c.update(id, doc) + _signed_emit("setup_intent.succeeded", _si_public(doc)) + _idempotent_remember(req, "setup_intents", 200, id) + return respond(200, _si_public(doc)) + + number = _card_number_for(pm) + oc = None + if number != "": + oc = _card_outcome(number) + if oc != None and oc["kind"] == "decline": + doc["status"] = "requires_payment_method" + doc["last_setup_error"] = { + "code": oc["code"], + "decline_code": oc["decline_code"], + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": oc["message"], + "payment_method": pm, + "type": "card_error", + } + c.update(id, doc) + _signed_emit("setup_intent.setup_failed", _si_public(doc)) + return _si_decline_error(oc, id) + if oc != None: + doc["status"] = "requires_action" + doc["next_action"] = _si_next_action(id) + c.update(id, doc) + _signed_emit("setup_intent.requires_action", _si_public(doc)) + _idempotent_remember(req, "setup_intents", 200, id) + return respond(200, _si_public(doc)) + + doc["status"] = "succeeded" + doc["next_action"] = None + c.update(id, doc) + _signed_emit("setup_intent.succeeded", _si_public(doc)) + _idempotent_remember(req, "setup_intents", 200, id) + return respond(200, _si_public(doc)) + +# POST /v1/setup_intents/{id}/cancel — cancel a SetupIntent +# (cancellation_reason requested_by_customer|duplicate|abandoned). +def on_cancel_setup_intent(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "setup_intents") + if cached != None: + return respond(cached["status"], _si_public(cached["doc"])) + + id = req["params"]["id"] + c = store_collection("setup_intents") + doc = c.get(id) + if doc == None: + return _not_found("setup_intent", id) + + status = doc.get("status", "") + if status not in ["requires_payment_method", "requires_confirmation", "requires_action"]: + return respond(400, {"error": {"type": "invalid_request_error", "message": "You cannot cancel this SetupIntent because it has a status of " + status + ". Only a SetupIntent with one of the following statuses may be canceled: requires_payment_method, requires_confirmation, requires_action."}}) + + body = req["body"] + if body == None: + body = {} + reason = body.get("cancellation_reason", None) + if reason not in ["requested_by_customer", "duplicate", "abandoned"]: + reason = None + + doc["status"] = "canceled" + doc["cancellation_reason"] = reason + doc["next_action"] = None + c.update(id, doc) + _signed_emit("setup_intent.canceled", _si_public(doc)) + _idempotent_remember(req, "setup_intents", 200, id) + return respond(200, _si_public(doc)) + +# POST /v1/setup_intents/{id} — update a SetupIntent (metadata, description). +def on_update_setup_intent(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + c = store_collection("setup_intents") + doc = c.get(id) + if doc == None: + return _not_found("setup_intent", id) + + body = req["body"] + if body != None: + meta = body.get("metadata", None) + if meta != None and type(meta) == "dict": + doc["metadata"] = meta + desc = body.get("description", None) + if desc != None: + doc["description"] = desc + + c.update(id, doc) + return respond(200, _si_public(doc)) diff --git a/adapters/stripe-style/scripts/subscription_items.star b/adapters/stripe-style/scripts/subscription_items.star new file mode 100644 index 00000000..9762765a --- /dev/null +++ b/adapters/stripe-style/scripts/subscription_items.star @@ -0,0 +1,304 @@ +# Subscription item handlers — the items array of a subscription +# (docs.stripe.com/api/subscription_items) and the metered usage records +# attached to metered items (docs.stripe.com/api/usage_records). +# +# Items are EMBEDDED on the subscription doc (SUBSCRIPTION DOC CONTRACT); +# these endpoints project and mutate them there. GET /v1/subscription_items +# requires the subscription query parameter, like the real API. Deleting the +# last item of a subscription is the real Stripe 400 — a subscription must +# keep at least one item (cancel the subscription instead). +# +# USAGE RECORDS live in the usage_records collection keyed by subscription +# item id, doc {id iid_*, object "usage_record", livemode, quantity, +# subscription_item, timestamp}. action=increment (default) appends a record; +# action=set replaces every record at the same timestamp. At billing +# (scripts/subscriptions.star) the metered invoice line sums the records of +# the billed window — or takes the last record ever when the price sets +# aggregate_usage=last_ever. This simulator also exposes a LIST endpoint at +# GET /v1/subscription_items/{id}/usage_records (newest first); real Stripe +# only offers period summaries (usage_record_summaries) there. +# +# Item endpoints operate on stored state directly (they do not run the +# subscription lifecycle derivation — reads of /v1/subscriptions do). +# Shared helpers (_require_auth, _next_id, _not_found, _get_query, +# _newest_first, _list_page, _idempotent_lookup, _idempotent_remember, _now, +# _num, _signed_emit) are in lib.star. + +# _si_bad_body reports a malformed JSON body authoritatively (req.body +# arrives as an empty dict for unparseable bodies; req.raw_body is the truth). +def _si_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +def _si_missing(param): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) + +# _si_last_item_error is the real Stripe 400 for deleting the last item on a +# subscription. +def _si_last_item_error(): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Could not delete the last subscription item on a subscription. Cancel the subscription instead using the cancel API.", "param": "subscription"}}) + +# _si_find locates the subscription doc containing item id. Returns +# (sub_doc, index) or (None, -1). +def _si_find(item_id): + subs = store_collection("subscriptions").list() + for i in range(len(subs)): + items = subs[i].get("items", []) + if items == None: + continue + for j in range(len(items)): + if items[j].get("id", "") == item_id: + return subs[i], j + return None, -1 + +# GET /v1/subscription_items?subscription= — list a subscription's items, +# projected from the embedded items array (newest subscription first, items +# in creation order, cursor pagination). +def on_list_subscription_items(req): + err = _require_auth(req) + if err != None: + return err + + sub_id = _get_query(req, "subscription") + if sub_id == "": + return _si_missing("subscription") + sub = store_collection("subscriptions").get(sub_id) + if sub == None: + return _not_found("subscription", sub_id) + + docs = [] + items = sub.get("items", []) + if items != None: + for j in range(len(items)): + docs.append(items[j]) + page, has_more, e = _list_page(req, docs, "subscription_item") + if e != None: + return e + return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/subscription_items"}) + +# POST /v1/subscription_items — add an item to a subscription +# {subscription, price, quantity, tax_rates, proration_behavior}. +# proration_behavior is accepted (always|always_invoice|create_prorations| +# none) and treated as "none": no proration line is generated (documented +# simulator simplification). +def on_create_subscription_item(req): + err = _require_auth(req) + if err != None: + return err + + if _si_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + sub_id = body.get("subscription", None) + if sub_id == None or sub_id == "": + return _si_missing("subscription") + sub = store_collection("subscriptions").get(sub_id) + if sub == None: + return _not_found("subscription", sub_id) + + price_id = body.get("price", None) + if price_id == None or price_id == "": + return _si_missing("price") + price = store_collection("prices").get(price_id) + if price == None: + return _not_found("price", price_id) + if price.get("recurring", None) == None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "The price specified is set to `type=one_time` but this field only accepts prices with `type=recurring`.", "param": "price"}}) + + qty = _num(body.get("quantity", 1)) + if qty < 1: + qty = 1 + tr = body.get("tax_rates", []) + if tr == None: + tr = [] + + item = { + "id": _next_id("si"), + "object": "subscription_item", + "created": _now(), + "price": price, + "quantity": qty, + "subscription": sub_id, + "tax_rates": tr, + } + items = sub.get("items", []) + if items == None: + items = [] + items.append(item) + sub["items"] = items + store_collection("subscriptions").update(sub_id, sub) + _signed_emit("customer.subscription.updated", _sub_items_public(sub)) + return respond(201, item) + +# _sub_items_public strips the internal "_" keys of a subscription doc so the +# customer.subscription.updated event carries the public object. +def _sub_items_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# POST /v1/subscription_items/{id} — update an item {quantity, metadata, +# tax_rates, proration_behavior (accepted, treated as none)}. +def on_update_subscription_item(req): + err = _require_auth(req) + if err != None: + return err + + if _si_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + id = req["params"]["id"] + sub, idx = _si_find(id) + if sub == None: + return _not_found("subscription_item", id) + + body = req["body"] + if body == None: + body = {} + item = sub["items"][idx] + if body.get("quantity", None) != None: + q = _num(body.get("quantity", 1)) + if q < 1: + q = 1 + item["quantity"] = q + if body.get("metadata", None) != None: + item["metadata"] = body["metadata"] + if body.get("tax_rates", None) != None: + tr = body.get("tax_rates", []) + if tr == None: + tr = [] + item["tax_rates"] = tr + sub["items"][idx] = item + store_collection("subscriptions").update(sub["id"], sub) + _signed_emit("customer.subscription.updated", _sub_items_public(sub)) + return respond(200, item) + +# DELETE /v1/subscription_items/{id} — remove an item from its subscription. +# The last remaining item cannot be deleted (real Stripe 400); cancel the +# subscription instead. Returns the deleted-object shape. +def on_delete_subscription_item(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + sub, idx = _si_find(id) + if sub == None: + return _not_found("subscription_item", id) + + items = sub.get("items", []) + if items == None or len(items) <= 1: + return _si_last_item_error() + keep = [] + for j in range(len(items)): + if j == idx: + continue + keep.append(items[j]) + sub["items"] = keep + store_collection("subscriptions").update(sub["id"], sub) + _signed_emit("customer.subscription.updated", _sub_items_public(sub)) + return respond(200, {"id": id, "object": "subscription_item", "deleted": True}) + +# --- Usage records (metered billing) --- + +# _siur_item_metered reports whether the item's price bills metered usage. +def _siur_item_metered(item): + price = item.get("price", None) + if price == None: + return False + rec = price.get("recurring", None) + if rec == None: + return False + return rec.get("usage_type", "licensed") == "metered" + +# POST /v1/subscription_items/{id}/usage_records — report usage +# {quantity (required), timestamp (default now), action increment|set}. +# Future timestamps are rejected like the real API; "set" overwrites every +# record at the same timestamp, "increment" (the default) appends. +def on_create_usage_record(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "usage_records") + if cached != None: + return respond(cached["status"], cached["doc"]) + + if _si_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + id = req["params"]["id"] + sub, idx = _si_find(id) + if sub == None: + return _not_found("subscription_item", id) + item = sub["items"][idx] + if not _siur_item_metered(item): + return respond(400, {"error": {"type": "invalid_request_error", "message": "The subscription item's price has usage_type=licensed and does not accept usage records.", "param": "subscription_item"}}) + + quantity = body.get("quantity", None) + if quantity == None: + return _si_missing("quantity") + qty = _num(quantity) + if qty < 0: + return respond(400, {"error": {"code": "parameter_invalid_integer", "type": "invalid_request_error", "message": "Invalid integer: " + str(quantity), "param": "quantity"}}) + + action = body.get("action", "increment") + if action != "increment" and action != "set": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid action: must be one of increment or set.", "param": "action"}}) + + ts = body.get("timestamp", None) + if ts == None or ts == "now": + ts = _now() + ts_n = _num(ts) + if ts_n <= 0: + return respond(400, {"error": {"code": "parameter_invalid_integer", "type": "invalid_request_error", "message": "Invalid integer: " + str(ts), "param": "timestamp"}}) + if ts_n > _now(): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Usage record timestamp must not be in the future.", "param": "timestamp"}}) + + if action == "set": + stale = query_select(store_collection("usage_records").list(), [["subscription_item", "=", id]]) + for i in range(len(stale)): + if _num(stale[i].get("timestamp", 0)) == ts_n: + store_collection("usage_records").delete(stale[i]["id"]) + + doc = { + "id": _next_id("iid"), + "object": "usage_record", + "livemode": False, + "quantity": qty, + "subscription_item": id, + "timestamp": ts_n, + } + store_collection("usage_records").insert(doc) + _idempotent_remember(req, "usage_records", 201, doc["id"]) + return respond(201, doc) + +# GET /v1/subscription_items/{id}/usage_records — list the item's usage +# records, newest first (simulator extension; real Stripe exposes period +# summaries instead). +def on_list_usage_records(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + sub, idx = _si_find(id) + if sub == None: + return _not_found("subscription_item", id) + + docs = query_select(store_collection("usage_records").list(), [["subscription_item", "=", id]]) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "usage_record") + if e != None: + return e + return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/subscription_items/" + id + "/usage_records"}) diff --git a/adapters/stripe-style/scripts/subscriptions.star b/adapters/stripe-style/scripts/subscriptions.star new file mode 100644 index 00000000..c9518082 --- /dev/null +++ b/adapters/stripe-style/scripts/subscriptions.star @@ -0,0 +1,1040 @@ +# Subscription handlers — Stripe Billing subscriptions +# (docs.stripe.com/api/subscriptions) plus the lifecycle engine that turns a +# subscription's items into invoices on the clock. +# +# MODEL +# The subscription doc carries its items EMBEDDED (SUBSCRIPTION DOC +# CONTRACT); /v1/subscription_items endpoints project from them. Metered +# usage lives in the usage_records collection keyed by subscription_item id +# (scripts/subscription_items.star). +# +# Lifecycle is DERIVED ON READ: every subscription read (single or list) +# first runs _advance_subscription, which — while _now() has passed a +# billing boundary — +# * converts a finished trial (status trialing -> active/past_due, first +# invoice covering [trial_end, trial_end + interval)), +# * cancels at current_period_end when cancel_at_period_end is set +# (status canceled + customer.subscription.deleted), or +# * renews (period += interval, new invoice via lib._subscription_invoice, +# auto-charged per the card-behavior rules: decline card -> past_due + +# open invoice + invoice.payment_failed + charge.failed; no payment +# method -> past_due + open invoice; success -> paid invoice + +# charge.succeeded + balance transaction). +# State is persisted BEFORE any event fires; each transition emits once. +# past_due subscriptions freeze (dunning/retries are not simulated). +# +# Documented simplifications vs real Stripe: proration_behavior is accepted +# but always treated as "none"; a failed first payment at creation yields +# status past_due (real Stripe: incomplete under the default +# payment_behavior); metered lines are billed strictly in arrears (skipped +# when zero usage was reported); coupon duration "repeating" ends after +# duration_in_months invoices (one per period). +# +# Shared helpers (_require_auth, _next_id, _not_found, _get_query, +# _created_filters, _created_check, _newest_first, _list_page, +# _idempotent_lookup, _idempotent_remember, _now, _num, _to_int, _usd, +# _card_number_for, _card_outcome, _charge_settle_hooks, _subscription_invoice, +# _invoice_public, _add_months, _signed_emit) are in lib.star. + +# _sub_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is +# the source of truth. +def _sub_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +def _sub_missing(param): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) + +# _sub_last_item_error is the real Stripe 400 for deleting the last item. +def _sub_last_item_error(): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Could not delete the last subscription item on a subscription. Cancel the subscription instead using the cancel API.", "param": "subscription"}}) + +# _sub_no_pm_error is the real Stripe 400 for creating a charge_automatically +# subscription when neither the subscription nor the customer has a payment +# method (webhook receivers see this verbatim in the wild). +def _sub_no_pm_error(): + return respond(400, {"error": {"type": "invalid_request_error", "message": "This customer has no attached payment source or default payment method. Please consider adding a default payment method."}}) + +def _sub_get(id): + return store_collection("subscriptions").get(id) + +# _sub_public renders the stored subscription doc (docs.stripe.com/api/ +# subscriptions/object), stripping internal "_" keys. Items are embedded on +# the doc in public shape already. +def _sub_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _sub_interval returns (interval, interval_count) of the first item's price. +_SUB_WEEK = 7 * 24 * 3600 +_SUB_DAY = 24 * 3600 + +def _sub_interval(doc): + items = doc.get("items", []) + if items == None or len(items) == 0: + return "month", 1 + price = items[0].get("price", None) + if price == None: + return "month", 1 + rec = price.get("recurring", None) + if rec == None: + return "month", 1 + count = _num(rec.get("interval_count", 1)) + if count < 1: + count = 1 + return rec.get("interval", "month"), count + +# _sub_add_period shifts ts forward one billing period. +def _sub_add_period(ts, interval, count): + if interval == "day": + return ts + _SUB_DAY * count + if interval == "week": + return ts + _SUB_WEEK * count + if interval == "year": + return _add_months(ts, 12 * count) + return _add_months(ts, count) + +# --- usage aggregation (usage_records collection, written by +# subscription_items.star) --- + +def _sub_usage_records(item_id): + docs = store_collection("usage_records").list() + return query_select(docs, [["subscription_item", "=", item_id]]) + +# _sub_usage_quantity aggregates the usage records of one metered item over +# [m_start, m_end): summed quantities (Stripe's default aggregate and the +# simulator's fallback), or the quantity of the last record ever when the +# price sets aggregate_usage=last_ever. +def _sub_usage_quantity(item_id, m_start, m_end, aggregate): + recs = _sub_usage_records(item_id) + if len(recs) == 0: + return 0 + if aggregate == "last_ever": + best = None + best_ts = -1 + for i in range(len(recs)): + ts = _num(recs[i].get("timestamp", 0)) + if ts >= best_ts: + best_ts = ts + best = recs[i] + if best == None: + return 0 + return _num(best.get("quantity", 0)) + total = 0 + for i in range(len(recs)): + ts = _num(recs[i].get("timestamp", 0)) + if ts >= m_start and ts < m_end: + total = total + _num(recs[i].get("quantity", 0)) + return total + +# _sub_unit_label renders the "/ month" style suffix of a line description. +def _sub_unit_label(interval, count): + if count > 1: + return " / " + str(count) + " " + interval + "s" + return " / " + interval + +# _sub_lines builds the lib._subscription_invoice line dicts for one cycle: +# licensed items -> one line each, period [p_start, p_end), upfront; +# metered items -> one line each for the usage reported in [m_start, +# p_start) (billed in arrears; skipped at zero usage). +def _sub_lines(doc, p_start, p_end, m_start): + lines = [] + items = doc.get("items", []) + if items == None: + return lines + for i in range(len(items)): + item = items[i] + price = item.get("price", None) + if price == None: + continue + amount = _num(price.get("unit_amount", 0)) + interval, count = "month", 1 + usage_type = "licensed" + aggregate = None + rec = price.get("recurring", None) + if rec != None: + interval = rec.get("interval", "month") + count = _num(rec.get("interval_count", 1)) + usage_type = rec.get("usage_type", "licensed") + aggregate = rec.get("aggregate_usage", None) + prod = store_collection("products").get(price.get("product", "")) + name = price.get("id", "") + if prod != None and prod.get("name", None) != None: + name = prod["name"] + if usage_type == "metered": + qty = _sub_usage_quantity(item.get("id", ""), m_start, p_start, aggregate) + if qty <= 0: + continue + lines.append({ + "type": "subscription", + "description": "Usage-based " + name + " (at " + _usd(amount) + _sub_unit_label(interval, count) + ")", + "amount": amount, + "quantity": qty, + "period": {"start": m_start, "end": p_start}, + "price": price, + }) + else: + qty = _num(item.get("quantity", 1)) + if qty < 1: + qty = 1 + lines.append({ + "type": "subscription", + "description": str(qty) + " × " + name + " (at " + _usd(amount) + _sub_unit_label(interval, count) + ")", + "amount": amount, + "quantity": qty, + "period": {"start": p_start, "end": p_end}, + "price": price, + }) + return lines + +def _sub_subtotal(lines): + subtotal = 0 + for i in range(len(lines)): + subtotal = subtotal + _num(lines[i].get("amount", 0)) * _num(lines[i].get("quantity", 1)) + return subtotal + +# _sub_discount_amt computes the discount cents for one invoice over +# `subtotal` (COUPON CONTRACT): percent_off -> int(subtotal*pct/100 + 0.5); +# amount_off capped at the subtotal. +def _sub_discount_amt(doc, subtotal): + d = doc.get("discount", None) + if d == None: + return 0 + pct = d.get("percent_off", None) + if pct != None and _num(pct) > 0: + return int(subtotal * _num(pct) / 100.0 + 0.5) + amt = _num(d.get("amount_off", 0)) + if amt > subtotal: + return subtotal + return amt + +# _sub_tax computes (tax_cents, inclusive) over the post-discount base from +# the subscription's default_tax_rates (TAX RATE CONTRACT): cents per rate = +# int(base * percentage / 100.0 + 0.5); exclusive rates add to the total, +# inclusive rates are shown only. With any exclusive rate present the +# invoice is treated as tax-exclusive overall (mixed sets collapse — the +# shared lib helper takes a single inclusive flag). +def _sub_tax(doc, base): + rates = doc.get("default_tax_rates", []) + if rates == None or len(rates) == 0: + return 0, True + tax = 0 + inclusive = True + for i in range(len(rates)): + r = store_collection("tax_rates").get(rates[i]) + if r == None: + continue + pct = r.get("percentage", 0) + tax = tax + int(base * _num(pct) / 100.0 + 0.5) + if r.get("inclusive", False) != True: + inclusive = False + return tax, inclusive + +# _sub_pm_id resolves the payment method that pays this subscription's +# invoices: the subscription's default_payment_method first, then the +# customer's invoice_settings.default_payment_method / default_source. +def _sub_pm_id(doc): + pm = doc.get("default_payment_method", None) + if pm != None and pm != "": + return pm + cust = store_collection("customers").get(doc.get("customer", "")) + if cust == None: + return None + ins = cust.get("invoice_settings", None) + if ins != None and type(ins) == "dict": + cpm = ins.get("default_payment_method", None) + if cpm != None and cpm != "": + return cpm + dsrc = cust.get("default_source", None) + if dsrc != None and dsrc != "": + return dsrc + return None + +def _sub_pm_exists(pm_id): + if pm_id == None or pm_id == "": + return False + if store_collection("payment_methods").get(pm_id) != None: + return True + if store_collection("tokens").get(pm_id) != None: + return True + return False + +# _sub_mark_paid mutates an invoice doc to its paid state. +def _sub_mark_paid(inv, now): + inv["status"] = "paid" + inv["paid"] = True + inv["attempted"] = True + inv["amount_paid"] = _num(inv.get("total", 0)) + inv["amount_remaining"] = 0 + st = inv.get("status_transitions", None) + if st == None: + st = {} + st["paid_at"] = now + inv["status_transitions"] = st + return inv + +# _sub_charge creates the charge object for a subscription invoice payment +# and records its balance transaction via the shared settlement hooks (fee + +# dispute test-card behavior included). Persisted here. Returns the charge. +def _sub_charge(doc, inv, number, outcome): + now = _now() + ch = { + "id": _next_id("ch"), + "object": "charge", + "amount": _num(inv.get("total", 0)), + "currency": inv.get("currency", "usd"), + "customer": doc.get("customer", None), + "description": None, + "invoice": inv.get("id", None), + "subscription": doc.get("id", None), + "refunded": False, + "created": now, + } + if outcome != None: + # Decline (or an SCA card, which cannot complete off-session): the + # charge object is still recorded with status failed, like real + # Stripe, and the subscription moves to past_due. + ch["status"] = "failed" + ch["captured"] = False + ch["failure_code"] = outcome.get("decline_code", "card_declined") + ch["failure_message"] = outcome.get("message", "Your card was declined.") + store_collection("charges").insert(ch) + return ch + ch["status"] = "succeeded" + ch["captured"] = True + ch["balance_transaction"] = None + ch["dispute"] = None + store_collection("charges").insert(ch) + _charge_settle_hooks(ch, {}, number) + return ch + +# _sub_attempt_invoice attempts payment of an OPEN invoice per the contract: +# total <= 0 -> paid, no charge +# collection_method send_invoice -> left open with a due_date +# no resolvable payment method -> past_due, invoice stays open, +# invoice.payment_failed +# decline/SCA card -> failed charge + past_due + open +# invoice + charge.failed + +# invoice.payment_failed +# otherwise -> paid + charge.succeeded + +# invoice.paid/payment_succeeded +# Every mutation is persisted BEFORE its events fire. `announce` controls the +# customer.subscription.updated emission (renewals announce; the creation +# path emits customer.subscription.created instead). +def _sub_attempt_invoice(doc, inv, announce): + subs = store_collection("subscriptions") + invs = store_collection("invoices") + now = _now() + total = _num(inv.get("total", 0)) + doc["latest_invoice"] = inv.get("id", None) + + if total <= 0: + _sub_mark_paid(inv, now) + invs.update(inv["id"], inv) + if doc.get("status", "") == "trialing": + doc["status"] = "active" + subs.update(doc["id"], doc) + pub = _invoice_public(inv) + _signed_emit("invoice.paid", pub) + _signed_emit("invoice.payment_succeeded", pub) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return + + if doc.get("collection_method", "charge_automatically") == "send_invoice": + days = _num(doc.get("days_until_due", 0)) + if days <= 0: + days = 30 + inv["due_date"] = now + days * _SUB_DAY + invs.update(inv["id"], inv) + if doc.get("status", "") == "trialing": + doc["status"] = "active" + subs.update(doc["id"], doc) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return + + pm = _sub_pm_id(doc) + if pm == None: + inv["attempted"] = True + invs.update(inv["id"], inv) + doc["status"] = "past_due" + subs.update(doc["id"], doc) + _signed_emit("invoice.payment_failed", _invoice_public(inv)) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return + + number = _card_number_for(pm) + outcome = _card_outcome(number) + if outcome != None and outcome.get("kind", "") == "sca_sdk": + outcome = {"kind": "decline", "decline_code": "authentication_required", "message": "This payment requires authentication to complete."} + if outcome != None and outcome.get("kind", "") == "sca_redirect": + outcome = {"kind": "decline", "decline_code": "authentication_required", "message": "This payment requires authentication to complete."} + if outcome != None: + ch = _sub_charge(doc, inv, number, outcome) + inv["attempted"] = True + invs.update(inv["id"], inv) + doc["status"] = "past_due" + subs.update(doc["id"], doc) + _signed_emit("charge.failed", ch) + _signed_emit("invoice.payment_failed", _invoice_public(inv)) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return + + ch = _sub_charge(doc, inv, number, None) + _sub_mark_paid(inv, now) + inv["charge"] = ch["id"] + invs.update(inv["id"], inv) + if doc.get("status", "") == "trialing": + doc["status"] = "active" + elif doc.get("status", "") == "past_due": + doc["status"] = "active" + subs.update(doc["id"], doc) + _signed_emit("charge.succeeded", ch) + pub = _invoice_public(inv) + _signed_emit("invoice.paid", pub) + _signed_emit("invoice.payment_succeeded", pub) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + +# _sub_drop_discount removes a spent discount before invoice n is built +# (COUPON CONTRACT): duration "once" discounts only the first invoice; +# "repeating" lasts duration_in_months invoices. +def _sub_drop_discount(doc, n): + d = doc.get("discount", None) + if d == None: + return + duration = d.get("duration", "once") + if duration == "once" and n > 1: + doc["discount"] = None + elif duration == "repeating": + months = _num(d.get("duration_in_months", 0)) + if months > 0 and n > months: + doc["discount"] = None + +# _sub_issue_invoice builds, persists and pays the invoice for one billing +# cycle (lib._subscription_invoice emits invoice.created), then attempts +# payment. n is the invoice ordinal (1 = first invoice). announce controls +# the customer.subscription.updated emission (creation announces +# customer.subscription.created instead). +def _sub_issue_invoice(doc, p_start, p_end, m_start, n, announce): + lines = _sub_lines(doc, p_start, p_end, m_start) + subtotal = _sub_subtotal(lines) + disc = _sub_discount_amt(doc, subtotal) + tax, inclusive = _sub_tax(doc, subtotal - disc) + inv = _subscription_invoice(doc, lines, disc, tax, inclusive) + if n <= 1 and doc.get("collection_method", "charge_automatically") == "send_invoice": + # send_invoice subscriptions start with a DRAFT first invoice that is + # emailed to the customer later; renewals finalize to open + due_date. + inv["status"] = "draft" + store_collection("invoices").update(inv["id"], inv) + doc["latest_invoice"] = inv["id"] + if doc.get("status", "") == "trialing": + doc["status"] = "active" + store_collection("subscriptions").update(doc["id"], doc) + if announce: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return + _sub_attempt_invoice(doc, inv, announce) + +# _sub_renew moves the subscription into the cycle that starts at p_start +# (metered usage reported in [m_start, p_start) is billed on this invoice), +# persists the period fields, then issues + attempts the invoice. Returns +# without invoicing when the subscription should end instead. +def _sub_renew(doc, p_start, m_start): + interval, count = _sub_interval(doc) + p_end = _sub_add_period(p_start, interval, count) + n = _num(doc.get("_period_no", 1)) + 1 + doc["_period_no"] = n + doc["current_period_start"] = p_start + doc["current_period_end"] = p_end + if doc.get("status", "") == "trialing": + doc["status"] = "active" + _sub_drop_discount(doc, n) + store_collection("subscriptions").update(doc["id"], doc) + _sub_issue_invoice(doc, p_start, p_end, m_start, n, True) + +# _advance_subscription derives a subscription's state from the clock +# (derive-on-read). Call before ANY read or mutation. Idempotent: settled +# boundaries never re-fire (the period advances, the status flips, or the +# subscription cancels on the first pass). +def _advance_subscription(doc): + if doc.get("status", "") == "canceled": + return doc + rounds = 0 + while rounds < 120: + rounds = rounds + 1 + now = _now() + status = doc.get("status", "") + if status == "past_due": + # Frozen: payment retries/dunning are not simulated. Updating + # default_payment_method re-attempts the open invoice. + break + if status == "trialing": + te = _num(doc.get("trial_end", 0)) + if te <= 0 or now < te: + break + _sub_renew(doc, te, _num(doc.get("start_date", 0))) + continue + cpe = _num(doc.get("current_period_end", 0)) + if cpe <= 0 or now < cpe: + break + if doc.get("cancel_at_period_end", False) == True: + doc["status"] = "canceled" + doc["canceled_at"] = cpe + doc["ended_at"] = cpe + doc["cancel_at_period_end"] = False + store_collection("subscriptions").update(doc["id"], doc) + _signed_emit("customer.subscription.deleted", _sub_public(doc)) + break + _sub_renew(doc, cpe, _num(doc.get("current_period_start", 0))) + return doc + +# _sub_new_item builds an embedded subscription item (SUBSCRIPTION DOC +# CONTRACT) around a stored price doc. +def _sub_new_item(sub_id, price_doc, quantity, tax_rates): + return { + "id": _next_id("si"), + "object": "subscription_item", + "created": _now(), + "price": price_doc, + "quantity": quantity, + "subscription": sub_id, + "tax_rates": tax_rates, + } + +# _sub_items_from_body validates the create/update items array against the +# prices collection. Returns (items, error_response). Legacy top-level +# price+quantity is normalized into a single-item array by the caller. +def _sub_items_from_body(sub_id, raw_items): + out = [] + for i in range(len(raw_items)): + e = raw_items[i] + if e == None: + continue + price_id = e.get("price", None) + if price_id == None or price_id == "": + return None, _sub_missing("items[" + str(i) + "][price]") + price_doc = store_collection("prices").get(price_id) + if price_doc == None: + return None, _not_found("price", price_id) + rec = price_doc.get("recurring", None) + if rec == None: + return None, respond(400, {"error": {"type": "invalid_request_error", "message": "The price specified is set to `type=one_time` but this field only accepts prices with `type=recurring`.", "param": "items[" + str(i) + "][price]"}}) + qty = _num(e.get("quantity", 1)) + if qty < 1: + qty = 1 + tr = e.get("tax_rates", []) + if tr == None: + tr = [] + out.append(_sub_new_item(sub_id, price_doc, qty, tr)) + if len(out) == 0: + return None, _sub_missing("items") + return out, None + +# _sub_discount_from_body resolves the coupon / promotion_code request +# params into the discount object stored on the subscription (COUPON +# CONTRACT: the coupon's public fields plus the promotion_code id). Reads +# the coupons/promotion_codes collections written by the billing domain. +def _sub_discount_from_body(body): + coupon_id = body.get("coupon", None) + pc_id = body.get("promotion_code", None) + if coupon_id == None and pc_id == None: + return None, None + promo = None + if pc_id != None and pc_id != "": + pc = store_collection("promotion_codes").get(pc_id) + if pc == None: + pcs = query_select(store_collection("promotion_codes").list(), [["code", "=", pc_id]]) + if len(pcs) > 0: + pc = pcs[0] + if pc == None: + return None, _not_found("promotion_code", pc_id) + promo = pc.get("id", None) + coupon_id = pc.get("coupon", None) + if coupon_id == None or coupon_id == "": + return None, _sub_missing("coupon") + c = store_collection("coupons").get(coupon_id) + if c == None: + return None, _not_found("coupon", coupon_id) + d = {} + for k in c: + if k.startswith("_"): + continue + d[k] = c[k] + d["promotion_code"] = promo + return d, None + +# _sub_tax_rates_from_body validates the default_tax_rates array against the +# tax_rates collection. +def _sub_tax_rates_from_body(body): + raw = body.get("default_tax_rates", None) + if raw == None: + return [], None + if type(raw) != "list": + return None, respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid array: default_tax_rates must be an array of tax rate IDs.", "param": "default_tax_rates"}}) + out = [] + for i in range(len(raw)): + rid = raw[i] + if store_collection("tax_rates").get(rid) == None: + return None, _not_found("tax_rate", rid) + out.append(rid) + return out, None + +# POST /v1/subscriptions — create a subscription (docs.stripe.com/api/ +# subscriptions/create). items[{price, quantity}] (or legacy top-level +# price+quantity), default_payment_method, cancel_at_period_end, trial_end, +# collection_method, coupon / promotion_code, default_tax_rates, +# billing_cycle_anchor, days_until_due, test_clock, metadata. +# +# charge_automatically + no trial: invoice #1 is created and paid inline per +# the card rules (valid card -> paid; decline card -> past_due with the +# failed charge recorded; NO payment method at all -> the real Stripe 400 +# and nothing is created). send_invoice: draft invoice #1, active +# subscription. trialing: no invoice until the trial ends. +def on_create_subscription(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "subscriptions") + if cached != None: + return respond(cached["status"], _sub_public(cached["doc"])) + + if _sub_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + + customer = body.get("customer", None) + if customer == None or customer == "": + return _sub_missing("customer") + if store_collection("customers").get(customer) == None: + return _not_found("customer", customer) + + raw_items = body.get("items", None) + if raw_items == None: + price_id = body.get("price", None) + if price_id == None or price_id == "": + return _sub_missing("items") + raw_items = [{"price": price_id, "quantity": body.get("quantity", 1)}] + if type(raw_items) != "list": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid array: items must be an array of subscription items.", "param": "items"}}) + + sub_id = _next_id("sub") + items, ierr = _sub_items_from_body(sub_id, raw_items) + if ierr != None: + return ierr + + discount, derr = _sub_discount_from_body(body) + if derr != None: + return derr + tax_rates, terr = _sub_tax_rates_from_body(body) + if terr != None: + return terr + + collection_method = body.get("collection_method", "charge_automatically") + if collection_method != "charge_automatically" and collection_method != "send_invoice": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid collection_method: must be one of charge_automatically or send_invoice.", "param": "collection_method"}}) + + now = _now() + anchor = body.get("billing_cycle_anchor", None) + if anchor == None or _num(anchor) <= 0: + anchor = now + anchor = _num(anchor) + + trial_end = body.get("trial_end", None) + trial_period_days = body.get("trial_period_days", None) + if trial_end == "now": + trial_end = None + if trial_end != None and _num(trial_end) <= 0: + trial_end = None + if trial_end == None and trial_period_days != None and _num(trial_period_days) > 0: + trial_end = anchor + _num(trial_period_days) * _SUB_DAY + trialing = trial_end != None and _num(trial_end) > now + + interval, count = _sub_interval({"items": items}) + period_end = _sub_add_period(anchor, interval, count) + + pm = body.get("default_payment_method", None) + if pm != None and pm != "" and not _sub_pm_exists(pm): + return _not_found("payment_method", pm) + if pm == None or pm == "": + pm = None + + status = "active" + if trialing: + status = "trialing" + elif collection_method == "charge_automatically" and _sub_pm_id({"customer": customer, "default_payment_method": pm}) == None: + # Real Stripe rejects the create outright when there is nothing to + # charge and no trial to defer to. + return _sub_no_pm_error() + + days_until_due = body.get("days_until_due", None) + + doc = { + "id": sub_id, + "object": "subscription", + "application": None, + "automatic_tax": {"enabled": False, "liability": None}, + "billing_cycle_anchor": anchor, + "current_period_start": anchor, + "current_period_end": period_end, + "cancel_at": None, + "cancel_at_period_end": body.get("cancel_at_period_end", False) == True, + "canceled_at": None, + "collection_method": collection_method, + "created": now, + "currency": items[0].get("price", {}).get("currency", "usd"), + "customer": customer, + "days_until_due": days_until_due, + "default_payment_method": pm, + "default_source": None, + "default_tax_rates": tax_rates, + "description": body.get("description", None), + "discount": discount, + "ended_at": None, + "items": items, + "latest_invoice": None, + "livemode": False, + "metadata": body.get("metadata", {}), + "next_pending_invoice_item_invoice": None, + "on_behalf_of": None, + "pause_collection": None, + "payment_settings": {"payment_method_options": None, "payment_method_types": None, "save_default_payment_method": "off"}, + "pending_update": None, + "schedule": None, + "start_date": now, + "status": status, + "test_clock": body.get("test_clock", None), + "trial_end": trial_end, + "trial_start": None, + "_period_no": 1, + } + if trialing: + doc["trial_start"] = now + doc["_period_no"] = 0 + store_collection("subscriptions").insert(doc) + _idempotent_remember(req, "subscriptions", 201, sub_id) + _signed_emit("customer.subscription.created", _sub_public(doc)) + return respond(201, _sub_public(doc)) + + store_collection("subscriptions").insert(doc) + _idempotent_remember(req, "subscriptions", 201, sub_id) + _signed_emit("customer.subscription.created", _sub_public(doc)) + # First invoice for [anchor, period_end); no prior usage exists, so the + # metered window is empty. + _sub_issue_invoice(doc, anchor, period_end, anchor, 1, False) + return respond(201, _sub_public(doc)) + +# GET /v1/subscriptions/{id} — retrieve a subscription (advanced first). +def on_retrieve_subscription(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _sub_get(id) + if doc == None: + return _not_found("subscription", id) + doc = _advance_subscription(doc) + return respond(200, _sub_public(doc)) + +# _sub_filters maps the subscription-list query params (customer, status, +# created exact/range) to query_select clauses; the price filter (any item on +# the subscription using that price) is applied afterwards by hand because +# query_select cannot look inside the embedded items array. +def _sub_filters(req, docs): + f = [] + cust = _get_query(req, "customer") + if cust != "": + f.append(["customer", "=", cust]) + status = _get_query(req, "status") + if status != "": + f.append(["status", "=", status]) + _created_filters(req, f) + if len(f) > 0: + docs = query_select(docs, f) + price = _get_query(req, "price") + if price != "": + out = [] + for i in range(len(docs)): + items = docs[i].get("items", []) + if items == None: + continue + for j in range(len(items)): + p = items[j].get("price", None) + if p != None and p.get("id", "") == price: + out.append(docs[i]) + break + docs = out + return docs + +# GET /v1/subscriptions — list subscriptions. Every subscription is advanced +# first (derive-on-read), then filtered (customer, status, price, created), +# newest-first, cursor paginated. +def on_list_subscriptions(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("subscriptions").list() + for i in range(len(docs)): + _advance_subscription(docs[i]) + docs = _sub_filters(req, docs) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "subscription") + if e != None: + return e + return respond(200, {"object": "list", "data": [_sub_public(d) for d in page], "has_more": has_more, "url": "/v1/subscriptions"}) + +# _sub_retry_open_invoice re-attempts the subscription's open invoice after +# the caller set a new default payment method (past_due recovery). +def _sub_retry_open_invoice(doc): + inv_id = doc.get("latest_invoice", None) + if inv_id == None: + return + inv = store_collection("invoices").get(inv_id) + if inv == None: + return + if inv.get("status", "") != "open": + return + if _num(inv.get("amount_remaining", 0)) <= 0: + return + _sub_attempt_invoice(doc, inv, True) + +# POST /v1/subscriptions/{id} — update a subscription: metadata, +# cancel_at_period_end, default_payment_method, collection_method, +# days_until_due, coupon/promotion_code, default_tax_rates and the items +# array (quantity changes, new items, removals via deleted=true — validated +# so a subscription never drops its last item). proration_behavior is +# accepted (always|always_invoice|create_prorations|none) and treated as +# "none": no proration lines are generated. +def on_update_subscription(req): + err = _require_auth(req) + if err != None: + return err + + if _sub_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + id = req["params"]["id"] + doc = _sub_get(id) + if doc == None: + return _not_found("subscription", id) + doc = _advance_subscription(doc) + if doc.get("status", "") == "canceled": + # Canceled subscriptions are immutable except metadata (real Stripe + # allows metadata updates on canceled subscriptions). + body = req["body"] + if body != None and body.get("metadata", None) != None: + doc["metadata"] = body["metadata"] + store_collection("subscriptions").update(id, doc) + return respond(200, _sub_public(doc)) + + body = req["body"] + if body == None: + body = {} + changed = False + + if body.get("metadata", None) != None: + doc["metadata"] = body["metadata"] + changed = True + if body.get("cancel_at_period_end", None) != None: + want = body.get("cancel_at_period_end", False) == True + if doc.get("cancel_at_period_end", False) != want: + doc["cancel_at_period_end"] = want + changed = True + if body.get("collection_method", None) != None: + cm = body.get("collection_method", "") + if cm != "charge_automatically" and cm != "send_invoice": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid collection_method: must be one of charge_automatically or send_invoice.", "param": "collection_method"}}) + if doc.get("collection_method", "") != cm: + doc["collection_method"] = cm + changed = True + if body.get("days_until_due", None) != None: + doc["days_until_due"] = body["days_until_due"] + changed = True + if body.get("description", None) != None: + doc["description"] = body["description"] + changed = True + if body.get("default_tax_rates", None) != None: + tax_rates, terr = _sub_tax_rates_from_body(body) + if terr != None: + return terr + doc["default_tax_rates"] = tax_rates + changed = True + if body.get("coupon", None) != None or body.get("promotion_code", None) != None: + if body.get("coupon", "") == "" and body.get("promotion_code", "") == "": + doc["discount"] = None + changed = True + else: + discount, derr = _sub_discount_from_body(body) + if derr != None: + return derr + doc["discount"] = discount + changed = True + + pm_set = False + if body.get("default_payment_method", None) != None: + pm = body.get("default_payment_method", "") + if pm != "" and not _sub_pm_exists(pm): + return _not_found("payment_method", pm) + if pm == "": + pm = None + if doc.get("default_payment_method", None) != pm: + doc["default_payment_method"] = pm + changed = True + pm_set = True + + raw_items = body.get("items", None) + if raw_items != None: + if type(raw_items) != "list": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid array: items must be an array of subscription items.", "param": "items"}}) + # Removals first (deleted=true), then in-place updates, then appends. + keep = [] + removed = {} + for i in range(len(raw_items)): + e = raw_items[i] + if e == None: + continue + eid = e.get("id", None) + if eid != None and e.get("deleted", False) == True: + removed[eid] = True + existing = doc.get("items", []) + for rid in removed: + ok = False + for j in range(len(existing)): + if existing[j].get("id", "") == rid: + ok = True + break + if not ok: + return _not_found("subscription_item", rid) + for i in range(len(existing)): + it = existing[i] + if removed.get(it.get("id", ""), None) == True: + continue + keep.append(it) + if len(removed) > 0: + changed = True + for i in range(len(raw_items)): + e = raw_items[i] + if e == None: + continue + eid = e.get("id", None) + if eid != None and removed.get(eid, None) == True: + # already handled as a removal above + continue + if eid == None: + price_id = e.get("price", None) + if price_id == None or price_id == "": + return _sub_missing("items[" + str(i) + "][price]") + price_doc = store_collection("prices").get(price_id) + if price_doc == None: + return _not_found("price", price_id) + if price_doc.get("recurring", None) == None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "The price specified is set to `type=one_time` but this field only accepts prices with `type=recurring`.", "param": "items[" + str(i) + "][price]"}}) + qty = _num(e.get("quantity", 1)) + if qty < 1: + qty = 1 + tr = e.get("tax_rates", []) + if tr == None: + tr = [] + keep.append(_sub_new_item(doc["id"], price_doc, qty, tr)) + changed = True + continue + found = False + for j in range(len(keep)): + if keep[j].get("id", "") == eid: + found = True + if e.get("quantity", None) != None: + q = _num(e.get("quantity", 1)) + if q < 1: + q = 1 + if _num(keep[j].get("quantity", 1)) != q: + keep[j]["quantity"] = q + changed = True + if e.get("price", None) != None: + price_doc = store_collection("prices").get(e.get("price", "")) + if price_doc == None: + return _not_found("price", e.get("price", "")) + if price_doc.get("recurring", None) == None: + return respond(400, {"error": {"type": "invalid_request_error", "message": "The price specified is set to `type=one_time` but this field only accepts prices with `type=recurring`.", "param": "items[" + str(i) + "][price]"}}) + keep[j]["price"] = price_doc + changed = True + if e.get("tax_rates", None) != None: + tr = e.get("tax_rates", []) + if tr == None: + tr = [] + keep[j]["tax_rates"] = tr + changed = True + if e.get("metadata", None) != None: + keep[j]["metadata"] = e["metadata"] + changed = True + break + if not found: + return _not_found("subscription_item", eid) + if len(keep) == 0: + return _sub_last_item_error() + doc["items"] = keep + + store_collection("subscriptions").update(id, doc) + if changed: + _signed_emit("customer.subscription.updated", _sub_public(doc)) + if pm_set and doc.get("status", "") == "past_due": + _sub_retry_open_invoice(doc) + return respond(200, _sub_public(doc)) + +# POST /v1/subscriptions/{id}/cancel — cancel a subscription +# (docs.stripe.com/api/subscriptions/cancel). No parameters: immediate — +# status canceled, canceled_at/ended_at stamped, one +# customer.subscription.deleted event. at_period_end=true: sets +# cancel_at_period_end instead (the cancellation lands at the next billing +# boundary via _advance_subscription). invoice_now / prorate / +# cancellation_details are accepted and ignored. +def on_cancel_subscription(req): + err = _require_auth(req) + if err != None: + return err + + if _sub_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + id = req["params"]["id"] + doc = _sub_get(id) + if doc == None: + return _not_found("subscription", id) + doc = _advance_subscription(doc) + + body = req["body"] + if body == None: + body = {} + + if body.get("at_period_end", False) == True: + if doc.get("status", "") != "canceled" and doc.get("cancel_at_period_end", False) != True: + doc["cancel_at_period_end"] = True + store_collection("subscriptions").update(id, doc) + _signed_emit("customer.subscription.updated", _sub_public(doc)) + return respond(200, _sub_public(doc)) + + if doc.get("status", "") != "canceled": + now = _now() + doc["status"] = "canceled" + doc["canceled_at"] = now + doc["ended_at"] = now + doc["cancel_at_period_end"] = False + store_collection("subscriptions").update(id, doc) + _signed_emit("customer.subscription.deleted", _sub_public(doc)) + return respond(200, _sub_public(doc)) diff --git a/adapters/stripe-style/scripts/tax_rates.star b/adapters/stripe-style/scripts/tax_rates.star new file mode 100644 index 00000000..0dc5fc1c --- /dev/null +++ b/adapters/stripe-style/scripts/tax_rates.star @@ -0,0 +1,229 @@ +# Tax rate handler — manual tax rates applied to invoices and subscriptions +# (docs.stripe.com/api/tax_rates). +# +# {id txr_*, object "tax_rate", active, display_name, inclusive, +# jurisdiction, percentage float, description, metadata} (TAX RATE DOC +# CONTRACT). Tax cents per rate over a line amount are computed by the +# billing files as int(amount * percentage / 100.0 + 0.5); exclusive rates +# add to the invoice total, inclusive rates do not. +# +# Update accepts ONLY active (+ metadata); delete is a soft delete that +# keeps the object retrievable with deleted: true, exactly like real Stripe. +# Shared helpers (_require_auth, _next_id, _now, _not_found, _list_page, +# _newest_first, _created_filters, _created_check, _signed_emit, +# _idempotent_lookup, _idempotent_remember) are in lib.star. + +_TXR_COLLECTION = "tax_rates" + +# _txr_err builds the real Stripe 400 envelope. +def _txr_err(msg, param): + e = {"type": "invalid_request_error", "message": msg} + if param != None: + e["param"] = param + return respond(400, {"error": e}) + +def _txr_missing(param): + return _txr_err("Missing required param: " + param + ".", param) + +# _txr_bad_body reports a malformed JSON body authoritatively. +def _txr_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _txr_percentage parses a percentage into a float: numbers pass through, +# numeric strings ("16", "8.875") are split manually (Starlark float() +# raises on bad input and there is no try/except). Returns None when the +# value is not a non-negative number. +def _txr_percentage(v): + if v == None: + return None + if type(v) == "int": + return float(v) + if type(v) == "float": + return v + if type(v) != "string": + return None + s = v.strip() + if s == "": + return None + whole = "" + frac = "" + seen_dot = False + for i in range(len(s)): + ch = s[i] + if ch >= "0" and ch <= "9": + if seen_dot: + frac = frac + ch + else: + whole = whole + ch + elif ch == "." and not seen_dot: + seen_dot = True + else: + return None + if whole == "" and frac == "": + return None + out = float(_to_int(whole)) + scale = 1.0 + for i in range(len(frac)): + scale = scale * 10.0 + out = out + float(_to_int(frac)) / scale + return out + +# _txr_public renders a stored tax rate (internal keys stripped). +def _txr_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# POST /v1/tax_rates — create a tax rate (display_name, inclusive and +# percentage required). +def on_create_tax_rate(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, _TXR_COLLECTION) + if cached != None: + return respond(cached["status"], _txr_public(cached["doc"])) + + if _txr_bad_body(req): + return _txr_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + display_name = body.get("display_name", None) + if display_name == None or display_name == "": + return _txr_missing("display_name") + + inclusive = body.get("inclusive", None) + if inclusive == None or type(inclusive) != "bool": + return _txr_missing("inclusive") + + pct = _txr_percentage(body.get("percentage", None)) + if pct == None or pct < 0: + return respond(400, {"error": {"code": "parameter_invalid_integer", "type": "invalid_request_error", "message": "Invalid integer: " + str(body.get("percentage", None)), "param": "percentage"}}) + + active = body.get("active", True) + if active == None: + active = True + + metadata = body.get("metadata", {}) + if metadata == None or type(metadata) != "dict": + metadata = {} + + doc = { + "id": _next_id("txr"), + "object": "tax_rate", + "active": active == True, + "display_name": display_name, + "inclusive": inclusive, + "jurisdiction": body.get("jurisdiction", None), + "percentage": pct, + "description": body.get("description", None), + "metadata": metadata, + "livemode": False, + "created": _now(), + "deleted": False, + } + store_collection(_TXR_COLLECTION).insert(doc) + _signed_emit("tax_rate.created", _txr_public(doc)) + _idempotent_remember(req, _TXR_COLLECTION, 201, doc["id"]) + return respond(201, _txr_public(doc)) + +# GET /v1/tax_rates/{id} — retrieve a tax rate (deleted rates stay readable +# with deleted: true). +def on_retrieve_tax_rate(req): + err = _require_auth(req) + if err != None: + return err + doc = store_collection(_TXR_COLLECTION).get(req["params"]["id"]) + if doc == None: + return _not_found("tax_rate", req["params"]["id"]) + return respond(200, _txr_public(doc)) + +# GET /v1/tax_rates — list tax rates (active + created filters, like the +# real API). +def on_list_tax_rates(req): + err = _require_auth(req) + if err != None: + return err + bad = _created_check(req) + if bad != None: + return bad + f = [] + active = _get_query(req, "active") + if active == "true": + f.append(["active", "=", True]) + elif active == "false": + f.append(["active", "=", False]) + _created_filters(req, f) + docs = store_collection(_TXR_COLLECTION).list() + if len(f) > 0: + docs = query_select(docs, f) + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "tax_rate") + if e != None: + return e + return respond(200, {"object": "list", "data": [_txr_public(d) for d in page], "has_more": has_more, "url": "/v1/tax_rates"}) + +# POST /v1/tax_rates/{id} — update a tax rate. The real API accepts ONLY +# active (plus metadata); archived rates keep applying to existing invoices +# and subscriptions but not new ones. +def on_update_tax_rate(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_TXR_COLLECTION).get(id) + if doc == None: + return _not_found("tax_rate", id) + if doc.get("deleted", False) == True: + return _txr_err("This tax rate has been deleted and can no longer be updated.", None) + + if _txr_bad_body(req): + return _txr_err("Invalid request body: could not parse as JSON.", None) + body = req["body"] + if body == None: + body = {} + + changed = False + if body.get("active", None) != None: + doc["active"] = body["active"] == True + changed = True + if body.get("metadata", None) != None and type(body["metadata"]) == "dict": + meta = doc.get("metadata", {}) + if meta == None or type(meta) != "dict": + meta = {} + for k in body["metadata"]: + meta[k] = body["metadata"][k] + doc["metadata"] = meta + changed = True + if not changed: + return _txr_err("This tax rate cannot be updated: only the active flag and metadata may be set.", None) + + store_collection(_TXR_COLLECTION).update(id, doc) + _signed_emit("tax_rate.updated", _txr_public(doc)) + return respond(200, _txr_public(doc)) + +# DELETE /v1/tax_rates/{id} — soft delete: the rate stays retrievable +# (deleted: true) and keeps applying where already set. +def on_delete_tax_rate(req): + err = _require_auth(req) + if err != None: + return err + id = req["params"]["id"] + doc = store_collection(_TXR_COLLECTION).get(id) + if doc == None: + return _not_found("tax_rate", id) + + if doc.get("deleted", False) != True: + doc["deleted"] = True + doc["active"] = False + store_collection(_TXR_COLLECTION).update(id, doc) + return respond(200, {"id": id, "object": "tax_rate", "deleted": True}) diff --git a/adapters/stripe-style/scripts/test_clocks.star b/adapters/stripe-style/scripts/test_clocks.star new file mode 100644 index 00000000..8097ce79 --- /dev/null +++ b/adapters/stripe-style/scripts/test_clocks.star @@ -0,0 +1,198 @@ +# Test Clocks handlers — deterministic time control. +# +# Real Stripe test clocks (docs.stripe.com/api/test_clocks) freeze time for +# the objects attached to them. stunt's engine clock is read-only, so this +# adapter runs ONE GLOBAL clock: creating or advancing a test clock moves +# _now() (lib.star) for EVERY object the adapter has ever created, not just +# attached ones. Both the short routes (/v1/test_clocks, used by stunt's +# tests) and Stripe's real routes (/v1/test_helpers/test_clocks) hit these +# handlers. +# +# Object shape mirrors the real test_helpers.test_clock: clock_* id, +# frozen_time, status ready|advancing, deletes_after (auto-delete horizon, +# one week), name. The internal_failure status and per-object attachment are +# not simulated. No test_helpers.test_clock.* webhooks are emitted: advancing +# here is synchronous, so the state-change webhooks of the advanced objects +# themselves are the observable signal. +# Shared helpers (_require_auth, _next_id, _not_found, _list_page, +# _newest_first, _created_filters, _created_check, _now, _tc_activate, +# _tc_clear) are in lib.star. + +_TC_WEEK = 7 * 24 * 3600 # deletes_after horizon: created + one week + +# _tc_public strips the internal soft-delete flag. +def _tc_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _tc_bad_body reports a malformed JSON body authoritatively: a body that +# fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is +# the source of truth. +def _tc_bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + +# _tc_missing_param is the real Stripe 400 for a missing required param. +def _tc_missing_param(param): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) + +# _tc_bad_int is the real Stripe 400 for a non-integer param value. +def _tc_bad_int(param, val): + return respond(400, {"error": {"code": "parameter_invalid_integer", "type": "invalid_request_error", "message": "Invalid integer: " + str(val), "param": param}}) + +# _tc_get loads a live (non-deleted) clock or None. +def _tc_get(id): + doc = store_collection("test_clocks").get(id) + if doc == None: + return None + if doc.get("_deleted", False) == True: + return None + return doc + +# POST /v1/test_clocks — create a test clock at frozen_time (required). +# Creating a clock makes it the active global clock: _now() jumps to +# frozen_time, so objects created afterwards are stamped there. +def on_create_test_clock(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "test_clocks") + if cached != None: + return respond(cached["status"], _tc_public(cached["doc"])) + + if _tc_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + body = req["body"] + if body == None: + body = {} + ft = body.get("frozen_time", None) + if ft == None: + return _tc_missing_param("frozen_time") + frozen = _num(ft) + if frozen <= 0: + return _tc_bad_int("frozen_time", ft) + now = _now() + doc = { + "id": _next_id("clock"), + "object": "test_helpers.test_clock", + "created": now, + "deletes_after": now + _TC_WEEK, + "frozen_time": frozen, + "livemode": False, + "name": body.get("name", None), + "status": "ready", + "_deleted": False, + } + store_collection("test_clocks").insert(doc) + _tc_activate(doc["id"], frozen) + _idempotent_remember(req, "test_clocks", 201, doc["id"]) + return respond(201, _tc_public(doc)) + +# GET /v1/test_clocks — list clocks (newest first, cursor pagination, +# created filters). Deleted clocks are excluded, like every Stripe list. +def on_list_test_clocks(req): + err = _require_auth(req) + if err != None: + return err + + bad = _created_check(req) + if bad != None: + return bad + + docs = store_collection("test_clocks").list() + docs = query_select(docs, [["_deleted", "!=", True]]) + docs = _newest_first(docs) + + page, has_more, e = _list_page(req, docs, "test_clock") + if e != None: + return e + return respond(200, {"object": "list", "data": [_tc_public(d) for d in page], "has_more": has_more, "url": "/v1/test_clocks"}) + +# GET /v1/test_clocks/{id} — retrieve a clock. +def on_retrieve_test_clock(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = _tc_get(id) + if doc == None: + return _not_found("test_clock", id) + return respond(200, _tc_public(doc)) + +# POST /v1/test_clocks/{id}/advance — move the global clock forward. +# Accepts `frozen_time` (the current Stripe param name) and `now` (the +# classic one). The target must be after the clock's current frozen time. +# The stored doc reflects the completed advance (status ready, frozen_time at +# the target); the response carries the documented in-progress status +# "advancing", like real Stripe's asynchronous advance. +def on_advance_test_clock(req): + err = _require_auth(req) + if err != None: + return err + + cached = _idempotent_lookup(req, "test_clocks") + if cached != None: + out = _tc_public(cached["doc"]) + out["status"] = "advancing" + return respond(cached["status"], out) + + if _tc_bad_body(req): + return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) + + id = req["params"]["id"] + doc = _tc_get(id) + if doc == None: + return _not_found("test_clock", id) + + body = req["body"] + if body == None: + body = {} + target = body.get("frozen_time", None) + param = "frozen_time" + if target == None: + target = body.get("now", None) + param = "now" + if target == None: + return _tc_missing_param("frozen_time") + t = _num(target) + if t <= 0: + return _tc_bad_int(param, target) + if t <= _num(doc.get("frozen_time", 0)): + return respond(400, {"error": {"code": "test_clock_changing_frozen_time", "type": "invalid_request_error", "message": "The test clock's frozen time cannot be changed to a time in the past, or to the current frozen time."}}) + + doc["frozen_time"] = t + doc["status"] = "ready" + store_collection("test_clocks").update(id, doc) + _tc_activate(id, t) + _idempotent_remember(req, "test_clocks", 200, id) + + out = _tc_public(doc) + out["status"] = "advancing" + return respond(200, out) + +# DELETE /v1/test_clocks/{id} — delete a clock (soft delete, like real +# Stripe's resource model). Deleting the ACTIVE clock clears the global time +# offset; deleting a stale clock leaves the active one running. +def on_delete_test_clock(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + c = store_collection("test_clocks") + doc = c.get(id) + if doc == None or doc.get("_deleted", False) == True: + return _not_found("test_clock", id) + + doc["_deleted"] = True + c.update(id, doc) + _tc_clear(id) + return respond(200, {"id": id, "object": "test_helpers.test_clock", "deleted": True}) diff --git a/adapters/stripe-style/scripts/tokens.star b/adapters/stripe-style/scripts/tokens.star index 21b59a21..a2c97fe9 100644 --- a/adapters/stripe-style/scripts/tokens.star +++ b/adapters/stripe-style/scripts/tokens.star @@ -58,7 +58,7 @@ def _create_card_token(card): "object": "token", "type": "card", "card": card_pub, - "created": clock.now_unix(), + "created": _now(), "livemode": False, "used": False, "client_ip": None, diff --git a/adapters/stripe-style/scripts/transfers.star b/adapters/stripe-style/scripts/transfers.star index e8817281..41a8e989 100644 --- a/adapters/stripe-style/scripts/transfers.star +++ b/adapters/stripe-style/scripts/transfers.star @@ -1,9 +1,18 @@ # Transfers handlers — Stripe Connect (platform → connected account). # -# Transfers move funds from the platform to a connected account. Stored in -# the transfers collection. Emits transfer.created and transfer.reversed. -# Shared helpers (_require_auth, _next_id, _not_found, _get_balance, -# _set_balance) are in lib.star. +# Transfers move funds from the platform to a connected account and are +# stored in the transfers collection; each reversal is a first-class +# transfer_reversal doc (trr_*) in the transfer_reversals collection. +# Money movement mirrors the ledger: creating a transfer credits the +# destination account and debits the platform (type transfer); a reversal +# moves the funds back (type transfer_reversal, both sides). Partial +# reversals accumulate on transfer.amount_reversed; the total may never +# exceed the transfer amount (real 400s for over-reversal and for reversing +# an already fully reversed transfer). Emits transfer.created and +# transfer.reversed (the real event fires for partial reversals too). +# Shared helpers (_require_auth, _next_id, _not_found, _num, _usd, _now, +# _bt_record, _get_balance, _set_balance, _signed_emit, _list_page, +# _newest_first, _idempotent_lookup, _idempotent_remember) are in lib.star. # _apply_transfer_filters maps the real Stripe transfer-list query params # (destination, created exact/range) to query_select clauses, applied before @@ -18,6 +27,56 @@ def _apply_transfer_filters(req, docs): return docs return query_select(docs, f) +# _reversals_for returns the reversal docs of one transfer. +def _reversals_for(transfer_id): + docs = store_collection("transfer_reversals").list() + return query_select(docs, [["transfer", "=", transfer_id]]) + +# _reversal_view renders the transfer_reversal object +# (docs.stripe.com/api/transfer_reversals/object). +def _reversal_view(doc): + return { + "id": doc["id"], + "object": "transfer_reversal", + "amount": _num(doc.get("amount", 0)), + "balance_transaction": doc.get("balance_transaction", None), + "created": _num(doc.get("created", 0)), + "currency": doc.get("currency", "usd"), + "destination_payment_refund": None, + "metadata": doc.get("metadata", {}), + "source_refund": None, + "transfer": doc.get("transfer", None), + } + +# _transfer_view renders the transfer object, with the embedded reversals +# list rebuilt from the stored reversal docs. +def _transfer_view(doc): + revs = _newest_first(_reversals_for(doc["id"])) + return { + "id": doc["id"], + "object": "transfer", + "amount": _num(doc.get("amount", 0)), + "amount_reversed": _num(doc.get("amount_reversed", 0)), + "balance_transaction": doc.get("balance_transaction", None), + "created": _num(doc.get("created", 0)), + "currency": doc.get("currency", "usd"), + "description": doc.get("description", None), + "destination": doc.get("destination", None), + "livemode": False, + "metadata": doc.get("metadata", {}), + "reversals": { + "object": "list", + "data": [_reversal_view(r) for r in revs], + "has_more": False, + "total_count": len(revs), + "url": "/v1/transfers/" + doc["id"] + "/reversals", + }, + "reversed": doc.get("reversed", False) == True, + "source_transaction": doc.get("source_transaction", None), + "source_type": "card", + "transfer_group": doc.get("transfer_group", None), + } + # POST /v1/transfers — create a transfer to a connected account. def on_create_transfer(req): err = _require_auth(req) @@ -28,17 +87,19 @@ def on_create_transfer(req): if body == None: body = {} - amount = body.get("amount", 0) + amount = _num(body.get("amount", 0)) currency = body.get("currency", "usd") destination = body.get("destination", None) - if destination == None: - return respond(400, {"error": {"message": "Must provide destination.", "type": "invalid_request_error"}}) + if destination == None or destination == "": + return respond(400, {"error": {"message": "Must provide destination.", "param": "destination", "type": "invalid_request_error"}}) + if amount <= 0: + return respond(400, {"error": {"code": "parameter_invalid_integer", "message": "Invalid positive integer: " + str(body.get("amount", 0)), "param": "amount", "type": "invalid_request_error"}}) - # Verify destination account exists. - accts = store_collection("connect_accounts") - acct = accts.get(destination) - if acct == None: + # Verify destination account exists. Real Stripe rejects an unknown + # destination with a resource-missing "No such account" error; the + # adapter-wide convention (and existing behavior) is a 404 envelope. + if store_collection("connect_accounts").get(destination) == None: return _not_found("account", destination) transfer_id = _next_id("tr") @@ -49,23 +110,31 @@ def on_create_transfer(req): "currency": currency, "destination": destination, "description": body.get("description", None), + "metadata": body.get("metadata", {}), + "source_transaction": body.get("source_transaction", None), + "transfer_group": body.get("transfer_group", None), "reversed": False, "amount_reversed": 0, - "reversals": {"object": "list", "data": [], "has_more": False, "url": "/v1/transfers/" + transfer_id + "/reversals"}, - "created": 1700000000, + "balance_transaction": None, + "created": _now(), } c = store_collection("transfers") c.insert(doc) - # Credit the connected account's balance. - bal = _get_balance(destination) - _set_balance(destination, bal + amount) + # Mirror the existing accounting with ledger rows: credit the connected + # account (+amount, type transfer) and debit the platform ledger + # (-amount). transfer.balance_transaction is the platform-side txn, like + # real Stripe. + _bt_record(destination, "transfer", amount, 0, currency, transfer_id, body.get("description", None)) + plat_bt = _bt_record("", "transfer", -amount, 0, currency, transfer_id, body.get("description", None)) + doc["balance_transaction"] = plat_bt["id"] + c.update(transfer_id, doc) # Emit webhook event (fire-and-forget). - _signed_emit("transfer.created", doc) + _signed_emit("transfer.created", _transfer_view(doc)) - return respond(201, doc) + return respond(201, _transfer_view(doc)) # GET /v1/transfers/{id} — retrieve a single transfer. def on_retrieve_transfer(req): @@ -74,11 +143,10 @@ def on_retrieve_transfer(req): return err id = req["params"]["id"] - c = store_collection("transfers") - doc = c.get(id) + doc = store_collection("transfers").get(id) if doc == None: return _not_found("transfer", id) - return respond(200, doc) + return respond(200, _transfer_view(doc)) # GET /v1/transfers — list all transfers (optionally ?destination=). def on_list_transfers(req): @@ -90,25 +158,31 @@ def on_list_transfers(req): if bad != None: return bad - c = store_collection("transfers") - docs = c.list() + docs = store_collection("transfers").list() # Real transfer-list params (destination, created exact/range), applied # before paging. transfer_group is not stored, so it is not honored. docs = _apply_transfer_filters(req, docs) docs = _newest_first(docs) - page, has_more, err = _list_page(req, docs, "transfer") - if err != None: - return err - return respond(200, {"object": "list", "data": page, "has_more": has_more, "url": "/v1/transfers"}) + page, has_more, err2 = _list_page(req, docs, "transfer") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_transfer_view(d) for d in page], "has_more": has_more, "url": "/v1/transfers"}) -# POST /v1/transfers/{id}/reversals — reverse (part of) a transfer. +# POST /v1/transfers/{id}/reversals — reverse a transfer, fully (amount +# omitted) or partially. Returns the transfer_reversal object, like the real +# API; the transfer's amount_reversed/reversed fields accumulate across +# partials. def on_reverse_transfer(req): err = _require_auth(req) if err != None: return err + cached = _idempotent_lookup(req, "transfer_reversals") + if cached != None: + return respond(cached["status"], _reversal_view(cached["doc"])) + id = req["params"]["id"] c = store_collection("transfers") doc = c.get(id) @@ -119,21 +193,80 @@ def on_reverse_transfer(req): if body == None: body = {} - amount = body.get("amount", doc.get("amount", 0)) - doc["reversed"] = True - doc["amount_reversed"] = amount + base = _num(doc.get("amount", 0)) + already = _num(doc.get("amount_reversed", 0)) + remaining = base - already + if remaining <= 0: + return respond(400, {"error": {"message": "This transfer is already fully reversed.", "param": "amount", "type": "invalid_request_error"}}) + + amount = _num(body.get("amount", 0)) + if amount == 0: + amount = remaining + if amount > remaining or amount <= 0: + return respond(400, {"error": {"message": "Transfer reversal amount (" + _usd(amount) + ") is greater than unreversed amount on transfer (" + _usd(remaining) + ")", "param": "amount", "type": "invalid_request_error"}}) + + trr_id = _next_id("trr") + # Debit the connected account's balance (ledger row: transfer_reversal, + # -amount) and credit the platform ledger back. The reversal's + # balance_transaction is the platform-side txn the API caller sees. + dest = doc.get("destination", None) + if dest != None and dest != "": + _bt_record(dest, "transfer_reversal", -amount, 0, doc.get("currency", "usd"), trr_id, "Transfer reversal") + if _get_balance(dest) < 0: + _set_balance(dest, 0) + plat_bt = _bt_record("", "transfer_reversal", amount, 0, doc.get("currency", "usd"), trr_id, "Transfer reversal") + + trr = { + "id": trr_id, + "object": "transfer_reversal", + "amount": amount, + "balance_transaction": plat_bt["id"], + "created": _now(), + "currency": doc.get("currency", "usd"), + "metadata": body.get("metadata", {}), + "transfer": id, + } + store_collection("transfer_reversals").insert(trr) + + doc["amount_reversed"] = already + amount + if doc["amount_reversed"] >= base: + doc["reversed"] = True c.update(id, doc) - # Debit the connected account's balance. - dest = doc.get("destination") - if dest != None: - bal = _get_balance(dest) - new_bal = bal - amount - if new_bal < 0: - new_bal = 0 - _set_balance(dest, new_bal) + # Emit webhook event after every state change is persisted. + _signed_emit("transfer.reversed", _transfer_view(doc)) - # Emit webhook event (fire-and-forget). - _signed_emit("transfer.reversed", doc) + _idempotent_remember(req, "transfer_reversals", 200, trr_id) + return respond(200, _reversal_view(trr)) + +# GET /v1/transfers/{id}/reversals — list a transfer's reversals (newest +# first, cursor pagination). +def on_list_transfer_reversals(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + if store_collection("transfers").get(id) == None: + return _not_found("transfer", id) + + docs = _newest_first(_reversals_for(id)) + page, has_more, err2 = _list_page(req, docs, "transfer_reversal") + if err2 != None: + return err2 + return respond(200, {"object": "list", "data": [_reversal_view(d) for d in page], "has_more": has_more, "url": "/v1/transfers/" + id + "/reversals"}) + +# GET /v1/transfers/{id}/reversals/{tr_id} — retrieve one reversal. +def on_retrieve_transfer_reversal(req): + err = _require_auth(req) + if err != None: + return err - return respond(200, doc) + id = req["params"]["id"] + tr_id = req["params"]["tr_id"] + if store_collection("transfers").get(id) == None: + return _not_found("transfer", id) + doc = store_collection("transfer_reversals").get(tr_id) + if doc == None or doc.get("transfer", None) != id: + return _not_found("transfer_reversal", tr_id) + return respond(200, _reversal_view(doc)) diff --git a/adapters/stripe-style/scripts/webhook_endpoints.star b/adapters/stripe-style/scripts/webhook_endpoints.star new file mode 100644 index 00000000..acddf87b --- /dev/null +++ b/adapters/stripe-style/scripts/webhook_endpoints.star @@ -0,0 +1,159 @@ +# Webhook Endpoints handlers — registering webhook receivers +# (docs.stripe.com/api/webhook_endpoints). +# +# REGISTRATION GATES EMISSION (lib.star _signed_emit + _events_enabled): with +# no webhook endpoints registered, every emitted event is delivered to the +# sink (the adapter's historical always-deliver behavior). Once any endpoint +# exists, only its enabled_events (exact type match or "*") are DELIVERED — +# the event object is still recorded in the events collection either way +# (GET /v1/events), like real Stripe. DELETE removes the registration +# (hard delete — the gate counts live endpoints only), so delivery reverts to +# always-deliver when the last endpoint goes away. +# +# Object shape (real): id we_*, object webhook_endpoint, api_version, +# application None, created, description, enabled_events, livemode False, +# metadata, secret, status enabled, url. +# +# secret: the mock's webhook signing secret. The single source of truth is +# _WEBHOOK_SECRET in scripts/lib.star (injected into every handler like every +# other lib global), so this file reads it directly — every endpoint shares +# the one secret stunt signs deliveries with. (hoist_requests: none needed; +# the constant is already a shared lib.star global.) +# +# Shared helpers (_require_auth, _next_id, _now, _not_found, _list_page, +# _newest_first, _get_query) are in lib.star. + +_WE_API_VERSION = "2025-01-27.acacia" + +# _we_public renders the public webhook_endpoint shape. +def _we_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + +# _we_enabled_events validates the enabled_events param: required and +# non-empty (a webhook endpoint must have a url and a list of enabled_events +# per the real API). Returns (list, error-response). +def _we_enabled_events(body): + evs = body.get("enabled_events", None) + if evs == None or type(evs) != "list" or len(evs) == 0: + return None, respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: enabled_events[0].", "param": "enabled_events[0]"}}) + out = [] + for i in range(len(evs)): + ev = evs[i] + if ev == None: + ev = "" + out.append(ev) + return out, None + +# POST /v1/webhook_endpoints — register a receiver (url required, +# enabled_events required non-empty, description + metadata optional). +def on_create_webhook_endpoint(req): + err = _require_auth(req) + if err != None: + return err + + body = req["body"] + if body == None: + body = {} + + url = body.get("url", None) + if url == None or url == "": + return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: url.", "param": "url"}}) + + evs, everr = _we_enabled_events(body) + if everr != None: + return everr + + doc = { + "id": _next_id("we"), + "object": "webhook_endpoint", + "api_version": _WE_API_VERSION, + "application": None, + "created": _now(), + "description": body.get("description", None), + "enabled_events": evs, + "livemode": False, + "metadata": body.get("metadata", {}), + "secret": _WEBHOOK_SECRET, + "status": "enabled", + "url": url, + } + store_collection("webhook_endpoints").insert(doc) + return respond(201, _we_public(doc)) + +# GET /v1/webhook_endpoints — list registered endpoints (newest first). +def on_list_webhook_endpoints(req): + err = _require_auth(req) + if err != None: + return err + + docs = store_collection("webhook_endpoints").list() + docs = _newest_first(docs) + page, has_more, e = _list_page(req, docs, "webhook_endpoint") + if e != None: + return e + return respond(200, {"object": "list", "data": [_we_public(d) for d in page], "has_more": has_more, "url": "/v1/webhook_endpoints"}) + +# GET /v1/webhook_endpoints/{id} — retrieve an endpoint. +def on_retrieve_webhook_endpoint(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + doc = store_collection("webhook_endpoints").get(id) + if doc == None: + return _not_found("webhook_endpoint", id) + return respond(200, _we_public(doc)) + +# POST /v1/webhook_endpoints/{id} — update an endpoint (enabled_events, +# description, metadata). +def on_update_webhook_endpoint(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + c = store_collection("webhook_endpoints") + doc = c.get(id) + if doc == None: + return _not_found("webhook_endpoint", id) + + body = req["body"] + if body == None: + body = {} + + if body.get("enabled_events", None) != None: + evs, everr = _we_enabled_events(body) + if everr != None: + return everr + doc["enabled_events"] = evs + if body.get("description", None) != None: + doc["description"] = body["description"] + meta = body.get("metadata", None) + if meta != None and type(meta) == "dict": + doc["metadata"] = meta + + c.update(id, doc) + return respond(200, _we_public(doc)) + +# DELETE /v1/webhook_endpoints/{id} — delete an endpoint. HARD delete (the +# doc leaves the collection) so lib._events_enabled stops counting it: the +# moment no endpoints remain, delivery reverts to always-deliver. +def on_delete_webhook_endpoint(req): + err = _require_auth(req) + if err != None: + return err + + id = req["params"]["id"] + c = store_collection("webhook_endpoints") + doc = c.get(id) + if doc == None: + return _not_found("webhook_endpoint", id) + + c.delete(id) + return respond(200, {"id": id, "object": "webhook_endpoint", "deleted": True}) diff --git a/internal/contrib/lint/lint.go b/internal/contrib/lint/lint.go index e8228a80..b4944cf5 100644 --- a/internal/contrib/lint/lint.go +++ b/internal/contrib/lint/lint.go @@ -674,10 +674,13 @@ var reEmail = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2 var reUUID = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) // reProviderID matches provider-specific prefixed IDs like cus_, ch_, pi_, -// sub_, txn_, acct_, tok_, etc. followed by 4+ alphanumeric characters. -// These are characteristic of recorded data from payment/SaaS APIs. +// sub_, txn_, acct_, tok_, etc. followed by 4+ alphanumeric characters that +// include at least one digit. These are characteristic of recorded data from +// payment/SaaS APIs, whose real object IDs always embed a digit (Stripe IDs +// carry a timestamp component). The digit requirement keeps all-alpha suffixes +// — legitimate API route/collection names like file_links — from matching. var reProviderID = regexp.MustCompile( - `\b(?:cus|ch|pi|sub|txn|acct|card|tok|evt|fee|file|ref|req|conn|src|dp|payout|setupi|plan|prod|price|coupon|promo)_[A-Za-z0-9]{4,}`) + `\b(?:cus|ch|pi|sub|txn|acct|card|tok|evt|fee|file|ref|req|conn|src|dp|payout|setupi|plan|prod|price|coupon|promo)_(?:[0-9][A-Za-z0-9]{3,}|[A-Za-z][0-9][A-Za-z0-9]{2,}|[A-Za-z]{2}[0-9][A-Za-z0-9]+|[A-Za-z]{3,}[0-9][A-Za-z0-9]*)`) // reCreditCard matches 13–19 digit sequences with optional separators // (spaces or hyphens), characteristic of card numbers. diff --git a/internal/contrib/lint/lint_test.go b/internal/contrib/lint/lint_test.go index ee48438a..68fb7176 100644 --- a/internal/contrib/lint/lint_test.go +++ b/internal/contrib/lint/lint_test.go @@ -97,6 +97,54 @@ func TestProviderIDInFixture(t *testing.T) { } } +// --- provider-style ids with a digit anywhere in the suffix are caught --- +// Real recorded IDs embed a digit at any position; all of these must flag. + +func TestProviderIDDigitAnywhere(t *testing.T) { + for _, id := range []string{"ch_1Mio2eLkdIwHu7ix", "txn_a1bc", "evt_ab1c", "file_abc1", "tok_abcd1ef"} { + dir := scaffold(t) + writeFile(t, dir, "fixtures/real.jsonl", + `{"id":"`+id+`","amount":5000}`+"\n") + + findings, err := Lint(dir) + if err != nil { + t.Fatalf("Lint(%s): %v", id, err) + } + if !hasFinding(findings, "provider") { + t.Errorf("expected a provider-id finding for %s, got: %+v", id, findings) + } + } +} + +// --- all-alpha route/collection names are NOT provider ids --- +// API surface names like file_links match the prefix but carry no digit; +// they are legitimate route/collection names, not recorded data. + +func TestProviderIDIgnoresAllAlphaNames(t *testing.T) { + dir := scaffold(t) + writeFile(t, dir, "adapter.yaml", + "id: test-api\n"+ + "name: \"Test API\"\n"+ + "api:\n"+ + " name: \"Test API\"\n"+ + " version: \"2025-01-01\"\n"+ + "endpoints:\n"+ + " - route: /v1/file_links\n"+ + " method: GET\n"+ + " handler: scripts/files.star#on_list_file_links\n"+ + "resources:\n"+ + " - name: file_links\n"+ + " kind: collection\n") + + findings, err := Lint(dir) + if err != nil { + t.Fatalf("Lint: %v", err) + } + for _, f := range findings { + t.Errorf("unexpected finding: %s:%d [%s] %s", f.File, f.Line, f.Severity, f.Message) + } +} + // --- api_key with real-looking value produces error --- func TestPIIFieldInFixture(t *testing.T) { diff --git a/internal/engine/stripe_checkout_test.go b/internal/engine/stripe_checkout_test.go new file mode 100644 index 00000000..f4f4c063 --- /dev/null +++ b/internal/engine/stripe_checkout_test.go @@ -0,0 +1,1140 @@ +package engine + +// d4-checkout domain tests: Checkout Sessions (payment/subscription/setup +// modes + the /c/pay/{id} hosted completion + expire + line items), +// SetupIntents (confirm/cancel state machine incl. SCA + decline), Webhook +// Endpoints (CRUD + the delivery gate both ways — the test that proves +// lib.star's _events_enabled gating works), and Files + File Links. +// +// Helper-name discipline: every new helper here is prefixed stripeCk so +// parallel adapter agents cannot collide. Shared helpers (postJSONAuth, +// getAuth, deleteAuth, postJSONAuthIdem, devToken, stripeCardNum, +// mintStripeCardToken, newCaptureSink) are reused from the existing stripe +// test files. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "stuntapi.com/stunt/internal/manifest" +) + +// stripeCkServer boots the stripe-style adapter on a random port, optionally +// registering a webhook sink URL. Returns the base URL. STRIPE_CK_ADAPTER_DIR +// overrides the adapter directory (used to validate these tests against a +// scratch copy with the d4-checkout routes merged before the stitch phase +// lands them in the repo adapter.yaml). +func stripeCkServer(t *testing.T, webhookURL string) string { + t.Helper() + adapterDir := os.Getenv("STRIPE_CK_ADAPTER_DIR") + if adapterDir == "" { + var err error + adapterDir, err = filepath.Abs(filepath.Join("..", "..", "adapters", "stripe-style")) + if err != nil { + t.Fatal(err) + } + } + svc := manifest.Service{Adapter: adapterDir} + if webhookURL != "" { + svc.Config = map[string]any{"webhook_url": webhookURL} + } + stateDir := t.TempDir() + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "stripe": svc, + }, + } + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + t.Cleanup(func() { e.Close() }) + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + t.Cleanup(cancel) + time.Sleep(50 * time.Millisecond) + return addrs["stripe"] +} + +// stripeCkGetNoRedirect GETs url WITHOUT following redirects (the hosted pay +// URL 302s to an external success_url). Returns status, Location header and +// body. +func stripeCkGetNoRedirect(t *testing.T, url string) (int, string, string) { + t.Helper() + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get(url) + if err != nil { + t.Fatalf("GET (no redirect) %s: %v", url, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, resp.Header.Get("Location"), string(b) +} + +// stripeCkPostMultipart POSTs a multipart/form-data body (one purpose/title +// style field set + one file part) and returns body + status. +func stripeCkPostMultipart(t *testing.T, url, token string, fields map[string]string, fileField, filename string, fileBytes []byte) (string, int) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatal(err) + } + } + fw, err := w.CreateFormFile(fileField, filename) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(fileBytes); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + req, err := http.NewRequest("POST", url, &buf) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// stripeCkEventTypes fetches /v1/events?type= and returns the payload +// objects of every matching recorded event. +func stripeCkEventObjects(t *testing.T, base, typ string) []map[string]any { + t.Helper() + body, status := getAuth(t, base+"/v1/events?type="+typ, devToken) + if status != 200 { + t.Fatalf("GET /v1/events?type=%s -> %d; body %s", typ, status, body) + } + var list map[string]any + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatalf("unmarshal events list: %v (body %s)", err, body) + } + data, _ := list["data"].([]any) + var out []map[string]any + for _, e := range data { + ev, ok := e.(map[string]any) + if !ok { + continue + } + if ev["type"] != typ { + t.Fatalf("type filter leaked %v", ev["type"]) + } + if payload, ok := ev["data"].(map[string]any)["object"].(map[string]any); ok { + out = append(out, payload) + } + } + return out +} + +// stripeCkHasEvent reports whether at least one event of typ is recorded. +func stripeCkHasEvent(t *testing.T, base, typ string) bool { + t.Helper() + return len(stripeCkEventObjects(t, base, typ)) > 0 +} + +// stripeCkSinkCount polls the capture sink until it holds at least want +// deliveries or the timeout elapses. Returns the deliveries seen so far. +func stripeCkSinkCount(s *captureSink, want int, timeout time.Duration) []notify { + deadline := time.Now().Add(timeout) + for { + s.mu.Lock() + n := len(s.notifies) + have := make([]notify, n) + copy(have, s.notifies) + s.mu.Unlock() + if n >= want || time.Now().After(deadline) { + return have + } + time.Sleep(20 * time.Millisecond) + } +} + +// stripeCkSinkLen returns the sink's delivery count under its lock. +func stripeCkSinkLen(s *captureSink) int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.notifies) +} + +// stripeCkSinkTypes decodes the sink's captured deliveries into event types. +func stripeCkSinkTypes(deliveries []notify) []string { + var out []string + for _, d := range deliveries { + var env map[string]any + if err := json.Unmarshal(d.body, &env); err != nil { + continue + } + if typ, ok := env["type"].(string); ok { + out = append(out, typ) + } + } + return out +} + +// stripeCkCreateSession creates a checkout session and returns the decoded +// session object. +func stripeCkCreateSession(t *testing.T, base string, body map[string]any) map[string]any { + t.Helper() + respBody, status := postJSONAuth(t, base+"/v1/checkout/sessions", devToken, body) + if status != 201 { + t.Fatalf("POST /v1/checkout/sessions -> %d, want 201; body %s", status, respBody) + } + var cs map[string]any + if err := json.Unmarshal([]byte(respBody), &cs); err != nil { + t.Fatalf("unmarshal session: %v (body %s)", err, respBody) + } + return cs +} + +// TestStripeCkCheckoutPaymentMode proves the full hosted-payment flow: create +// a payment-mode session (amounts computed, url /c/pay/{id}), GET the hosted +// pay URL (no auth) -> 302 to success_url with {CHECKOUT_SESSION_ID} +// substituted, the session completes (status complete / payment_status paid), +// the backing PaymentIntent + captured Charge + balance transaction are +// visible, the underlying events are recorded, and completion is one-shot. +func TestStripeCkCheckoutPaymentMode(t *testing.T) { + base := stripeCkServer(t, "") + + cs := stripeCkCreateSession(t, base, map[string]any{ + "mode": "payment", + "success_url": "https://example.test/success?session_id={CHECKOUT_SESSION_ID}", + "cancel_url": "https://example.test/cancel", + "line_items": []any{ + map[string]any{ + "price_data": map[string]any{ + "currency": "usd", + "unit_amount": 2198, + "product_data": map[string]any{"name": "T-shirt"}, + }, + "quantity": 2, + }, + }, + "metadata": map[string]any{"order": "673"}, + }) + csID, ok := cs["id"].(string) + if !ok || !strings.HasPrefix(csID, "cs_") { + t.Fatalf("session id = %v, want cs_* prefix", cs["id"]) + } + if cs["object"] != "checkout.session" { + t.Fatalf("session object = %v", cs["object"]) + } + if cs["status"] != "open" || cs["payment_status"] != "unpaid" { + t.Fatalf("fresh session = status %v / payment_status %v, want open/unpaid", cs["status"], cs["payment_status"]) + } + if cs["amount_subtotal"].(float64) != 4396 || cs["amount_total"].(float64) != 4396 { + t.Fatalf("amounts = %v/%v, want 4396/4396", cs["amount_subtotal"], cs["amount_total"]) + } + if cs["currency"] != "usd" { + t.Fatalf("currency = %v", cs["currency"]) + } + if cs["url"] != "/c/pay/"+csID { + t.Fatalf("url = %v, want /c/pay/%s", cs["url"], csID) + } + created := cs["created"].(float64) + if cs["expires_at"].(float64) <= created { + t.Fatalf("expires_at %v not after created %v", cs["expires_at"], created) + } + if cs["metadata"].(map[string]any)["order"] != "673" { + t.Fatalf("metadata = %v", cs["metadata"]) + } + + // Validation errors: missing success_url, unknown price. + body, status := postJSONAuth(t, base+"/v1/checkout/sessions", devToken, map[string]any{ + "mode": "payment", + "line_items": []any{ + map[string]any{"price_data": map[string]any{"currency": "usd", "unit_amount": 500}}, + }, + }) + if status != 400 { + t.Fatalf("missing success_url -> %d, want 400; body %s", status, body) + } + if errObj := stripeCkErr(t, body); errObj["param"] != "success_url" { + t.Fatalf("missing success_url param = %v", errObj["param"]) + } + body, status = postJSONAuth(t, base+"/v1/checkout/sessions", devToken, map[string]any{ + "mode": "payment", + "success_url": "https://x.test/s", + "line_items": []any{map[string]any{"price": "price_nope"}}, + }) + if status != 400 || !strings.Contains(body, "No such price") { + t.Fatalf("unknown price -> %d, want 400 resource_missing; body %s", status, body) + } + + // Line items use the session item shape (object "item", not price). + body, status = getAuth(t, base+"/v1/checkout/sessions/"+csID+"/line_items", devToken) + if status != 200 { + t.Fatalf("GET line_items -> %d; body %s", status, body) + } + var liList map[string]any + json.Unmarshal([]byte(body), &liList) + liData, _ := liList["data"].([]any) + if len(liData) != 1 { + t.Fatalf("line items = %v, want 1", liData) + } + item := liData[0].(map[string]any) + if item["object"] != "item" || !strings.HasPrefix(item["id"].(string), "li_") { + t.Fatalf("item = %v, want object item with li_* id", item) + } + if item["amount_total"].(float64) != 4396 || item["quantity"].(float64) != 2 { + t.Fatalf("item amounts = %v/%v", item["amount_total"], item["quantity"]) + } + if item["price"].(map[string]any)["unit_amount"].(float64) != 2198 { + t.Fatalf("item price = %v", item["price"]) + } + + // Hosted pay: NO auth, 302 + substituted session id. + payURL := base + cs["url"].(string) + code, loc, payBody := stripeCkGetNoRedirect(t, payURL) + wantLoc := "https://example.test/success?session_id=" + csID + if code != 302 { + t.Fatalf("GET /c/pay/{id} -> %d, want 302; body %s", code, payBody) + } + if loc != wantLoc { + t.Fatalf("Location = %q, want %q", loc, wantLoc) + } + + // Session is complete + paid; the PI is linked and succeeded. + body, status = getAuth(t, base+"/v1/checkout/sessions/"+csID, devToken) + if status != 200 { + t.Fatalf("GET session -> %d", status) + } + json.Unmarshal([]byte(body), &cs) + if cs["status"] != "complete" || cs["payment_status"] != "paid" { + t.Fatalf("completed session = %v/%v, want complete/paid", cs["status"], cs["payment_status"]) + } + if cs["url"] != nil { + t.Fatalf("url after completion = %v, want null", cs["url"]) + } + piID, _ := cs["payment_intent"].(string) + if !strings.HasPrefix(piID, "pi_") { + t.Fatalf("session.payment_intent = %v, want pi_*", cs["payment_intent"]) + } + + body, status = getAuth(t, base+"/v1/payment_intents/"+piID, devToken) + if status != 200 { + t.Fatalf("GET PI -> %d", status) + } + var pi map[string]any + json.Unmarshal([]byte(body), &pi) + if pi["status"] != "succeeded" || pi["amount_received"].(float64) != 4396 { + t.Fatalf("checkout PI = %v/%v, want succeeded/4396", pi["status"], pi["amount_received"]) + } + chID, _ := pi["latest_charge"].(string) + if !strings.HasPrefix(chID, "ch_") { + t.Fatalf("PI latest_charge = %v", pi["latest_charge"]) + } + + // The captured charge carries its balance transaction (funds moved). + body, status = getAuth(t, base+"/v1/charges/"+chID, devToken) + if status != 200 { + t.Fatalf("GET charge -> %d", status) + } + var ch map[string]any + json.Unmarshal([]byte(body), &ch) + if ch["status"] != "succeeded" || ch["captured"] != true { + t.Fatalf("checkout charge = %v/%v", ch["status"], ch["captured"]) + } + btID, _ := ch["balance_transaction"].(string) + if !strings.HasPrefix(btID, "txn_") { + t.Fatalf("charge balance_transaction = %v, want txn_*", ch["balance_transaction"]) + } + + // Events recorded: the session completion + the underlying money events. + for _, typ := range []string{"checkout.session.completed", "payment_intent.succeeded", "charge.created", "payment_intent.created"} { + if !stripeCkHasEvent(t, base, typ) { + t.Fatalf("no %s event recorded", typ) + } + } + + // Re-visiting the completed session redirects again but emits nothing new. + before := len(stripeCkEventObjects(t, base, "checkout.session.completed")) + code, loc, _ = stripeCkGetNoRedirect(t, payURL) + if code != 302 || loc != wantLoc { + t.Fatalf("re-pay = %d/%v, want 302/%s", code, loc, wantLoc) + } + if after := len(stripeCkEventObjects(t, base, "checkout.session.completed")); after != before { + t.Fatalf("checkout.session.completed count = %d, want %d (one-shot)", after, before) + } + + // List filters: status + payment_intent. + body, status = getAuth(t, base+"/v1/checkout/sessions?status=complete", devToken) + if status != 200 { + t.Fatalf("list sessions -> %d", status) + } + var slist map[string]any + json.Unmarshal([]byte(body), &slist) + for _, d := range slist["data"].([]any) { + if d.(map[string]any)["status"] != "complete" { + t.Fatal("status filter leaked a non-complete session") + } + } + body, status = getAuth(t, base+"/v1/checkout/sessions?payment_intent="+piID, devToken) + json.Unmarshal([]byte(body), &slist) + if ids := slist["data"].([]any); len(ids) != 1 || ids[0].(map[string]any)["id"] != csID { + t.Fatalf("payment_intent filter = %v, want [%s]", slist["data"], csID) + } + + // 404s. + if _, status := getAuth(t, base+"/v1/checkout/sessions/cs_nope", devToken); status != 404 { + t.Fatalf("unknown session -> %d, want 404", status) + } + if code, _, _ := stripeCkGetNoRedirect(t, base+"/c/pay/cs_nope"); code != 404 { + t.Fatalf("unknown pay URL -> %d, want 404", code) + } +} + +// stripeCkErr extracts the error object from a Stripe error envelope. +func stripeCkErr(t *testing.T, body string) map[string]any { + t.Helper() + var env map[string]any + if err := json.Unmarshal([]byte(body), &env); err != nil { + t.Fatalf("unmarshal error body %q: %v", body, err) + } + errObj, ok := env["error"].(map[string]any) + if !ok { + t.Fatalf("error = %v, want a dict", env["error"]) + } + return errObj +} + +// TestStripeCkCheckoutSubscriptionMode proves subscription-mode completion: +// the subscription doc is created directly in active state (per the shared +// SUBSCRIPTION DOC CONTRACT) with its first invoice paid, both verifiable +// through the recorded events, and session.subscription links to it. +func TestStripeCkCheckoutSubscriptionMode(t *testing.T) { + base := stripeCkServer(t, "") + + body, status := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"email": "ck-subs@example.test"}) + if status != 201 { + t.Fatalf("create customer -> %d; body %s", status, body) + } + var cust map[string]any + json.Unmarshal([]byte(body), &cust) + custID := cust["id"].(string) + + cs := stripeCkCreateSession(t, base, map[string]any{ + "mode": "subscription", + "success_url": "https://example.test/s?sid={CHECKOUT_SESSION_ID}", + "customer": custID, + "line_items": []any{ + map[string]any{ + "price_data": map[string]any{ + "currency": "usd", + "unit_amount": 2500, + "recurring": map[string]any{"interval": "month"}, + "product_data": map[string]any{ + "name": "Gold Plan", + }, + }, + }, + }, + "subscription_data": map[string]any{"metadata": map[string]any{"tier": "gold"}}, + }) + csID := cs["id"].(string) + + code, loc, payBody := stripeCkGetNoRedirect(t, base+cs["url"].(string)) + if code != 302 || loc != "https://example.test/s?sid="+csID { + t.Fatalf("subscription pay = %d/%v; body %s", code, loc, payBody) + } + + body, status = getAuth(t, base+"/v1/checkout/sessions/"+csID, devToken) + if status != 200 { + t.Fatalf("GET session -> %d", status) + } + json.Unmarshal([]byte(body), &cs) + if cs["status"] != "complete" || cs["payment_status"] != "paid" { + t.Fatalf("subscription session = %v/%v", cs["status"], cs["payment_status"]) + } + subID, _ := cs["subscription"].(string) + if !strings.HasPrefix(subID, "sub_") { + t.Fatalf("session.subscription = %v, want sub_*", cs["subscription"]) + } + + // customer.subscription.created carries the active subscription doc. + subs := stripeCkEventObjects(t, base, "customer.subscription.created") + var subPayload map[string]any + for _, s := range subs { + if s["id"] == subID { + subPayload = s + } + } + if subPayload == nil { + t.Fatalf("no customer.subscription.created for %s", subID) + } + if subPayload["status"] != "active" || subPayload["customer"] != custID { + t.Fatalf("subscription payload = %v/%v, want active/%s", subPayload["status"], subPayload["customer"], custID) + } + if subPayload["metadata"].(map[string]any)["tier"] != "gold" { + t.Fatalf("subscription_data.metadata not passed through: %v", subPayload["metadata"]) + } + items, _ := subPayload["items"].([]any) + if len(items) != 1 { + t.Fatalf("subscription items = %v, want 1", subPayload["items"]) + } + si := items[0].(map[string]any) + if !strings.HasPrefix(si["id"].(string), "si_") || si["subscription"] != subID { + t.Fatalf("subscription item = %v", si) + } + if si["price"].(map[string]any)["unit_amount"].(float64) != 2500 { + t.Fatalf("item price = %v", si["price"]) + } + if subPayload["current_period_end"].(float64) <= subPayload["current_period_start"].(float64) { + t.Fatalf("current period = %v..%v", subPayload["current_period_start"], subPayload["current_period_end"]) + } + + // The first invoice exists in paid state with the charge linked. + invs := stripeCkEventObjects(t, base, "invoice.paid") + var invPayload map[string]any + for _, i := range invs { + if i["subscription"] == subID { + invPayload = i + } + } + if invPayload == nil { + t.Fatalf("no invoice.paid for subscription %s", subID) + } + if invPayload["status"] != "paid" || invPayload["amount_paid"].(float64) != 2500 || invPayload["amount_remaining"].(float64) != 0 { + t.Fatalf("paid invoice = %v (paid %v, remaining %v)", invPayload["status"], invPayload["amount_paid"], invPayload["amount_remaining"]) + } + if ch, _ := invPayload["charge"].(string); !strings.HasPrefix(ch, "ch_") { + t.Fatalf("invoice charge = %v, want ch_*", invPayload["charge"]) + } + if !stripeCkHasEvent(t, base, "invoice.created") { + t.Fatal("no invoice.created recorded") + } +} + +// TestStripeCkCheckoutSetupMode proves setup-mode completion: a SetupIntent +// is created and succeeds, payment_status is no_payment_required (docs), and +// the session completes. +func TestStripeCkCheckoutSetupMode(t *testing.T) { + base := stripeCkServer(t, "") + + cs := stripeCkCreateSession(t, base, map[string]any{ + "mode": "setup", + "success_url": "https://example.test/setup-done", + }) + if cs["payment_status"] != "no_payment_required" { + t.Fatalf("setup payment_status = %v, want no_payment_required", cs["payment_status"]) + } + + code, loc, payBody := stripeCkGetNoRedirect(t, base+cs["url"].(string)) + if code != 302 || loc != "https://example.test/setup-done" { + t.Fatalf("setup pay = %d/%v; body %s", code, loc, payBody) + } + + body, status := getAuth(t, base+"/v1/checkout/sessions/"+cs["id"].(string), devToken) + if status != 200 { + t.Fatalf("GET session -> %d", status) + } + json.Unmarshal([]byte(body), &cs) + if cs["status"] != "complete" { + t.Fatalf("setup session status = %v, want complete", cs["status"]) + } + setiID, _ := cs["setup_intent"].(string) + if !strings.HasPrefix(setiID, "seti_") { + t.Fatalf("session.setup_intent = %v, want seti_*", cs["setup_intent"]) + } + + body, status = getAuth(t, base+"/v1/setup_intents/"+setiID, devToken) + if status != 200 { + t.Fatalf("GET setup_intent -> %d; body %s", status, body) + } + var seti map[string]any + json.Unmarshal([]byte(body), &seti) + if seti["status"] != "succeeded" { + t.Fatalf("checkout SetupIntent status = %v, want succeeded", seti["status"]) + } + if !stripeCkHasEvent(t, base, "setup_intent.succeeded") { + t.Fatal("no setup_intent.succeeded recorded") + } +} + +// TestStripeCkCheckoutExpireAndDecline proves the expire endpoint (open -> +// expired + checkout.session.expired, one-shot, pay page refuses expired +// sessions) and the decline path on the hosted pay URL (session stays open, +// 402 card_error, async-payment-failed events fire, a retry with a good card +// completes). +func TestStripeCkCheckoutExpireAndDecline(t *testing.T) { + base := stripeCkServer(t, "") + + // --- expire --- + cs := stripeCkCreateSession(t, base, map[string]any{ + "mode": "payment", + "success_url": "https://x.test/s", + "line_items": []any{ + map[string]any{"price_data": map[string]any{"currency": "usd", "unit_amount": 100}}, + }, + }) + body, status := postJSONAuth(t, base+"/v1/checkout/sessions/"+cs["id"].(string)+"/expire", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("expire -> %d; body %s", status, body) + } + var expired map[string]any + json.Unmarshal([]byte(body), &expired) + if expired["status"] != "expired" || expired["url"] != nil { + t.Fatalf("expired session = %v/%v", expired["status"], expired["url"]) + } + if !stripeCkHasEvent(t, base, "checkout.session.expired") { + t.Fatal("no checkout.session.expired recorded") + } + + // Only open sessions are expireable. + if _, status := postJSONAuth(t, base+"/v1/checkout/sessions/"+cs["id"].(string)+"/expire", devToken, map[string]any{}); status != 400 { + t.Fatalf("re-expire -> %d, want 400", status) + } + + // The hosted page shows the expired message instead of redirecting. + code, loc, page := stripeCkGetNoRedirect(t, base+"/c/pay/"+cs["id"].(string)) + if code != 200 || loc != "" || !strings.Contains(strings.ToLower(page), "expired") { + t.Fatalf("expired pay page = %d/%q/%q, want 200 with the expired message", code, loc, page) + } + if _, status := postJSONAuth(t, base+"/v1/checkout/sessions/"+cs["id"].(string)+"/expire", devToken, map[string]any{}); status != 400 { + t.Fatalf("expire expired -> %d, want 400", status) + } + if _, status := postJSONAuth(t, base+"/v1/checkout/sessions/nope/expire", devToken, map[string]any{}); status != 404 { + t.Fatalf("expire unknown -> %d, want 404", status) + } + + // --- decline on the pay URL --- + declineTok := mintStripeCardToken(t, base, stripeCardNum("4000", "0000", "0000", "0002")) + goodTok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + csd := stripeCkCreateSession(t, base, map[string]any{ + "mode": "payment", + "success_url": "https://x.test/s", + "line_items": []any{ + map[string]any{"price_data": map[string]any{"currency": "usd", "unit_amount": 900}}, + }, + }) + code, _, declBody := stripeCkGetNoRedirect(t, base+"/c/pay/"+csd["id"].(string)+"?payment_method="+declineTok) + if code != 402 { + t.Fatalf("declined pay -> %d, want 402; body %s", code, declBody) + } + errObj := stripeCkErr(t, declBody) + if errObj["type"] != "card_error" || errObj["code"] != "card_declined" || errObj["decline_code"] != "generic_decline" { + t.Fatalf("decline envelope = %v", errObj) + } + + body, status = getAuth(t, base+"/v1/checkout/sessions/"+csd["id"].(string), devToken) + if status != 200 { + t.Fatalf("GET declined session -> %d", status) + } + var after map[string]any + json.Unmarshal([]byte(body), &after) + if after["status"] != "open" || after["payment_status"] != "unpaid" { + t.Fatalf("declined session = %v/%v, want open/unpaid", after["status"], after["payment_status"]) + } + if !stripeCkHasEvent(t, base, "checkout.session.async_payment_failed") { + t.Fatal("no checkout.session.async_payment_failed recorded") + } + if !stripeCkHasEvent(t, base, "payment_intent.payment_failed") { + t.Fatal("no payment_intent.payment_failed recorded") + } + + // Retrying the same session with a good card completes it. + code, loc, _ = stripeCkGetNoRedirect(t, base+"/c/pay/"+csd["id"].(string)+"?payment_method="+goodTok) + if code != 302 || loc != "https://x.test/s" { + t.Fatalf("retry pay = %d/%v, want 302/https://x.test/s", code, loc) + } + body, status = getAuth(t, base+"/v1/checkout/sessions/"+csd["id"].(string), devToken) + json.Unmarshal([]byte(body), &after) + if after["status"] != "complete" || after["payment_status"] != "paid" { + t.Fatalf("retry session = %v/%v, want complete/paid", after["status"], after["payment_status"]) + } +} + +// TestStripeCkSetupIntentFlows proves the SetupIntent state machine: create +// (requires_payment_method), confirm-without-payment_method 400, normal card +// -> succeeded, SCA card -> requires_action (use_stripe_sdk) -> re-confirm -> +// succeeded (mock 3DS), decline -> 402 with last_setup_error persisted, +// cancel, update metadata, list filters, 404. +func TestStripeCkSetupIntentFlows(t *testing.T) { + base := stripeCkServer(t, "") + + body, status := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"email": "ck-seti@example.test"}) + var cust map[string]any + json.Unmarshal([]byte(body), &cust) + custID := cust["id"].(string) + + // Create: no payment_method -> requires_payment_method (real Stripe). + body, status = postJSONAuth(t, base+"/v1/setup_intents", devToken, map[string]any{ + "customer": custID, + "usage": "off_session", + }) + if status != 201 { + t.Fatalf("create SetupIntent -> %d; body %s", status, body) + } + var seti1 map[string]any + json.Unmarshal([]byte(body), &seti1) + seti1ID := seti1["id"].(string) + if !strings.HasPrefix(seti1ID, "seti_") || seti1["object"] != "setup_intent" { + t.Fatalf("SetupIntent = %v", seti1) + } + if seti1["status"] != "requires_payment_method" { + t.Fatalf("status = %v, want requires_payment_method", seti1["status"]) + } + if seti1["last_setup_error"] != nil || seti1["latest_attempt"] != nil { + t.Fatalf("last_setup_error/latest_attempt = %v/%v, want nulls", seti1["last_setup_error"], seti1["latest_attempt"]) + } + if seti1["usage"] != "off_session" || seti1["client_secret"] == "" { + t.Fatalf("usage/client_secret = %v/%v", seti1["usage"], seti1["client_secret"]) + } + + // Confirm without payment_method -> real 400. + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti1ID+"/confirm", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("confirm without PM -> %d, want 400; body %s", status, body) + } + if errObj := stripeCkErr(t, body); errObj["param"] != "payment_method" { + t.Fatalf("confirm-without-PM param = %v", errObj["param"]) + } + + // Confirm with a normal card -> succeeded immediately. + goodTok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti1ID+"/confirm", devToken, map[string]any{"payment_method": goodTok}) + if status != 200 { + t.Fatalf("confirm good card -> %d; body %s", status, body) + } + json.Unmarshal([]byte(body), &seti1) + if seti1["status"] != "succeeded" || seti1["payment_method"] != goodTok || seti1["next_action"] != nil { + t.Fatalf("confirmed SetupIntent = %v", seti1) + } + if !stripeCkHasEvent(t, base, "setup_intent.succeeded") { + t.Fatal("no setup_intent.succeeded recorded") + } + // Confirming a succeeded SetupIntent -> 400. + if _, status := postJSONAuth(t, base+"/v1/setup_intents/"+seti1ID+"/confirm", devToken, map[string]any{"payment_method": goodTok}); status != 400 { + t.Fatalf("confirm succeeded -> %d, want 400", status) + } + + // SCA card: requires_action + use_stripe_sdk, then re-confirm -> succeeded. + scaTok := mintStripeCardToken(t, base, stripeCardNum("4000", "0027", "6000", "3184")) + body, status = postJSONAuth(t, base+"/v1/setup_intents", devToken, map[string]any{"customer": custID}) + if status != 201 { + t.Fatalf("create SCA SetupIntent -> %d", status) + } + var seti2 map[string]any + json.Unmarshal([]byte(body), &seti2) + seti2ID := seti2["id"].(string) + + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti2ID+"/confirm", devToken, map[string]any{"payment_method": scaTok}) + if status != 200 { + t.Fatalf("confirm SCA -> %d; body %s", status, body) + } + json.Unmarshal([]byte(body), &seti2) + if seti2["status"] != "requires_action" { + t.Fatalf("SCA status = %v, want requires_action", seti2["status"]) + } + na, _ := seti2["next_action"].(map[string]any) + if na == nil || na["type"] != "use_stripe_sdk" { + t.Fatalf("SCA next_action = %v, want use_stripe_sdk", seti2["next_action"]) + } + if !stripeCkHasEvent(t, base, "setup_intent.requires_action") { + t.Fatal("no setup_intent.requires_action recorded") + } + + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti2ID+"/confirm", devToken, map[string]any{"payment_method": scaTok}) + if status != 200 { + t.Fatalf("re-confirm SCA -> %d; body %s", status, body) + } + json.Unmarshal([]byte(body), &seti2) + if seti2["status"] != "succeeded" || seti2["next_action"] != nil { + t.Fatalf("completed SCA SetupIntent = %v", seti2) + } + + // Decline: 402 card_error naming the SetupIntent, which keeps + // requires_payment_method + last_setup_error. + declineTok := mintStripeCardToken(t, base, stripeCardNum("4000", "0000", "0000", "9995")) + body, status = postJSONAuth(t, base+"/v1/setup_intents", devToken, map[string]any{"customer": custID}) + var seti3 map[string]any + json.Unmarshal([]byte(body), &seti3) + seti3ID := seti3["id"].(string) + + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti3ID+"/confirm", devToken, map[string]any{"payment_method": declineTok}) + if status != 402 { + t.Fatalf("confirm decline -> %d, want 402; body %s", status, body) + } + errObj := stripeCkErr(t, body) + if errObj["type"] != "card_error" || errObj["decline_code"] != "insufficient_funds" || errObj["setup_intent"] != seti3ID { + t.Fatalf("decline envelope = %v", errObj) + } + body, status = getAuth(t, base+"/v1/setup_intents/"+seti3ID, devToken) + json.Unmarshal([]byte(body), &seti3) + if seti3["status"] != "requires_payment_method" { + t.Fatalf("declined SetupIntent status = %v", seti3["status"]) + } + lse, _ := seti3["last_setup_error"].(map[string]any) + if lse == nil || lse["decline_code"] != "insufficient_funds" { + t.Fatalf("last_setup_error = %v", seti3["last_setup_error"]) + } + if !stripeCkHasEvent(t, base, "setup_intent.setup_failed") { + t.Fatal("no setup_intent.setup_failed recorded") + } + + // Cancel: requires_* -> canceled with the reason; canceling a terminal + // SetupIntent -> 400. + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti3ID+"/cancel", devToken, map[string]any{"cancellation_reason": "requested_by_customer"}) + if status != 200 { + t.Fatalf("cancel -> %d; body %s", status, body) + } + var canceled map[string]any + json.Unmarshal([]byte(body), &canceled) + if canceled["status"] != "canceled" || canceled["cancellation_reason"] != "requested_by_customer" { + t.Fatalf("canceled SetupIntent = %v/%v", canceled["status"], canceled["cancellation_reason"]) + } + if !stripeCkHasEvent(t, base, "setup_intent.canceled") { + t.Fatal("no setup_intent.canceled recorded") + } + if _, status := postJSONAuth(t, base+"/v1/setup_intents/"+seti3ID+"/cancel", devToken, map[string]any{}); status != 400 { + t.Fatalf("re-cancel -> %d, want 400", status) + } + + // Update metadata. + body, status = postJSONAuth(t, base+"/v1/setup_intents/"+seti1ID, devToken, map[string]any{"metadata": map[string]any{"k": "v"}}) + if status != 200 { + t.Fatalf("update SetupIntent -> %d; body %s", status, body) + } + json.Unmarshal([]byte(body), &seti1) + if seti1["metadata"].(map[string]any)["k"] != "v" { + t.Fatalf("metadata = %v", seti1["metadata"]) + } + + // List filters: customer + payment_method. + body, status = getAuth(t, base+"/v1/setup_intents?customer="+custID, devToken) + if status != 200 { + t.Fatalf("list SetupIntents -> %d", status) + } + var slist map[string]any + json.Unmarshal([]byte(body), &slist) + sdata, _ := slist["data"].([]any) + if len(sdata) < 3 { + t.Fatalf("customer filter returned %d, want >= 3", len(sdata)) + } + for _, d := range sdata { + if d.(map[string]any)["customer"] != custID { + t.Fatal("customer filter leaked another customer's SetupIntent") + } + } + body, status = getAuth(t, base+"/v1/setup_intents?payment_method="+goodTok, devToken) + json.Unmarshal([]byte(body), &slist) + sdata, _ = slist["data"].([]any) + if len(sdata) != 1 || sdata[0].(map[string]any)["id"] != seti1ID { + t.Fatalf("payment_method filter = %v, want [%s]", slist["data"], seti1ID) + } + + // 404. + body, status = getAuth(t, base+"/v1/setup_intents/seti_nope", devToken) + if status != 404 || !strings.Contains(body, "No such setup_intent") { + t.Fatalf("unknown SetupIntent -> %d/%s, want 404", status, body) + } +} + +// TestStripeCkWebhookEndpointGating is the test that proves lib.star's +// delivery gate works, in both directions, plus the endpoint CRUD: +// - no endpoints registered: events deliver (always-deliver behavior); +// - one endpoint with enabled_events ["invoice.paid"]: only invoice.paid +// delivers (a charge's charge.created does NOT) while BOTH are still +// recorded in /v1/events; +// - DELETE the endpoint: delivery reverts to always-deliver. +func TestStripeCkWebhookEndpointGating(t *testing.T) { + sink := newCaptureSink() + defer sink.close() + base := stripeCkServer(t, sink.srv.URL) + + // --- CRUD + validation --- + body, status := postJSONAuth(t, base+"/v1/webhook_endpoints", devToken, map[string]any{ + "url": "https://example.test/hook", + }) + if status != 400 { + t.Fatalf("missing enabled_events -> %d, want 400; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/webhook_endpoints", devToken, map[string]any{ + "enabled_events": []any{"invoice.paid"}, + }) + if status != 400 { + t.Fatalf("missing url -> %d, want 400; body %s", status, body) + } + + body, status = postJSONAuth(t, base+"/v1/webhook_endpoints", devToken, map[string]any{ + "url": sink.srv.URL, + "enabled_events": []any{"invoice.paid"}, + "description": "d4 gate test", + }) + if status != 201 { + t.Fatalf("create webhook endpoint -> %d; body %s", status, body) + } + var we map[string]any + json.Unmarshal([]byte(body), &we) + weID := we["id"].(string) + if we["object"] != "webhook_endpoint" || we["status"] != "enabled" { + t.Fatalf("webhook endpoint = %v", we) + } + if we["api_version"] != "2025-01-27.acacia" { + t.Fatalf("api_version = %v", we["api_version"]) + } + // The secret is the mock signing secret lib.star signs deliveries with. + if we["secret"] != "whsec_stunt_mock_0123456789abcdef0123456789abcdef" { + t.Fatalf("secret = %v, want the lib.star mock signing secret", we["secret"]) + } + + // --- Phase 1: only invoice.paid is enabled --- + // A plain charge's charge.created must NOT deliver... + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{"amount": 1234, "currency": "usd"}) + if status != 201 { + t.Fatalf("create charge (gated) -> %d; body %s", status, body) + } + // events_emit delivers synchronously, but poll briefly for safety. + if got := stripeCkSinkTypes(stripeCkSinkCount(sink, 1, 300*time.Millisecond)); len(got) != 0 { + t.Fatalf("charge.created delivered while only invoice.paid is enabled: %v", got) + } + // ...but it IS still recorded, like real Stripe's GET /v1/events. + if !stripeCkHasEvent(t, base, "charge.created") { + t.Fatal("charge.created not recorded while gated") + } + + // invoice.paid (subscription-mode checkout) DOES deliver. + body, status = postJSONAuth(t, base+"/v1/checkout/sessions", devToken, map[string]any{ + "mode": "subscription", + "success_url": "https://x.test/s", + "line_items": []any{ + map[string]any{"price_data": map[string]any{ + "currency": "usd", "unit_amount": 1500, "recurring": map[string]any{"interval": "month"}, + }}, + }, + }) + if status != 201 { + t.Fatalf("create subscription session -> %d; body %s", status, body) + } + var cs map[string]any + json.Unmarshal([]byte(body), &cs) + if code, _, _ := stripeCkGetNoRedirect(t, base+cs["url"].(string)); code != 302 { + t.Fatalf("subscription pay -> %d, want 302", code) + } + delivered := stripeCkSinkTypes(stripeCkSinkCount(sink, 1, 2*time.Second)) + foundInvoicePaid := false + for _, typ := range delivered { + if typ == "invoice.paid" { + foundInvoicePaid = true + } + if typ != "invoice.paid" { + t.Fatalf("event %q delivered while only invoice.paid is enabled (delivered: %v)", typ, delivered) + } + } + if !foundInvoicePaid { + t.Fatalf("invoice.paid not delivered; sink saw %v", delivered) + } + + // Update: enabled_events must be non-empty. + if body, status := postJSONAuth(t, base+"/v1/webhook_endpoints/"+weID, devToken, map[string]any{"enabled_events": []any{}}); status != 400 { + t.Fatalf("empty enabled_events update -> %d, want 400; body %s", status, body) + } + if _, status := getAuth(t, base+"/v1/webhook_endpoints/"+weID, devToken); status != 200 { + t.Fatalf("retrieve endpoint -> %d", status) + } + if body, status := getAuth(t, base+"/v1/webhook_endpoints", devToken); status != 200 { + t.Fatalf("list endpoints -> %d; body %s", status, body) + } + + // --- Phase 2: DELETE the endpoint -> delivery reverts to always-deliver --- + if body, status := deleteAuth(t, base+"/v1/webhook_endpoints/"+weID, devToken); status != 200 { + t.Fatalf("delete endpoint -> %d; body %s", status, body) + } else { + var del map[string]any + json.Unmarshal([]byte(body), &del) + if del["deleted"] != true || del["id"] != weID { + t.Fatalf("delete response = %v", del) + } + } + if _, status := getAuth(t, base+"/v1/webhook_endpoints/"+weID, devToken); status != 404 { + t.Fatalf("deleted endpoint retrieve -> %d, want 404", status) + } + + before := stripeCkSinkLen(sink) + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{"amount": 4321, "currency": "usd"}) + if status != 201 { + t.Fatalf("create charge (ungated) -> %d; body %s", status, body) + } + delivered = stripeCkSinkTypes(stripeCkSinkCount(sink, before+1, 2*time.Second)) + foundCharge := false + for _, typ := range delivered[before:] { + if typ == "charge.created" { + foundCharge = true + } + } + if !foundCharge { + t.Fatalf("charge.created not delivered after the endpoint was deleted; sink saw %v", delivered) + } +} + +// TestStripeCkFilesAndLinks proves the multipart file upload (purpose enum +// validation, size/type/filename, content-hash retention is internal), the +// file list/retrieve endpoints, and file-link create/list/update with the +// derived expired flag. +func TestStripeCkFilesAndLinks(t *testing.T) { + base := stripeCkServer(t, "") + + fileBytes := []byte{0x89, 'P', 'N', 'G', 0x00, 0xff, 0xfe, 0x01, 0x02} + body, status := stripeCkPostMultipart(t, base+"/v1/files", devToken, + map[string]string{"purpose": "identity_document"}, "file", "doc.PNG", fileBytes) + if status != 201 { + t.Fatalf("POST /v1/files -> %d, want 201; body %s", status, body) + } + var file map[string]any + if err := json.Unmarshal([]byte(body), &file); err != nil { + t.Fatalf("unmarshal file: %v (body %s)", err, body) + } + fileID := file["id"].(string) + if !strings.HasPrefix(fileID, "file_") || file["object"] != "file" { + t.Fatalf("file = %v", file) + } + if file["purpose"] != "identity_document" || file["filename"] != "doc.PNG" { + t.Fatalf("file purpose/filename = %v/%v", file["purpose"], file["filename"]) + } + if file["size"].(float64) != float64(len(fileBytes)) { + t.Fatalf("file size = %v, want %d", file["size"], len(fileBytes)) + } + if file["type"] != "png" { + t.Fatalf("file type = %v, want png (extension, lowercased)", file["type"]) + } + links, _ := file["links"].(map[string]any) + if links["object"] != "list" { + t.Fatalf("file links = %v", file["links"]) + } + if strings.Contains(body, "sha256") { + t.Fatalf("internal content hash leaked into the public file object: %s", body) + } + + // Bad purpose -> 400 naming the param. + body, status = stripeCkPostMultipart(t, base+"/v1/files", devToken, + map[string]string{"purpose": "bogus"}, "file", "x.png", []byte("xx")) + if status != 400 { + t.Fatalf("bad purpose -> %d, want 400; body %s", status, body) + } + if errObj := stripeCkErr(t, body); errObj["param"] != "purpose" { + t.Fatalf("bad purpose param = %v", errObj["param"]) + } + + // Not multipart -> 400. + if body, status := postJSONAuth(t, base+"/v1/files", devToken, map[string]any{"purpose": "identity_document"}); status != 400 { + t.Fatalf("non-multipart upload -> %d, want 400; body %s", status, body) + } + + // List (filter purpose) + retrieve. + body, status = getAuth(t, base+"/v1/files?purpose=identity_document", devToken) + if status != 200 { + t.Fatalf("list files -> %d", status) + } + var flist map[string]any + json.Unmarshal([]byte(body), &flist) + fdata, _ := flist["data"].([]any) + if len(fdata) != 1 || fdata[0].(map[string]any)["id"] != fileID { + t.Fatalf("purpose filter = %v, want [%s]", flist["data"], fileID) + } + body, status = getAuth(t, base+"/v1/files/"+fileID, devToken) + if status != 200 { + t.Fatalf("retrieve file -> %d", status) + } + + // File links. + body, status = postJSONAuth(t, base+"/v1/file_links", devToken, map[string]any{ + "file": fileID, + "metadata": map[string]any{"who": "d4"}, + }) + if status != 201 { + t.Fatalf("create file link -> %d; body %s", status, body) + } + var link map[string]any + json.Unmarshal([]byte(body), &link) + linkID := link["id"].(string) + if !strings.HasPrefix(linkID, "link_") || link["object"] != "file_link" { + t.Fatalf("link = %v", link) + } + if link["file"] != fileID || link["expired"] != false || link["expires_at"] != nil { + t.Fatalf("link file/expired/expires_at = %v/%v/%v", link["file"], link["expired"], link["expires_at"]) + } + if !strings.HasPrefix(link["url"].(string), "https://files.stripe.com/links/") { + t.Fatalf("link url = %v", link["url"]) + } + + // Validation: missing file, unknown file. + if body, status := postJSONAuth(t, base+"/v1/file_links", devToken, map[string]any{}); status != 400 { + t.Fatalf("link without file -> %d, want 400; body %s", status, body) + } + if body, status := postJSONAuth(t, base+"/v1/file_links", devToken, map[string]any{"file": "file_nope"}); status != 400 || !strings.Contains(body, "No such file") { + t.Fatalf("link to unknown file -> %d, want 400 resource_missing; body %s", status, body) + } + + // The file now lists its link. + body, status = getAuth(t, base+"/v1/files/"+fileID, devToken) + if status != 200 { + t.Fatalf("retrieve file (linked) -> %d", status) + } + json.Unmarshal([]byte(body), &file) + linkData, _ := file["links"].(map[string]any)["data"].([]any) + if len(linkData) != 1 || linkData[0].(map[string]any)["id"] != linkID { + t.Fatalf("file.links = %v, want [%s]", file["links"], linkID) + } + + // Links list filter. + body, status = getAuth(t, base+"/v1/file_links?file="+fileID, devToken) + if status != 200 { + t.Fatalf("list links -> %d", status) + } + var llist map[string]any + json.Unmarshal([]byte(body), &llist) + ldata, _ := llist["data"].([]any) + if len(ldata) != 1 || ldata[0].(map[string]any)["id"] != linkID { + t.Fatalf("file filter = %v, want [%s]", llist["data"], linkID) + } + + // Update: an expires_at in the past flips the derived expired flag. + body, status = postJSONAuth(t, base+"/v1/file_links/"+linkID, devToken, map[string]any{ + "expires_at": time.Now().Add(-time.Minute).Unix(), + }) + if status != 200 { + t.Fatalf("update link -> %d; body %s", status, body) + } + json.Unmarshal([]byte(body), &link) + if link["expired"] != true { + t.Fatalf("expired after past expires_at = %v, want true", link["expired"]) + } + + // 404s. + if _, status := getAuth(t, base+"/v1/files/file_nope", devToken); status != 404 { + t.Fatalf("unknown file -> %d, want 404", status) + } + if _, status := getAuth(t, base+"/v1/file_links/link_nope", devToken); status != 404 { + t.Fatalf("unknown link -> %d, want 404", status) + } +} diff --git a/internal/engine/stripe_connect_test.go b/internal/engine/stripe_connect_test.go index 1701e339..fda61103 100644 --- a/internal/engine/stripe_connect_test.go +++ b/internal/engine/stripe_connect_test.go @@ -415,7 +415,11 @@ func TestStripeStyleConnect(t *testing.T) { t.Fatalf("balance after payout = %v, want %v", firstAfter["amount"], expectedAfterPayout) } - // ===== Transfer reversal ===== + // ===== Transfer reversal (partial) ===== + // The real API returns the transfer_reversal object; the transfer's + // amount_reversed/reversed fields accumulate across partials, so a + // partial reversal leaves reversed=false (updated from the old mock, + // which returned the transfer with reversed=true). body, status = postJSONAuthHeader(t, base+"/v1/transfers/"+transferID+"/reversals", token, map[string]any{ "amount": 3000, @@ -423,12 +427,49 @@ func TestStripeStyleConnect(t *testing.T) { if status != 200 { t.Fatalf("POST /v1/transfers/%s/reversals -> status %d, want 200; body %s", transferID, status, body) } - var reversed map[string]any - if err := json.Unmarshal([]byte(body), &reversed); err != nil { - t.Fatalf("unmarshal reversed transfer: %v", err) + var reversal map[string]any + if err := json.Unmarshal([]byte(body), &reversal); err != nil { + t.Fatalf("unmarshal transfer reversal: %v (body %s)", err, body) } - if reversed["reversed"] != true { - t.Fatalf("transfer reversed = %v, want true", reversed["reversed"]) + if reversal["object"] != "transfer_reversal" { + t.Fatalf("reversal object = %v, want 'transfer_reversal'", reversal["object"]) + } + reversalID, ok := reversal["id"].(string) + if !ok || !strings.HasPrefix(reversalID, "trr_") { + t.Fatalf("reversal id = %v, want trr_* prefix", reversal["id"]) + } + if reversal["amount"].(float64) != 3000 { + t.Fatalf("reversal amount = %v, want 3000", reversal["amount"]) + } + if reversal["transfer"] != transferID { + t.Fatalf("reversal transfer = %v, want %s", reversal["transfer"], transferID) + } + if reversal["balance_transaction"] == nil { + t.Fatal("reversal balance_transaction is missing") + } + + // The transfer reflects the partial reversal. + body, status = getAuth(t, base+"/v1/transfers/"+transferID, token) + if status != 200 { + t.Fatalf("GET /v1/transfers/%s (after partial reversal) -> status %d, want 200; body %s", transferID, status, body) + } + var afterPartial map[string]any + if err := json.Unmarshal([]byte(body), &afterPartial); err != nil { + t.Fatalf("unmarshal transfer after partial reversal: %v", err) + } + if afterPartial["amount_reversed"].(float64) != 3000 { + t.Fatalf("transfer amount_reversed = %v, want 3000", afterPartial["amount_reversed"]) + } + if afterPartial["reversed"] != false { + t.Fatalf("transfer reversed = %v, want false (partial reversal)", afterPartial["reversed"]) + } + revList, ok := afterPartial["reversals"].(map[string]any) + if !ok { + t.Fatalf("transfer reversals = %v, want a list object", afterPartial["reversals"]) + } + revData, _ := revList["data"].([]any) + if len(revData) != 1 { + t.Fatalf("embedded reversals has %d items, want 1", len(revData)) } // ===== Assert Connect webhook events fired ===== @@ -518,15 +559,41 @@ func TestStripeStyleConnectAuth(t *testing.T) { {"POST", "/v1/transfers"}, {"GET", "/v1/transfers"}, {"GET", "/v1/transfers/tr_1"}, + {"POST", "/v1/transfers/tr_1/reversals"}, + {"GET", "/v1/transfers/tr_1/reversals"}, + {"GET", "/v1/transfers/tr_1/reversals/trr_1"}, {"POST", "/v1/payouts"}, {"GET", "/v1/payouts"}, + {"GET", "/v1/payouts/po_1"}, + {"POST", "/v1/payouts/po_1"}, + {"POST", "/v1/payouts/po_1/cancel"}, + {"POST", "/v1/accounts/acct_1/persons"}, + {"GET", "/v1/accounts/acct_1/persons"}, + {"GET", "/v1/accounts/acct_1/persons/person_1"}, + {"POST", "/v1/accounts/acct_1/persons/person_1"}, + {"DELETE", "/v1/accounts/acct_1/persons/person_1"}, + {"GET", "/v1/persons/person_1"}, + {"POST", "/v1/persons/person_1"}, + {"POST", "/v1/accounts/acct_1/external_accounts"}, + {"GET", "/v1/accounts/acct_1/external_accounts"}, + {"GET", "/v1/accounts/acct_1/external_accounts/ba_1"}, + {"DELETE", "/v1/accounts/acct_1/external_accounts/ba_1"}, + {"POST", "/v1/accounts/acct_1/login_links"}, + {"GET", "/v1/application_fees"}, + {"GET", "/v1/application_fees/fee_1"}, + {"POST", "/v1/application_fees/fee_1/refund"}, + {"POST", "/v1/application_fees/fee_1/refunds"}, + {"GET", "/v1/application_fees/fee_1/refunds"}, } for _, ep := range connectEndpoints { var req *http.Request - if ep.method == "GET" { + switch ep.method { + case "GET": req, _ = http.NewRequest("GET", base+ep.path, nil) - } else { + case "DELETE": + req, _ = http.NewRequest("DELETE", base+ep.path, nil) + default: req, _ = http.NewRequest("POST", base+ep.path, bytes.NewReader([]byte("{}"))) req.Header.Set("Content-Type", "application/json") } @@ -644,7 +711,1046 @@ func TestStripeStylePayoutAccountScoping(t *testing.T) { if len(bodies) == 0 { t.Fatal("no payout.created webhook delivered") } - if strings.Contains(bodies[0], "_account") { - t.Fatal("_account leaked into the payout.created webhook payload") + // The invariant is that the internal scoping KEY never leaks. A plain + // substring check is not enough: the real payout object legitimately + // carries "bank_account" values (source_type/type enums), which contain + // the "_account" substring. + var env map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &env); err != nil { + t.Fatalf("webhook body is not JSON: %v (%s)", err, bodies[0]) + } + payload, ok := env["payload"].(map[string]any) + if !ok { + t.Fatalf("webhook body has no payload object: %s", bodies[0]) + } + if _, leaked := payload["_account"]; leaked { + t.Fatalf("_account leaked into the payout.created webhook payload: %s", bodies[0]) + } +} + +// ============================================================================ +// d5-connect deepening: persons, capabilities, external accounts, login +// links, application fees, transfer reversals, payout lifecycle. Every new +// helper is prefixed stripeCn so parallel agents cannot collide. +// ============================================================================ + +// stripeCnEngine boots the stripe-style adapter with an optional webhook sink +// and returns its base URL. +func stripeCnEngine(t *testing.T, sinkURL string) string { + t.Helper() + adapterDir := filepath.Join("..", "..", "adapters", "stripe-style") + absAdapterDir, err := filepath.Abs(adapterDir) + if err != nil { + t.Fatal(err) + } + cfg := map[string]any{} + if sinkURL != "" { + cfg["webhook_url"] = sinkURL + } + stateDir := t.TempDir() + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "stripe": {Adapter: absAdapterDir, Config: cfg}, + }, + } + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + t.Cleanup(func() { e.Close() }) + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + t.Cleanup(cancel) + time.Sleep(50 * time.Millisecond) + return addrs["stripe"] +} + +// stripeCnDeleteAuth performs an authenticated DELETE with extra headers. +func stripeCnDeleteAuth(t *testing.T, url, token string, extra map[string]string) (string, int) { + t.Helper() + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + for k, v := range extra { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// stripeCnEventCount counts recorded events of one type (GET /v1/events). +func stripeCnEventCount(t *testing.T, base, eventType string) int { + t.Helper() + body, status := getAuth(t, base+"/v1/events?type="+eventType+"&limit=100", devToken) + if status != 200 { + t.Fatalf("GET /v1/events?type=%s -> %d; %s", eventType, status, body) + } + var list map[string]any + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatal(err) + } + data, _ := list["data"].([]any) + n := 0 + for _, d := range data { + if ev, ok := d.(map[string]any); ok && ev["type"] == eventType { + n++ + } + } + return n +} + +// stripeCnCreateAccount creates a connected account of the given type. +func stripeCnCreateAccount(t *testing.T, base, acctType string) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/accounts", devToken, map[string]any{ + "type": acctType, + "country": "US", + "email": "cn-test@example.com", + }) + if status != 201 { + t.Fatalf("create account -> %d; %s", status, body) + } + var acct map[string]any + if err := json.Unmarshal([]byte(body), &acct); err != nil { + t.Fatal(err) + } + id, ok := acct["id"].(string) + if !ok { + t.Fatalf("account id = %v", acct["id"]) + } + return id +} + +// stripeCnCreateClock activates the global test clock at frozenTime. +func stripeCnCreateClock(t *testing.T, base string, frozenTime int64) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/test_clocks", devToken, map[string]any{ + "frozen_time": frozenTime, + }) + if status != 201 { + t.Fatalf("create test clock -> %d; %s", status, body) + } + var clock map[string]any + if err := json.Unmarshal([]byte(body), &clock); err != nil { + t.Fatal(err) + } + id, ok := clock["id"].(string) + if !ok { + t.Fatalf("clock id = %v", clock["id"]) + } + return id +} + +// stripeCnAdvanceClock moves the global test clock forward. +func stripeCnAdvanceClock(t *testing.T, base, clockID string, frozenTime int64) { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{ + "frozen_time": frozenTime, + }) + if status != 200 { + t.Fatalf("advance test clock to %d -> %d; %s", frozenTime, status, body) + } +} + +// TestStripeCnPersons exercises the person CRUD surface: nested create, list +// with the relationship filter, nested + standalone retrieve, nested + +// standalone update, and soft delete. +func TestStripeCnPersons(t *testing.T) { + base := stripeCnEngine(t, "") + acctID := stripeCnCreateAccount(t, base, "express") + + // Create an owner/representative person. + body, status := postJSONAuth(t, base+"/v1/accounts/"+acctID+"/persons", devToken, map[string]any{ + "first_name": "Ada", + "last_name": "Lovelace", + "email": "ada@example.com", + "dob": map[string]any{"day": 10, "month": 12, "year": 1815}, + "relationship": map[string]any{ + "owner": true, + "representative": true, + }, + }) + if status != 200 { + t.Fatalf("create person -> %d; %s", status, body) + } + var person map[string]any + if err := json.Unmarshal([]byte(body), &person); err != nil { + t.Fatal(err) + } + personID, ok := person["id"].(string) + if !ok || !strings.HasPrefix(personID, "person_") { + t.Fatalf("person id = %v, want person_* prefix", person["id"]) + } + if person["object"] != "person" { + t.Fatalf("person object = %v", person["object"]) + } + if person["account"] != acctID { + t.Fatalf("person account = %v, want %s", person["account"], acctID) + } + verification, _ := person["verification"].(map[string]any) + if verification["status"] != "unverified" { + t.Fatalf("verification.status = %v, want unverified", verification["status"]) + } + if verification["document"] != nil { + t.Fatalf("verification.document = %v, want null", verification["document"]) + } + reqs, _ := person["requirements"].(map[string]any) + if due, _ := reqs["currently_due"].([]any); len(due) != 0 { + t.Fatalf("requirements.currently_due = %v, want []", reqs["currently_due"]) + } + rel, _ := person["relationship"].(map[string]any) + if rel["owner"] != true || rel["representative"] != true { + t.Fatalf("relationship owner/representative = %v/%v, want true/true", rel["owner"], rel["representative"]) + } + dob, _ := person["dob"].(map[string]any) + if dob["year"].(float64) != 1815 { + t.Fatalf("dob.year = %v, want 1815", dob["year"]) + } + + // A second, non-owner person. + _, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/persons", devToken, map[string]any{ + "first_name": "Grace", + "last_name": "Hopper", + "relationship": map[string]any{"executive": true}, + }) + if status != 200 { + t.Fatalf("create second person -> %d", status) + } + + // List: 2 total; the relationship[owner]=true filter narrows to 1. + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/persons", devToken) + if status != 200 { + t.Fatalf("list persons -> %d; %s", status, body) + } + var list map[string]any + if err := json.Unmarshal([]byte(body), &list); err != nil { + t.Fatal(err) + } + if data, _ := list["data"].([]any); len(data) != 2 { + t.Fatalf("person list has %d items, want 2", len(data)) + } + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/persons?relationship[owner]=true", devToken) + if status != 200 { + t.Fatalf("list persons (owner) -> %d; %s", status, body) + } + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 1 { + t.Fatalf("owner-filtered person list has %d items, want 1", len(data)) + } + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/persons?relationship[representative]=false", devToken) + if status != 200 { + t.Fatalf("list persons (representative=false) -> %d; %s", status, body) + } + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 1 { + t.Fatalf("representative=false person list has %d items, want 1", len(data)) + } + + // Retrieve: nested and standalone routes see the same doc. + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/persons/"+personID, devToken) + if status != 200 { + t.Fatalf("GET nested person -> %d; %s", status, body) + } + body, status = getAuth(t, base+"/v1/persons/"+personID, devToken) + if status != 200 { + t.Fatalf("GET /v1/persons/%s -> %d; %s", personID, status, body) + } + + // Update: nested route, verified via the standalone route. + body, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/persons/"+personID, devToken, map[string]any{ + "first_name": "Augusta", + }) + if status != 200 { + t.Fatalf("update person -> %d; %s", status, body) + } + body, _ = getAuth(t, base+"/v1/persons/"+personID, devToken) + var updated map[string]any + _ = json.Unmarshal([]byte(body), &updated) + if updated["first_name"] != "Augusta" { + t.Fatalf("first_name after update = %v, want Augusta", updated["first_name"]) + } + + // Standalone update route. + body, status = postJSONAuth(t, base+"/v1/persons/"+personID, devToken, map[string]any{ + "last_name": "King", + }) + if status != 200 { + t.Fatalf("standalone person update -> %d; %s", status, body) + } + + // Delete: soft — hidden from lists, 404 on retrieve. + body, status = stripeCnDeleteAuth(t, base+"/v1/accounts/"+acctID+"/persons/"+personID, devToken, nil) + if status != 200 { + t.Fatalf("delete person -> %d; %s", status, body) + } + var deleted map[string]any + _ = json.Unmarshal([]byte(body), &deleted) + if deleted["deleted"] != true { + t.Fatalf("delete response = %v, want deleted true", body) + } + body, _ = getAuth(t, base+"/v1/accounts/"+acctID+"/persons", devToken) + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 1 { + t.Fatalf("person list after delete has %d items, want 1", len(data)) + } + body, status = getAuth(t, base+"/v1/persons/"+personID, devToken) + if status != 404 { + t.Fatalf("GET deleted person -> %d, want 404", status) + } + + // Persons on a missing account -> 404. + body, status = postJSONAuth(t, base+"/v1/accounts/acct_missing/persons", devToken, map[string]any{ + "first_name": "No", + "last_name": "One", + }) + if status != 404 { + t.Fatalf("create person on missing account -> %d, want 404; %s", status, body) + } + + // Person lifecycle events were recorded. + if n := stripeCnEventCount(t, base, "person.created"); n != 2 { + t.Fatalf("person.created events = %d, want 2", n) + } + if n := stripeCnEventCount(t, base, "person.updated"); n != 2 { + t.Fatalf("person.updated events = %d, want 2", n) + } + if n := stripeCnEventCount(t, base, "person.deleted"); n != 1 { + t.Fatalf("person.deleted events = %d, want 1", n) + } +} + +// TestStripeCnCapabilities pins the capability state machine: an Express +// account auto-requests transfers + card_payments (pending at creation), and +// the pending capabilities flip to active one test-clock day later, driving +// payouts_enabled / charges_enabled. +func TestStripeCnCapabilities(t *testing.T) { + base := stripeCnEngine(t, "") + + const t0 = int64(1750000000) + clockID := stripeCnCreateClock(t, base, t0) + acctID := stripeCnCreateAccount(t, base, "express") + + body, status := getAuth(t, base+"/v1/accounts/"+acctID, devToken) + if status != 200 { + t.Fatalf("GET account -> %d; %s", status, body) + } + var acct map[string]any + if err := json.Unmarshal([]byte(body), &acct); err != nil { + t.Fatal(err) + } + caps, _ := acct["capabilities"].(map[string]any) + if caps["transfers"] != "pending" { + t.Fatalf("express account transfers capability = %v, want pending (auto-requested)", caps["transfers"]) + } + if caps["card_payments"] != "pending" { + t.Fatalf("express account card_payments capability = %v, want pending", caps["card_payments"]) + } + if acct["payouts_enabled"] != false { + t.Fatalf("payouts_enabled while pending = %v, want false", acct["payouts_enabled"]) + } + settings, _ := acct["settings"].(map[string]any) + if settings == nil { + t.Fatal("settings object missing") + } + payouts, _ := settings["payouts"].(map[string]any) + schedule, _ := payouts["schedule"].(map[string]any) + if schedule == nil || schedule["interval"] != "daily" { + t.Fatalf("settings.payouts.schedule = %v, want interval daily", settings["payouts"]) + } + if acct["default_currency"] != "usd" { + t.Fatalf("default_currency = %v, want usd", acct["default_currency"]) + } + reqs, _ := acct["requirements"].(map[string]any) + for _, k := range []string{"currently_due", "eventually_due", "past_due", "alternatives", "errors", "pending_verification"} { + if _, present := reqs[k]; !present { + t.Fatalf("requirements.%s missing from %v", k, reqs) + } + } + if reqs["disabled_reason"] != nil { + t.Fatalf("requirements.disabled_reason = %v, want null", reqs["disabled_reason"]) + } + + // One day later the review window closes: capabilities activate. + stripeCnAdvanceClock(t, base, clockID, t0+24*3600+5) + body, status = getAuth(t, base+"/v1/accounts/"+acctID, devToken) + if status != 200 { + t.Fatalf("GET account after advance -> %d; %s", status, body) + } + acct = nil + if err := json.Unmarshal([]byte(body), &acct); err != nil { + t.Fatal(err) + } + caps, _ = acct["capabilities"].(map[string]any) + if caps["transfers"] != "active" { + t.Fatalf("transfers after one day = %v, want active", caps["transfers"]) + } + if caps["card_payments"] != "active" { + t.Fatalf("card_payments after one day = %v, want active", caps["card_payments"]) + } + if acct["payouts_enabled"] != true { + t.Fatalf("payouts_enabled after activation = %v, want true", acct["payouts_enabled"]) + } + if acct["charges_enabled"] != true { + t.Fatalf("charges_enabled after activation = %v, want true", acct["charges_enabled"]) + } + + // account.updated fired once at creation and once for the activation. + if n := stripeCnEventCount(t, base, "account.updated"); n != 2 { + t.Fatalf("account.updated events = %d, want 2 (create + activation)", n) + } +} + +// TestStripeCnExternalAccounts covers external bank-account attach (inline +// hash), list/retrieve, the embedded list on the account, the default- +// for-currency semantics, the default-account delete restriction, and the +// raw account number never being stored or echoed. +func TestStripeCnExternalAccounts(t *testing.T) { + base := stripeCnEngine(t, "") + acctID := stripeCnCreateAccount(t, base, "custom") + + makeEA := func(number, currency string) map[string]any { + return map[string]any{ + "country": "US", + "currency": currency, + "account_number": number, + "routing_number": "110000000", + "account_holder_name": "Ada Lovelace", + "account_holder_type": "individual", + } + } + + // First usd bank account becomes the default for the currency. + body, status := postJSONAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken, map[string]any{ + "external_account": makeEA("000111111116", "usd"), + }) + if status != 201 { + t.Fatalf("create external account -> %d; %s", status, body) + } + var ba map[string]any + if err := json.Unmarshal([]byte(body), &ba); err != nil { + t.Fatal(err) + } + if ba["object"] != "bank_account" { + t.Fatalf("external account object = %v, want bank_account", ba["object"]) + } + baID, ok := ba["id"].(string) + if !ok || !strings.HasPrefix(baID, "ba_") { + t.Fatalf("external account id = %v, want ba_* prefix", ba["id"]) + } + if ba["last4"] != "1116" { + t.Fatalf("last4 = %v, want 1116", ba["last4"]) + } + if ba["default_for_currency"] != true { + t.Fatalf("first external account default_for_currency = %v, want true", ba["default_for_currency"]) + } + if ba["bank_name"] != "STRIPE TEST BANK" { + t.Fatalf("bank_name = %v, want STRIPE TEST BANK", ba["bank_name"]) + } + if strings.Contains(body, "000111111116") { + t.Fatal("raw account number echoed in the response") + } + + // A second usd account is NOT the default; the first keeps the flag. + _, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken, map[string]any{ + "external_account": makeEA("000999999999", "usd"), + }) + if status != 201 { + t.Fatalf("create second external account -> %d", status) + } + + // The first eur account defaults for eur. + body, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken, map[string]any{ + "external_account": makeEA("000888888888", "eur"), + }) + if status != 201 { + t.Fatalf("create eur external account -> %d; %s", status, body) + } + var eur map[string]any + _ = json.Unmarshal([]byte(body), &eur) + eurID, _ := eur["id"].(string) + if eur["default_for_currency"] != true { + t.Fatalf("eur external account default_for_currency = %v, want true", eur["default_for_currency"]) + } + + // List + embedded account list + retrieve. + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken) + if status != 200 { + t.Fatalf("list external accounts -> %d; %s", status, body) + } + var list map[string]any + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 3 { + t.Fatalf("external account list has %d items, want 3", len(data)) + } + body, status = getAuth(t, base+"/v1/accounts/"+acctID, devToken) + if status != 200 { + t.Fatalf("GET account -> %d", status) + } + var acct map[string]any + _ = json.Unmarshal([]byte(body), &acct) + embedded, _ := acct["external_accounts"].(map[string]any) + if embedded == nil || embedded["total_count"].(float64) != 3 { + t.Fatalf("embedded external_accounts = %v, want total_count 3", acct["external_accounts"]) + } + body, status = getAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts/"+baID, devToken) + if status != 200 { + t.Fatalf("GET external account -> %d; %s", status, body) + } + + // Another account's external account does not resolve under this one. + otherID := stripeCnCreateAccount(t, base, "custom") + body, status = getAuth(t, base+"/v1/accounts/"+otherID+"/external_accounts/"+baID, devToken) + if status != 404 { + t.Fatalf("GET external account under wrong account -> %d, want 404", status) + } + + // Deleting a non-default-currency default (eur on a usd account) is + // allowed; deleting the default usd account is refused. + body, status = stripeCnDeleteAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts/"+eurID, devToken, nil) + if status != 200 { + t.Fatalf("delete eur external account -> %d; %s", status, body) + } + var del map[string]any + _ = json.Unmarshal([]byte(body), &del) + if del["deleted"] != true || del["object"] != "bank_account" { + t.Fatalf("delete response = %s, want deleted bank_account", body) + } + body, status = stripeCnDeleteAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts/"+baID, devToken, nil) + if status != 400 { + t.Fatalf("delete default-currency external account -> %d, want 400; %s", status, body) + } + + // Unknown token -> 404 like the adapter's other missing-resource refs. + body, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken, map[string]any{ + "external_account": "tok_missing", + }) + if status != 404 { + t.Fatalf("create external account with unknown token -> %d, want 404; %s", status, body) + } + + // External-account lifecycle events. + if n := stripeCnEventCount(t, base, "account.external_account.created"); n != 3 { + t.Fatalf("account.external_account.created events = %d, want 3", n) + } + if n := stripeCnEventCount(t, base, "account.external_account.deleted"); n != 1 { + t.Fatalf("account.external_account.deleted events = %d, want 1", n) + } +} + +// TestStripeCnLoginLinks covers Express dashboard login links: 200 with the +// login_link object, 404 for a missing account, and the documented refusal +// for standard accounts. +func TestStripeCnLoginLinks(t *testing.T) { + base := stripeCnEngine(t, "") + acctID := stripeCnCreateAccount(t, base, "express") + + body, status := postJSONAuth(t, base+"/v1/accounts/"+acctID+"/login_links", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("create login link -> %d; %s", status, body) + } + var link map[string]any + if err := json.Unmarshal([]byte(body), &link); err != nil { + t.Fatal(err) + } + if link["object"] != "login_link" { + t.Fatalf("login link object = %v, want login_link", link["object"]) + } + url, ok := link["url"].(string) + if !ok || !strings.HasPrefix(url, "https://connect.stunt.local/"+acctID+"/") { + t.Fatalf("login link url = %v, want https://connect.stunt.local/%s/...", link["url"], acctID) + } + if created, _ := link["created"].(float64); created <= 0 { + t.Fatalf("login link created = %v, want a timestamp", link["created"]) + } + + // Missing account -> 404. + body, status = postJSONAuth(t, base+"/v1/accounts/acct_missing/login_links", devToken, map[string]any{}) + if status != 404 { + t.Fatalf("login link for missing account -> %d, want 404; %s", status, body) + } + + // Standard accounts manage their own login: refused. + stdID := stripeCnCreateAccount(t, base, "standard") + body, status = postJSONAuth(t, base+"/v1/accounts/"+stdID+"/login_links", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("login link for standard account -> %d, want 400; %s", status, body) + } +} + +// TestStripeCnTransferReversals pins partial reversals: the reversal object +// response, accumulation on the transfer, over-reversal + already-reversed +// 400s, and the reversal list/retrieve routes. +func TestStripeCnTransferReversals(t *testing.T) { + base := stripeCnEngine(t, "") + acctID := stripeCnCreateAccount(t, base, "express") + + mkTransfer := func() string { + body, status := postJSONAuth(t, base+"/v1/transfers", devToken, map[string]any{ + "amount": 10000, + "currency": "usd", + "destination": acctID, + }) + if status != 201 { + t.Fatalf("create transfer -> %d; %s", status, body) + } + var tr map[string]any + _ = json.Unmarshal([]byte(body), &tr) + return tr["id"].(string) + } + + // Partial reversal returns the transfer_reversal object. + trID := mkTransfer() + body, status := postJSONAuth(t, base+"/v1/transfers/"+trID+"/reversals", devToken, map[string]any{ + "amount": 3000, + }) + if status != 200 { + t.Fatalf("partial reversal -> %d; %s", status, body) + } + var rev map[string]any + if err := json.Unmarshal([]byte(body), &rev); err != nil { + t.Fatal(err) + } + if rev["object"] != "transfer_reversal" || rev["amount"].(float64) != 3000 { + t.Fatalf("reversal = %v", body) + } + revID, _ := rev["id"].(string) + if !strings.HasPrefix(revID, "trr_") { + t.Fatalf("reversal id = %v, want trr_* prefix", rev["id"]) + } + + // Reversing more than the remainder -> 400 (7000 left of 10000). + body, status = postJSONAuth(t, base+"/v1/transfers/"+trID+"/reversals", devToken, map[string]any{ + "amount": 9999, + }) + if status != 400 { + t.Fatalf("over-reversal -> %d, want 400; %s", status, body) + } + if !strings.Contains(body, "greater than unreversed amount") { + t.Fatalf("over-reversal error = %s, want the unreversed-amount message", body) + } + + // Reversal list + retrieve. + body, status = getAuth(t, base+"/v1/transfers/"+trID+"/reversals", devToken) + if status != 200 { + t.Fatalf("list reversals -> %d; %s", status, body) + } + var list map[string]any + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 1 { + t.Fatalf("reversal list has %d items, want 1", len(data)) + } + body, status = getAuth(t, base+"/v1/transfers/"+trID+"/reversals/"+revID, devToken) + if status != 200 { + t.Fatalf("GET reversal -> %d; %s", status, body) + } + + // A reversal under the wrong transfer is a 404. + otherTr := mkTransfer() + body, status = getAuth(t, base+"/v1/transfers/"+otherTr+"/reversals/"+revID, devToken) + if status != 404 { + t.Fatalf("GET reversal under wrong transfer -> %d, want 404", status) + } + + // Full remaining reversal closes the transfer. + body, status = postJSONAuth(t, base+"/v1/transfers/"+trID+"/reversals", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("full reversal -> %d; %s", status, body) + } + body, status = getAuth(t, base+"/v1/transfers/"+trID, devToken) + if status != 200 { + t.Fatalf("GET transfer after full reversal -> %d; %s", status, body) + } + var tr map[string]any + _ = json.Unmarshal([]byte(body), &tr) + if tr["reversed"] != true || tr["amount_reversed"].(float64) != 10000 { + t.Fatalf("transfer after full reversal = reversed %v amount_reversed %v", tr["reversed"], tr["amount_reversed"]) + } + + // Reversing a fully reversed transfer -> 400. + body, status = postJSONAuth(t, base+"/v1/transfers/"+trID+"/reversals", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("reversal of fully reversed transfer -> %d, want 400; %s", status, body) + } + if !strings.Contains(body, "already fully reversed") { + t.Fatalf("fully-reversed error = %s, want the already-fully-reversed message", body) + } + + // Connected-account funds left with the platform after the full reversal. + body, status = getAuthHeader(t, base+"/v1/balance", devToken, map[string]string{"Stripe-Account": acctID}) + if status != 200 { + t.Fatalf("GET balance -> %d; %s", status, body) + } + var bal map[string]any + _ = json.Unmarshal([]byte(body), &bal) + avail, _ := bal["available"].([]any) + if first, _ := avail[0].(map[string]any); first["amount"].(float64) != 10000 { + t.Fatalf("balance after transfer-out + full reversal = %v, want 10000 (other transfer still in)", avail[0]) + } + + // transfer.reversed fires once per reversal: the partial + the full. + if n := stripeCnEventCount(t, base, "transfer.reversed"); n != 2 { + t.Fatalf("transfer.reversed events = %d, want 2", n) + } +} + +// TestStripeCnPayoutLifecycle pins the payout state machine on the test +// clock (pending -> in_transit -> paid, each transition emitted exactly +// once), the arrival_date math, the implicit destination from the default +// external account, metadata updates, cancel semantics, and the funds +// return on cancel. +func TestStripeCnPayoutLifecycle(t *testing.T) { + base := stripeCnEngine(t, "") + + const t0 = int64(1760000000) + clockID := stripeCnCreateClock(t, base, t0) + acctID := stripeCnCreateAccount(t, base, "express") + + // Fund the account and attach a default bank account. + _, status := postJSONAuth(t, base+"/v1/transfers", devToken, map[string]any{ + "amount": 10000, + "currency": "usd", + "destination": acctID, + }) + if status != 201 { + t.Fatalf("seed transfer -> %d", status) + } + _, status = postJSONAuth(t, base+"/v1/accounts/"+acctID+"/external_accounts", devToken, map[string]any{ + "external_account": map[string]any{ + "country": "US", + "currency": "usd", + "account_number": "000123456789", + "routing_number": "110000000", + }, + }) + if status != 201 { + t.Fatalf("attach bank account -> %d", status) + } + + poHeader := map[string]string{"Stripe-Account": acctID} + createPayout := func(amount float64, method string) map[string]any { + t.Helper() + payload := map[string]any{"amount": amount, "currency": "usd"} + if method != "" { + payload["method"] = method + } + body, st := postJSONAuthHeader(t, base+"/v1/payouts", devToken, payload, poHeader) + if st != 201 { + t.Fatalf("create payout -> %d; %s", st, body) + } + var po map[string]any + if err := json.Unmarshal([]byte(body), &po); err != nil { + t.Fatal(err) + } + return po + } + + // Standard payout: pending at t0, arrival = t0 + 4 days, destination + // implicitly the default external account. + p1 := createPayout(4000, "standard") + p1ID, _ := p1["id"].(string) + if p1["status"] != "pending" { + t.Fatalf("payout status at t0 = %v, want pending", p1["status"]) + } + if p1["arrival_date"].(float64) != float64(t0+4*24*3600) { + t.Fatalf("standard arrival_date = %v, want %d", p1["arrival_date"], t0+4*24*3600) + } + if dest, _ := p1["destination"].(string); !strings.HasPrefix(dest, "ba_") { + t.Fatalf("payout destination = %v, want the default external account ba_*", p1["destination"]) + } + + // Instant payout: arrival = created + 60 seconds. + p3 := createPayout(1000, "instant") + if p3["arrival_date"].(float64) != p3["created"].(float64)+60 { + t.Fatalf("instant arrival_date = %v, want created+60", p3["arrival_date"]) + } + + // +5s: still pending. + stripeCnAdvanceClock(t, base, clockID, t0+5) + body, status := getAuth(t, base+"/v1/payouts/"+p1ID, devToken) + if status != 200 { + t.Fatalf("GET payout (+5s) -> %d; %s", status, body) + } + var po map[string]any + _ = json.Unmarshal([]byte(body), &po) + if po["status"] != "pending" { + t.Fatalf("payout status at +5s = %v, want pending", po["status"]) + } + + // +15s: in_transit. + stripeCnAdvanceClock(t, base, clockID, t0+15) + body, _ = getAuth(t, base+"/v1/payouts/"+p1ID, devToken) + _ = json.Unmarshal([]byte(body), &po) + if po["status"] != "in_transit" { + t.Fatalf("payout status at +15s = %v, want in_transit", po["status"]) + } + if n := stripeCnEventCount(t, base, "payout.updated"); n < 1 { + t.Fatal("payout.updated not emitted on the in_transit transition") + } + + // +61s: paid — exactly once, even across repeated reads. + stripeCnAdvanceClock(t, base, clockID, t0+61) + for i := 0; i < 3; i++ { + body, status = getAuth(t, base+"/v1/payouts/"+p1ID, devToken) + if status != 200 { + t.Fatalf("GET payout (+61s, read %d) -> %d", i, status) + } + _ = json.Unmarshal([]byte(body), &po) + if po["status"] != "paid" { + t.Fatalf("payout status at +61s (read %d) = %v, want paid", i, po["status"]) + } + } + if n := stripeCnEventCount(t, base, "payout.paid"); n != 1 { + t.Fatalf("payout.paid events = %d, want exactly 1", n) + } + + // A paid payout can no longer be canceled. + body, status = postJSONAuthHeader(t, base+"/v1/payouts/"+p1ID+"/cancel", devToken, map[string]any{}, poHeader) + if status != 400 { + t.Fatalf("cancel paid payout -> %d, want 400; %s", status, body) + } + + // A fresh payout created at +61s is still pending: cancel returns the + // funds (a positive payout ledger row linked from + // failure_balance_transaction). + p2 := createPayout(4000, "standard") + p2ID, _ := p2["id"].(string) + if p2["status"] != "pending" { + t.Fatalf("fresh payout status = %v, want pending", p2["status"]) + } + + // Metadata/description update on a pending payout. + body, status = postJSONAuthHeader(t, base+"/v1/payouts/"+p2ID, devToken, map[string]any{ + "metadata": map[string]any{"ref": "cn-1"}, + "description": "rent", + }, poHeader) + if status != 200 { + t.Fatalf("update payout -> %d; %s", status, body) + } + var p2u map[string]any + _ = json.Unmarshal([]byte(body), &p2u) + md, _ := p2u["metadata"].(map[string]any) + if md["ref"] != "cn-1" || p2u["description"] != "rent" { + t.Fatalf("payout after update = metadata %v description %v", p2u["metadata"], p2u["description"]) + } + + body, status = postJSONAuthHeader(t, base+"/v1/payouts/"+p2ID+"/cancel", devToken, map[string]any{}, poHeader) + if status != 200 { + t.Fatalf("cancel pending payout -> %d; %s", status, body) + } + var canceled map[string]any + _ = json.Unmarshal([]byte(body), &canceled) + if canceled["status"] != "canceled" { + t.Fatalf("canceled payout status = %v, want canceled", canceled["status"]) + } + if canceled["failure_balance_transaction"] == nil { + t.Fatal("canceled payout is missing failure_balance_transaction (funds-return ledger row)") + } + if n := stripeCnEventCount(t, base, "payout.canceled"); n != 1 { + t.Fatalf("payout.canceled events = %d, want 1", n) + } + + // Funds: 10000 in; p1 (4000), p3 (1000) and p2 (4000) each debited the + // balance at creation; p2's cancel returned its 4000 -> 5000 available. + body, status = getAuthHeader(t, base+"/v1/balance", devToken, poHeader) + if status != 200 { + t.Fatalf("GET balance after cancel -> %d; %s", status, body) + } + var bal map[string]any + _ = json.Unmarshal([]byte(body), &bal) + avail, _ := bal["available"].([]any) + first, _ := avail[0].(map[string]any) + if first["amount"].(float64) != 5000 { + t.Fatalf("available balance after cancel = %v, want 5000", avail[0]) + } + + // The status filter matches the derived statuses: p1 and the instant + // payout (created at t0, now past +60s) are both paid; p2 is canceled. + body, status = getAuth(t, base+"/v1/payouts?status=paid", devToken) + if status != 200 { + t.Fatalf("list payouts (paid) -> %d; %s", status, body) + } + var list map[string]any + _ = json.Unmarshal([]byte(body), &list) + paidCount := 0 + for _, d := range list["data"].([]any) { + if p, _ := d.(map[string]any); p["status"] == "paid" { + paidCount++ + } + } + if paidCount != 2 { + t.Fatalf("paid payouts listed = %d, want 2 (p1 + the instant payout)", paidCount) + } + body, status = getAuth(t, base+"/v1/payouts?status=canceled", devToken) + if status != 200 { + t.Fatalf("list payouts (canceled) -> %d; %s", status, body) + } + _ = json.Unmarshal([]byte(body), &list) + canceledCount := 0 + for _, d := range list["data"].([]any) { + if p, _ := d.(map[string]any); p["status"] == "canceled" { + canceledCount++ + } + } + if canceledCount != 1 { + t.Fatalf("canceled payouts listed = %d, want 1", canceledCount) + } +} + +// TestStripeCnApplicationFee covers the application fee recorded by the +// charge hook (application_fee_amount), the list/retrieve endpoints, and +// partial + full refunds over both refund routes. +func TestStripeCnApplicationFee(t *testing.T) { + base := stripeCnEngine(t, "") + + // A normal test card token so the charge succeeds and settles. + body, status := postJSONAuth(t, base+"/v1/tokens", devToken, map[string]any{ + "card": map[string]any{ + "number": "4242424242424242", + "exp_month": 12, + "exp_year": 2030, + "cvc": "123", + }, + }) + if status != 201 { + t.Fatalf("create card token -> %d; %s", status, body) + } + var tok map[string]any + _ = json.Unmarshal([]byte(body), &tok) + tokID, _ := tok["id"].(string) + + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 2000, + "currency": "usd", + "source": tokID, + "application_fee_amount": 500, + }) + if status != 201 { + t.Fatalf("create charge with application fee -> %d; %s", status, body) + } + var ch map[string]any + _ = json.Unmarshal([]byte(body), &ch) + chargeID, _ := ch["id"].(string) + + // List + charge filter. + body, status = getAuth(t, base+"/v1/application_fees", devToken) + if status != 200 { + t.Fatalf("list application fees -> %d; %s", status, body) + } + var list map[string]any + _ = json.Unmarshal([]byte(body), &list) + if data, _ := list["data"].([]any); len(data) != 1 { + t.Fatalf("application fee list has %d items, want 1", len(data)) + } + body, status = getAuth(t, base+"/v1/application_fees?charge="+chargeID, devToken) + if status != 200 { + t.Fatalf("list application fees (charge filter) -> %d; %s", status, body) + } + _ = json.Unmarshal([]byte(body), &list) + data, _ := list["data"].([]any) + if len(data) != 1 { + t.Fatalf("charge-filtered application fees = %d, want 1", len(data)) + } + fee, _ := data[0].(map[string]any) + feeID, _ := fee["id"].(string) + if !strings.HasPrefix(feeID, "fee_") || fee["object"] != "application_fee" { + t.Fatalf("fee = %v", fee) + } + if fee["amount"].(float64) != 500 || fee["charge"] != chargeID { + t.Fatalf("fee amount/charge = %v / %v", fee["amount"], fee["charge"]) + } + + // Retrieve by id. + body, status = getAuth(t, base+"/v1/application_fees/"+feeID, devToken) + if status != 200 { + t.Fatalf("GET application fee -> %d; %s", status, body) + } + + // Partial refund via the legacy /refund route -> updated fee object. + body, status = postJSONAuth(t, base+"/v1/application_fees/"+feeID+"/refund", devToken, map[string]any{ + "amount": 200, + }) + if status != 200 { + t.Fatalf("partial fee refund -> %d; %s", status, body) + } + var afterPartial map[string]any + _ = json.Unmarshal([]byte(body), &afterPartial) + if afterPartial["object"] != "application_fee" { + t.Fatalf("refund response object = %v, want application_fee", afterPartial["object"]) + } + if afterPartial["amount_refunded"].(float64) != 200 || afterPartial["refunded"] != false { + t.Fatalf("fee after partial refund = amount_refunded %v refunded %v", afterPartial["amount_refunded"], afterPartial["refunded"]) + } + + // Over-refund -> 400. + body, status = postJSONAuth(t, base+"/v1/application_fees/"+feeID+"/refund", devToken, map[string]any{ + "amount": 9999, + }) + if status != 400 { + t.Fatalf("over-refund of fee -> %d, want 400; %s", status, body) + } + + // Full remaining refund via the real /refunds route -> fee_refund object. + body, status = postJSONAuth(t, base+"/v1/application_fees/"+feeID+"/refunds", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("full fee refund -> %d; %s", status, body) + } + var fr map[string]any + _ = json.Unmarshal([]byte(body), &fr) + if fr["object"] != "fee_refund" { + t.Fatalf("fee refund object = %v, want fee_refund", fr["object"]) + } + if fr["amount"].(float64) != 300 || fr["fee"] != feeID { + t.Fatalf("fee refund = amount %v fee %v", fr["amount"], fr["fee"]) + } + frID, _ := fr["id"].(string) + if !strings.HasPrefix(frID, "fr_") { + t.Fatalf("fee refund id = %v, want fr_*", fr["id"]) + } + + // The fee is now fully refunded; another attempt is a 400. + body, status = postJSONAuth(t, base+"/v1/application_fees/"+feeID+"/refund", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("refund of fully refunded fee -> %d, want 400; %s", status, body) + } + body, status = getAuth(t, base+"/v1/application_fees/"+feeID, devToken) + if status != 200 { + t.Fatalf("GET fee after refunds -> %d", status) + } + var feeFinal map[string]any + _ = json.Unmarshal([]byte(body), &feeFinal) + if feeFinal["refunded"] != true || feeFinal["amount_refunded"].(float64) != 500 { + t.Fatalf("fee final = refunded %v amount_refunded %v", feeFinal["refunded"], feeFinal["amount_refunded"]) + } + + // The fee's refunds list carries both refund records. + body, status = getAuth(t, base+"/v1/application_fees/"+feeID+"/refunds", devToken) + if status != 200 { + t.Fatalf("list fee refunds -> %d; %s", status, body) + } + _ = json.Unmarshal([]byte(body), &list) + if frData, _ := list["data"].([]any); len(frData) != 2 { + t.Fatalf("fee refund list has %d items, want 2", len(frData)) + } + + // application_fee.refunded fires per refund (partial + full), like the + // real event (which includes partial refunds). (application_fee.created + // belongs to the lib charge hook that records the fee, not these + // endpoints — see the d5-connect hoist request.) + if n := stripeCnEventCount(t, base, "application_fee.refunded"); n != 2 { + t.Fatalf("application_fee.refunded events = %d, want 2", n) } } diff --git a/internal/engine/stripe_disputes_test.go b/internal/engine/stripe_disputes_test.go new file mode 100644 index 00000000..da471bc1 --- /dev/null +++ b/internal/engine/stripe_disputes_test.go @@ -0,0 +1,760 @@ +package engine + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// Disputes + balance-transactions + refund-cancel tests for the stripe-style +// adapter (domain d1-disputes). Shared helpers (newStripeTestServer, +// postJSONAuth, getAuth, postJSONAuthIdem, postJSONAuthHeader, getAuthHeader, +// mintStripeCardToken, stripeCardNum, devToken, stripeGroundDecode, +// stripeGroundCreateClock, stripeGroundNewestEvent, stripeGroundEventPayload) +// live in the existing stripe test files. New helpers here are prefixed +// stripeDis so parallel agents cannot collide. + +// stripeDisputeOnCard charges `amount` cents with a raw card number and +// returns the parsed charge doc (the dispute test cards raise a dispute on +// capture, so the charge carries a dp_* id). +func stripeDisputeOnCard(t *testing.T, base, number string, amount float64) map[string]any { + t.Helper() + tok := mintStripeCardToken(t, base, number) + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": amount, "currency": "usd", "source": tok, + }) + if status != 201 { + t.Fatalf("charge with %s -> %d, want 201; body %s", number, status, body) + } + return stripeGroundDecode(t, body) +} + +// stripeDisputeViaPI creates + confirms a PaymentIntent with a raw card +// number and returns the parsed PI doc (its charge carries the dispute). +func stripeDisputeViaPI(t *testing.T, base, number string, amount float64) map[string]any { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/payment_methods", devToken, map[string]any{ + "type": "card", + "card": map[string]any{"number": number, "exp_month": 12, "exp_year": 2030, "cvc": "123"}, + }) + if status != 201 { + t.Fatalf("create payment_method -> %d; body %s", status, body) + } + pm := stripeGroundDecode(t, body)["id"].(string) + body, status = postJSONAuth(t, base+"/v1/payment_intents", devToken, map[string]any{ + "amount": amount, "currency": "usd", "payment_method": pm, "confirm": true, + }) + if status != 201 { + t.Fatalf("create+confirm PI -> %d; body %s", status, body) + } + return stripeGroundDecode(t, body) +} + +// TestStripeDisputeSurface covers GET /v1/disputes (list + retrieve + filters +// + 404) and the evidence flow on POST /v1/disputes/{id}: staging (submit +// absent), metadata, and submitting (needs_response -> under_review, then the +// won ruling one day later via the test clock), with the real 400 on evidence +// for a resolved dispute. +func TestStripeDisputeSurface(t *testing.T) { + base := newStripeTestServer(t) + + ch := stripeDisputeOnCard(t, base, stripeCardNum("4000", "0000", "0000", "0259"), 4400) + dpID, _ := ch["dispute"].(string) + if dpID == "" || !strings.HasPrefix(dpID, "dp_") { + t.Fatalf("charge dispute = %v, want dp_*", ch["dispute"]) + } + + // ===== List: newest first, list envelope, the fresh dispute on top ===== + body, status := getAuth(t, base+"/v1/disputes", devToken) + if status != 200 { + t.Fatalf("GET /v1/disputes -> %d; body %s", status, body) + } + list := stripeGroundDecode(t, body) + if list["object"] != "list" || list["url"] != "/v1/disputes" { + t.Fatalf("dispute list envelope = %v / %v", list["object"], list["url"]) + } + data, _ := list["data"].([]any) + if len(data) < 1 { + t.Fatalf("dispute list empty: %s", body) + } + first, _ := data[0].(map[string]any) + if first["id"] != dpID { + t.Fatalf("newest dispute = %v, want %s", first["id"], dpID) + } + + // ===== Retrieve: the full public shape ===== + body, status = getAuth(t, base+"/v1/disputes/"+dpID, devToken) + if status != 200 { + t.Fatalf("GET /v1/disputes/%s -> %d; body %s", dpID, status, body) + } + dp := stripeGroundDecode(t, body) + if dp["object"] != "dispute" || dp["status"] != "needs_response" || dp["reason"] != "fraudulent" { + t.Fatalf("dispute = %v", dp) + } + if dp["amount"].(float64) != 4400 || dp["charge"] != ch["id"] || dp["currency"] != "usd" { + t.Fatalf("dispute amount/charge/currency = %v/%v/%v", dp["amount"], dp["charge"], dp["currency"]) + } + if dp["livemode"] != false || dp["is_charge_refundable"] != true { + t.Fatalf("dispute livemode/is_charge_refundable = %v/%v", dp["livemode"], dp["is_charge_refundable"]) + } + if ev, _ := dp["evidence"].(map[string]any); len(ev) != 0 { + t.Fatalf("fresh dispute evidence = %v, want empty", dp["evidence"]) + } + ed, _ := dp["evidence_details"].(map[string]any) + if ed == nil || ed["has_evidence"] != false || ed["past_due"] != false || ed["submission_count"].(float64) != 0 { + t.Fatalf("fresh dispute evidence_details = %v", dp["evidence_details"]) + } + if dueBy := int64(ed["due_by"].(float64)); dueBy <= time.Now().Unix() { + t.Fatalf("dispute due_by = %d, want in the future", dueBy) + } + if bts, _ := dp["balance_transactions"].([]any); len(bts) != 1 { + t.Fatalf("dispute balance_transactions = %v, want the withdrawal row", dp["balance_transactions"]) + } + + // 404 with the real message. + body, status = getAuth(t, base+"/v1/disputes/dp_nope", devToken) + if status != 404 || !strings.Contains(body, "No such dispute: dp_nope") { + t.Fatalf("GET unknown dispute -> %d %s, want 404 with real message", status, body) + } + + // ===== Filters: charge, payment_intent ===== + body, status = getAuth(t, base+"/v1/disputes?charge="+ch["id"].(string), devToken) + if status != 200 { + t.Fatalf("GET /v1/disputes?charge -> %d; body %s", status, body) + } + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 || data[0].(map[string]any)["id"] != dpID { + t.Fatalf("?charge= filter data = %v", data) + } + body, status = getAuth(t, base+"/v1/disputes?charge=ch_missing", devToken) + if status != 200 { + t.Fatalf("GET /v1/disputes?charge=missing -> %d", status) + } + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 0 { + t.Fatalf("?charge=missing data = %v, want empty", data) + } + + pi := stripeDisputeViaPI(t, base, stripeCardNum("4000", "0000", "0000", "2685"), 2600) + piID := pi["id"].(string) + body, status = getAuth(t, base+"/v1/disputes?payment_intent="+piID, devToken) + if status != 200 { + t.Fatalf("GET /v1/disputes?payment_intent -> %d; body %s", status, body) + } + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("?payment_intent= data = %v, want the PI dispute", data) + } + dpPI, _ := data[0].(map[string]any) + if dpPI["payment_intent"] != piID || dpPI["reason"] != "product_not_received" { + t.Fatalf("PI dispute = %v", dpPI) + } + + // ===== Update: staging evidence (no submit) ===== + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID, devToken, map[string]any{ + "evidence": map[string]any{ + "product_description": "Widget, blue", + "customer_name": "Ada Lovelace", + "shipping_tracking_number": "1Z999AA10123456784", + "uncategorized_text": "Customer confirmed delivery by email.", + "not_a_real_evidence_field": "ignored", + }, + }) + if status != 200 { + t.Fatalf("stage evidence -> %d; body %s", status, body) + } + dp = stripeGroundDecode(t, body) + if dp["status"] != "needs_response" { + t.Fatalf("staged dispute status = %v, want needs_response", dp["status"]) + } + ev, _ := dp["evidence"].(map[string]any) + if ev["product_description"] != "Widget, blue" || ev["customer_name"] != "Ada Lovelace" { + t.Fatalf("staged evidence = %v", ev) + } + if _, present := ev["not_a_real_evidence_field"]; present { + t.Fatalf("unknown evidence field stored: %v", ev) + } + ed, _ = dp["evidence_details"].(map[string]any) + if ed["has_evidence"] != true || ed["submission_count"].(float64) != 0 { + t.Fatalf("staged evidence_details = %v", dp["evidence_details"]) + } + // Staging is a dispute update: the real webhook is recorded. + stripeGroundNewestEvent(t, base, "charge.dispute.updated") + + // ===== Update: metadata ===== + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID, devToken, map[string]any{ + "metadata": map[string]any{"order_id": "6735"}, + }) + if status != 200 { + t.Fatalf("update metadata -> %d; body %s", status, body) + } + dp = stripeGroundDecode(t, body) + md, _ := dp["metadata"].(map[string]any) + if md["order_id"] != "6735" { + t.Fatalf("dispute metadata = %v", dp["metadata"]) + } + if ev, _ = dp["evidence"].(map[string]any); ev["product_description"] != "Widget, blue" { + t.Fatalf("metadata update dropped staged evidence: %v", dp["evidence"]) + } + + // ===== Update: submit without any evidence is a 400 ===== + piDP := dpPI["id"].(string) + body, status = postJSONAuth(t, base+"/v1/disputes/"+piDP, devToken, map[string]any{"submit": true}) + if status != 400 { + t.Fatalf("submit without evidence -> %d, want 400; body %s", status, body) + } + if typ := stripeGroundDecode(t, body)["error"].(map[string]any)["type"]; typ != "invalid_request_error" { + t.Fatalf("submit-without-evidence error type = %v, want invalid_request_error", typ) + } + + // ===== Update: submit moves needs_response -> under_review ===== + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID, devToken, map[string]any{"submit": true}) + if status != 200 { + t.Fatalf("submit evidence -> %d; body %s", status, body) + } + dp = stripeGroundDecode(t, body) + if dp["status"] != "under_review" { + t.Fatalf("submitted dispute status = %v, want under_review", dp["status"]) + } + ed, _ = dp["evidence_details"].(map[string]any) + if ed["submission_count"].(float64) != 1 || ed["has_evidence"] != true { + t.Fatalf("submitted evidence_details = %v", dp["evidence_details"]) + } + upd := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.updated")) + if upd["id"] != dpID || upd["status"] != "under_review" { + t.Fatalf("charge.dispute.updated payload = %v", upd) + } + + // ===== One day later the ruling lands: won, funds reinstated, closed ===== + now := time.Now().Unix() + clockID := stripeGroundCreateClock(t, base, now) + body, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{ + "frozen_time": now + 25*3600, + }) + if status != 200 { + t.Fatalf("advance clock -> %d; body %s", status, body) + } + body, status = getAuth(t, base+"/v1/disputes/"+dpID, devToken) + if status != 200 { + t.Fatalf("GET dispute after settle window -> %d; body %s", status, body) + } + dp = stripeGroundDecode(t, body) + if dp["status"] != "won" { + t.Fatalf("dispute after +25h = %v, want won", dp["status"]) + } + // The reinstatement ledger row rides on the dispute (withdrawal + reversal). + if bts, _ := dp["balance_transactions"].([]any); len(bts) != 2 { + t.Fatalf("won dispute balance_transactions = %v, want 2 rows", dp["balance_transactions"]) + } + won := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.funds_reinstated")) + if won["id"] != dpID || won["status"] != "won" { + t.Fatalf("funds_reinstated payload = %v", won) + } + closed := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.closed")) + if closed["id"] != dpID { + t.Fatalf("charge.dispute.closed payload = %v", closed) + } + // The dispute_reversal ledger row is queryable via the real filters. + body, status = getAuth(t, base+"/v1/balance_transactions?type=dispute_reversal&source="+dpID, devToken) + if status != 200 { + t.Fatalf("GET balance_transactions?type=dispute_reversal -> %d; body %s", status, body) + } + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("dispute_reversal rows = %v, want 1", data) + } + + // ===== A resolved dispute rejects evidence updates + close ===== + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID, devToken, map[string]any{ + "evidence": map[string]any{"uncategorized_text": "too late"}, + }) + if status != 400 { + t.Fatalf("evidence on won dispute -> %d, want 400; body %s", status, body) + } + errObj := stripeGroundDecode(t, body)["error"].(map[string]any) + if errObj["type"] != "invalid_request_error" || !strings.Contains(errObj["message"].(string), "closed") { + t.Fatalf("closed-dispute evidence error = %v", errObj) + } + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID+"/close", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("close on won dispute -> %d, want 400; body %s", status, body) + } +} + +// TestStripeDisputeClose covers POST /v1/disputes/{id}/close (accept as lost), +// the irreversibility error, and the derive-on-read deadline close: a dispute +// whose evidence window passed resolves to lost on the next read. +func TestStripeDisputeClose(t *testing.T) { + base := newStripeTestServer(t) + + ch := stripeDisputeOnCard(t, base, stripeCardNum("4000", "0000", "0000", "0259"), 3100) + dpID := ch["dispute"].(string) + + body, status := postJSONAuth(t, base+"/v1/disputes/"+dpID+"/close", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("close dispute -> %d; body %s", status, body) + } + dp := stripeGroundDecode(t, body) + if dp["status"] != "lost" || dp["id"] != dpID { + t.Fatalf("closed dispute = %v", dp) + } + closed := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.closed")) + if closed["id"] != dpID || closed["status"] != "lost" { + t.Fatalf("charge.dispute.closed payload = %v", closed) + } + + // Closing is irreversible: a second close is the real 400 shape. + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID+"/close", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("close lost dispute -> %d, want 400; body %s", status, body) + } + errObj := stripeGroundDecode(t, body)["error"].(map[string]any) + if errObj["type"] != "invalid_request_error" || !strings.Contains(errObj["message"].(string), "already closed") { + t.Fatalf("double-close error = %v", errObj) + } + + // Evidence on the lost dispute is rejected too. + body, status = postJSONAuth(t, base+"/v1/disputes/"+dpID, devToken, map[string]any{ + "evidence": map[string]any{"uncategorized_text": "late"}, + }) + if status != 400 { + t.Fatalf("evidence on lost dispute -> %d, want 400; body %s", status, body) + } + + // 404s keep the real message. + body, status = postJSONAuth(t, base+"/v1/disputes/dp_nope/close", devToken, map[string]any{}) + if status != 404 || !strings.Contains(body, "No such dispute: dp_nope") { + t.Fatalf("close unknown dispute -> %d %s, want 404 with real message", status, body) + } + + // ===== Deadline: needs_response + clock past due_by derives lost ===== + ch2 := stripeDisputeOnCard(t, base, stripeCardNum("4000", "0000", "0000", "2685"), 2800) + dp2 := ch2["dispute"].(string) + now := time.Now().Unix() + clockID := stripeGroundCreateClock(t, base, now) + if _, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{ + "frozen_time": now + 8*24*3600, + }); status != 200 { + t.Fatalf("advance clock past due_by -> %d", status) + } + body, status = getAuth(t, base+"/v1/disputes/"+dp2, devToken) + if status != 200 { + t.Fatalf("GET dispute past deadline -> %d; body %s", status, body) + } + dp = stripeGroundDecode(t, body) + if dp["status"] != "lost" { + t.Fatalf("dispute past due_by = %v, want lost (funds stay withdrawn)", dp["status"]) + } + // Only the withdrawal row: a lost dispute never reinstates funds. + if bts, _ := dp["balance_transactions"].([]any); len(bts) != 1 { + t.Fatalf("lost dispute balance_transactions = %v, want 1 row", dp["balance_transactions"]) + } +} + +// TestStripeDisBalanceTransactions covers GET /v1/balance_transactions (list, +// retrieve, real filters, account scoping) and the deepened GET /v1/balance +// object (connect_reserved + issuing, platform defaults unchanged). +func TestStripeDisBalanceTransactions(t *testing.T) { + base := newStripeTestServer(t) + + // A captured card charge books the charge ledger row with the processing + // fee: 2.9% + 30c in pure integer math. + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 10000, "currency": "usd", "source": tok, + }) + if status != 201 { + t.Fatalf("create charge -> %d; body %s", status, body) + } + ch := stripeGroundDecode(t, body) + chID := ch["id"].(string) + btID, _ := ch["balance_transaction"].(string) + if btID == "" || !strings.HasPrefix(btID, "txn_") { + t.Fatalf("charge balance_transaction = %v", ch["balance_transaction"]) + } + + // ===== Retrieve: the full balance_transaction object ===== + body, status = getAuth(t, base+"/v1/balance_transactions/"+btID, devToken) + if status != 200 { + t.Fatalf("GET /v1/balance_transactions/%s -> %d; body %s", btID, status, body) + } + bt := stripeGroundDecode(t, body) + if bt["object"] != "balance_transaction" || bt["type"] != "charge" || bt["reporting_category"] != "charge" { + t.Fatalf("bt object = %v", bt) + } + if bt["amount"].(float64) != 10000 || bt["fee"].(float64) != 320 || bt["net"].(float64) != 9680 { + t.Fatalf("bt amount/fee/net = %v/%v/%v, want 10000/320/9680", bt["amount"], bt["fee"], bt["net"]) + } + if bt["source"] != chID || bt["status"] != "available" || bt["currency"] != "usd" { + t.Fatalf("bt source/status/currency = %v/%v/%v", bt["source"], bt["status"], bt["currency"]) + } + fds, _ := bt["fee_details"].([]any) + if len(fds) != 1 || fds[0].(map[string]any)["type"] != "stripe_fee" { + t.Fatalf("bt fee_details = %v", bt["fee_details"]) + } + + // 404 with the real resource name. + body, status = getAuth(t, base+"/v1/balance_transactions/txn_nope", devToken) + if status != 404 || !strings.Contains(body, "No such balance_transaction: txn_nope") { + t.Fatalf("GET unknown bt -> %d %s, want 404 with real message", status, body) + } + + // ===== List: newest first + the real filters ===== + body, status = getAuth(t, base+"/v1/balance_transactions", devToken) + if status != 200 { + t.Fatalf("GET /v1/balance_transactions -> %d; body %s", status, body) + } + list := stripeGroundDecode(t, body) + if list["object"] != "list" || list["url"] != "/v1/balance_transactions" { + t.Fatalf("bt list envelope = %v / %v", list["object"], list["url"]) + } + data, _ := list["data"].([]any) + if len(data) < 1 { + t.Fatalf("bt list empty: %s", body) + } + if data[0].(map[string]any)["id"] != btID { + t.Fatalf("newest bt = %v, want %s", data[0].(map[string]any)["id"], btID) + } + + // type filter + body, _ = getAuth(t, base+"/v1/balance_transactions?type=charge", devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 || data[0].(map[string]any)["id"] != btID { + t.Fatalf("?type=charge data = %v", data) + } + body, _ = getAuth(t, base+"/v1/balance_transactions?type=payout", devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 0 { + t.Fatalf("?type=payout data = %v, want empty", data) + } + // source + the charge alias + body, _ = getAuth(t, base+"/v1/balance_transactions?source="+chID, devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("?source= data = %v", data) + } + body, _ = getAuth(t, base+"/v1/balance_transactions?charge="+chID, devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("?charge= alias data = %v", data) + } + // created range + body, _ = getAuth(t, base+"/v1/balance_transactions?created[gte]="+fmt.Sprint(time.Now().Unix()-3600), devToken) + if got := len(stripeGroundDecode(t, body)["data"].([]any)); got < 1 { + t.Fatalf("?created[gte] data empty") + } + body, _ = getAuth(t, base+"/v1/balance_transactions?created[gt]="+fmt.Sprint(time.Now().Unix()+3600), devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 0 { + t.Fatalf("?created[gt] future data = %v, want empty", data) + } + + // A refund books a negative row of type refund, queryable via the alias. + if _, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": chID, "amount": 4000}); status != 201 { + t.Fatalf("create refund -> %d; body %s", status, body) + } + body, _ = getAuth(t, base+"/v1/balance_transactions?type=refund", devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 || data[0].(map[string]any)["amount"].(float64) != -4000 { + t.Fatalf("?type=refund data = %v", data) + } + + // A dispute books the withdrawal row (type dispute) + the $15 fee. + dpCh := stripeDisputeOnCard(t, base, stripeCardNum("4000", "0000", "0000", "0259"), 5000) + dpID := dpCh["dispute"].(string) + body, _ = getAuth(t, base+"/v1/balance_transactions?dispute="+dpID, devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("?dispute= data = %v", data) + } + drow, _ := data[0].(map[string]any) + if drow["amount"].(float64) != -5000 || drow["fee"].(float64) != 1500 || drow["net"].(float64) != -6500 { + t.Fatalf("dispute row amount/fee/net = %v/%v/%v", drow["amount"], drow["fee"], drow["net"]) + } + + // ===== Account scoping: the Stripe-Account header sees only its rows ===== + body, status = postJSONAuth(t, base+"/v1/accounts", devToken, map[string]any{"type": "express", "country": "US"}) + if status != 201 { + t.Fatalf("create account -> %d; body %s", status, body) + } + acctID := stripeGroundDecode(t, body)["id"].(string) + if _, status = postJSONAuth(t, base+"/v1/transfers", devToken, map[string]any{ + "amount": 5000, "currency": "usd", "destination": acctID, + }); status != 201 { + t.Fatalf("create transfer -> %d", status) + } + body, status = postJSONAuthHeader(t, base+"/v1/payouts", devToken, map[string]any{ + "amount": 2000, "currency": "usd", + }, map[string]string{"Stripe-Account": acctID}) + if status != 201 { + t.Fatalf("create payout -> %d; body %s", status, body) + } + + body, status = getAuthHeader(t, base+"/v1/balance_transactions", devToken, map[string]string{"Stripe-Account": acctID}) + if status != 200 { + t.Fatalf("GET account-scoped balance_transactions -> %d; body %s", status, body) + } + data, _ = stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 2 { + t.Fatalf("account bt rows = %v, want transfer+payout only", data) + } + types := map[string]bool{} + for _, d := range data { + types[d.(map[string]any)["type"].(string)] = true + } + if !types["transfer"] || !types["payout"] { + t.Fatalf("account bt types = %v, want transfer+payout", types) + } + + // The platform view excludes the connected account's rows but keeps its own. + body, _ = getAuth(t, base+"/v1/balance_transactions", devToken) + data, _ = stripeGroundDecode(t, body)["data"].([]any) + for _, d := range data { + row := d.(map[string]any) + if row["type"] == "transfer" && row["amount"].(float64) > 0 { + t.Fatalf("platform view leaked a connected-account transfer row: %v", row) + } + } + + // A row scoped to the connected account 404s under a foreign account id. + acctRowsBody, astat := getAuthHeader(t, base+"/v1/balance_transactions?type=payout", devToken, map[string]string{"Stripe-Account": acctID}) + if astat != 200 { + t.Fatalf("GET account payout rows -> %d; body %s", astat, acctRowsBody) + } + acctRow := stripeGroundDecode(t, acctRowsBody)["data"].([]any)[0].(map[string]any) + body, status = getAuthHeader(t, base+"/v1/balance_transactions/"+acctRow["id"].(string), devToken, map[string]string{"Stripe-Account": "acct_foreign"}) + if status != 404 { + t.Fatalf("GET foreign-account bt -> %d, want 404; body %s", status, body) + } + + // ===== Balance object: defaults unchanged + connect_reserved / issuing ===== + body, status = getAuth(t, base+"/v1/balance", devToken) + if status != 200 { + t.Fatalf("GET /v1/balance -> %d; body %s", status, body) + } + bal := stripeGroundDecode(t, body) + avail := bal["available"].([]any)[0].(map[string]any) + pend := bal["pending"].([]any)[0].(map[string]any) + inst := bal["instant_available"].([]any)[0].(map[string]any) + if avail["amount"].(float64) != 100000 || pend["amount"].(float64) != 50000 || inst["amount"].(float64) != 25000 { + t.Fatalf("platform balance defaults changed: %v", bal) + } + if cr, _ := bal["connect_reserved"].([]any); cr == nil || len(cr) != 0 { + t.Fatalf("balance connect_reserved = %v, want empty array", bal["connect_reserved"]) + } + issuing, _ := bal["issuing"].(map[string]any) + if issuing == nil { + t.Fatalf("balance issuing = %v, want BalanceDetail object", bal["issuing"]) + } + issAvail, _ := issuing["available"].([]any) + if len(issAvail) != 1 || issAvail[0].(map[string]any)["amount"].(float64) != 0 { + t.Fatalf("issuing.available = %v", issuing["available"]) + } + + // Connected account: KV balance, pending stays 0, same new arrays. + body, status = getAuthHeader(t, base+"/v1/balance", devToken, map[string]string{"Stripe-Account": acctID}) + if status != 200 { + t.Fatalf("GET account balance -> %d; body %s", status, body) + } + bal = stripeGroundDecode(t, body) + avail = bal["available"].([]any)[0].(map[string]any) + pend = bal["pending"].([]any)[0].(map[string]any) + if avail["amount"].(float64) != 3000 || pend["amount"].(float64) != 0 { + t.Fatalf("account balance available/pending = %v/%v, want 3000/0", avail["amount"], pend["amount"]) + } + if _, ok := bal["connect_reserved"].([]any); !ok { + t.Fatalf("account balance missing connect_reserved: %v", bal) + } + if _, ok := bal["issuing"].(map[string]any); !ok { + t.Fatalf("account balance missing issuing: %v", bal) + } +} + +// TestStripeDisRefundCancel covers POST /v1/refunds/{id}/cancel: pending -> +// canceled (with the real failure fields, balance restored, charge bookkeeping +// rolled back, refund.updated), the 400 on non-pending refunds, 404s, and the +// uncaptured-charge behavior (auth release, and the requires_capture +// PaymentIntent rejection). +func TestStripeDisRefundCancel(t *testing.T) { + base := newStripeTestServer(t) + + capturedCharge := func(amount float64) string { + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": amount, "currency": "usd", + }) + if status != 201 { + t.Fatalf("create charge -> %d; body %s", status, body) + } + id := stripeGroundDecode(t, body)["id"].(string) + if _, status = postJSONAuth(t, base+"/v1/charges/"+id+"/capture", devToken, map[string]any{}); status != 200 { + t.Fatalf("capture charge -> %d", status) + } + return id + } + + // ===== Cancel a pending refund: terminal canceled + failure fields ===== + ch1 := capturedCharge(8000) + body, status := postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": ch1}) + if status != 201 { + t.Fatalf("create refund -> %d; body %s", status, body) + } + re1 := stripeGroundDecode(t, body) + re1ID := re1["id"].(string) + if re1["status"] != "pending" { + t.Fatalf("fresh refund status = %v, want pending", re1["status"]) + } + + body, status = postJSONAuth(t, base+"/v1/refunds/"+re1ID+"/cancel", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("cancel refund -> %d; body %s", status, body) + } + re1 = stripeGroundDecode(t, body) + if re1["status"] != "canceled" || re1["id"] != re1ID { + t.Fatalf("canceled refund = %v", re1) + } + if re1["failure_reason"] != "merchant_request" { + t.Fatalf("canceled refund failure_reason = %v", re1["failure_reason"]) + } + fbt, _ := re1["failure_balance_transaction"].(string) + if fbt == "" || !strings.HasPrefix(fbt, "txn_") { + t.Fatalf("canceled refund failure_balance_transaction = %v", re1["failure_balance_transaction"]) + } + upd := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "refund.updated")) + if upd["id"] != re1ID || upd["status"] != "canceled" { + t.Fatalf("refund.updated payload = %v", upd) + } + + // The charge bookkeeping rolled back: the full amount is refundable again. + body, status = getAuth(t, base+"/v1/charges/"+ch1, devToken) + if status != 200 { + t.Fatalf("GET charge after cancel -> %d", status) + } + ch := stripeGroundDecode(t, body) + if ch["amount_refunded"].(float64) != 0 || ch["refunded"] != false || ch["status"] != "succeeded" { + t.Fatalf("charge after refund cancel = amount_refunded %v refunded %v status %v", ch["amount_refunded"], ch["refunded"], ch["status"]) + } + if _, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": ch1}); status != 201 { + t.Fatalf("re-refund after cancel -> %d, want 201 (balance freed)", status) + } + + // ===== Partial refund cancel frees exactly its share ===== + ch2 := capturedCharge(9000) + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": ch2, "amount": 3000}) + if status != 201 { + t.Fatalf("partial refund -> %d; body %s", status, body) + } + // Use the create response's id (a list call would derive the +3s terminal + // state and race the cancel window). + partID := stripeGroundDecode(t, body)["id"].(string) + if _, status = postJSONAuth(t, base+"/v1/refunds/"+partID+"/cancel", devToken, map[string]any{}); status != 200 { + t.Fatalf("cancel partial refund -> %d; body %s", status, body) + } + if _, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": ch2}); status != 201 { + t.Fatalf("full re-refund after partial cancel -> %d, want 201", status) + } + + // ===== Canceling a non-pending refund is the real 400 ===== + ch3 := capturedCharge(7000) + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": ch3}) + if status != 201 { + t.Fatalf("create refund for state test -> %d; body %s", status, body) + } + re3ID := stripeGroundDecode(t, body)["id"].(string) + now := time.Now().Unix() + clockID := stripeGroundCreateClock(t, base, now) + if _, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{ + "frozen_time": now + 10, + }); status != 200 { + t.Fatalf("advance clock -> %d", status) + } + body, status = getAuth(t, base+"/v1/refunds/"+re3ID, devToken) + if status != 200 || stripeGroundDecode(t, body)["status"] != "succeeded" { + t.Fatalf("refund after clock advance = %s", body) + } + body, status = postJSONAuth(t, base+"/v1/refunds/"+re3ID+"/cancel", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("cancel succeeded refund -> %d, want 400; body %s", status, body) + } + errObj := stripeGroundDecode(t, body)["error"].(map[string]any) + if errObj["type"] != "invalid_request_error" || !strings.Contains(errObj["message"].(string), "succeeded") { + t.Fatalf("cancel non-pending error = %v", errObj) + } + + // 404 + malformed body. + body, status = postJSONAuth(t, base+"/v1/refunds/re_nope/cancel", devToken, map[string]any{}) + if status != 404 || !strings.Contains(body, "No such refund: re_nope") { + t.Fatalf("cancel unknown refund -> %d %s, want 404 with real message", status, body) + } + if _, status = stripeGroundPostRaw(t, base+"/v1/refunds/"+re3ID+"/cancel", devToken, `{"refund": `); status != 400 { + t.Fatalf("cancel with malformed body -> %d, want 400", status) + } + + // ===== Uncaptured charge: refunding releases the authorization ===== + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 5000, "currency": "usd", + }) + if status != 201 { + t.Fatalf("create uncaptured charge -> %d; body %s", status, body) + } + unc := stripeGroundDecode(t, body) + uncID := unc["id"].(string) + if unc["captured"] != false || unc["status"] != "pending" { + t.Fatalf("uncaptured charge = %v", unc) + } + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": uncID}) + if status != 201 { + t.Fatalf("refund uncaptured charge -> %d; body %s", status, body) + } + rel := stripeGroundDecode(t, body) + if rel["status"] != "succeeded" { + t.Fatalf("release refund status = %v, want succeeded", rel["status"]) + } + if rel["balance_transaction"] != nil { + t.Fatalf("release refund balance_transaction = %v, want null (no funds moved)", rel["balance_transaction"]) + } + if rn, _ := rel["receipt_number"].(string); rn == "" { + t.Fatalf("release refund receipt_number = %v", rel["receipt_number"]) + } + body, status = getAuth(t, base+"/v1/charges/"+uncID, devToken) + if status != 200 { + t.Fatalf("GET released charge -> %d", status) + } + ch = stripeGroundDecode(t, body) + if ch["refunded"] != true || ch["amount_refunded"].(float64) != 5000 || ch["status"] != "refunded" { + t.Fatalf("released charge = refunded %v amount_refunded %v status %v", ch["refunded"], ch["amount_refunded"], ch["status"]) + } + // No refund ledger row was booked for the release. + body, _ = getAuth(t, base+"/v1/balance_transactions?source="+rel["id"].(string), devToken) + relRows, _ := stripeGroundDecode(t, body)["data"].([]any) + if len(relRows) != 0 { + t.Fatalf("release refund booked a ledger row: %v", relRows) + } + // The over-refund guard still applies to uncaptured charges. + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"charge": uncID}) + if status != 400 { + t.Fatalf("re-refund released charge -> %d, want 400; body %s", status, body) + } + + // ===== requires_capture PaymentIntents cannot be refunded ===== + body, status = postJSONAuth(t, base+"/v1/payment_intents", devToken, map[string]any{ + "amount": 6000, "currency": "usd", "capture_method": "manual", + }) + if status != 201 { + t.Fatalf("create manual PI -> %d; body %s", status, body) + } + piID := stripeGroundDecode(t, body)["id"].(string) + if _, status = postJSONAuth(t, base+"/v1/payment_intents/"+piID+"/confirm", devToken, map[string]any{ + "payment_method": mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")), + }); status != 200 { + t.Fatalf("confirm manual PI -> %d", status) + } + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{"payment_intent": piID}) + if status != 400 { + t.Fatalf("refund requires_capture PI -> %d, want 400; body %s", status, body) + } + errObj = stripeGroundDecode(t, body)["error"].(map[string]any) + if errObj["code"] != "payment_intent_unexpected_state" || errObj["type"] != "invalid_request_error" { + t.Fatalf("requires_capture refund error = %v", errObj) + } +} diff --git a/internal/engine/stripe_groundwork_test.go b/internal/engine/stripe_groundwork_test.go new file mode 100644 index 00000000..367af498 --- /dev/null +++ b/internal/engine/stripe_groundwork_test.go @@ -0,0 +1,615 @@ +package engine + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// Groundwork-phase tests for the stripe-style adapter: test clocks, the +// balance-transaction ledger hooks on charges/refunds/transfers/payouts, the +// dispute test cards, and the PaymentIntent->charge link. Shared helpers +// (newStripeTestServer, postJSONAuth, getAuth, deleteAuth, postJSONAuthIdem, +// mintStripeCardToken, stripeCardNum, devToken) live in the existing stripe +// test files. New helpers here are prefixed stripeGround so parallel agents +// cannot collide. + +// stripeGroundPostRaw performs an HTTP POST with a Bearer token and a raw +// (pre-marshaled) body, returning the body + status code. Used for malformed +// JSON payloads. +func stripeGroundPostRaw(t *testing.T, url, token, raw string) (string, int) { + t.Helper() + req, err := http.NewRequest("POST", url, bytes.NewReader([]byte(raw))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// stripeGroundDecode unmarshals a JSON body into map[string]any. +func stripeGroundDecode(t *testing.T, body string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + t.Fatalf("unmarshal %s: %v", body, err) + } + return m +} + +// stripeGroundCreateClock creates a test clock at frozenTime and returns its id. +func stripeGroundCreateClock(t *testing.T, base string, frozen int64) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/test_clocks", devToken, map[string]any{ + "frozen_time": frozen, + }) + if status != 201 { + t.Fatalf("POST /v1/test_clocks -> %d; body %s", status, body) + } + m := stripeGroundDecode(t, body) + id, ok := m["id"].(string) + if !ok || !strings.HasPrefix(id, "clock_") { + t.Fatalf("clock id = %v, want clock_* prefix", m["id"]) + } + if m["object"] != "test_helpers.test_clock" { + t.Fatalf("clock object = %v, want test_helpers.test_clock", m["object"]) + } + if m["status"] != "ready" { + t.Fatalf("clock status = %v, want ready", m["status"]) + } + if ft, _ := m["frozen_time"].(float64); int64(ft) != frozen { + t.Fatalf("clock frozen_time = %v, want %d", m["frozen_time"], frozen) + } + return id +} + +// stripeGroundNewestEvent fetches the newest recorded event of a type and +// returns the full event object (use stripeGroundEventPayload for data.object). +func stripeGroundNewestEvent(t *testing.T, base, eventType string) map[string]any { + t.Helper() + body, status := getAuth(t, base+"/v1/events?type="+eventType+"&limit=1", devToken) + if status != 200 { + t.Fatalf("GET /v1/events?type=%s -> %d; body %s", eventType, status, body) + } + list := stripeGroundDecode(t, body) + data, _ := list["data"].([]any) + if len(data) < 1 { + t.Fatalf("no %s events recorded", eventType) + } + ev, _ := data[0].(map[string]any) + if ev["type"] != eventType { + t.Fatalf("event type = %v, want %s", ev["type"], eventType) + } + return ev +} + +// stripeGroundEventPayload returns an event's data.object payload. +func stripeGroundEventPayload(ev map[string]any) map[string]any { + obj, _ := ev["data"].(map[string]any)["object"].(map[string]any) + return obj +} + +// stripeGroundCharge emits one plain charge (no card) and returns its id — +// the charge.created event it triggers is the observable _now() probe. +func stripeGroundCharge(t *testing.T, base string) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 1200, "currency": "usd", + }) + if status != 201 { + t.Fatalf("POST /v1/charges -> %d; body %s", status, body) + } + return stripeGroundDecode(t, body)["id"].(string) +} + +// TestStripeGroundworkTestClocks proves the KV-backed global clock drives +// every timestamp the adapter mints: creating a clock freezes _now() at +// frozen_time, advance moves it forward, and deleting the active clock +// restores wall time — with the real Stripe validation errors along the way. +func TestStripeGroundworkTestClocks(t *testing.T) { + base := newStripeTestServer(t) + + // Auth is enforced. + if _, status := postJSONAuth(t, base+"/v1/test_clocks", "", map[string]any{"frozen_time": 1}); status != 401 { + t.Fatalf("POST /v1/test_clocks without auth -> %d, want 401", status) + } + + // Malformed JSON body -> 400 (req.body would be an empty dict; the + // handler must reject it via the raw body). + if _, status := stripeGroundPostRaw(t, base+"/v1/test_clocks", devToken, `{"frozen_time": `); status != 400 { + t.Fatalf("POST /v1/test_clocks malformed body -> %d, want 400", status) + } + + // Missing required param -> 400 with the real message shape. + body, status := postJSONAuth(t, base+"/v1/test_clocks", devToken, map[string]any{}) + if status != 400 { + t.Fatalf("POST /v1/test_clocks without frozen_time -> %d; body %s", status, body) + } + errObj := stripeGroundDecode(t, body)["error"].(map[string]any) + if errObj["type"] != "invalid_request_error" || !strings.Contains(errObj["message"].(string), "frozen_time") { + t.Fatalf("missing frozen_time error = %v", errObj) + } + + // Create a clock frozen 30 days in the past: every _now() stamp follows. + frozen := time.Now().Unix() - 30*24*3600 + body, status = postJSONAuthIdem(t, base+"/v1/test_clocks", devToken, "ground-clock-1", map[string]any{ + "frozen_time": frozen, + }) + if status != 201 { + t.Fatalf("POST /v1/test_clocks -> %d; body %s", status, body) + } + created := stripeGroundDecode(t, body) + clockID := created["id"].(string) + if !strings.HasPrefix(clockID, "clock_") || created["object"] != "test_helpers.test_clock" || created["status"] != "ready" { + t.Fatalf("created clock = %v", created) + } + if ft, _ := created["frozen_time"].(float64); int64(ft) != frozen { + t.Fatalf("clock frozen_time = %v, want %d", created["frozen_time"], frozen) + } + + // An Idempotency-Key replay returns the same clock without a new one. + body2, status2 := postJSONAuthIdem(t, base+"/v1/test_clocks", devToken, "ground-clock-1", map[string]any{"frozen_time": frozen}) + if status2 != 201 { + t.Fatalf("idempotent clock create -> %d; body %s", status2, body2) + } + if m := stripeGroundDecode(t, body2); m["id"] != clockID { + t.Fatalf("idempotent clock create id = %v, want %s", m["id"], clockID) + } + + // The charge.created event (and its charge payload) are stamped at the + // frozen time. + stripeGroundCharge(t, base) + ev := stripeGroundNewestEvent(t, base, "charge.created") + if c := int64(ev["created"].(float64)); c < frozen || c > frozen+300 { + t.Fatalf("charge.created event created = %d, want within [frozen=%d, frozen+300]", c, frozen) + } + if c := int64(stripeGroundEventPayload(ev)["created"].(float64)); c < frozen || c > frozen+300 { + t.Fatalf("charge payload created = %d, want within [frozen=%d, frozen+300]", c, frozen) + } + + // Retrieve + 404. + body, status = getAuth(t, base+"/v1/test_clocks/"+clockID, devToken) + if status != 200 { + t.Fatalf("GET /v1/test_clocks/%s -> %d; body %s", clockID, status, body) + } + if stripeGroundDecode(t, body)["id"] != clockID { + t.Fatalf("retrieved clock id mismatch") + } + if _, status = getAuth(t, base+"/v1/test_clocks/clock_nope", devToken); status != 404 { + t.Fatalf("GET unknown clock -> %d, want 404", status) + } + + // List contains the clock (both the short and real Stripe route aliases). + for _, route := range []string{"/v1/test_clocks", "/v1/test_helpers/test_clocks"} { + body, status = getAuth(t, base+route, devToken) + if status != 200 { + t.Fatalf("GET %s -> %d; body %s", route, status, body) + } + list := stripeGroundDecode(t, body) + if list["object"] != "list" { + t.Fatalf("%s object = %v, want list", route, list["object"]) + } + found := false + for _, d := range list["data"].([]any) { + if d.(map[string]any)["id"] == clockID { + found = true + } + } + if !found { + t.Fatalf("clock %s missing from %s listing", clockID, route) + } + } + + // Advance 90 days forward of the frozen time: the response carries the + // documented in-progress status; the stored clock settles ready at the + // new frozen time, and _now() follows. + target := frozen + 90*24*3600 + body, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{"frozen_time": target}) + if status != 200 { + t.Fatalf("POST advance -> %d; body %s", status, body) + } + if m := stripeGroundDecode(t, body); m["status"] != "advancing" { + t.Fatalf("advance status = %v, want advancing", m["status"]) + } + body, status = getAuth(t, base+"/v1/test_clocks/"+clockID, devToken) + if status != 200 { + t.Fatalf("GET clock after advance -> %d", status) + } + m := stripeGroundDecode(t, body) + if m["status"] != "ready" { + t.Fatalf("settled clock status = %v, want ready", m["status"]) + } + if ft, _ := m["frozen_time"].(float64); int64(ft) != target { + t.Fatalf("settled frozen_time = %v, want %d", m["frozen_time"], target) + } + stripeGroundCharge(t, base) + ev = stripeGroundNewestEvent(t, base, "charge.created") + if c := int64(ev["created"].(float64)); c < target-300 || c > target+300 { + t.Fatalf("post-advance charge.created created = %d, want within ±300 of %d", c, target) + } + + // The classic `now` param name is accepted too; going backwards is the + // real test_clock_changing_frozen_time 400. + body, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{"now": target + 3600}) + if status != 200 { + t.Fatalf("POST advance (now param) -> %d; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{"frozen_time": target}) + if status != 400 { + t.Fatalf("advance to the same time -> %d, want 400; body %s", status, body) + } + if code := stripeGroundDecode(t, body)["error"].(map[string]any)["code"]; code != "test_clock_changing_frozen_time" { + t.Fatalf("backwards advance code = %v, want test_clock_changing_frozen_time", code) + } + + // Deleting the active clock clears the offset: _now() returns to wall time. + body, status = deleteAuth(t, base+"/v1/test_clocks/"+clockID, devToken) + if status != 200 { + t.Fatalf("DELETE /v1/test_clocks/%s -> %d; body %s", clockID, status, body) + } + del := stripeGroundDecode(t, body) + if del["deleted"] != true || del["id"] != clockID || del["object"] != "test_helpers.test_clock" { + t.Fatalf("delete response = %v", del) + } + if _, status = getAuth(t, base+"/v1/test_clocks/"+clockID, devToken); status != 404 { + t.Fatalf("GET deleted clock -> %d, want 404", status) + } + stripeGroundCharge(t, base) + ev = stripeGroundNewestEvent(t, base, "charge.created") + real := time.Now().Unix() + if c := int64(ev["created"].(float64)); c < real-300 || c > real+300 { + t.Fatalf("post-delete charge.created created = %d, want within ±300 of wall time %d", c, real) + } +} + +// TestStripeGroundworkDisputes proves the documented dispute test cards +// (docs.stripe.com/testing) succeed and immediately raise a dispute: the +// charge links to it, the funds-withdrawal ledger row exists, and the real +// dispute webhooks are recorded — while a normal card leaves dispute null. +func TestStripeGroundworkDisputes(t *testing.T) { + base := newStripeTestServer(t) + + fraudCard := stripeCardNum("4000", "0000", "0000", "0259") + tok := mintStripeCardToken(t, base, fraudCard) + + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 4400, "currency": "usd", "source": tok, + }) + if status != 201 { + t.Fatalf("charge with dispute card -> %d; body %s", status, body) + } + ch := stripeGroundDecode(t, body) + if ch["status"] != "succeeded" || ch["captured"] != true { + t.Fatalf("dispute-card charge = %v, want succeeded+captured", ch) + } + dpID, _ := ch["dispute"].(string) + if dpID == "" || !strings.HasPrefix(dpID, "dp_") { + t.Fatalf("charge dispute = %v, want dp_* id", ch["dispute"]) + } + if bt, _ := ch["balance_transaction"].(string); bt == "" || !strings.HasPrefix(bt, "txn_") { + t.Fatalf("charge balance_transaction = %v, want txn_* id", ch["balance_transaction"]) + } + + // The link persists on retrieval. + body, status = getAuth(t, base+"/v1/charges/"+ch["id"].(string), devToken) + if status != 200 { + t.Fatalf("GET charge -> %d", status) + } + if got := stripeGroundDecode(t, body)["dispute"]; got != dpID { + t.Fatalf("retrieved charge dispute = %v, want %s", got, dpID) + } + + // charge.dispute.created carries the real dispute object shape. + dp := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.created")) + if dp["id"] != dpID { + t.Fatalf("dispute event id = %v, want %s", dp["id"], dpID) + } + if dp["object"] != "dispute" || dp["reason"] != "fraudulent" || dp["status"] != "needs_response" { + t.Fatalf("dispute event = %v", dp) + } + if dp["amount"].(float64) != 4400 || dp["charge"] != ch["id"] { + t.Fatalf("dispute event amount/charge = %v/%v", dp["amount"], dp["charge"]) + } + ed, _ := dp["evidence_details"].(map[string]any) + if ed == nil || ed["has_evidence"] != false || ed["past_due"] != false { + t.Fatalf("dispute evidence_details = %v", ed) + } + dueBy := int64(ed["due_by"].(float64)) + if dueBy <= time.Now().Unix() { + t.Fatalf("dispute due_by = %d, want in the future", dueBy) + } + if bts, _ := dp["balance_transactions"].([]any); len(bts) != 1 { + t.Fatalf("dispute balance_transactions = %v, want the withdrawal row", dp["balance_transactions"]) + } + + // The funds-withdrawal webhook is recorded alongside. + fw := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.funds_withdrawn")) + if fw["id"] != dpID { + t.Fatalf("funds_withdrawn dispute = %v, want %s", fw["id"], dpID) + } + + // The second documented dispute card raises product_not_received. + tok2 := mintStripeCardToken(t, base, stripeCardNum("4000", "0000", "0000", "2685")) + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 1500, "currency": "usd", "source": tok2, + }) + if status != 201 { + t.Fatalf("charge with product_not_received card -> %d; body %s", status, body) + } + dp2 := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.dispute.created")) + if dp2["reason"] != "product_not_received" { + t.Fatalf("second dispute reason = %v, want product_not_received", dp2["reason"]) + } + + // A normal card never disputes. + tokOK := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + body, status = postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 2000, "currency": "usd", "source": tokOK, + }) + if status != 201 { + t.Fatalf("charge with normal card -> %d; body %s", status, body) + } + chOK := stripeGroundDecode(t, body) + if chOK["dispute"] != nil { + t.Fatalf("normal-card dispute = %v, want null", chOK["dispute"]) + } + if bt, _ := chOK["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("normal-card balance_transaction = %v, want txn_*", chOK["balance_transaction"]) + } +} + +// TestStripeGroundworkPaymentIntentCharge proves every successful +// PaymentIntent mints its underlying charge (latest_charge), with the ledger +// and dispute hooks applied — and that failed/unsuccessful paths never do. +func TestStripeGroundworkPaymentIntentCharge(t *testing.T) { + base := newStripeTestServer(t) + + createPM := func(number string) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/payment_methods", devToken, map[string]any{ + "type": "card", + "card": map[string]any{"number": number, "exp_month": 12, "exp_year": 2030, "cvc": "123"}, + }) + if status != 201 { + t.Fatalf("create payment_method -> %d; body %s", status, body) + } + return stripeGroundDecode(t, body)["id"].(string) + } + + // Automatic capture with the dispute card: PI succeeds, its charge + // disputes immediately. + pmDispute := createPM(stripeCardNum("4000", "0000", "0000", "0259")) + body, status := postJSONAuth(t, base+"/v1/payment_intents", devToken, map[string]any{ + "amount": 3300, "currency": "usd", "payment_method": pmDispute, "confirm": true, + }) + if status != 201 { + t.Fatalf("create+confirm PI (dispute card) -> %d; body %s", status, body) + } + pi := stripeGroundDecode(t, body) + if pi["status"] != "succeeded" { + t.Fatalf("PI status = %v, want succeeded", pi["status"]) + } + chID, _ := pi["latest_charge"].(string) + if chID == "" || !strings.HasPrefix(chID, "ch_") { + t.Fatalf("PI latest_charge = %v, want ch_*", pi["latest_charge"]) + } + body, status = getAuth(t, base+"/v1/charges/"+chID, devToken) + if status != 200 { + t.Fatalf("GET PI charge -> %d", status) + } + pich := stripeGroundDecode(t, body) + if pich["payment_intent"] != pi["id"] || pich["status"] != "succeeded" { + t.Fatalf("PI charge = %v", pich) + } + if dp, _ := pich["dispute"].(string); dp == "" || !strings.HasPrefix(dp, "dp_") { + t.Fatalf("PI charge dispute = %v, want dp_*", pich["dispute"]) + } + if bt, _ := pich["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("PI charge balance_transaction = %v, want txn_*", pich["balance_transaction"]) + } + + // Manual capture: no charge until capture; the capture call settles it. + pmOK := createPM(stripeCardNum("4242", "4242", "4242", "4242")) + body, status = postJSONAuth(t, base+"/v1/payment_intents", devToken, map[string]any{ + "amount": 2100, "currency": "usd", "capture_method": "manual", + }) + if status != 201 { + t.Fatalf("create manual PI -> %d; body %s", status, body) + } + piMan := stripeGroundDecode(t, body) + body, status = postJSONAuth(t, base+"/v1/payment_intents/"+piMan["id"].(string)+"/confirm", devToken, map[string]any{ + "payment_method": pmOK, + }) + if status != 200 { + t.Fatalf("confirm manual PI -> %d; body %s", status, body) + } + if m := stripeGroundDecode(t, body); m["status"] != "requires_capture" || m["latest_charge"] != nil { + t.Fatalf("confirmed manual PI = %v, want requires_capture without a charge", m) + } + body, status = postJSONAuth(t, base+"/v1/payment_intents/"+piMan["id"].(string)+"/capture", devToken, map[string]any{ + "application_fee_amount": 300, + }) + if status != 200 { + t.Fatalf("capture manual PI -> %d; body %s", status, body) + } + piCap := stripeGroundDecode(t, body) + if piCap["status"] != "succeeded" { + t.Fatalf("captured PI status = %v", piCap["status"]) + } + chCap, _ := piCap["latest_charge"].(string) + if chCap == "" { + t.Fatalf("captured PI latest_charge = %v", piCap["latest_charge"]) + } + body, status = getAuth(t, base+"/v1/charges/"+chCap, devToken) + if status != 200 { + t.Fatalf("GET captured PI charge -> %d", status) + } + chCapDoc := stripeGroundDecode(t, body) + if chCapDoc["dispute"] != nil { + t.Fatalf("captured PI charge dispute = %v, want null", chCapDoc["dispute"]) + } + if bt, _ := chCapDoc["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("captured PI charge balance_transaction = %v, want txn_*", chCapDoc["balance_transaction"]) + } + + // A declined PI never mints a charge. + pmDecline := createPM(stripeCardNum("4000", "0000", "0000", "9995")) + body, status = postJSONAuth(t, base+"/v1/payment_intents", devToken, map[string]any{ + "amount": 900, "currency": "usd", + }) + if status != 201 { + t.Fatalf("create bare PI -> %d; body %s", status, body) + } + piBad := stripeGroundDecode(t, body) + if _, status = postJSONAuth(t, base+"/v1/payment_intents/"+piBad["id"].(string)+"/confirm", devToken, map[string]any{ + "payment_method": pmDecline, + }); status != 402 { + t.Fatalf("confirm declined PI -> %d, want 402", status) + } + body, status = getAuth(t, base+"/v1/payment_intents/"+piBad["id"].(string), devToken) + if status != 200 { + t.Fatalf("GET declined PI -> %d", status) + } + if m := stripeGroundDecode(t, body); m["latest_charge"] != nil { + t.Fatalf("declined PI latest_charge = %v, want null", m["latest_charge"]) + } +} + +// TestStripeGroundworkLedger proves the balance-transaction ledger rows ride +// along the money movements: card charges, capture-settled charges, refunds +// (with balance_transaction + receipt_number), transfers, and payouts — while +// the existing KV balance semantics stay intact. +func TestStripeGroundworkLedger(t *testing.T) { + base := newStripeTestServer(t) + + // A pending (card-less) charge carries null BT fields; capture records + // the charge BT and honors application_fee_amount on the capture call. + body, status := postJSONAuth(t, base+"/v1/charges", devToken, map[string]any{ + "amount": 6000, "currency": "usd", + }) + if status != 201 { + t.Fatalf("create pending charge -> %d; body %s", status, body) + } + pending := stripeGroundDecode(t, body) + if pending["balance_transaction"] != nil || pending["dispute"] != nil { + t.Fatalf("pending charge BT fields = %v/%v, want null/null", pending["balance_transaction"], pending["dispute"]) + } + chID := pending["id"].(string) + body, status = postJSONAuth(t, base+"/v1/charges/"+chID+"/capture", devToken, map[string]any{ + "application_fee_amount": 700, + }) + if status != 200 { + t.Fatalf("capture charge -> %d; body %s", status, body) + } + captured := stripeGroundDecode(t, body) + if bt, _ := captured["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("captured charge balance_transaction = %v, want txn_*", captured["balance_transaction"]) + } + + // The refund object carries its own BT + receipt number. + body, status = postJSONAuth(t, base+"/v1/refunds", devToken, map[string]any{ + "charge": chID, "amount": 2500, + }) + if status != 201 { + t.Fatalf("create refund -> %d; body %s", status, body) + } + rf := stripeGroundDecode(t, body) + if bt, _ := rf["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("refund balance_transaction = %v, want txn_*", rf["balance_transaction"]) + } + if rn, _ := rf["receipt_number"].(string); rn == "" || !strings.Contains(rn, "-") { + t.Fatalf("refund receipt_number = %v, want a non-empty dash-separated value", rf["receipt_number"]) + } + + // Connect accounting: transfer credits the connected account (response + // exposes the platform-side BT); the payout debits it and keeps the + // historical balance clamp semantics. + acctBody, status := postJSONAuth(t, base+"/v1/accounts", devToken, map[string]any{ + "type": "express", "country": "US", + }) + if status != 201 { + t.Fatalf("create account -> %d; body %s", status, acctBody) + } + acctID := stripeGroundDecode(t, acctBody)["id"].(string) + + body, status = postJSONAuthHeader(t, base+"/v1/transfers", devToken, map[string]any{ + "amount": 10000, "currency": "usd", "destination": acctID, + }, nil) + if status != 201 { + t.Fatalf("create transfer -> %d; body %s", status, body) + } + tr := stripeGroundDecode(t, body) + if bt, _ := tr["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("transfer balance_transaction = %v, want txn_*", tr["balance_transaction"]) + } + + body, status = postJSONAuthHeader(t, base+"/v1/payouts", devToken, map[string]any{ + "amount": 4000, "currency": "usd", + }, map[string]string{"Stripe-Account": acctID}) + if status != 201 { + t.Fatalf("create payout -> %d; body %s", status, body) + } + po := stripeGroundDecode(t, body) + if bt, _ := po["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("payout balance_transaction = %v, want txn_*", po["balance_transaction"]) + } + + body, status = getAuthHeader(t, base+"/v1/balance", devToken, map[string]string{"Stripe-Account": acctID}) + if status != 200 { + t.Fatalf("GET balance -> %d; body %s", status, body) + } + bal := stripeGroundDecode(t, body) + avail := bal["available"].([]any)[0].(map[string]any) + if avail["amount"].(float64) != 6000 { + t.Fatalf("balance after transfer-payout = %v, want 6000 (ledger rows preserve KV semantics)", avail["amount"]) + } + + // Transfer reversal still settles and records its rows. The create- + // reversal endpoint returns the transfer_reversal object (not the + // transfer), per docs.stripe.com/api/transfer_reversals/create. + body, status = postJSONAuthHeader(t, base+"/v1/transfers/"+tr["id"].(string)+"/reversals", devToken, map[string]any{ + "amount": 3000, + }, nil) + if status != 200 { + t.Fatalf("reverse transfer -> %d; body %s", status, body) + } + trr := stripeGroundDecode(t, body) + if trr["object"] != "transfer_reversal" { + t.Fatalf("reverse transfer object = %v, want transfer_reversal", trr["object"]) + } + if trr["transfer"] != tr["id"] { + t.Fatalf("reversal transfer = %v, want %v", trr["transfer"], tr["id"]) + } + if trr["amount"].(float64) != 3000 { + t.Fatalf("reversal amount = %v, want 3000", trr["amount"]) + } + if bt, _ := trr["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { + t.Fatalf("reversal balance_transaction = %v, want txn_*", trr["balance_transaction"]) + } + + // The transfer itself accumulates the partial reversal. + body, status = getAuth(t, base+"/v1/transfers/"+tr["id"].(string), devToken) + if status != 200 { + t.Fatalf("retrieve reversed transfer -> %d; body %s", status, body) + } + tr2 := stripeGroundDecode(t, body) + if tr2["amount_reversed"].(float64) != 3000 { + t.Fatalf("transfer amount_reversed = %v, want 3000", tr2["amount_reversed"]) + } + if tr2["reversed"] == true { + t.Fatalf("transfer reversed = %v, want false for a partial reversal", tr2["reversed"]) + } +} diff --git a/internal/engine/stripe_invoices_test.go b/internal/engine/stripe_invoices_test.go new file mode 100644 index 00000000..94d0d3d9 --- /dev/null +++ b/internal/engine/stripe_invoices_test.go @@ -0,0 +1,981 @@ +package engine + +import ( + "strings" + "testing" +) + +// d3-invoices tests: invoices (manual lifecycle + upcoming preview), invoice +// items, credit notes, coupons, promotion codes, tax rates. +// +// Shared helpers (newStripeTestServer, postJSONAuth, getAuth, deleteAuth, +// postJSONAuthIdem, stripeCardNum, mintStripeCardToken, devToken, +// stripeGroundPostRaw, stripeGroundDecode, stripeGroundNewestEvent, +// stripeGroundEventPayload) live in the existing stripe test files. Every +// helper defined here is prefixed stripeInv so parallel agents cannot +// collide. + +// stripeInvInt reads a JSON number field as int64 (0 when absent). +func stripeInvInt(m map[string]any, key string) int64 { + v, _ := m[key].(float64) + return int64(v) +} + +// stripeInvStr reads a JSON string field ("" when absent). +func stripeInvStr(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +// stripeInvErr extracts the nested error object from a Stripe error body. +func stripeInvErr(t *testing.T, body string) map[string]any { + t.Helper() + m := stripeGroundDecode(t, body) + e, _ := m["error"].(map[string]any) + if e == nil { + t.Fatalf("no error envelope in %s", body) + } + return e +} + +// stripeInvCustomer creates a customer and returns its cus_* id. +func stripeInvCustomer(t *testing.T, base string) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{ + "name": "Invoice Tester", "email": "inv-tester@example.com", + }) + if status != 201 { + t.Fatalf("POST /v1/customers -> %d; body %s", status, body) + } + return stripeGroundDecode(t, body)["id"].(string) +} + +// stripeInvTaxRate creates a tax rate and returns its txr_* id. +func stripeInvTaxRate(t *testing.T, base, display string, inclusive bool, pct float64) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/tax_rates", devToken, map[string]any{ + "display_name": display, "inclusive": inclusive, "percentage": pct, + "jurisdiction": "US-CA", "description": "test rate", + }) + if status != 201 { + t.Fatalf("POST /v1/tax_rates -> %d; body %s", status, body) + } + m := stripeGroundDecode(t, body) + if m["object"] != "tax_rate" || !strings.HasPrefix(m["id"].(string), "txr_") { + t.Fatalf("tax rate shape = %v", m) + } + return m["id"].(string) +} + +// stripeInvCreateInvoice creates a draft manual invoice and returns the +// decoded invoice object. +func stripeInvCreateInvoice(t *testing.T, base string, body map[string]any) map[string]any { + t.Helper() + raw, status := postJSONAuth(t, base+"/v1/invoices", devToken, body) + if status != 201 { + t.Fatalf("POST /v1/invoices -> %d; body %s", status, raw) + } + return stripeGroundDecode(t, raw) +} + +// stripeInvPayInvoice pays an invoice with a payment method, returning the +// body + status. +func stripeInvPayInvoice(t *testing.T, base, invID, pm string) (string, int) { + t.Helper() + return postJSONAuth(t, base+"/v1/invoices/"+invID+"/pay", devToken, map[string]any{ + "payment_method": pm, + }) +} + +// TestStripeInvManualLifecycle walks a manual invoice end to end: draft +// (items + exclusive tax) -> draft edits -> finalize -> open -> failed pay +// (decline card 402, invoice stays open) -> successful pay (real charge + +// balance transaction + events) -> terminal-state 400s -> draft deletion. +func TestStripeInvManualLifecycle(t *testing.T) { + base := newStripeTestServer(t) + + // Auth is enforced on the invoice surface. + if _, status := getAuth(t, base+"/v1/invoices", ""); status != 401 { + t.Fatalf("no-auth list invoices -> %d, want 401", status) + } + + cus := stripeInvCustomer(t, base) + txr := stripeInvTaxRate(t, base, "Sales Tax", false, 10) + + inv := stripeInvCreateInvoice(t, base, map[string]any{ + "customer": cus, + "collection_method": "send_invoice", + "due_date": 1800000000, + "description": "First manual invoice", + "currency": "usd", + "default_tax_rates": []string{txr}, + "auto_advance": false, + "metadata": map[string]any{"phase": "build"}, + "items": []map[string]any{ + {"unit_amount": 2000, "quantity": 2, "description": "Gold plan"}, + {"unit_amount": 500, "quantity": 1, "description": "Setup fee"}, + }, + }) + invID := inv["id"].(string) + if !strings.HasPrefix(invID, "in_") { + t.Fatalf("invoice id = %v", invID) + } + // draft math: subtotal 2*2000 + 500 = 4500; exclusive 10% tax 450. + for k, want := range map[string]int64{ + "subtotal": 4500, "tax": 450, "total": 4950, "amount_due": 4950, + "amount_paid": 0, "amount_remaining": 4950, + } { + if got := stripeInvInt(inv, k); got != want { + t.Fatalf("draft invoice %s = %d, want %d (%v)", k, got, want, inv) + } + } + if inv["status"] != "draft" || inv["object"] != "invoice" { + t.Fatalf("draft status/object = %v/%v", inv["status"], inv["object"]) + } + if inv["currency"] != "usd" || inv["collection_method"] != "send_invoice" { + t.Fatalf("draft currency/collection_method = %v/%v", inv["currency"], inv["collection_method"]) + } + if stripeInvInt(inv, "due_date") != 1800000000 { + t.Fatalf("due_date = %v, want 1800000000", inv["due_date"]) + } + if inv["subscription"] != nil { + t.Fatalf("manual invoice subscription = %v, want null", inv["subscription"]) + } + lines, _ := inv["lines"].([]any) + if len(lines) != 2 { + t.Fatalf("draft lines = %d, want 2", len(lines)) + } + l0, _ := lines[0].(map[string]any) + if l0["object"] != "line_item" || l0["type"] != "invoice_item" { + t.Fatalf("line shape = %v", l0) + } + if stripeInvInt(l0, "amount") != 2000 || stripeInvInt(l0, "quantity") != 2 { + t.Fatalf("line 0 amount/quantity = %v/%v", l0["amount"], l0["quantity"]) + } + if l0["proration"] != false { + t.Fatalf("line proration = %v", l0["proration"]) + } + + // invoice.created is recorded in /v1/events. + evPay := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.created")) + if evPay["id"] != invID { + t.Fatalf("invoice.created payload id = %v", evPay["id"]) + } + + // Draft edits: description + metadata merge. + body, status := postJSONAuth(t, base+"/v1/invoices/"+invID, devToken, map[string]any{ + "description": "Updated memo", + "metadata": map[string]any{"order": "6735"}, + }) + if status != 200 { + t.Fatalf("draft update -> %d; body %s", status, body) + } + upd := stripeGroundDecode(t, body) + if upd["description"] != "Updated memo" { + t.Fatalf("updated description = %v", upd["description"]) + } + meta, _ := upd["metadata"].(map[string]any) + if meta["phase"] != "build" || meta["order"] != "6735" { + t.Fatalf("merged metadata = %v", meta) + } + + // GET /v1/invoices/{id}/lines pages the line items. + body, status = getAuth(t, base+"/v1/invoices/"+invID+"/lines", devToken) + if status != 200 { + t.Fatalf("GET lines -> %d; body %s", status, body) + } + linesList := stripeGroundDecode(t, body) + if linesList["object"] != "list" { + t.Fatalf("lines list object = %v", linesList["object"]) + } + linesData, _ := linesList["data"].([]any) + if len(linesData) != 2 { + t.Fatalf("lines data = %d, want 2", len(linesData)) + } + + // Finalize: draft -> open, finalized_at stamped. + body, status = postJSONAuth(t, base+"/v1/invoices/"+invID+"/finalize", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("finalize -> %d; body %s", status, body) + } + fin := stripeGroundDecode(t, body) + if fin["status"] != "open" || fin["auto_advance"] != false { + t.Fatalf("finalized invoice = %v", fin) + } + st, _ := fin["status_transitions"].(map[string]any) + if st["finalized_at"] == nil { + t.Fatalf("finalized_at = %v", st["finalized_at"]) + } + + // The list endpoint filters by customer + status. + body, status = getAuth(t, base+"/v1/invoices?customer="+cus+"&status=open", devToken) + if status != 200 { + t.Fatalf("list open invoices -> %d; body %s", status, body) + } + openList := stripeGroundDecode(t, body) + openData, _ := openList["data"].([]any) + if len(openData) != 1 || openData[0].(map[string]any)["id"] != invID { + t.Fatalf("open filter = %v", openData) + } + + // Draft-only fields are rejected on a finalized invoice (real 400). + body, status = postJSONAuth(t, base+"/v1/invoices/"+invID, devToken, map[string]any{ + "collection_method": "charge_automatically", + }) + if status != 400 { + t.Fatalf("finalized collection_method update -> %d; body %s", status, body) + } + if e := stripeInvErr(t, body); e["type"] != "invalid_request_error" || e["param"] != "collection_method" { + t.Fatalf("draft-only update error = %v", e) + } + // metadata is still editable after finalization. + body, status = postJSONAuth(t, base+"/v1/invoices/"+invID, devToken, map[string]any{ + "metadata": map[string]any{"stage": "finalized"}, + }) + if status != 200 { + t.Fatalf("finalized metadata update -> %d; body %s", status, body) + } + + // Pay with the generic-decline test card: 402 card_error, invoice stays + // open, invoice.payment_failed fires, a failed charge exists. + declineTok := mintStripeCardToken(t, base, stripeCardNum("4000", "0000", "0000", "0002")) + body, status = stripeInvPayInvoice(t, base, invID, declineTok) + if status != 402 { + t.Fatalf("declined pay -> %d; body %s", status, body) + } + e := stripeInvErr(t, body) + if e["type"] != "card_error" || e["code"] != "card_declined" || e["decline_code"] != "generic_decline" { + t.Fatalf("decline error = %v", e) + } + if !strings.HasPrefix(e["charge"].(string), "ch_") { + t.Fatalf("decline error charge = %v", e["charge"]) + } + body, status = getAuth(t, base+"/v1/invoices/"+invID, devToken) + if status != 200 { + t.Fatalf("retrieve after decline -> %d", status) + } + after := stripeGroundDecode(t, body) + if after["status"] != "open" || after["attempted"] != true { + t.Fatalf("invoice after decline = %v", after) + } + failedPay := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.payment_failed")) + if failedPay["id"] != invID { + t.Fatalf("invoice.payment_failed payload = %v", failedPay["id"]) + } + + // Pay with a normal card: paid + a real captured charge with its balance + // transaction + the full event set. + goodTok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + body, status = stripeInvPayInvoice(t, base, invID, goodTok) + if status != 200 { + t.Fatalf("successful pay -> %d; body %s", status, body) + } + paid := stripeGroundDecode(t, body) + if paid["status"] != "paid" || paid["paid"] != true { + t.Fatalf("paid invoice = %v", paid) + } + if stripeInvInt(paid, "amount_paid") != 4950 || stripeInvInt(paid, "amount_remaining") != 0 { + t.Fatalf("paid amounts = %v/%v", paid["amount_paid"], paid["amount_remaining"]) + } + st, _ = paid["status_transitions"].(map[string]any) + if st["paid_at"] == nil { + t.Fatalf("paid_at = %v", st["paid_at"]) + } + chID := paid["charge"].(string) + if !strings.HasPrefix(chID, "ch_") { + t.Fatalf("paid invoice charge = %v", chID) + } + body, status = getAuth(t, base+"/v1/charges/"+chID, devToken) + if status != 200 { + t.Fatalf("GET charge -> %d; body %s", status, body) + } + ch := stripeGroundDecode(t, body) + if ch["status"] != "succeeded" || ch["captured"] != true || ch["invoice"] != invID { + t.Fatalf("invoice charge = %v", ch) + } + if stripeInvInt(ch, "amount") != 4950 || ch["balance_transaction"] == nil { + t.Fatalf("charge amount/bt = %v/%v", ch["amount"], ch["balance_transaction"]) + } + for _, evType := range []string{"invoice.paid", "invoice.payment_succeeded"} { + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, evType)); p["id"] != invID { + t.Fatalf("%s payload id = %v, want %s", evType, p["id"], invID) + } + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "charge.succeeded")); p["id"] != chID { + t.Fatalf("charge.succeeded payload = %v", p["id"]) + } + + // Terminal states: paying again, deleting a paid invoice, and paying a + // draft all fail with the real 400 envelope. + body, status = stripeInvPayInvoice(t, base, invID, goodTok) + if status != 400 || !strings.Contains(stripeInvErr(t, body)["message"].(string), "status of paid") { + t.Fatalf("double pay -> %d; body %s", status, body) + } + if body, status := deleteAuth(t, base+"/v1/invoices/"+invID, devToken); status != 400 { + t.Fatalf("delete paid invoice -> %d; body %s", status, body) + } + + // A separate draft: pay (400, still draft), then delete succeeds. + empty := stripeInvCreateInvoice(t, base, map[string]any{"customer": cus}) + emptyID := empty["id"].(string) + body, status = stripeInvPayInvoice(t, base, emptyID, goodTok) + if status != 400 || !strings.Contains(stripeInvErr(t, body)["message"].(string), "status of draft") { + t.Fatalf("pay draft -> %d; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/invoices/"+emptyID+"/finalize", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("finalize draft #2 -> %d; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/invoices/"+emptyID+"/finalize", devToken, map[string]any{}) + if status != 400 || !strings.Contains(stripeInvErr(t, body)["message"].(string), "status of open") { + t.Fatalf("double finalize -> %d; body %s", status, body) + } + + draft3 := stripeInvCreateInvoice(t, base, map[string]any{"customer": cus}) + body, status = deleteAuth(t, base+"/v1/invoices/"+draft3["id"].(string), devToken) + if status != 200 { + t.Fatalf("delete draft -> %d; body %s", status, body) + } + del := stripeGroundDecode(t, body) + if del["deleted"] != true || del["object"] != "invoice" || del["id"] != draft3["id"] { + t.Fatalf("deleted shape = %v", del) + } + if body, status := getAuth(t, base+"/v1/invoices/"+draft3["id"].(string), devToken); status != 404 { + t.Fatalf("GET deleted draft -> %d; body %s", status, body) + } + evDel := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.deleted")) + if evDel["id"] != draft3["id"] { + t.Fatalf("invoice.deleted payload = %v", evDel["id"]) + } + + // Malformed JSON body -> 400 (not a silent empty-body create). + if body, status := stripeGroundPostRaw(t, base+"/v1/invoices", devToken, "{"); status != 400 { + t.Fatalf("malformed create body -> %d; body %s", status, body) + } + + // Unknown invoice -> the real Stripe 404 message. + body, status = getAuth(t, base+"/v1/invoices/in_nope", devToken) + if status != 404 || stripeInvErr(t, body)["message"] != "No such invoice: in_nope" { + t.Fatalf("missing invoice -> %d; body %s", status, body) + } +} + +// TestStripeInvInclusiveTax proves the TAX CONTRACT on a manual invoice: an +// inclusive rate shows its tax in `tax` but does NOT raise the total. +func TestStripeInvInclusiveTax(t *testing.T) { + base := newStripeTestServer(t) + cus := stripeInvCustomer(t, base) + txr := stripeInvTaxRate(t, base, "VAT", true, 20) + + inv := stripeInvCreateInvoice(t, base, map[string]any{ + "customer": cus, + "default_tax_rates": []string{txr}, + "items": []map[string]any{ + {"unit_amount": 2000, "quantity": 2}, + {"unit_amount": 500, "quantity": 1}, + }, + }) + // subtotal 4500, inclusive 20% tax = 900 shown, total stays 4500. + if stripeInvInt(inv, "subtotal") != 4500 || stripeInvInt(inv, "tax") != 900 || stripeInvInt(inv, "total") != 4500 { + t.Fatalf("inclusive totals = %v/%v/%v", inv["subtotal"], inv["tax"], inv["total"]) + } + + // paid_out_of_band pays a zero-charge invoice (no charge minted). + invID := inv["id"].(string) + if _, status := postJSONAuth(t, base+"/v1/invoices/"+invID+"/finalize", devToken, map[string]any{}); status != 200 { + t.Fatalf("finalize -> %d", status) + } + body, status := postJSONAuth(t, base+"/v1/invoices/"+invID+"/pay", devToken, map[string]any{ + "paid_out_of_band": true, + }) + if status != 200 { + t.Fatalf("paid_out_of_band -> %d; body %s", status, body) + } + oob := stripeGroundDecode(t, body) + if oob["status"] != "paid" || oob["charge"] != nil { + t.Fatalf("out-of-band paid invoice = %v", oob) + } +} + +// TestStripeInvVoidSendUncollectible covers the remaining open-invoice +// transitions: void, mark_uncollectible, and send. +func TestStripeInvVoidSendUncollectible(t *testing.T) { + base := newStripeTestServer(t) + cus := stripeInvCustomer(t, base) + + mk := func() map[string]any { + inv := stripeInvCreateInvoice(t, base, map[string]any{ + "customer": cus, + "items": []map[string]any{{"unit_amount": 1500, "quantity": 1}}, + }) + id := inv["id"].(string) + if _, status := postJSONAuth(t, base+"/v1/invoices/"+id+"/finalize", devToken, map[string]any{}); status != 200 { + t.Fatalf("finalize -> %d", status) + } + return inv + } + + // void + v := mk() + body, status := postJSONAuth(t, base+"/v1/invoices/"+v["id"].(string)+"/void", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("void -> %d; body %s", status, body) + } + voided := stripeGroundDecode(t, body) + if voided["status"] != "void" { + t.Fatalf("voided = %v", voided["status"]) + } + st, _ := voided["status_transitions"].(map[string]any) + if st["voided_at"] == nil { + t.Fatalf("voided_at = %v", st) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.voided")); p["id"] != v["id"] { + t.Fatalf("invoice.voided payload = %v", p["id"]) + } + + // mark_uncollectible + u := mk() + body, status = postJSONAuth(t, base+"/v1/invoices/"+u["id"].(string)+"/mark_uncollectible", devToken, map[string]any{}) + if status != 200 || stripeGroundDecode(t, body)["status"] != "uncollectible" { + t.Fatalf("mark_uncollectible -> %d; body %s", status, body) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.marked_uncollectible")); p["id"] != u["id"] { + t.Fatalf("invoice.marked_uncollectible payload = %v", p["id"]) + } + + // send: stays open, attempted flips, invoice.sent fires. + s := mk() + body, status = postJSONAuth(t, base+"/v1/invoices/"+s["id"].(string)+"/send", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("send -> %d; body %s", status, body) + } + sent := stripeGroundDecode(t, body) + if sent["status"] != "open" || sent["attempted"] != true { + t.Fatalf("sent invoice = %v", sent) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoice.sent")); p["id"] != s["id"] { + t.Fatalf("invoice.sent payload = %v", p["id"]) + } + + // send on a draft -> real 400. + draft := stripeInvCreateInvoice(t, base, map[string]any{"customer": cus}) + if body, status := postJSONAuth(t, base+"/v1/invoices/"+draft["id"].(string)+"/send", devToken, map[string]any{}); status != 400 { + t.Fatalf("send draft -> %d; body %s", status, body) + } + // void on a voided invoice -> 400. + if body, status := postJSONAuth(t, base+"/v1/invoices/"+v["id"].(string)+"/void", devToken, map[string]any{}); status != 400 { + t.Fatalf("double void -> %d; body %s", status, body) + } +} + +// TestStripeInvInvoiceItems covers invoice-item CRUD: pending items with +// amount = unit_amount x quantity, list filters, updates, deletion, and +// negative-amount credits. +func TestStripeInvInvoiceItems(t *testing.T) { + base := newStripeTestServer(t) + cus := stripeInvCustomer(t, base) + + body, status := postJSONAuth(t, base+"/v1/invoice_items", devToken, map[string]any{ + "customer": cus, "unit_amount": 1500, "quantity": 2, "currency": "usd", + "description": "Extra usage", "discountable": true, + }) + if status != 201 { + t.Fatalf("create invoice item -> %d; body %s", status, body) + } + ii := stripeGroundDecode(t, body) + iiID := ii["id"].(string) + if !strings.HasPrefix(iiID, "ii_") || ii["object"] != "invoice_item" { + t.Fatalf("invoice item shape = %v", ii) + } + if stripeInvInt(ii, "amount") != 3000 || stripeInvInt(ii, "unit_amount") != 1500 || stripeInvInt(ii, "quantity") != 2 { + t.Fatalf("invoice item amounts = %v/%v/%v", ii["amount"], ii["unit_amount"], ii["quantity"]) + } + if ii["invoice"] != nil { + t.Fatalf("new invoice item is not pending: %v", ii["invoice"]) + } + + // retrieve + body, status = getAuth(t, base+"/v1/invoice_items/"+iiID, devToken) + if status != 200 || stripeGroundDecode(t, body)["id"] != iiID { + t.Fatalf("retrieve invoice item -> %d; body %s", status, body) + } + + // list: customer + pending filters both surface it + body, status = getAuth(t, base+"/v1/invoice_items?customer="+cus+"&pending=true", devToken) + if status != 200 { + t.Fatalf("list pending invoice items -> %d; body %s", status, body) + } + data, _ := stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("pending list = %v", data) + } + + // update quantity recomputes amount + body, status = postJSONAuth(t, base+"/v1/invoice_items/"+iiID, devToken, map[string]any{ + "quantity": 3, "metadata": map[string]any{"tag": "usage"}, + }) + if status != 200 { + t.Fatalf("update invoice item -> %d; body %s", status, body) + } + if got := stripeInvInt(stripeGroundDecode(t, body), "amount"); got != 4500 { + t.Fatalf("updated amount = %d, want 4500", got) + } + + // negative amounts reduce the next invoice (real Stripe behavior) + body, status = postJSONAuth(t, base+"/v1/invoice_items", devToken, map[string]any{ + "customer": cus, "unit_amount": -500, "currency": "usd", "description": "Goodwill credit", + }) + if status != 201 { + t.Fatalf("negative invoice item -> %d; body %s", status, body) + } + if got := stripeInvInt(stripeGroundDecode(t, body), "amount"); got != -500 { + t.Fatalf("negative amount = %d", got) + } + + // delete -> deleted shape; the item is gone afterwards + body, status = deleteAuth(t, base+"/v1/invoice_items/"+iiID, devToken) + if status != 200 { + t.Fatalf("delete invoice item -> %d; body %s", status, body) + } + del := stripeGroundDecode(t, body) + if del["deleted"] != true || del["object"] != "invoice_item" || del["id"] != iiID { + t.Fatalf("deleted invoice item shape = %v", del) + } + if body, status := getAuth(t, base+"/v1/invoice_items/"+iiID, devToken); status != 404 { + t.Fatalf("GET deleted invoice item -> %d; body %s", status, body) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "invoiceitem.deleted")); p["id"] != iiID { + t.Fatalf("invoiceitem.deleted payload = %v", p["id"]) + } + + // validation: missing customer / unknown customer + if body, status := postJSONAuth(t, base+"/v1/invoice_items", devToken, map[string]any{"unit_amount": 100}); status != 400 { + t.Fatalf("missing customer -> %d; body %s", status, body) + } + if body, status := postJSONAuth(t, base+"/v1/invoice_items", devToken, map[string]any{"customer": "cus_nope", "unit_amount": 100}); status != 404 { + t.Fatalf("unknown customer -> %d; body %s", status, body) + } + if body, status := stripeGroundPostRaw(t, base+"/v1/invoice_items", devToken, "{"); status != 400 { + t.Fatalf("malformed invoice item body -> %d; body %s", status, body) + } +} + +// TestStripeInvUpcomingPreview previews the next invoice from pending +// invoice items (no persistence), the invoice_upcoming_none 404, and — when +// the subscriptions domain routes are live — a subscription renewal preview +// with tax. +func TestStripeInvUpcomingPreview(t *testing.T) { + base := newStripeTestServer(t) + cus := stripeInvCustomer(t, base) + + // Nothing pending -> the real 404. + body, status := getAuth(t, base+"/v1/invoices/upcoming?customer="+cus, devToken) + if status != 404 { + t.Fatalf("empty upcoming -> %d; body %s", status, body) + } + if e := stripeInvErr(t, body); e["code"] != "invoice_upcoming_none" { + t.Fatalf("empty upcoming error = %v", e) + } + + // Pending items alone drive the preview; nothing is persisted. + for _, ii := range []map[string]any{ + {"customer": cus, "unit_amount": 1500, "quantity": 2, "currency": "usd", "description": "Usage overage"}, + {"customer": cus, "unit_amount": 700, "quantity": 1, "currency": "usd", "description": "Support"}, + } { + if b, s := postJSONAuth(t, base+"/v1/invoice_items", devToken, ii); s != 201 { + t.Fatalf("create pending item -> %d; body %s", s, b) + } + } + body, status = getAuth(t, base+"/v1/invoices/upcoming?customer="+cus, devToken) + if status != 200 { + t.Fatalf("upcoming preview -> %d; body %s", status, body) + } + up := stripeGroundDecode(t, body) + if up["object"] != "invoice" || up["id"] != nil { + t.Fatalf("upcoming shape = %v/%v", up["object"], up["id"]) + } + if up["status"] != "open" || up["subscription"] != nil { + t.Fatalf("upcoming status/subscription = %v/%v", up["status"], up["subscription"]) + } + // 1500*2 + 700 = 3700 + for k, want := range map[string]int64{"subtotal": 3700, "total": 3700, "amount_due": 3700} { + if got := stripeInvInt(up, k); got != want { + t.Fatalf("upcoming %s = %d, want %d", k, got, want) + } + } + upLines, _ := up["lines"].([]any) + if len(upLines) != 2 { + t.Fatalf("upcoming lines = %d, want 2", len(upLines)) + } + body, status = getAuth(t, base+"/v1/invoices?customer="+cus, devToken) + if status != 200 { + t.Fatalf("list invoices after preview -> %d", status) + } + saved, _ := stripeGroundDecode(t, body)["data"].([]any) + if len(saved) != 0 { + t.Fatalf("preview persisted %d invoices", len(saved)) + } + + // Subscription renewal preview (subscriptions domain routes): guarded + // because those routes land in the stitch phase with this file's. + subBody, subStatus := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cus, + "items": []map[string]any{{"price_data": map[string]any{"currency": "usd", "unit_amount": 2000, "recurring": map[string]any{"interval": "month"}}}}, + "default_tax_rates": []string{stripeInvTaxRate(t, base, "Sub Tax", false, 10)}, + }) + if subStatus != 200 && subStatus != 201 { + t.Logf("subscription create -> %d (subscriptions domain not merged yet?); skipping sub preview assertions; body %s", subStatus, subBody) + return + } + sub := stripeGroundDecode(t, subBody) + subID, _ := sub["id"].(string) + body, status = getAuth(t, base+"/v1/invoices/upcoming?customer="+cus+"&subscription="+subID, devToken) + if status != 200 { + t.Fatalf("subscription upcoming preview -> %d; body %s", status, body) + } + sup := stripeGroundDecode(t, body) + // subscription line 2000 + pending items 3700 = 5700, exclusive 10% = 570. + if got := stripeInvInt(sup, "subtotal"); got != 5700 { + t.Fatalf("subscription upcoming subtotal = %d, want 5700 (%v)", got, sup) + } + if got := stripeInvInt(sup, "tax"); got != 570 { + t.Fatalf("subscription upcoming tax = %d, want 570", got) + } + if got := stripeInvInt(sup, "total"); got != 6270 { + t.Fatalf("subscription upcoming total = %d, want 6270", got) + } + supLines, _ := sup["lines"].([]any) + if len(supLines) != 3 { + t.Fatalf("subscription upcoming lines = %d, want 3", len(supLines)) + } + if sup["subscription"] != subID { + t.Fatalf("subscription upcoming subscription = %v", sup["subscription"]) + } +} + +// TestStripeInvCreditNotes covers credit notes on a paid invoice: real +// refund via the lib refund helpers, customer-balance credit, the preview +// endpoint (no persistence), void, and the max-creditable guard. +func TestStripeInvCreditNotes(t *testing.T) { + base := newStripeTestServer(t) + cus := stripeInvCustomer(t, base) + + // Build a paid 3000-cent invoice with a real charge behind it. + inv := stripeInvCreateInvoice(t, base, map[string]any{ + "customer": cus, + "items": []map[string]any{{"unit_amount": 3000, "quantity": 1}}, + }) + invID := inv["id"].(string) + if _, status := postJSONAuth(t, base+"/v1/invoices/"+invID+"/finalize", devToken, map[string]any{}); status != 200 { + t.Fatalf("finalize -> %d", status) + } + goodTok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + body, status := stripeInvPayInvoice(t, base, invID, goodTok) + if status != 200 { + t.Fatalf("pay -> %d; body %s", status, body) + } + chID := stripeGroundDecode(t, body)["charge"].(string) + + // Credit note: 1000 total on a fully-paid invoice -> post-payment; 600 + // refunded against the charge, 400 credited to the customer balance. + body, status = postJSONAuth(t, base+"/v1/credit_notes", devToken, map[string]any{ + "invoice": invID, "amount": 1000, "refund_amount": 600, "credit_amount": 400, + "reason": "duplicate", "memo": "partial adjustment", + }) + if status != 201 { + t.Fatalf("create credit note -> %d; body %s", status, body) + } + cn := stripeGroundDecode(t, body) + cnID := cn["id"].(string) + if !strings.HasPrefix(cnID, "cn_") || cn["object"] != "credit_note" { + t.Fatalf("credit note shape = %v", cn) + } + for k, want := range map[string]int64{ + "amount": 1000, "pre_payment_amount": 0, "post_payment_amount": 1000, + } { + if got := stripeInvInt(cn, k); got != want { + t.Fatalf("credit note %s = %d, want %d", k, got, want) + } + } + if cn["status"] != "issued" || cn["type"] != "post_payment" || cn["reason"] != "duplicate" { + t.Fatalf("credit note status/type/reason = %v/%v/%v", cn["status"], cn["type"], cn["reason"]) + } + if cn["invoice"] != invID || cn["customer"] != cus { + t.Fatalf("credit note invoice/customer = %v/%v", cn["invoice"], cn["customer"]) + } + refunds, _ := cn["refunds"].([]any) + if len(refunds) != 1 { + t.Fatalf("credit note refunds = %v", cn["refunds"]) + } + reID, _ := refunds[0].(string) + + // The refund is a real refund doc against the invoice's charge. + body, status = getAuth(t, base+"/v1/refunds/"+reID, devToken) + if status != 200 { + t.Fatalf("GET credit-note refund -> %d; body %s", status, body) + } + re := stripeGroundDecode(t, body) + if re["object"] != "refund" || stripeInvInt(re, "amount") != 600 || re["charge"] != chID { + t.Fatalf("credit note refund = %v", re) + } + // The charge reflects the refund. + body, status = getAuth(t, base+"/v1/charges/"+chID, devToken) + if status != 200 { + t.Fatalf("GET charge -> %d", status) + } + if got := stripeInvInt(stripeGroundDecode(t, body), "amount_refunded"); got != 600 { + t.Fatalf("charge amount_refunded = %d, want 600", got) + } + // Customer balance goes NEGATIVE by the credited amount (credit). + body, status = getAuth(t, base+"/v1/customers/"+cus, devToken) + if status != 200 { + t.Fatalf("GET customer -> %d", status) + } + if got := stripeInvInt(stripeGroundDecode(t, body), "balance"); got != -400 { + t.Fatalf("customer balance = %d, want -400", got) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "credit_note.created")); p["id"] != cnID { + t.Fatalf("credit_note.created payload = %v", p["id"]) + } + + // retrieve + list + body, status = getAuth(t, base+"/v1/credit_notes/"+cnID, devToken) + if status != 200 || stripeGroundDecode(t, body)["id"] != cnID { + t.Fatalf("retrieve credit note -> %d; body %s", status, body) + } + body, status = getAuth(t, base+"/v1/credit_notes?customer="+cus+"&invoice="+invID, devToken) + if status != 200 { + t.Fatalf("list credit notes -> %d; body %s", status, body) + } + data, _ := stripeGroundDecode(t, body)["data"].([]any) + if len(data) != 1 { + t.Fatalf("credit note list = %v", data) + } + + // Preview computes the same shape WITHOUT persisting or moving money. + body, status = getAuth(t, base+"/v1/credit_notes/preview?invoice="+invID+"&amount=500", devToken) + if status != 200 { + t.Fatalf("preview credit note -> %d; body %s", status, body) + } + pv := stripeGroundDecode(t, body) + if pv["id"] != nil || stripeInvInt(pv, "amount") != 500 { + t.Fatalf("preview credit note = %v", pv) + } + body, status = getAuth(t, base+"/v1/credit_notes?customer="+cus, devToken) + if len(stripeGroundDecode(t, body)["data"].([]any)) != 1 { + t.Fatalf("preview persisted a credit note") + } + // customer balance unchanged by the preview (still -400) + body, _ = getAuth(t, base+"/v1/customers/"+cus, devToken) + if got := stripeInvInt(stripeGroundDecode(t, body), "balance"); got != -400 { + t.Fatalf("balance after preview = %d", got) + } + + // update (metadata) then void + body, status = postJSONAuth(t, base+"/v1/credit_notes/"+cnID, devToken, map[string]any{ + "metadata": map[string]any{"audit": "yes"}, + }) + if status != 200 { + t.Fatalf("update credit note -> %d; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/credit_notes/"+cnID+"/void", devToken, map[string]any{}) + if status != 200 { + t.Fatalf("void credit note -> %d; body %s", status, body) + } + voided := stripeGroundDecode(t, body) + if voided["status"] != "voided" || voided["voided_at"] == nil { + t.Fatalf("voided credit note = %v", voided) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "credit_note.voided")); p["id"] != cnID { + t.Fatalf("credit_note.voided payload = %v", p["id"]) + } + + // Guards: draft invoices cannot be credited; the max-creditable cap. + draft := stripeInvCreateInvoice(t, base, map[string]any{"customer": cus}) + if body, status := postJSONAuth(t, base+"/v1/credit_notes", devToken, map[string]any{"invoice": draft["id"].(string), "amount": 100}); status != 400 { + t.Fatalf("credit note on draft -> %d; body %s", status, body) + } + inv2 := stripeInvCreateInvoice(t, base, map[string]any{ + "customer": cus, + "items": []map[string]any{{"unit_amount": 2000, "quantity": 1}}, + }) + inv2ID := inv2["id"].(string) + if _, status := postJSONAuth(t, base+"/v1/invoices/"+inv2ID+"/finalize", devToken, map[string]any{}); status != 200 { + t.Fatalf("finalize inv2 -> %d", status) + } + if _, status := stripeInvPayInvoice(t, base, inv2ID, goodTok); status != 200 { + t.Fatalf("pay inv2 -> %d", status) + } + body, status = postJSONAuth(t, base+"/v1/credit_notes", devToken, map[string]any{"invoice": inv2ID, "amount": 2500}) + if status != 400 || !strings.Contains(stripeInvErr(t, body)["message"].(string), "maximum creditable") { + t.Fatalf("over-credit -> %d; body %s", status, body) + } + if body, status := stripeGroundPostRaw(t, base+"/v1/credit_notes", devToken, "{"); status != 400 { + t.Fatalf("malformed credit note body -> %d; body %s", status, body) + } +} + +// TestStripeInvCouponsPromosTaxRates covers coupon CRUD (+deleted shape), +// promotion codes (explicit + auto-generated codes, filters, update), and +// tax-rate CRUD (active-only update, deleted shape). +func TestStripeInvCouponsPromosTaxRates(t *testing.T) { + base := newStripeTestServer(t) + + // percent coupon (repeating needs duration_in_months) + body, status := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{ + "percent_off": 25, "duration": "repeating", "duration_in_months": 3, "name": "Spring", + }) + if status != 201 { + t.Fatalf("create percent coupon -> %d; body %s", status, body) + } + pc := stripeGroundDecode(t, body) + pcID := pc["id"].(string) + if !strings.HasPrefix(pcID, "coupon_") || pc["object"] != "coupon" { + t.Fatalf("coupon shape = %v", pc) + } + if pc["percent_off"] != float64(25) || pc["duration"] != "repeating" || stripeInvInt(pc, "duration_in_months") != 3 { + t.Fatalf("percent coupon fields = %v", pc) + } + if stripeInvInt(pc, "times_redeemed") != 0 || pc["valid"] != true { + t.Fatalf("coupon redemption state = %v", pc) + } + + // amount coupon requires currency + body, status = postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{ + "amount_off": 500, "currency": "usd", "duration": "once", + }) + if status != 201 { + t.Fatalf("create amount coupon -> %d; body %s", status, body) + } + ac := stripeGroundDecode(t, body) + acID := ac["id"].(string) + if stripeInvInt(ac, "amount_off") != 500 || ac["currency"] != "usd" || ac["percent_off"] != nil { + t.Fatalf("amount coupon = %v", ac) + } + + // validation: neither, both, amount without currency, zero percent + if body, status := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{"duration": "once"}); status != 400 { + t.Fatalf("empty coupon -> %d; body %s", status, body) + } + if body, status := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{"percent_off": 20, "amount_off": 500}); status != 400 { + t.Fatalf("both coupon -> %d; body %s", status, body) + } + if body, status := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{"amount_off": 500}); status != 400 { + t.Fatalf("amount coupon without currency -> %d; body %s", status, body) + } + if body, status := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{"percent_off": 0}); status != 400 { + t.Fatalf("zero percent coupon -> %d; body %s", status, body) + } + + // coupon update (name) + list + body, status = postJSONAuth(t, base+"/v1/coupons/"+pcID, devToken, map[string]any{"name": "Spring 2"}) + if status != 200 || stripeGroundDecode(t, body)["name"] != "Spring 2" { + t.Fatalf("update coupon -> %d; body %s", status, body) + } + body, status = getAuth(t, base+"/v1/coupons", devToken) + if status != 200 { + t.Fatalf("list coupons -> status %d", status) + } + if n := len(stripeGroundDecode(t, body)["data"].([]any)); n != 2 { + t.Fatalf("coupon list has %d, want 2", n) + } + + // coupon delete: deleted flag shape; the object stays retrievable + body, status = deleteAuth(t, base+"/v1/coupons/"+acID, devToken) + if status != 200 { + t.Fatalf("delete coupon -> %d; body %s", status, body) + } + del := stripeGroundDecode(t, body) + if del["deleted"] != true || del["object"] != "coupon" || del["id"] != acID { + t.Fatalf("deleted coupon shape = %v", del) + } + body, status = getAuth(t, base+"/v1/coupons/"+acID, devToken) + if status != 200 { + t.Fatalf("GET deleted coupon -> %d; body %s", status, body) + } + dead := stripeGroundDecode(t, body) + if dead["deleted"] != true || dead["valid"] != false { + t.Fatalf("deleted coupon read = %v", dead) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "coupon.deleted")); p["id"] != acID { + t.Fatalf("coupon.deleted payload = %v", p["id"]) + } + + // promotion code with an explicit code; coupon renders EXPANDED + body, status = postJSONAuth(t, base+"/v1/promotion_codes", devToken, map[string]any{ + "coupon": pcID, "code": "SPRING25", + "restrictions": map[string]any{"first_time_transaction": true, "minimum_amount": 2000, "minimum_amount_currency": "usd"}, + }) + if status != 201 { + t.Fatalf("create promotion code -> %d; body %s", status, body) + } + promo := stripeGroundDecode(t, body) + promoID := promo["id"].(string) + if !strings.HasPrefix(promoID, "promo_") || promo["object"] != "promotion_code" { + t.Fatalf("promotion code shape = %v", promo) + } + if promo["code"] != "SPRING25" || promo["active"] != true { + t.Fatalf("promotion code = %v", promo) + } + expanded, _ := promo["coupon"].(map[string]any) + if expanded == nil || expanded["id"] != pcID || stripeInvInt(expanded, "percent_off") != 25 { + t.Fatalf("promotion code coupon = %v", promo["coupon"]) + } + r, _ := promo["restrictions"].(map[string]any) + if r["first_time_transaction"] != true || stripeInvInt(r, "minimum_amount") != 2000 { + t.Fatalf("promotion code restrictions = %v", r) + } + + // auto-generated code is 8 uppercase alphanumerics + body, status = postJSONAuth(t, base+"/v1/promotion_codes", devToken, map[string]any{"coupon": pcID}) + if status != 201 { + t.Fatalf("create auto promotion code -> %d; body %s", status, body) + } + auto := stripeInvStr(stripeGroundDecode(t, body), "code") + if len(auto) != 8 || strings.ToUpper(auto) != auto { + t.Fatalf("auto-generated code = %q", auto) + } + + // list filters: code + active + body, status = getAuth(t, base+"/v1/promotion_codes?code=SPRING25", devToken) + if status != 200 || len(stripeGroundDecode(t, body)["data"].([]any)) != 1 { + t.Fatalf("promotion codes by code -> %d; body %s", status, body) + } + body, status = postJSONAuth(t, base+"/v1/promotion_codes/"+promoID, devToken, map[string]any{"active": false}) + if status != 200 || stripeGroundDecode(t, body)["active"] != false { + t.Fatalf("deactivate promotion code -> %d; body %s", status, body) + } + body, status = getAuth(t, base+"/v1/promotion_codes?active=false", devToken) + if status != 200 || len(stripeGroundDecode(t, body)["data"].([]any)) != 1 { + t.Fatalf("inactive promotion codes -> %d; body %s", status, body) + } + if p := stripeGroundEventPayload(stripeGroundNewestEvent(t, base, "promotion_code.updated")); p["id"] != promoID { + t.Fatalf("promotion_code.updated payload = %v", p["id"]) + } + + // tax rate create + retrieve are covered by the helper; here: active- + // only update, delete shape, deleted read, missing-param validation. + txrID := stripeInvTaxRate(t, base, "VAT", false, 8.875) + body, status = postJSONAuth(t, base+"/v1/tax_rates/"+txrID, devToken, map[string]any{"active": false}) + if status != 200 { + t.Fatalf("archive tax rate -> %d; body %s", status, body) + } + archived := stripeGroundDecode(t, body) + if archived["active"] != false || archived["percentage"] != 8.875 { + t.Fatalf("archived tax rate = %v", archived) + } + if body, status := postJSONAuth(t, base+"/v1/tax_rates", devToken, map[string]any{"display_name": "X", "inclusive": true}); status != 400 { + t.Fatalf("tax rate without percentage -> %d; body %s", status, body) + } + body, status = deleteAuth(t, base+"/v1/tax_rates/"+txrID, devToken) + if status != 200 { + t.Fatalf("delete tax rate -> %d; body %s", status, body) + } + if del := stripeGroundDecode(t, body); del["deleted"] != true || del["object"] != "tax_rate" { + t.Fatalf("deleted tax rate shape = %v", del) + } + body, status = getAuth(t, base+"/v1/tax_rates/"+txrID, devToken) + if status != 200 || stripeGroundDecode(t, body)["deleted"] != true { + t.Fatalf("GET deleted tax rate -> %d; body %s", status, body) + } +} diff --git a/internal/engine/stripe_subscriptions_test.go b/internal/engine/stripe_subscriptions_test.go new file mode 100644 index 00000000..c108d962 --- /dev/null +++ b/internal/engine/stripe_subscriptions_test.go @@ -0,0 +1,716 @@ +package engine + +// stripe_subscriptions_test.go — d2-subs domain: products, prices, +// subscriptions (full billing lifecycle driven by test clocks), subscription +// items and metered usage records. +// +// These tests run against adapters/stripe-style once the stitch phase has +// merged manifest_patch.d2-subs.yaml (routes) plus the billing-domain patch +// (coupons, tax_rates endpoints used for seeding) into adapter.yaml. +// +// Reused helpers: postJSONAuth, getAuth, deleteAuth, postJSONAuthIdem, +// devToken, newStripeTestServer, mintStripeCardToken, stripeCardNum. +// Domain-prefixed helpers defined here: stripeSubJSON, stripeSubNewestEvent, +// stripeSubEventCount, stripeSubCreateClock, stripeSubAdvanceClock. + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// stripeSubJSON parses a JSON response body into a map. +func stripeSubJSON(t *testing.T, body string) map[string]any { + t.Helper() + var v map[string]any + if err := json.Unmarshal([]byte(body), &v); err != nil { + t.Fatalf("stripeSubJSON: bad json %q: %v", body, err) + } + return v +} + +// stripeSubNewestEvent returns the data.object payload of the newest +// recorded event of the given type (events are stored newest first). +func stripeSubNewestEvent(t *testing.T, base, typ string) map[string]any { + t.Helper() + body, status := getAuth(t, base+"/v1/events?type="+typ+"&limit=1", devToken) + if status != 200 { + t.Fatalf("GET /v1/events?type=%s -> %d; body %s", typ, status, body) + } + ev := stripeSubJSON(t, body) + data, _ := ev["data"].([]any) + if len(data) == 0 { + t.Fatalf("no %s events recorded", typ) + } + return data[0].(map[string]any)["data"].(map[string]any)["object"].(map[string]any) +} + +// stripeSubEventCount counts recorded events of one type. +func stripeSubEventCount(t *testing.T, base, typ string) int { + t.Helper() + body, status := getAuth(t, base+"/v1/events?type="+typ+"&limit=100", devToken) + if status != 200 { + t.Fatalf("GET /v1/events?type=%s -> %d", typ, status) + } + data, _ := stripeSubJSON(t, body)["data"].([]any) + return len(data) +} + +// stripeSubCreateClock creates a test clock frozen at ts and returns its id. +func stripeSubCreateClock(t *testing.T, base string, ts int64) string { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/test_clocks", devToken, map[string]any{"frozen_time": ts}) + if status != 201 { + t.Fatalf("POST /v1/test_clocks -> %d; body %s", status, body) + } + id, _ := stripeSubJSON(t, body)["id"].(string) + if id == "" { + t.Fatalf("test clock id missing: %s", body) + } + return id +} + +// stripeSubAdvanceClock advances the clock past ts. +func stripeSubAdvanceClock(t *testing.T, base, clockID string, ts int64) { + t.Helper() + body, status := postJSONAuth(t, base+"/v1/test_clocks/"+clockID+"/advance", devToken, map[string]any{"frozen_time": ts}) + if status != 200 { + t.Fatalf("POST /v1/test_clocks/%s/advance -> %d; body %s", clockID, status, body) + } +} + +// TestStripeSubBillingCycle drives one full billing cycle with the test +// clock: a licensed monthly subscription is created and its first invoice +// paid inline; advancing the clock past current_period_end renews it on the +// next read — new period, second paid invoice, second charge with a balance +// transaction — without any sleeping. +func TestStripeSubBillingCycle(t *testing.T) { + base := newStripeTestServer(t) + clockID := stripeSubCreateClock(t, base, 1735689600) // 2025-01-01T00:00:00Z + + cusBody, s := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Sub Tester"}) + if s != 201 { + t.Fatalf("customer -> %d", s) + } + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + + prodBody, s := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "Gold Plan"}) + if s != 201 { + t.Fatalf("product -> %d; %s", s, prodBody) + } + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + + priceBody, s := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 2000, "currency": "usd", + "recurring": map[string]any{"interval": "month"}, + }) + if s != 201 { + t.Fatalf("price -> %d; %s", s, priceBody) + } + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + subBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, + "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("subscription -> %d; %s", s, subBody) + } + sub := stripeSubJSON(t, subBody) + subID, _ := sub["id"].(string) + if sub["status"] != "active" { + t.Fatalf("status = %v, want active", sub["status"]) + } + inv1, _ := sub["latest_invoice"].(string) + if inv1 == "" { + t.Fatalf("latest_invoice missing: %v", sub) + } + p0e, _ := sub["current_period_end"].(float64) + if p0e <= 1735689600 { + t.Fatalf("current_period_end = %v", p0e) + } + + // First invoice paid inline with a succeeded charge behind it. + invObj := stripeSubNewestEvent(t, base, "invoice.paid") + if invObj["id"] != inv1 || invObj["total"].(float64) != 2000 || invObj["billing_reason"] != "subscription_create" { + t.Fatalf("invoice#1 = %v", invObj) + } + chID, _ := invObj["charge"].(string) + chBody, s := getAuth(t, base+"/v1/charges/"+chID, devToken) + if s != 200 { + t.Fatalf("charge -> %d", s) + } + ch := stripeSubJSON(t, chBody) + if ch["status"] != "succeeded" || ch["balance_transaction"] == nil || ch["invoice"] != inv1 { + t.Fatalf("invoice#1 charge = %v", ch) + } + + // Advance past the period end; the next GET derives the renewal. + stripeSubAdvanceClock(t, base, clockID, int64(p0e)+3600) + getBody, s := getAuth(t, base+"/v1/subscriptions/"+subID, devToken) + if s != 200 { + t.Fatalf("GET subscription -> %d; %s", s, getBody) + } + sub2 := stripeSubJSON(t, getBody) + p1s, _ := sub2["current_period_start"].(float64) + p1e, _ := sub2["current_period_end"].(float64) + if int64(p1s) != int64(p0e) { + t.Fatalf("renewed period start %v, want %v", p1s, p0e) + } + wantEnd := time.Unix(int64(p0e), 0).UTC().AddDate(0, 1, 0).Unix() // calendar month + if int64(p1e) != wantEnd { + t.Fatalf("renewed period end %v, want %v", p1e, wantEnd) + } + inv2, _ := sub2["latest_invoice"].(string) + if inv2 == "" || inv2 == inv1 { + t.Fatalf("latest_invoice %q, want a new invoice id", inv2) + } + if sub2["status"] != "active" { + t.Fatalf("status after renewal = %v", sub2["status"]) + } + + // Two paid invoices, two succeeded charges, one renewal announcement. + if n := stripeSubEventCount(t, base, "invoice.paid"); n != 2 { + t.Fatalf("invoice.paid count = %d, want 2", n) + } + if n := stripeSubEventCount(t, base, "invoice.payment_succeeded"); n != 2 { + t.Fatalf("invoice.payment_succeeded count = %d, want 2", n) + } + if n := stripeSubEventCount(t, base, "charge.succeeded"); n != 2 { + t.Fatalf("charge.succeeded count = %d, want 2", n) + } + if n := stripeSubEventCount(t, base, "customer.subscription.updated"); n != 1 { + t.Fatalf("customer.subscription.updated count = %d, want 1", n) + } + + // The renewal invoice is a cycle invoice with a charge + BT behind it. + inv2obj := stripeSubNewestEvent(t, base, "invoice.paid") + if inv2obj["id"] != inv2 || inv2obj["total"].(float64) != 2000 || inv2obj["billing_reason"] != "subscription_cycle" { + t.Fatalf("invoice#2 = %v", inv2obj) + } + ch2ID, _ := inv2obj["charge"].(string) + ch2Body, s := getAuth(t, base+"/v1/charges/"+ch2ID, devToken) + if s != 200 { + t.Fatalf("charge#2 -> %d", s) + } + ch2 := stripeSubJSON(t, ch2Body) + if ch2["status"] != "succeeded" || ch2["balance_transaction"] == nil { + t.Fatalf("invoice#2 charge = %v", ch2) + } + if ch2["invoice"] != inv2 || ch2["subscription"] != subID { + t.Fatalf("invoice#2 charge linkage = %v", ch2) + } +} + +// TestStripeSubMeteredUsage proves metered billing: the first invoice of a +// metered-only subscription is $0 (usage is billed in arrears), reported +// usage sums onto the NEXT invoice at the renewal boundary. +func TestStripeSubMeteredUsage(t *testing.T) { + base := newStripeTestServer(t) + clockID := stripeSubCreateClock(t, base, 1735689600) + + cusBody, _ := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Metered"}) + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + prodBody, _ := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "API calls"}) + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + priceBody, s := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 100, "currency": "usd", + "recurring": map[string]any{"interval": "month", "usage_type": "metered"}, + }) + if s != 201 { + t.Fatalf("metered price -> %d; %s", s, priceBody) + } + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + subBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("metered subscription -> %d; %s", s, subBody) + } + sub := stripeSubJSON(t, subBody) + items, _ := sub["items"].([]any) + if len(items) != 1 { + t.Fatalf("items = %v", sub["items"]) + } + siID, _ := items[0].(map[string]any)["id"].(string) + if !strings.HasPrefix(siID, "si_") { + t.Fatalf("subscription item id = %q", siID) + } + + // First invoice: $0, paid without a charge. + inv1 := stripeSubNewestEvent(t, base, "invoice.paid") + if inv1["total"].(float64) != 0 || inv1["subscription"] != sub["id"] { + t.Fatalf("metered invoice#1 = %v", inv1) + } + + // Report 50 units now, then 30 more after moving the clock mid-period + // (usage records must not be timestamped in the future). + ur1Body, s := postJSONAuth(t, base+"/v1/subscription_items/"+siID+"/usage_records", devToken, map[string]any{"quantity": 50}) + if s != 201 { + t.Fatalf("usage record 50 -> %d; %s", s, ur1Body) + } + ur1 := stripeSubJSON(t, ur1Body) + if ur1["quantity"].(float64) != 50 || ur1["subscription_item"] != siID || ur1["object"] != "usage_record" { + t.Fatalf("usage record = %v", ur1) + } + p0s, _ := sub["current_period_start"].(float64) + p0e, _ := sub["current_period_end"].(float64) + mid := (int64(p0s) + int64(p0e)) / 2 + stripeSubAdvanceClock(t, base, clockID, mid) + ur2Body, s := postJSONAuth(t, base+"/v1/subscription_items/"+siID+"/usage_records", devToken, map[string]any{"quantity": 30}) + if s != 201 { + t.Fatalf("usage record 30 -> %d; %s", s, ur2Body) + } + + // The usage list endpoint returns both records, newest first. + urListBody, s := getAuth(t, base+"/v1/subscription_items/"+siID+"/usage_records", devToken) + if s != 200 { + t.Fatalf("usage records list -> %d", s) + } + urData, _ := stripeSubJSON(t, urListBody)["data"].([]any) + if len(urData) != 2 { + t.Fatalf("usage records = %d, want 2", len(urData)) + } + + // action=set replaces the usage at one timestamp: overwriting the + // mid-period record's 30 units with 10 makes the cycle total 50 + 10. + latest := urData[0].(map[string]any) + setBody, s := postJSONAuth(t, base+"/v1/subscription_items/"+siID+"/usage_records", devToken, map[string]any{ + "quantity": 10, "timestamp": latest["timestamp"], "action": "set", + }) + if s != 201 { + t.Fatalf("usage record set -> %d; %s", s, setBody) + } + + stripeSubAdvanceClock(t, base, clockID, int64(p0e)+60) + getBody, _ := getAuth(t, base+"/v1/subscriptions/"+sub["id"].(string), devToken) + sub2 := stripeSubJSON(t, getBody) + + // 50 + 30 units became 50 + 10 after the set: 60 units × $1.00. + inv2 := stripeSubNewestEvent(t, base, "invoice.paid") + if inv2["subscription"] != sub["id"] || inv2["total"].(float64) != 6000 { + t.Fatalf("metered invoice#2 = %v", inv2) + } + if sub2["latest_invoice"] != inv2["id"] { + t.Fatalf("metered latest_invoice = %v", sub2["latest_invoice"]) + } + lines, _ := inv2["lines"].([]any) + if len(lines) != 1 { + t.Fatalf("metered invoice#2 lines = %v", inv2["lines"]) + } + ln := lines[0].(map[string]any) + if ln["quantity"].(float64) != 60 || ln["amount"].(float64) != 100 || ln["object"] != "line_item" { + t.Fatalf("metered line = %v", ln) + } +} + +// TestStripeSubPaymentFailures covers the card-behavior rules: a decline +// card as the default payment method leaves the subscription past_due with +// an open invoice and a recorded failed charge; no payment method at all +// under charge_automatically is the real Stripe 400 and creates nothing. +func TestStripeSubPaymentFailures(t *testing.T) { + base := newStripeTestServer(t) + stripeSubCreateClock(t, base, 1735689600) + + cusBody, _ := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Declines"}) + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + prodBody, _ := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "Silver"}) + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + priceBody, _ := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 1500, "currency": "usd", + "recurring": map[string]any{"interval": "month"}, + }) + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + + // No payment method: the real Stripe 400, nothing created. + noPMBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + }) + if s != 400 { + t.Fatalf("no-PM subscription -> %d; %s", s, noPMBody) + } + if !strings.Contains(noPMBody, "This customer has no attached payment source or default payment method") { + t.Fatalf("no-PM error message: %s", noPMBody) + } + listBody, _ := getAuth(t, base+"/v1/subscriptions?customer="+cusID, devToken) + if data := stripeSubJSON(t, listBody)["data"].([]any); len(data) != 0 { + t.Fatalf("subscriptions created despite no-PM 400: %v", data) + } + + // Decline card: subscription past_due, invoice stays open, charge + // recorded as failed. + declTok := mintStripeCardToken(t, base, stripeCardNum("4000", "0000", "0000", "0002")) + subBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": declTok, + }) + if s != 201 { + t.Fatalf("decline subscription -> %d; %s", s, subBody) + } + sub := stripeSubJSON(t, subBody) + if sub["status"] != "past_due" { + t.Fatalf("decline status = %v, want past_due", sub["status"]) + } + if sub["latest_invoice"] == nil || sub["latest_invoice"] == "" { + t.Fatalf("decline latest_invoice = %v", sub["latest_invoice"]) + } + failed := stripeSubNewestEvent(t, base, "charge.failed") + if failed["status"] != "failed" || failed["amount"].(float64) != 1500 { + t.Fatalf("failed charge = %v", failed) + } + pf := stripeSubNewestEvent(t, base, "invoice.payment_failed") + if pf["id"] != sub["latest_invoice"] || pf["status"] != "open" { + t.Fatalf("payment_failed invoice = %v", pf) + } +} + +// TestStripeSubCancel covers both cancellation modes: immediate (status +// canceled + ended_at + customer.subscription.deleted) and at period end +// (flag set; the cancellation lands when the clock passes the boundary). +func TestStripeSubCancel(t *testing.T) { + base := newStripeTestServer(t) + clockID := stripeSubCreateClock(t, base, 1735689600) + + cusBody, _ := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Cancels"}) + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + prodBody, _ := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "Bronze"}) + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + priceBody, _ := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 900, "currency": "usd", + "recurring": map[string]any{"interval": "month"}, + }) + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + mkSub := func() map[string]any { + body, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("subscription -> %d; %s", s, body) + } + return stripeSubJSON(t, body) + } + + // Immediate cancel. + imm := mkSub() + cancelBody, s := postJSONAuth(t, base+"/v1/subscriptions/"+imm["id"].(string)+"/cancel", devToken, map[string]any{}) + if s != 200 { + t.Fatalf("cancel -> %d; %s", s, cancelBody) + } + canceled := stripeSubJSON(t, cancelBody) + if canceled["status"] != "canceled" || canceled["ended_at"] == nil || canceled["canceled_at"] == nil { + t.Fatalf("canceled subscription = %v", canceled) + } + del := stripeSubNewestEvent(t, base, "customer.subscription.deleted") + if del["id"] != imm["id"] { + t.Fatalf("deleted event = %v", del["id"]) + } + + // Cancel at period end, then advance: the boundary derives the + // cancellation (ended_at == period end, no further invoice). + cape := mkSub() + capeID, _ := cape["id"].(string) + updBody, s := postJSONAuth(t, base+"/v1/subscriptions/"+capeID, devToken, map[string]any{"cancel_at_period_end": true}) + if s != 200 { + t.Fatalf("update cancel_at_period_end -> %d; %s", s, updBody) + } + if stripeSubJSON(t, updBody)["cancel_at_period_end"] != true { + t.Fatalf("cancel_at_period_end not set: %s", updBody) + } + p0e, _ := cape["current_period_end"].(float64) + paidBefore := stripeSubEventCount(t, base, "invoice.paid") + stripeSubAdvanceClock(t, base, clockID, int64(p0e)+60) + getBody, _ := getAuth(t, base+"/v1/subscriptions/"+capeID, devToken) + got := stripeSubJSON(t, getBody) + if got["status"] != "canceled" || got["ended_at"] == nil { + t.Fatalf("boundary-canceled subscription = %v", got) + } + if int64(got["ended_at"].(float64)) != int64(p0e) { + t.Fatalf("ended_at = %v, want period end %v", got["ended_at"], p0e) + } + if n := stripeSubEventCount(t, base, "invoice.paid"); n != paidBefore { + t.Fatalf("invoice.paid count %d after cancel-at-boundary, want %d (no renewal invoice)", n, paidBefore) + } +} + +// TestStripeSubCouponAndTaxRates covers the discount + tax computations on +// subscription invoices: a percent_off coupon with duration once discounts +// only the first invoice and is then dropped; an exclusive tax rate adds to +// the total while an inclusive rate is only shown. +func TestStripeSubCouponAndTaxRates(t *testing.T) { + base := newStripeTestServer(t) + clockID := stripeSubCreateClock(t, base, 1735689600) + + cusBody, _ := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Coupons"}) + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + prodBody, _ := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "Platinum"}) + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + priceBody, _ := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 2000, "currency": "usd", + "recurring": map[string]any{"interval": "month"}, + }) + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + couponBody, s := postJSONAuth(t, base+"/v1/coupons", devToken, map[string]any{ + "percent_off": 25, "duration": "once", + }) + if s != 201 { + t.Fatalf("coupon -> %d; %s (billing-domain routes must be stitched)", s, couponBody) + } + couponID, _ := stripeSubJSON(t, couponBody)["id"].(string) + + exclBody, s := postJSONAuth(t, base+"/v1/tax_rates", devToken, map[string]any{ + "display_name": "Sales", "percentage": 10, "inclusive": false, + }) + if s != 201 { + t.Fatalf("tax rate -> %d; %s (billing-domain routes must be stitched)", s, exclBody) + } + exclID, _ := stripeSubJSON(t, exclBody)["id"].(string) + inclBody, s := postJSONAuth(t, base+"/v1/tax_rates", devToken, map[string]any{ + "display_name": "VAT", "percentage": 20, "inclusive": true, + }) + if s != 201 { + t.Fatalf("tax rate -> %d; %s", s, inclBody) + } + inclID, _ := stripeSubJSON(t, inclBody)["id"].(string) + + // First invoice: 2000 - 25% (500) = 1500 post-discount, +10% exclusive + // tax (150) -> total 1650. + subBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, "coupon": couponID, + "default_tax_rates": []any{exclID}, + }) + if s != 201 { + t.Fatalf("coupon subscription -> %d; %s", s, subBody) + } + sub := stripeSubJSON(t, subBody) + inv1 := stripeSubNewestEvent(t, base, "invoice.paid") + if inv1["subscription"] != sub["id"] { + t.Fatalf("invoice for another subscription: %v", inv1["subscription"]) + } + if inv1["subtotal"].(float64) != 2000 { + t.Fatalf("subtotal = %v", inv1["subtotal"]) + } + if inv1["total"].(float64) != 1650 || inv1["tax"].(float64) != 150 { + t.Fatalf("exclusive totals: total %v tax %v, want 1650/150", inv1["total"], inv1["tax"]) + } + disc, _ := inv1["discount"].(map[string]any) + if disc == nil || disc["id"] != couponID { + t.Fatalf("invoice discount = %v", inv1["discount"]) + } + + // Renew: duration=once means the discount is gone; tax now applies to + // the full 2000 -> total 2200. + p0e, _ := sub["current_period_end"].(float64) + stripeSubAdvanceClock(t, base, clockID, int64(p0e)+60) + getBody, _ := getAuth(t, base+"/v1/subscriptions/"+sub["id"].(string), devToken) + sub2 := stripeSubJSON(t, getBody) + if sub2["status"] != "active" { + t.Fatalf("coupon subscription status = %v", sub2["status"]) + } + inv2 := stripeSubNewestEvent(t, base, "invoice.paid") + if inv2["subscription"] != sub["id"] { + t.Fatalf("renewal invoice for another subscription") + } + if inv2["total"].(float64) != 2200 || inv2["tax"].(float64) != 200 || inv2["discount"] != nil { + t.Fatalf("renewal totals: %v", inv2) + } + if sub2["discount"] != nil { + t.Fatalf("duration-once discount not dropped from subscription: %v", sub2["discount"]) + } + + // Inclusive rate: tax shown (20% of 2000 = 400) but NOT added to total. + inclSubBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, "default_tax_rates": []any{inclID}, + }) + if s != 201 { + t.Fatalf("inclusive subscription -> %d; %s", s, inclSubBody) + } + inclSub := stripeSubJSON(t, inclSubBody) + invIncl := stripeSubNewestEvent(t, base, "invoice.paid") + if invIncl["subscription"] != inclSub["id"] { + t.Fatalf("inclusive invoice for another subscription") + } + if invIncl["tax"].(float64) != 400 || invIncl["total"].(float64) != 2000 { + t.Fatalf("inclusive totals: tax %v total %v, want 400/2000", invIncl["tax"], invIncl["total"]) + } +} + +// TestStripeSubItemsAndCatalog covers the subscription-items endpoints +// (list, add, update quantity, delete, last-item guard) and the +// products/prices CRUD behaviors (archived product tombstone + list +// exclusion, price update of mutable fields, list filters, name required). +func TestStripeSubItemsAndCatalog(t *testing.T) { + base := newStripeTestServer(t) + stripeSubCreateClock(t, base, 1735689600) + + cusBody, _ := postJSONAuth(t, base+"/v1/customers", devToken, map[string]any{"name": "Items"}) + cusID, _ := stripeSubJSON(t, cusBody)["id"].(string) + prodBody, _ := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{"name": "Widgets"}) + prodID, _ := stripeSubJSON(t, prodBody)["id"].(string) + priceBody, _ := postJSONAuth(t, base+"/v1/prices", devToken, map[string]any{ + "product": prodID, "unit_amount": 500, "currency": "usd", + "recurring": map[string]any{"interval": "month"}, + }) + priceID, _ := stripeSubJSON(t, priceBody)["id"].(string) + tok := mintStripeCardToken(t, base, stripeCardNum("4242", "4242", "4242", "4242")) + + subBody, s := postJSONAuth(t, base+"/v1/subscriptions", devToken, map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID, "quantity": 2}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("subscription -> %d; %s", s, subBody) + } + sub := stripeSubJSON(t, subBody) + subID, _ := sub["id"].(string) + + // Invoice #1 reflects quantity 2 × 500. + inv1 := stripeSubNewestEvent(t, base, "invoice.paid") + if inv1["total"].(float64) != 1000 { + t.Fatalf("invoice#1 total = %v, want 1000", inv1["total"]) + } + + // items list requires the subscription param and projects the embedded + // item. + if b, s := getAuth(t, base+"/v1/subscription_items", devToken); s != 400 || !strings.Contains(b, "Missing required param: subscription") { + t.Fatalf("items without subscription -> %d %s", s, b) + } + itemsBody, s := getAuth(t, base+"/v1/subscription_items?subscription="+subID, devToken) + if s != 200 { + t.Fatalf("items list -> %d; %s", s, itemsBody) + } + itemsData, _ := stripeSubJSON(t, itemsBody)["data"].([]any) + if len(itemsData) != 1 { + t.Fatalf("items = %v", itemsBody) + } + siID, _ := itemsData[0].(map[string]any)["id"].(string) + + // Update the quantity via the item endpoint. + updBody, s := postJSONAuth(t, base+"/v1/subscription_items/"+siID, devToken, map[string]any{"quantity": 3}) + if s != 200 { + t.Fatalf("item update -> %d; %s", s, updBody) + } + if stripeSubJSON(t, updBody)["quantity"].(float64) != 3 { + t.Fatalf("item quantity = %s", updBody) + } + + // Add a second item, then delete it again. + addBody, s := postJSONAuth(t, base+"/v1/subscription_items", devToken, map[string]any{ + "subscription": subID, "price": priceID, "quantity": 1, "proration_behavior": "none", + }) + if s != 201 { + t.Fatalf("item add -> %d; %s", s, addBody) + } + added := stripeSubJSON(t, addBody) + if added["subscription"] != subID || added["object"] != "subscription_item" { + t.Fatalf("added item = %v", added) + } + delBody, s := deleteAuth(t, base+"/v1/subscription_items/"+added["id"].(string), devToken) + if s != 200 || !strings.Contains(delBody, `"deleted":true`) { + t.Fatalf("item delete -> %d %s", s, delBody) + } + + // The last item can never be deleted — cancel the subscription instead. + lastDelBody, s := deleteAuth(t, base+"/v1/subscription_items/"+siID, devToken) + if s != 400 || !strings.Contains(lastDelBody, "last subscription item") { + t.Fatalf("last-item delete -> %d %s", s, lastDelBody) + } + + // Products: name required, archived products stay retrievable with + // deleted:true but leave the list; prices: mutable-field update, filter. + if b, s := postJSONAuth(t, base+"/v1/products", devToken, map[string]any{}); s != 400 || !strings.Contains(b, "Missing required param: name") { + t.Fatalf("product without name -> %d %s", s, b) + } + pGetBody, s := getAuth(t, base+"/v1/products/"+prodID, devToken) + if s != 200 || stripeSubJSON(t, pGetBody)["name"] != "Widgets" { + t.Fatalf("product retrieve -> %d %s", s, pGetBody) + } + pDelBody, s := deleteAuth(t, base+"/v1/products/"+prodID, devToken) + if s != 200 || !strings.Contains(pDelBody, `"deleted":true`) { + t.Fatalf("product delete -> %d %s", s, pDelBody) + } + pGet2Body, s := getAuth(t, base+"/v1/products/"+prodID, devToken) + if s != 200 || stripeSubJSON(t, pGet2Body)["deleted"] != true { + t.Fatalf("archived product retrieve -> %d %s", s, pGet2Body) + } + pListBody, _ := getAuth(t, base+"/v1/products?active=true", devToken) + for _, x := range stripeSubJSON(t, pListBody)["data"].([]any) { + if x.(map[string]any)["id"] == prodID { + t.Fatalf("archived product still listed") + } + } + + priceUpdBody, s := postJSONAuth(t, base+"/v1/prices/"+priceID, devToken, map[string]any{"nickname": "Monthly"}) + if s != 200 || stripeSubJSON(t, priceUpdBody)["nickname"] != "Monthly" { + t.Fatalf("price update -> %d %s", s, priceUpdBody) + } + priceListBody, s := getAuth(t, base+"/v1/prices?product="+prodID+"&type=recurring", devToken) + if s != 200 { + t.Fatalf("price list -> %d", s) + } + plData, _ := stripeSubJSON(t, priceListBody)["data"].([]any) + if len(plData) != 1 || plData[0].(map[string]any)["id"] != priceID { + t.Fatalf("price filter list = %s", priceListBody) + } + + // Subscription list filters by customer and status. + subListBody, s := getAuth(t, base+"/v1/subscriptions?customer="+cusID+"&status=active", devToken) + if s != 200 { + t.Fatalf("subscription list -> %d", s) + } + slData, _ := stripeSubJSON(t, subListBody)["data"].([]any) + if len(slData) != 1 || slData[0].(map[string]any)["id"] != subID { + t.Fatalf("subscription list = %s", subListBody) + } + + // Idempotent subscription create replays the same subscription. + idemBody1, s := postJSONAuthIdem(t, base+"/v1/subscriptions", devToken, "sub-idem-1", map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("idempotent create -> %d; %s", s, idemBody1) + } + idemBody2, s := postJSONAuthIdem(t, base+"/v1/subscriptions", devToken, "sub-idem-1", map[string]any{ + "customer": cusID, "items": []any{map[string]any{"price": priceID}}, + "default_payment_method": tok, + }) + if s != 201 { + t.Fatalf("idempotent replay -> %d", s) + } + if stripeSubJSON(t, idemBody1)["id"] != stripeSubJSON(t, idemBody2)["id"] { + t.Fatalf("idempotent replay created a second subscription: %s vs %s", idemBody1, idemBody2) + } + + // Archiving the price afterwards (active=false) keeps it listed under the + // active=false filter — prices have no delete endpoint. + if b, st := postJSONAuth(t, base+"/v1/prices/"+priceID, devToken, map[string]any{"active": false}); st != 200 || stripeSubJSON(t, b)["active"] != false { + t.Fatalf("price deactivate -> %d %s", st, b) + } + if b, st := getAuth(t, base+"/v1/prices?active=false", devToken); st != 200 { + t.Fatalf("inactive price list -> %d", st) + } else { + found := false + for _, x := range stripeSubJSON(t, b)["data"].([]any) { + if x.(map[string]any)["id"] == priceID { + found = true + } + } + if !found { + t.Fatalf("deactivated price missing from active=false list: %s", b) + } + } +} From 8192fd976563a8c6da6cc187a90451b184b63e1e Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 16 Aug 2026 09:24:16 +0300 Subject: [PATCH 2/4] docs(stripe-style): full-coverage README, changelog 0.38.0, root adapter table --- CHANGELOG.md | 49 ++ README.md | 2 +- adapters/stripe-style/README.md | 813 +++++++++++++++++++------------- 3 files changed, 545 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d30864c..3cf1063d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,55 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.38.0] — 2026-08-16 + +### Adapters + +- **stripe-style: full API coverage** — 48 → **158 endpoints**, 15 → 32 + collections. Every core Stripe resource family is now simulated: + - **Test Clocks** (`/v1/test_clocks` + the real-path + `/v1/test_helpers/test_clocks` aliases): a KV time offset drives every + adapter timestamp, so subscription renewals, dispute settlement, payout + lifecycles, capability activation and session expiry are deterministic + and assertable without sleeps. + - **Disputes**: test-card triggered (fraudulent / product_not_received) + with the `needs_response → under_review → won/lost` derive-on-read + lifecycle, all 27 evidence fields, submit/close, evidence due-by, funds + withdrawn and reinstated through the ledger, the full + `charge.dispute.*` event set. + - **Balance transactions**: a real ledger over every money movement + (charge incl. the 2.9%+30¢ processing fee, refund, refund_failure, + payout, transfer, transfer_reversal, application_fee(+refund), dispute, + dispute_reversal), account-scoped rows, full filter set. + - **Billing**: products, prices, subscriptions (clock-driven renewal + + auto-charge, `past_due` on decline/no-PM, coupon + tax support), + subscription items, usage records (metered), invoices (draft → open → + paid/void/uncollectible; finalize/pay/void/send/lines/upcoming), + invoice items, credit notes (issuing real refunds), coupons, promotion + codes, tax rates (exclusive + inclusive). + - **Checkout Sessions**: payment/subscription/setup modes, hosted + `/c/pay/{id}` completion page with `{CHECKOUT_SESSION_ID}` redirect + substitution, decline injection via `payment_method`, expire, line + items. **SetupIntents** with SCA challenge + decline behavior. + - **Webhook endpoints**: CRUD + registration-gated delivery per + `enabled_events` (events always recorded in `/v1/events`). + - **Files + file links**: multipart upload with real purpose enum. + - **Connect depth**: persons, capabilities (requested → pending → active), + external bank accounts (last4 only, default resolution), application + fees (+ partial refunds, both refund routes), login links, transfer + partial reversals (`trr_*`, list/retrieve), payout full lifecycle + (pending → in_transit → paid) + cancel with funds return. + - **Refunds completion**: cancel (pending → canceled with + `failure_reason` + `refund_failure` ledger row), charge/payment_intent + list filters, `balance_transaction` linkage, uncaptured-charge refunds + release the authorization. + +### Engine + +- Adapter lint: the provider-ID heuristic now requires a digit in the + suffix, so real API names (`file_links`) are no longer flagged while + actual ids (`ch_1Mio2eLkdIwHu7ix`) still are; regression tests added. + ## [0.37.0] — 2026-08-16 ### Adapters diff --git a/README.md b/README.md index 59456e37..c499ceaa 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search` | Adapter | Simulates | Backing | |---|---|---| -| `stripe-style` | payments — **PaymentIntents (confirm/capture) + PaymentMethods + Refunds**, charges, customers, Connect, **Idempotency-Key**, cursor-paginated lists, **signed webhooks** | Collection + Starlark | +| `stripe-style` | payments — full API surface (158 endpoints): **PaymentIntents, disputes, refunds, the Billing suite (subscriptions/invoices/credit notes), Checkout Sessions, SetupIntents, balance transactions**, Connect (persons/capabilities/application fees), **Test Clocks** (deterministic billing), **Idempotency-Key**, cursor-paginated lists, **signed webhooks + registration-gated delivery** | Collection + Starlark | | `salesforce-style` | CRM — sObjects CRUD, **general SOQL** (WHERE/IN/LIKE/AND/OR, ORDER BY, LIMIT/OFFSET), OAuth (password/auth-code/**refresh**) | Collection + Starlark | | `discord-style` | bot API — REST + **WebSocket Gateway (HELLO→IDENTIFY→READY→dispatch)** + **Ed25519-signed interactions** | Collection + Starlark | | `drive-style` | files API — upload/get/download/list/patch/delete, folders, about/quota, resumable uploads | Blob + Collection | diff --git a/adapters/stripe-style/README.md b/adapters/stripe-style/README.md index 65b0b074..60ac7c26 100644 --- a/adapters/stripe-style/README.md +++ b/adapters/stripe-style/README.md @@ -1,8 +1,11 @@ # Stripe-style adapter -A stunt adapter for simulating a **Stripe-style payments API** locally, -including **Stripe Connect** (marketplace/platform flows: connected accounts, -onboarding links, transfers, payouts). +A stunt adapter for simulating a **Stripe-style payments API** locally at full +API breadth — payments, disputes, refunds, the Billing suite (products, +prices, subscriptions, invoices, credit notes), Checkout Sessions, +SetupIntents, balance transactions, webhook endpoints, files — plus **Stripe +Connect** (connected accounts, capabilities, persons, external accounts, +transfers, application fees, payouts). **158 endpoints.** All data is synthetic — no real API data is included. > **Unofficial / not affiliated.** This adapter is not affiliated with, endorsed @@ -12,32 +15,54 @@ All data is synthetic — no real API data is included. ## What it simulates -A broader-than-minimal MVP of a Stripe-style payments API: **PaymentIntents** -(create/retrieve/list, confirm, capture — the canonical SCA/3DS-ready flow -with `requires_payment_method → succeeded`/`requires_capture` states, plus -real decline and SCA/3DS test-card behavior), **PaymentMethods** -(create/retrieve/attach/detach/list), card **tokens** (`POST /v1/tokens`), -**Refunds** (create/retrieve/list, full or partial, with their own -pending → succeeded/failed async lifecycle and an over-refund guard), -**Events** (`GET /v1/events` — the recorded copies of emitted webhooks), -charges (create/retrieve/list/capture/ -refund), customers (CRUD), and account balance — plus **Stripe Connect**: -connected accounts (create/retrieve/update/list), account links (onboarding -URLs), transfers (create/retrieve/list/reverse), and payouts (create/list). +The complete core surface a Stripe integration exercises: + +- **PaymentIntents** (create/confirm/capture/retrieve/list — the SCA/3DS-ready + flow with `requires_payment_method → succeeded` / `requires_capture` states + and real decline/SCA test-card behavior), **PaymentMethods**, card + **tokens**, **Charges** (incl. capture + charge-level refunds) +- **Refunds** — full/partial, `pending → succeeded/failed` derive-on-read + lifecycle, over-refund guard, **cancel** (with `failure_reason` + + `refund_failure` ledger row) +- **Disputes** — test-card triggered, `needs_response → under_review → + won/lost` lifecycle, all 27 evidence fields, submit/close, funds + withdrawn/reinstated through the ledger +- **Balance + balance transactions** — a real ledger across every money + movement (charge incl. processing fee, refund, payout, transfer, + application fee, dispute, and their reversals), account-scoped +- **Billing** — products, prices, subscriptions (clock-driven renewal + + auto-charge with `past_due` on decline), subscription items, usage records + (metered), invoices (draft → open → paid/void/uncollectible with + finalize/pay/void/send/lines/upcoming), invoice items, credit notes + (issuing real refunds), coupons, promotion codes, tax rates (exclusive + + inclusive) +- **Checkout Sessions** — payment/subscription/setup modes, hosted completion + page, expire, line items +- **SetupIntents** — confirm/cancel with SCA challenge + decline behavior +- **Webhook endpoints** — CRUD + registration-gated delivery per + `enabled_events` +- **Files + file links** — multipart upload with purpose validation +- **Test Clocks** — Stripe's deterministic-time mechanism, so billing cycles, + dispute settlement and payout lifecycles are assertable **without sleeps** +- **Customers** (CRUD + soft delete), **Events** (recorded webhook copies) +- **Stripe Connect** — connected accounts (capabilities, requirements, + settings), persons, external bank accounts (last4 only), account links, + login links, transfers (+ partial reversals), application fees (+ refunds), + payouts (full lifecycle + cancel) State persists in an on-disk SQLite-backed collection store (under `.stunt/state/`), so data you create in one request is visible in subsequent requests and survives across `stunt up` restarts. Run `stunt clean` to reset state to the seed fixtures. -Webhook events are emitted on charge, PaymentIntent, refund, and Connect -lifecycle transitions to a configurable webhook sink. Mutating endpoints -honour Stripe's `Idempotency-Key` header, and all list endpoints use -Stripe-style cursor pagination (`limit`/`starting_after`). +Webhook events are emitted on lifecycle transitions to a configurable webhook +sink, signed with `Stripe-Signature`. Mutating endpoints honour Stripe's +`Idempotency-Key` header, and all list endpoints use Stripe-style cursor +pagination (`limit`/`starting_after`). ## Auth -All endpoints (except `/v1/tokens`) require a valid `Authorization: Bearer -` header. +All endpoints (except `/v1/tokens` and the hosted `/c/pay/{id}` page) require +a valid `Authorization: Bearer ` header. ### Token validation @@ -52,98 +77,76 @@ returns `401` with a JSON body: ### Dev bypass (`sk_test`) For frictionless local testing, **any token starting with `sk_test`** is -accepted **without** `identity_validate`. This lets you use a well-known dev -token like `sk_test_local` in scripts, curl commands, and tests without -needing to mint a real token first: +accepted **without** `identity_validate`: ```bash curl -H "Authorization: Bearer sk_test_local" http://localhost:PORT/v1/charges ``` -This bypass exists **only** in the local simulator and never touches a real -API. - ### Minting a real token -For integration tests that need a real (validated) token, `POST /v1/tokens` -mints one via the identity issuer: - -```bash -curl -X POST http://localhost:PORT/v1/tokens -# → {"token": "eyJhbGciOiJIUzI1NiIs..."} -``` - -The returned token can then be used as a Bearer token for subsequent -requests. Optional body fields `subject` and `scopes` customise the claims -(defaults: `subject="test_user"`, `scopes=["write"]`). +`POST /v1/tokens` with an empty body mints a real identity token; with a card +body it creates a card token (`tok_*`) whose stored number drives the +test-card behavior below (the full number is never returned — only +brand/last4). -## Idempotency (`Idempotency-Key`) +## Test Clocks (deterministic time) -Mutating endpoints honour Stripe's `Idempotency-Key` header: a write carrying -the header is cached on success, and a retry with the same key **replays the -original response verbatim** (same status code and resource) instead of -creating a duplicate. - -- **Scoping** — the cache key is `method|path|collection|key`, so the same key - on different endpoints (or a different HTTP method) never collides. -- **Replay semantics** — the cached entry stores `":"`; - on replay the resource is re-rendered from its stored doc, so for mutating - endpoints the replay reflects the resource's final state. -- **Only successes are cached** — like Stripe, non-2xx responses are not - remembered; a retry after a failure performs the write again. -- **Persistence** — the cache lives in the KV store, so idempotent replays - survive `stunt up` restarts (and clear with `stunt clean`). - -Supported on: `POST /v1/charges`, `POST /v1/payment_intents`, -`POST /v1/payment_intents/{id}/confirm`, `POST /v1/payment_intents/{id}/capture`, -and `POST /v1/refunds`. +Real Stripe's Test Clocks make time-dependent flows testable; this adapter +implements them on a KV-backed offset that drives **every** adapter timestamp. +Create a clock, advance it, and every derive-on-read lifecycle settles on the +next request — no sleeps, no flaky timing: ```bash -curl -X POST http://localhost:PORT/v1/charges \ +curl -X POST http://localhost:PORT/v1/test_clocks \ + -H "Authorization: Bearer sk_test_local" \ + -d '{"frozen_time":1735689600}' +# → {"id":"clock_1","object":"test_helpers.test_clock","status":"advancing",...} + +curl -X POST http://localhost:PORT/v1/test_clocks/clock_1/advance \ -H "Authorization: Bearer sk_test_local" \ - -H "Idempotency-Key: my-unique-key" \ - -d '{"amount":5000,"currency":"usd"}' -# Retrying with the same Idempotency-Key returns the same charge, not a new one. + -d '{"now":1736208000}' # +6 days: past the billing period end +# the next GET /v1/subscriptions/sub_1 now shows the renewed period, +# a paid invoice, and the underlying charge + ledger rows ``` +Both the `/v1/test_clocks` and the real-path `/v1/test_helpers/test_clocks` +routes work. Simulator note: the clock is **global** (one offset shared by all +objects), unlike real Stripe's per-object clocks. + +Clock-driven lifecycles: subscription renewal/cancel-at-period-end, invoice +payment attempts, dispute evidence due-by/settlement, payout +`pending → in_transit → paid`, capability `pending → active`, checkout +session expiry. + ## Pagination All list endpoints use Stripe-style cursor pagination: - `?limit=` — page size, default **10**, capped at **100**. -- `?starting_after=` — return results after this object id. Pass the last - id of the current page to fetch the next one (Stripe does not echo a cursor). -- Responses are `{"object": "list", "data": [...], "has_more": bool, "url": ...}`; - keep paging while `has_more` is `true`. -- A `starting_after` id that no longer exists (deleted object / stale cursor) - returns `400` with `param: "starting_after"` — mirroring Stripe's - `resource_missing` error instead of silently restarting from the beginning. +- `?starting_after=` — return results after this object id. +- Responses are `{"object": "list", "data": [...], "has_more": bool, "url": ...}`. +- A `starting_after` id that no longer exists returns `400` with + `param: "starting_after"` (Stripe's `resource_missing`), never a silent + restart. +- Non-numeric `created` / `created[gt|gte|lt|lte]` filter values return + Stripe's `400 parameter_invalid_integer`. -## Decline & SCA test cards +## Decline, SCA & dispute test cards Like real Stripe test mode, specific card numbers deterministically trigger -card outcomes. Create a card token with `POST /v1/tokens` (auth required; -the full number is stored privately and never returned — only brand/last4): - -```bash -curl -X POST http://localhost:PORT/v1/tokens \ - -H "Authorization: Bearer sk_test_local" \ - -d '{"card":{"number":"4000000000009995","exp_month":12,"exp_year":2030,"cvc":"123"}}' -# → {"id":"tok_1","object":"token","type":"card","card":{"brand":"visa","last4":"9995",...},...} -``` - -Use the token at PaymentIntent confirm (`payment_method: "tok_..."`) or charge -create (`source: "tok_..."`). A PaymentMethod created with an explicit +outcomes. Create a card token with `POST /v1/tokens`, then use it at +PaymentIntent confirm (`payment_method: "tok_..."`), charge create +(`source: "tok_..."`), SetupIntent confirm, subscription/invoice payment, or +the Checkout completion page. A PaymentMethod created with an explicit `card[number]` behaves the same way. ### Declines -Confirming a PaymentIntent (or creating a charge) with these cards fails with -`402` and the real `card_error` shape — `error.code` plus the real -`decline_code`. The PI stays `requires_payment_method` and records -`last_payment_error`; a `payment_intent.payment_failed` webhook fires. On the -Charges API the failed charge object is still recorded (status `failed`) and -`charge.failed` fires. +These cards fail with `402` and the real `card_error` shape — `error.code` +plus the real `decline_code`. The PI stays `requires_payment_method` and +records `last_payment_error`; on the Charges API the failed charge object is +still recorded and `charge.failed` fires. | Card number | `error.code` | `decline_code` | |-------------|--------------|----------------| @@ -157,8 +160,7 @@ Charges API the failed charge object is still recorded (status `failed`) and ### SCA / 3DS These cards force an authentication step on PaymentIntent confirm: the PI -returns `200` with `status: "requires_action"` and a `next_action` object -(the `payment_intent.requires_action` webhook fires): +returns `200` with `status: "requires_action"` and a `next_action` object: | Card number | `next_action.type` | |-------------|--------------------| @@ -166,78 +168,207 @@ returns `200` with `status: "requires_action"` and a `next_action` object | `4000 0025 0000 3155` | `redirect_to_url` (hosted `url` + your `return_url`) | | `4000 0000 0000 3220` | `redirect_to_url` | -Confirm the same PaymentIntent again to complete authentication — it lands on -`succeeded` (automatic capture) or `requires_capture` (manual). The legacy -Charges API cannot run 3DS, so SCA cards on `POST /v1/charges` decline with -`decline_code: "authentication_required"`. +Confirm the same PaymentIntent again to complete authentication. SetupIntents +run the same challenge flow. The legacy Charges API cannot run 3DS, so SCA +cards on `POST /v1/charges` decline with `authentication_required`. On the +Checkout hosted page SCA cards succeed (the hosted UI runs 3DS — simulator +simplification). + +### Disputes + +These cards create a dispute on charge success (the charge itself succeeds +and is captured; the dispute then withdraws the funds + a $15 dispute fee +from the ledger, emits `charge.dispute.created` + +`charge.dispute.funds_withdrawn`, and links `charge.dispute`): + +| Card number | `reason` | +|-------------|----------| +| `4000 0000 0000 0259` | `fraudulent` | +| `4000 0000 0000 2685` | `product_not_received` | ## Refunds Refunds have their own async lifecycle: `POST /v1/refunds` (or -`POST /v1/charges/{id}/refund`, which additionally returns the updated -charge) creates a refund with `status: "pending"`. Every read (retrieve or -list) derives the terminal state from the clock — `succeeded` after 3 seconds, -or `failed` when created with the simulator-only `simulate_fail: true` flag — -persists the transition, and fires `refund.updated` exactly once. +`POST /v1/charges/{id}/refund`) creates a refund with `status: "pending"` +linked to its ledger row (`balance_transaction`). Every read derives the +terminal state from the clock — `succeeded` after 3 seconds, or `failed` when +created with the simulator-only `simulate_fail: true` flag — persists the +transition, and fires `refund.updated` exactly once. - **Amount** — omitted `amount` refunds the full remaining unrefunded balance; - an explicit `amount` refunds partially (the charge keeps `status: - "succeeded"`, `refunded: false`, and gains `amount_refunded` until the - balance hits the original amount). -- **Over-refund guard** — the unrefunded balance is computed across *all* - non-failed refunds (pending included) of the payment intent / charge. - Refunding more than the remainder returns Stripe's real `400`: - `Refund amount ($X) is greater than unrefunded amount on charge ($Y)` - (or `charge_already_refunded` when nothing remains). + partial refunds accumulate `amount_refunded` on the charge. +- **Over-refund guard** — computed across all non-failed, non-canceled + refunds; refunding more than the remainder returns Stripe's real `400`. +- **Cancel** — `POST /v1/refunds/{id}/cancel` cancels a *pending* refund + (`failure_reason: "merchant_request"`, a `refund_failure` ledger row returns + the reserved funds); non-pending refunds return the real error. +- Refunding an **uncaptured** charge releases the authorization instead of + moving money. + +## Disputes + +`GET /v1/disputes` (filters `charge`, `payment_intent`, `created`), +`GET /v1/disputes/{id}`, `POST /v1/disputes/{id}` (evidence + +`submit: true`), `POST /v1/disputes/{id}/close`. + +The lifecycle is derive-on-read from the clock: + +``` +needs_response ──(submit=true)──▶ under_review ──(+1 day)──▶ won (funds reinstated) + │ ▲ + └──(past due_by: created+7d)──▶ lost ◀──(close)──────────────────┘ +``` + +- **Evidence** — all 27 real string fields (`product_description`, + `customer_name`, `receipt`, `uncategorized_text`, …). Saving evidence + without `submit` sets `evidence_details.has_evidence` but keeps the status; + `submit: true` with zero evidence is a `400`. +- **Won** — restores the disputed amount + fee through a + `dispute_reversal` ledger row, emits `charge.dispute.funds_reinstated` + + `charge.dispute.closed` (status `won`). +- **Lost** — past `due_by` or `POST /{id}/close`; funds stay withdrawn, + `charge.dispute.closed` (status `lost`). +- Terminal disputes reject further evidence updates with `400`. + +## Billing + +### Products & prices + +Standard CRUD for products (`DELETE` archives, retrievable afterwards) and +prices (no delete, like the real API). Prices support `unit_amount` / +`unit_amount_decimal`, `recurring` (`interval` day/week/month/year, +`interval_count`, `usage_type` licensed|metered, `aggregate_usage`, +`trial_period_days`), `lookup_key` (unique), filters (`product`, `active`, +`type`, `currency`, `lookup_keys`). + +### Subscriptions + +Create with `items: [{price, quantity}]` (or the legacy top-level +`price`/`quantity` form), `default_payment_method`, `cancel_at_period_end`, +`trial_end`, `collection_method`, `coupon` / `promotion_code`, +`default_tax_rates`, `billing_cycle_anchor`. The first invoice is created — +and under `charge_automatically` paid — immediately, using the card-behavior +rules above. + +**Renewal is derive-on-read from the clock**: when `_now() >= +current_period_end` (i.e. after a test-clock advance), the subscription +either renews (new period + a new invoice, auto-charged — decline/no-PM → +`past_due` + open invoice + `invoice.payment_failed`) or ends +(`cancel_at_period_end` → `canceled` + `customer.subscription.deleted`). +`GET` and `LIST` both settle pending transitions first. + +Updates handle item quantity changes, additions, and removals +(`deleted: true`; the last item cannot be removed). Proration params are +accepted but treated as `none` — no proration lines are generated +(simulator simplification). + +### Subscription items & usage records + +`/v1/subscription_items` (list/create/update/delete, projected from their +subscriptions) and `/v1/subscription_items/{id}/usage_records` +(`quantity`, `timestamp`, `action: increment|set`). Metered prices bill the +sum of records (`last_ever` aggregates the newest record) on the next +invoice. + +### Invoices + +Draft → open → paid/void/uncollectible, with `finalize`, `pay` +(`paid_out_of_band`, or a real charge via the resolved payment method — +declines keep the invoice open + `invoice.payment_failed`), `void`, +`mark_uncollectible`, `send`, `lines`, and `upcoming` (preview for a customer ++ subscription incl. pending invoice items and tax — never persisted). +Invoices carry `subtotal`, `discount`, `tax`, `total`, `amount_due`, and +`status_transitions`. + +### Invoice items, credit notes, coupons, promotion codes, tax rates + +- **Invoice items** (`/v1/invoice_items`, CRUD) — pending items are pulled + into the next invoice. +- **Credit notes** — against a paid invoice; `lines` or + `refund_amount`/`credit_amount` + real reason enum; `refund: true` issues a + real refund; `preview` endpoint included. +- **Coupons** — `percent_off` XOR `amount_off`, `duration` + once/forever/repeating; `duration: once` applies to the first invoice only. +- **Promotion codes** — wrap a coupon, restrictions, `code` auto-generated. +- **Tax rates** — `inclusive` rates are shown in `tax` but not added to the + total; `exclusive` rates are added. Amounts stay integer cents. + +## Checkout Sessions + +Create sessions in `payment` / `subscription` / `setup` mode with +`line_items` (`price` + `quantity`, or inline `price_data`), `success_url` / +`cancel_url` (supporting `{CHECKOUT_SESSION_ID}` substitution), `customer` or +`customer_email`. + +Completion is driven through the hosted page — the session's `url` is +`/c/pay/{id}` (no auth, like a real hosted page). `GET`ting it: + +- **payment mode** — creates + confirms the PaymentIntent and its charge, + flips the session to `complete`/`paid`, emits + `checkout.session.completed` + `payment_intent.succeeded` + + `charge.succeeded`, and `302`s to your `success_url` with the session id + substituted. +- **subscription mode** — creates the subscription (active, first invoice + paid) + underlying objects. +- **setup mode** — creates + succeeds a SetupIntent + (`payment_status: "no_payment_required"`). +- A `?payment_method=` param runs the card behavior: a decline card fails the + attempt (`checkout.session.async_payment_failed`, session stays open); + retrying with a good card completes. +- Completion is one-shot (guarded + serialized); expired sessions render an + expired page; `POST /v1/checkout/sessions/{id}/expire` expires an open + session; `GET .../line_items` lists the session's items. + +## Webhook endpoints & delivery gating + +`/v1/webhook_endpoints` (create/list/retrieve/update/delete) mirrors the real +resource: `url`, `enabled_events` (validated non-empty), `description`, +`metadata`, and the mock signing `secret` + `api_version`. + +Delivery is **registration-gated**, like real Stripe: while no endpoint is +registered, every event delivers to the configured sink (frictionless local +use); once one exists, only its `enabled_events` (or `*`) deliver. Events are +always recorded in `/v1/events` either way. `DELETE` is a hard delete so the +gate stops counting the endpoint. ## Events -Every emitted webhook is also recorded as a Stripe event object, readable via -the real API surface: +Every emitted webhook is also recorded as a Stripe event object: -- `GET /v1/events` — cursor-paginated list (newest first, `limit` / - `starting_after`, filters `type=` and `created` / `created[gt|gte|lt|lte]`). -- `GET /v1/events/{id}` — single event (`404` for unknown ids). +- `GET /v1/events` — cursor-paginated (newest first, `type=` and `created` + filters). +- `GET /v1/events/{id}` — single event. ```json { "id": "evt_1", "object": "event", "type": "charge.created", "api_version": "2025-01-27.acacia", "created": 1739577600, - "data": {"object": {"id": "ch_1", "amount": 5000, "...": "..."}}, + "data": {"object": {"id": "ch_1", "amount": 5000}}, "livemode": false, "pending_webhooks": 0, "request": {"id": null, "idempotency_key": null} } ``` -The event list always agrees with webhook delivery: both are fed from the same -emission points. - ## Webhooks The adapter emits webhook events on lifecycle transitions. Events are **fire-and-forget**: if no webhook sink is configured or the delivery fails, the operation still succeeds. -| Trigger | Event type | -|---------|-----------| -| `POST /v1/charges` (create) | `charge.created` (or `charge.failed` for decline test cards) | -| `POST /v1/charges/{id}/capture` | `charge.updated` | -| `POST /v1/charges/{id}/refund` | `charge.refunded` (+ `refund.created`) | -| `POST /v1/payment_intents` (create) | `payment_intent.created` (+ `.succeeded`/`.requires_capture` if confirmed, `.payment_failed` on decline) | -| `POST /v1/payment_intents/{id}/confirm` | `payment_intent.succeeded` / `.requires_capture` / `.requires_action` / `.payment_failed` | -| `POST /v1/payment_intents/{id}/capture` | `payment_intent.succeeded` | -| `POST /v1/refunds` | `refund.created`, `charge.refunded` (charge refunds) | -| refund terminal transition (on first read after ~3s) | `refund.updated` | -| `POST /v1/accounts` (create) | `account.updated` | -| `POST /v1/accounts/{id}` (update) | `account.updated` | -| `POST /v1/transfers` (create) | `transfer.created` | -| `POST /v1/transfers/{id}/reversals` | `transfer.reversed` | -| `POST /v1/payouts` (create) | `payout.created` | +| Area | Event types | +|------|-------------| +| Charges | `charge.created`, `charge.failed`, `charge.updated`, `charge.refunded`, `charge.captured` | +| PaymentIntents | `payment_intent.created` / `.succeeded` / `.requires_capture` / `.requires_action` / `.payment_failed` | +| SetupIntents | `setup_intent.created` / `.succeeded` / `.setup_failed` / `.canceled` | +| Refunds | `refund.created`, `refund.updated` | +| Disputes | `charge.dispute.created` / `.updated` / `.funds_withdrawn` / `.funds_reinstated` / `.closed` | +| Billing | `customer.subscription.created` / `.updated` / `.deleted`, `invoice.created` / `.finalized` / `.paid` / `.payment_failed` / `.payment_succeeded` / `.voided` / `.sent` / `.mark_uncollectible`, `credit_note.created` / `.updated` / `.voided` | +| Checkout | `checkout.session.completed` / `.expired` / `.async_payment_failed` | +| Connect | `account.updated`, `person.created` / `.updated` / `.deleted`, `transfer.created` / `.reversed`, `payout.created` / `.updated` / `.paid` / `.canceled`, `application_fee.refunded` | +| Customers | `customer.created` / `.updated` / `.deleted` | ### Configuring the webhook sink -Set `config.webhook_url` in the service definition to receive events: - ```yaml services: stripe: @@ -246,246 +377,299 @@ services: webhook_url: http://localhost:9090/webhook ``` -The webhook body is a JSON envelope: - -```json -{ - "type": "charge.created", - "payload": { "id": "ch_1", "amount": 5000, "currency": "usd", "status": "pending", ... } -} -``` - ### Webhook signatures -Every delivery is signed with a Stripe-style `Stripe-Signature` header, so your -receiver's signature-verification code path runs against stunt: +Every delivery is signed with a Stripe-style `Stripe-Signature` header: ``` Stripe-Signature: t=,v1= ``` -**Mock signing secret** (configure your receiver with this exact string; public -+ low-entropy, local stunt only): +**Mock signing secret** (also returned by `POST /v1/webhook_endpoints`): ``` whsec_stunt_mock_0123456789abcdef0123456789abcdef ``` ```go -// Verify with the real Stripe formula (timestamp is in the t= field): mac := hmac.New(sha256.New, []byte("whsec_stunt_mock_0123456789abcdef0123456789abcdef")) mac.Write([]byte(fmt.Sprintf("%d.%s", t, rawBody))) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), v1) { return 401 } ``` -Stunt delivers the `{type, payload}` envelope, so the raw-body MAC verifies but -this exercises your signature-verification path, not Stripe's event-schema parser. +## Balance & balance transactions -Other stunt adapters sign their deliveries with their provider's scheme -(GitHub `X-Hub-Signature-256`, WhatsApp/Square/Twilio/Discord, ...). See the -**signed-delivery roster** in [`../../README.md`](../../README.md) for the full -list of headers, mock secrets, and encodings. +`GET /v1/balance` (platform synthetic defaults; `Stripe-Account` header +scopes to a connected account's tracked balance). -## Stripe Connect - -Stripe Connect is the marketplace/platform surface. It lets a platform create -connected accounts (sellers/service providers), onboard them via hosted forms, -transfer funds to them, and let them pay out to their bank. +`GET /v1/balance_transactions` (+`/{id}`) is the real ledger: every money +movement records a `txn_*` row with `amount`, `fee`, `net`, `type` (`charge` +— including the 2.9% + 30¢ processing fee, `refund`, `refund_failure`, +`payout`, `transfer`, `transfer_reversal`, `application_fee`, +`application_fee_refund`, `dispute`, `dispute_reversal`), `source`, +`description`, `available_on`. Rows are account-scoped like real Stripe +(`Stripe-Account` header → that account's rows, otherwise platform-only); +filters `payout`/`charge`/`refund`/`dispute`/`transfer`/`source`/`type`/ +`currency` + `created` ranges. -### Connected accounts +## Stripe Connect -Create a Custom/Express/Standard connected account: +Connected accounts (create/retrieve/update/list) with `capabilities` +(requested → `pending` → `active` after a day on the clock; Express/Custom +accounts auto-request the core capabilities), `requirements`, `settings` +(payouts schedule, branding), `business_profile`; **persons** (CRUD + +`relationship` filter); **external bank accounts** (last4/fingerprint only, +first per currency auto-defaults, payouts resolve the default destination); +**login links** (Express only); **account links** (onboarding URLs). ```bash curl -X POST http://localhost:PORT/v1/accounts \ -H "Authorization: Bearer sk_test_local" \ - -H "Content-Type: application/json" \ -d '{"type":"express","country":"US","email":"seller@example.com"}' # → {"id":"acct_1","object":"account","type":"express",...} ``` -Update capabilities (triggers `charges_enabled`/`payouts_enabled` flags and -emits `account.updated`): +### Transfers (+ reversals) -```bash -curl -X POST http://localhost:PORT/v1/accounts/acct_1 \ - -H "Authorization: Bearer sk_test_local" \ - -H "Content-Type: application/json" \ - -d '{"capabilities":{"card_payments":"active","transfers":"active"}}' -``` +`POST /v1/transfers` moves platform balance to a connected account (both +sides get ledger rows). `POST /v1/transfers/{id}/reversals` returns the real +`transfer_reversal` object and supports **partial** amounts (multiple +partials accumulate in `amount_reversed`; over-reversal is a `400`). +Reversals are listable/retrievable under +`GET /v1/transfers/{id}/reversals[/{trr_id}]`. -### Account links (onboarding) +### Application fees -Generate a synthetic onboarding URL for a connected account: +Charges created with `application_fee_amount` record a `fee_*` +application-fee doc (+ platform ledger row). `GET /v1/application_fees`, +`GET /v1/application_fees/{id}`, and refunds — partial or full — via both +the real `/v1/application_fees/{id}/refunds` route and the legacy +`/v1/application_fees/{id}/refund`, each writing an +`application_fee_refund` ledger row and emitting `application_fee.refunded`. -```bash -curl -X POST http://localhost:PORT/v1/account_links \ - -H "Authorization: Bearer sk_test_local" \ - -H "Content-Type: application/json" \ - -d '{"account":"acct_1","refresh_url":"https://app.example.com/refresh","return_url":"https://app.example.com/return","type":"account_onboarding"}' -# → {"object":"account_link","url":"https://onboarding.stunt.local/acct_1/1","expires_at":1700003600} -``` +### Payouts -### Transfers (platform → connected account) +`POST /v1/payouts` debits the connected account (`Stripe-Account` header) and +records the ledger row; `arrival_date` derives from the method (standard +4 +days, instant +60s). The status is derive-on-read from the clock: +`pending → in_transit (+10s) → paid (+60s)`, each transition emitting its +event exactly once. `GET`/`POST /v1/payouts/{id}` (metadata updates), +`POST /v1/payouts/{id}/cancel` returns the funds (allowed while +`pending`/`in_transit`; `paid` is a `400`). -Move funds from the platform balance to a connected account: +## Simulator notes -```bash -curl -X POST http://localhost:PORT/v1/transfers \ - -H "Authorization: Bearer sk_test_local" \ - -H "Content-Type: application/json" \ - -d '{"amount":15000,"currency":"usd","destination":"acct_1"}' -# → {"id":"tr_1","object":"transfer","amount":15000,...} -``` - -The destination account's balance increases by the transfer amount (tracked in KV). +Deliberate simplifications (all also commented in the scripts): -### Per-account balance - -Use the `Stripe-Account` header to scope `/v1/balance` to a connected account: - -```bash -curl http://localhost:PORT/v1/balance \ - -H "Authorization: Bearer sk_test_local" \ - -H "Stripe-Account: acct_1" -# → {"object":"balance","available":[{"amount":15000,"currency":"usd"}],...} -``` - -Without the header, the platform balance is returned (synthetic defaults). - -### Payouts (connected account → bank) - -Create a payout from a connected account's balance: - -```bash -curl -X POST http://localhost:PORT/v1/payouts \ - -H "Authorization: Bearer sk_test_local" \ - -H "Stripe-Account: acct_1" \ - -H "Content-Type: application/json" \ - -d '{"amount":5000,"currency":"usd","method":"standard"}' -# → {"id":"po_1","object":"payout","amount":5000,"status":"pending",...} -``` - -The connected account's balance is debited by the payout amount. +- Test clocks are global (one offset), not per-object. +- No proration lines are generated on subscription item changes + (`proration_behavior` accepted, treated as `none`). +- Checkout's hosted page runs 3DS for SCA cards (succeeds) rather than + challenging. +- `POST /v1/files` returns `201` (adapter-wide create convention). +- Dispute ids are `dp_*` (real Stripe currently mints `du_*`). +- Webhook delivery goes to the single configured sink; endpoint + registration gates *which* event types deliver (not per-endpoint URLs). ## Endpoints -| Method | Route | Handler | Description | -|--------|-------|---------|-------------| -| POST | `/v1/tokens` | `tokens.star#on_mint_token` | Mint a test token (no auth required) | -| POST | `/v1/charges` | `charges.star#on_create_charge` | Create a charge (status → `pending`; idempotent) | -| GET | `/v1/charges/{id}` | `charges.star#on_retrieve_charge` | Retrieve a charge | -| GET | `/v1/charges` | `charges.star#on_list_charges` | List charges (`?customer=`, `created` exact/range filters, paginated) | -| POST | `/v1/charges/{id}/capture` | `charges.star#on_capture_charge` | Capture a charge (→ `succeeded`) | -| POST | `/v1/charges/{id}/refund` | `charges.star#on_refund_charge` | Refund a charge (→ `refunded`) | -| POST | `/v1/payment_intents` | `payment_intents.star#on_create_payment_intent` | Create a PaymentIntent (optional `confirm`-at-create; idempotent) | -| POST | `/v1/payment_intents/{id}/confirm` | `payment_intents.star#on_confirm_payment_intent` | Confirm with a `payment_method` (idempotent) | -| POST | `/v1/payment_intents/{id}/capture` | `payment_intents.star#on_capture_payment_intent` | Capture a `requires_capture` intent (idempotent) | -| GET | `/v1/payment_intents/{id}` | `payment_intents.star#on_retrieve_payment_intent` | Retrieve a PaymentIntent | -| GET | `/v1/payment_intents` | `payment_intents.star#on_list_payment_intents` | List PaymentIntents (`?customer=`, `created` exact/range filters, paginated) | -| POST | `/v1/payment_methods` | `payment_methods.star#on_create_payment_method` | Create a PaymentMethod (default: synthetic Visa card) | -| POST | `/v1/payment_methods/{id}/attach` | `payment_methods.star#on_attach_payment_method` | Attach to a customer | -| POST | `/v1/payment_methods/{id}/detach` | `payment_methods.star#on_detach_payment_method` | Detach from its customer | -| GET | `/v1/payment_methods/{id}` | `payment_methods.star#on_retrieve_payment_method` | Retrieve a PaymentMethod | -| GET | `/v1/payment_methods` | `payment_methods.star#on_list_payment_methods` | List PaymentMethods (`?customer=`, `?type=` filters, paginated) | -| POST | `/v1/refunds` | `refunds.star#on_create_refund` | Refund a PaymentIntent or charge, full or partial (idempotent) | -| GET | `/v1/refunds/{id}` | `refunds.star#on_retrieve_refund` | Retrieve a refund | -| GET | `/v1/refunds` | `refunds.star#on_list_refunds` | List refunds (`?charge=`, `?payment_intent=`, `created` exact/range filters, paginated) | -| POST | `/v1/customers` | `customers.star#on_create_customer` | Create a customer | -| GET | `/v1/customers/{id}` | `customers.star#on_retrieve_customer` | Retrieve a customer | -| GET | `/v1/customers` | `customers.star#on_list_customers` | List customers (`?email=`, `created` exact/range filters, paginated) | -| POST | `/v1/customers/{id}` | `customers.star#on_update_customer` | Update a customer | -| DELETE | `/v1/customers/{id}` | `customers.star#on_delete_customer` | Delete a customer (soft delete: `deleted:true`) | -| GET | `/v1/balance` | `balance.star#on_get_balance` | Return account balance (supports `Stripe-Account` header) | -| POST | `/v1/accounts` | `accounts.star#on_create_account` | Create a connected account | -| GET | `/v1/accounts/{id}` | `accounts.star#on_retrieve_account` | Retrieve a connected account | -| POST | `/v1/accounts/{id}` | `accounts.star#on_update_account` | Update a connected account (e.g. capabilities) | -| GET | `/v1/accounts` | `accounts.star#on_list_accounts` | List connected accounts (`created` exact/range filters, paginated) | -| POST | `/v1/account_links` | `account_links.star#on_create_account_link` | Create an account link (onboarding URL) | -| POST | `/v1/transfers` | `transfers.star#on_create_transfer` | Create a transfer to a connected account | -| GET | `/v1/transfers/{id}` | `transfers.star#on_retrieve_transfer` | Retrieve a transfer | -| GET | `/v1/transfers` | `transfers.star#on_list_transfers` | List transfers (`?destination=`, `created` exact/range filters, paginated) | -| POST | `/v1/transfers/{id}/reversals` | `transfers.star#on_reverse_transfer` | Reverse a transfer | -| POST | `/v1/payouts` | `payouts.star#on_create_payout` | Create a payout | -| GET | `/v1/payouts` | `payouts.star#on_list_payouts` | List payouts (`?destination=`, `?status=`, `arrival_date`/`created` exact/range filters, paginated) | +| Method | Route | Handler | +|--------|-------|---------| +| POST | `/v1/tokens` | `tokens.star#on_mint_token` | +| GET | `/v1/events` | `events.star#on_list_events` | +| GET | `/v1/events/{id}` | `events.star#on_retrieve_event` | +| POST | `/v1/test_clocks` | `test_clocks.star#on_create_test_clock` | +| GET | `/v1/test_clocks` | `test_clocks.star#on_list_test_clocks` | +| POST | `/v1/test_clocks/{id}/advance` | `test_clocks.star#on_advance_test_clock` | +| GET | `/v1/test_clocks/{id}` | `test_clocks.star#on_retrieve_test_clock` | +| DELETE | `/v1/test_clocks/{id}` | `test_clocks.star#on_delete_test_clock` | +| POST/GET | `/v1/test_helpers/test_clocks…` | real-path aliases of the above | +| POST | `/v1/charges` | `charges.star#on_create_charge` | +| GET | `/v1/charges/{id}` | `charges.star#on_retrieve_charge` | +| GET | `/v1/charges` | `charges.star#on_list_charges` | +| POST | `/v1/charges/{id}/capture` | `charges.star#on_capture_charge` | +| POST | `/v1/charges/{id}/refund` | `charges.star#on_refund_charge` | +| POST | `/v1/payment_intents` | `payment_intents.star#on_create_payment_intent` | +| POST | `/v1/payment_intents/{id}/confirm` | `payment_intents.star#on_confirm_payment_intent` | +| POST | `/v1/payment_intents/{id}/capture` | `payment_intents.star#on_capture_payment_intent` | +| GET | `/v1/payment_intents/{id}` | `payment_intents.star#on_retrieve_payment_intent` | +| GET | `/v1/payment_intents` | `payment_intents.star#on_list_payment_intents` | +| POST | `/v1/payment_methods` | `payment_methods.star#on_create_payment_method` | +| POST | `/v1/payment_methods/{id}/attach` | `payment_methods.star#on_attach_payment_method` | +| POST | `/v1/payment_methods/{id}/detach` | `payment_methods.star#on_detach_payment_method` | +| GET | `/v1/payment_methods/{id}` | `payment_methods.star#on_retrieve_payment_method` | +| GET | `/v1/payment_methods` | `payment_methods.star#on_list_payment_methods` | +| POST | `/v1/refunds` | `refunds.star#on_create_refund` | +| GET | `/v1/refunds/{id}` | `refunds.star#on_retrieve_refund` | +| GET | `/v1/refunds` | `refunds.star#on_list_refunds` | +| POST | `/v1/refunds/{id}/cancel` | `refunds.star#on_cancel_refund` | +| POST | `/v1/customers` | `customers.star#on_create_customer` | +| GET | `/v1/customers/{id}` | `customers.star#on_retrieve_customer` | +| GET | `/v1/customers` | `customers.star#on_list_customers` | +| POST | `/v1/customers/{id}` | `customers.star#on_update_customer` | +| DELETE | `/v1/customers/{id}` | `customers.star#on_delete_customer` | +| POST | `/v1/products` | `products.star#on_create_product` | +| GET | `/v1/products` | `products.star#on_list_products` | +| GET | `/v1/products/{id}` | `products.star#on_retrieve_product` | +| POST | `/v1/products/{id}` | `products.star#on_update_product` | +| DELETE | `/v1/products/{id}` | `products.star#on_delete_product` | +| POST | `/v1/prices` | `prices.star#on_create_price` | +| GET | `/v1/prices` | `prices.star#on_list_prices` | +| GET | `/v1/prices/{id}` | `prices.star#on_retrieve_price` | +| POST | `/v1/prices/{id}` | `prices.star#on_update_price` | +| POST | `/v1/subscriptions` | `subscriptions.star#on_create_subscription` | +| GET | `/v1/subscriptions` | `subscriptions.star#on_list_subscriptions` | +| GET | `/v1/subscriptions/{id}` | `subscriptions.star#on_retrieve_subscription` | +| POST | `/v1/subscriptions/{id}` | `subscriptions.star#on_update_subscription` | +| POST | `/v1/subscriptions/{id}/cancel` | `subscriptions.star#on_cancel_subscription` | +| GET | `/v1/subscription_items` | `subscription_items.star#on_list_subscription_items` | +| POST | `/v1/subscription_items` | `subscription_items.star#on_create_subscription_item` | +| POST | `/v1/subscription_items/{id}` | `subscription_items.star#on_update_subscription_item` | +| DELETE | `/v1/subscription_items/{id}` | `subscription_items.star#on_delete_subscription_item` | +| POST | `/v1/subscription_items/{id}/usage_records` | `subscription_items.star#on_create_usage_record` | +| GET | `/v1/subscription_items/{id}/usage_records` | `subscription_items.star#on_list_usage_records` | +| POST | `/v1/invoices` | `invoices.star#on_create_invoice` | +| GET | `/v1/invoices` | `invoices.star#on_list_invoices` | +| GET | `/v1/invoices/upcoming` | `invoices.star#on_upcoming_invoice` | +| GET | `/v1/invoices/{id}` | `invoices.star#on_retrieve_invoice` | +| POST | `/v1/invoices/{id}` | `invoices.star#on_update_invoice` | +| DELETE | `/v1/invoices/{id}` | `invoices.star#on_delete_invoice` | +| POST | `/v1/invoices/{id}/finalize` | `invoices.star#on_finalize_invoice` | +| POST | `/v1/invoices/{id}/pay` | `invoices.star#on_pay_invoice` | +| POST | `/v1/invoices/{id}/send` | `invoices.star#on_send_invoice` | +| POST | `/v1/invoices/{id}/void` | `invoices.star#on_void_invoice` | +| POST | `/v1/invoices/{id}/mark_uncollectible` | `invoices.star#on_mark_uncollectible_invoice` | +| GET | `/v1/invoices/{id}/lines` | `invoices.star#on_list_invoice_lines` | +| POST | `/v1/invoice_items` | `invoice_items.star#on_create_invoice_item` | +| GET | `/v1/invoice_items` | `invoice_items.star#on_list_invoice_items` | +| GET | `/v1/invoice_items/{id}` | `invoice_items.star#on_retrieve_invoice_item` | +| POST | `/v1/invoice_items/{id}` | `invoice_items.star#on_update_invoice_item` | +| DELETE | `/v1/invoice_items/{id}` | `invoice_items.star#on_delete_invoice_item` | +| POST | `/v1/credit_notes` | `credit_notes.star#on_create_credit_note` | +| GET | `/v1/credit_notes` | `credit_notes.star#on_list_credit_notes` | +| GET | `/v1/credit_notes/preview` | `credit_notes.star#on_preview_credit_note` | +| GET | `/v1/credit_notes/{id}` | `credit_notes.star#on_retrieve_credit_note` | +| POST | `/v1/credit_notes/{id}` | `credit_notes.star#on_update_credit_note` | +| POST | `/v1/credit_notes/{id}/void` | `credit_notes.star#on_void_credit_note` | +| POST | `/v1/coupons` | `coupons.star#on_create_coupon` | +| GET | `/v1/coupons` | `coupons.star#on_list_coupons` | +| GET | `/v1/coupons/{id}` | `coupons.star#on_retrieve_coupon` | +| POST | `/v1/coupons/{id}` | `coupons.star#on_update_coupon` | +| DELETE | `/v1/coupons/{id}` | `coupons.star#on_delete_coupon` | +| POST | `/v1/promotion_codes` | `promotion_codes.star#on_create_promotion_code` | +| GET | `/v1/promotion_codes` | `promotion_codes.star#on_list_promotion_codes` | +| GET | `/v1/promotion_codes/{id}` | `promotion_codes.star#on_retrieve_promotion_code` | +| POST | `/v1/promotion_codes/{id}` | `promotion_codes.star#on_update_promotion_code` | +| POST | `/v1/tax_rates` | `tax_rates.star#on_create_tax_rate` | +| GET | `/v1/tax_rates` | `tax_rates.star#on_list_tax_rates` | +| GET | `/v1/tax_rates/{id}` | `tax_rates.star#on_retrieve_tax_rate` | +| POST | `/v1/tax_rates/{id}` | `tax_rates.star#on_update_tax_rate` | +| DELETE | `/v1/tax_rates/{id}` | `tax_rates.star#on_delete_tax_rate` | +| GET | `/v1/balance` | `balance.star#on_get_balance` | +| GET | `/v1/balance_transactions` | `balance.star#on_list_balance_transactions` | +| GET | `/v1/balance_transactions/{id}` | `balance.star#on_retrieve_balance_transaction` | +| GET | `/v1/disputes` | `disputes.star#on_list_disputes` | +| GET | `/v1/disputes/{id}` | `disputes.star#on_retrieve_dispute` | +| POST | `/v1/disputes/{id}` | `disputes.star#on_update_dispute` | +| POST | `/v1/disputes/{id}/close` | `disputes.star#on_close_dispute` | +| GET | `/v1/application_fees` | `application_fees.star#on_list_application_fees` | +| GET | `/v1/application_fees/{id}` | `application_fees.star#on_retrieve_application_fee` | +| GET | `/v1/application_fees/{id}/refunds` | `application_fees.star#on_list_fee_refunds` | +| POST | `/v1/application_fees/{id}/refunds` | `application_fees.star#on_create_fee_refund` | +| POST | `/v1/application_fees/{id}/refund` | `application_fees.star#on_refund_application_fee` | +| POST | `/v1/checkout/sessions` | `checkout.star#on_create_checkout_session` | +| GET | `/v1/checkout/sessions` | `checkout.star#on_list_checkout_sessions` | +| GET | `/v1/checkout/sessions/{id}/line_items` | `checkout.star#on_list_checkout_session_line_items` | +| POST | `/v1/checkout/sessions/{id}/expire` | `checkout.star#on_expire_checkout_session` | +| GET | `/v1/checkout/sessions/{id}` | `checkout.star#on_retrieve_checkout_session` | +| GET | `/c/pay/{id}` | `checkout.star#on_pay_checkout_session` | +| POST | `/v1/setup_intents` | `setup_intents.star#on_create_setup_intent` | +| GET | `/v1/setup_intents` | `setup_intents.star#on_list_setup_intents` | +| POST | `/v1/setup_intents/{id}/confirm` | `setup_intents.star#on_confirm_setup_intent` | +| POST | `/v1/setup_intents/{id}/cancel` | `setup_intents.star#on_cancel_setup_intent` | +| GET | `/v1/setup_intents/{id}` | `setup_intents.star#on_retrieve_setup_intent` | +| POST | `/v1/setup_intents/{id}` | `setup_intents.star#on_update_setup_intent` | +| POST | `/v1/webhook_endpoints` | `webhook_endpoints.star#on_create_webhook_endpoint` | +| GET | `/v1/webhook_endpoints` | `webhook_endpoints.star#on_list_webhook_endpoints` | +| GET | `/v1/webhook_endpoints/{id}` | `webhook_endpoints.star#on_retrieve_webhook_endpoint` | +| POST | `/v1/webhook_endpoints/{id}` | `webhook_endpoints.star#on_update_webhook_endpoint` | +| DELETE | `/v1/webhook_endpoints/{id}` | `webhook_endpoints.star#on_delete_webhook_endpoint` | +| POST | `/v1/files` | `files.star#on_create_file` | +| GET | `/v1/files` | `files.star#on_list_files` | +| GET | `/v1/files/{id}` | `files.star#on_retrieve_file` | +| POST | `/v1/file_links` | `files.star#on_create_file_link` | +| GET | `/v1/file_links` | `files.star#on_list_file_links` | +| GET | `/v1/file_links/{id}` | `files.star#on_retrieve_file_link` | +| POST | `/v1/file_links/{id}` | `files.star#on_update_file_link` | +| POST | `/v1/accounts` | `accounts.star#on_create_account` | +| GET | `/v1/accounts/{id}` | `accounts.star#on_retrieve_account` | +| POST | `/v1/accounts/{id}` | `accounts.star#on_update_account` | +| GET | `/v1/accounts` | `accounts.star#on_list_accounts` | +| POST | `/v1/accounts/{id}/persons` | `persons.star#on_create_person` | +| GET | `/v1/accounts/{id}/persons` | `persons.star#on_list_persons` | +| GET | `/v1/accounts/{id}/persons/{person_id}` | `persons.star#on_retrieve_person` | +| POST | `/v1/accounts/{id}/persons/{person_id}` | `persons.star#on_update_person` | +| DELETE | `/v1/accounts/{id}/persons/{person_id}` | `persons.star#on_delete_person` | +| GET | `/v1/persons/{id}` | `persons.star#on_retrieve_person_standalone` | +| POST | `/v1/persons/{id}` | `persons.star#on_update_person_standalone` | +| POST | `/v1/accounts/{id}/external_accounts` | `accounts.star#on_create_external_account` | +| GET | `/v1/accounts/{id}/external_accounts` | `accounts.star#on_list_external_accounts` | +| GET | `/v1/accounts/{id}/external_accounts/{ea_id}` | `accounts.star#on_retrieve_external_account` | +| DELETE | `/v1/accounts/{id}/external_accounts/{ea_id}` | `accounts.star#on_delete_external_account` | +| POST | `/v1/accounts/{id}/login_links` | `accounts.star#on_create_login_link` | +| POST | `/v1/account_links` | `account_links.star#on_create_account_link` | +| POST | `/v1/transfers` | `transfers.star#on_create_transfer` | +| GET | `/v1/transfers/{id}` | `transfers.star#on_retrieve_transfer` | +| GET | `/v1/transfers` | `transfers.star#on_list_transfers` | +| POST | `/v1/transfers/{id}/reversals` | `transfers.star#on_reverse_transfer` | +| GET | `/v1/transfers/{id}/reversals` | `transfers.star#on_list_transfer_reversals` | +| GET | `/v1/transfers/{id}/reversals/{tr_id}` | `transfers.star#on_retrieve_transfer_reversal` | +| POST | `/v1/payouts` | `payouts.star#on_create_payout` | +| GET | `/v1/payouts` | `payouts.star#on_list_payouts` | +| GET | `/v1/payouts/{id}` | `payouts.star#on_retrieve_payout` | +| POST | `/v1/payouts/{id}` | `payouts.star#on_update_payout` | +| POST | `/v1/payouts/{id}/cancel` | `payouts.star#on_cancel_payout` | Any unmatched route returns `404 {"error":"resource_not_found"}`. -List endpoints Stripe documents as newest-first (`charges`, `customers`, `payment_intents`, `refunds`, `payouts`, `transfers`) return the most recently created objects first, like the real API. A non-numeric `created` / `created[gt|gte|lt|lte]` filter value returns Stripe's `400 parameter_invalid_integer` error. +List endpoints Stripe documents as newest-first return the most recently +created objects first, like the real API. ### Deleted customers (soft delete) -Real Stripe never destroys a customer object: `DELETE /v1/customers/{id}` marks it deleted, and stunt reproduces the full observable behavior: - -- `DELETE /v1/customers/{id}` → `200 {"id": "cus_…", "object": "customer", "deleted": true}` and a signed `customer.deleted` event (webhook delivery + the `events` collection). -- `GET /v1/customers/{id}` after delete → `200` with the full customer object including `"deleted": true` (deleted customers stay retrievable). -- `GET /v1/customers` (with any filter combination) → deleted customers are excluded; real Stripe lists never return deleted customers. -- `POST /v1/customers/{id}` on a deleted customer → `404 invalid_request_error` ("No such customer"), matching Stripe's resource_missing behavior for mutations on deleted objects. -- A `starting_after` cursor naming the deleted customer → `400` (the id no longer appears in list results, so the cursor is stale, exactly like Stripe's resource_missing). +Real Stripe never destroys a customer object: `DELETE /v1/customers/{id}` +marks it deleted (`200 {"deleted": true}` + signed `customer.deleted`), the +object stays retrievable, lists exclude it, mutations 404, and a +`starting_after` cursor naming it is stale (`400`) — the full observable +behavior. ## Backing stores -| Collection | Seed fixture | Purpose | -|------------|-------------|---------| -| `charges` | `fixtures/charges.jsonl` | Charge records | -| `customers` | `fixtures/customers.jsonl` | Customer records | -| `connect_accounts` | `fixtures/connect_accounts.jsonl` | Connected accounts | -| `transfers` | — | Transfer records (start empty) | -| `payouts` | — | Payout records (start empty) | -| `payment_intents` | — | PaymentIntent records (start empty) | -| `payment_methods` | — | PaymentMethod records (start empty) | -| `refunds` | — | Refund records (start empty) | - -IDs are generated with provider-style prefixes (`ch_`, `cus_`, `acct_`, `tr_`, -`po_`, `pi_`, `pm_`, `re_`) via a KV-backed sequence counter. - -Per-account balances for Connect are tracked in the KV store under -`bal_` keys. Idempotency replays are cached under `idem_` -keys in the same KV store. +| Collection | Seed fixture | +|------------|-------------| +| `charges`, `customers`, `connect_accounts` | `fixtures/*.jsonl` | +| `payment_intents`, `payment_methods`, `refunds`, `tokens`, `events`, `test_clocks`, `disputes`, `balance_transactions`, `application_fees` | — | +| `products`, `prices`, `subscriptions`, `usage_records`, `invoice_items`, `invoices`, `credit_notes`, `coupons`, `promotion_codes`, `tax_rates` | — | +| `checkout_sessions`, `setup_intents`, `webhook_endpoints`, `files`, `file_links` | — | +| `persons`, `external_accounts`, `transfer_reversals`, `transfers`, `payouts` | — | -## Shared library - -Shared helpers (`_bearer_token`, `_require_auth`, `_next_id`, `_to_int`, -`_stripe_account`, `_get_balance`, `_set_balance`, `_not_found`, `_list_page`, -`_signed_emit`, `_idempotent_lookup`, `_idempotent_remember`) are defined -in `scripts/lib.star` and preloaded into every handler script via stunt's -`LoadWithLib` mechanism. This avoids code duplication across handler scripts. +IDs use provider-style prefixes (`ch_`, `pi_`, `in_`, `sub_`, `dp_`, `txn_`, +`cs_`, `seti_`, `fee_`, `po_`, `trr_`, …) via a KV-backed sequence counter. +Per-account Connect balances (`bal_*`), the test-clock offset (`tc_offset`), +and idempotency replays (`idem_*`) live in the KV store. ## Layout ``` adapter.yaml Manifest: endpoints, resources, rules, identity -DISCLAIMER Not affiliated / synthetic-only notice -README.md This file -scripts/ - lib.star Shared helpers (auth, IDs, balance, etc.) - tokens.star Token mint endpoint (POST /v1/tokens) - charges.star Charge CRUD + capture/refund - payment_intents.star PaymentIntents (create/retrieve/list/confirm/capture) - payment_methods.star PaymentMethods (create/retrieve/list/attach/detach) - refunds.star Refunds (create/retrieve/list) - customers.star Customer CRUD - balance.star Balance endpoint (platform + per-account via Stripe-Account header) - accounts.star Connect: connected accounts (CRUD + capabilities) - account_links.star Connect: account links (onboarding URLs) - transfers.star Connect: transfers (create/retrieve/list/reverse) - payouts.star Connect: payouts (create/list) -fixtures/ - charges.jsonl Seed data for the charges collection - customers.jsonl Seed data for the customers collection - connect_accounts.jsonl Seed data for connected accounts -templates/ - charge.json Example charge response (faker placeholders) - customer.json Example customer response (faker placeholders) -schemas/ - charge.schema.json JSON Schema for a charge object +scripts/ 29 handler scripts + lib.star (shared helpers) +fixtures/ Seed data (charges, customers, connect_accounts) +templates/ schemas/ Example response + charge JSON Schema ``` ## Usage -Point a `stunt.yaml` service at this directory: - ```yaml services: stripe: @@ -495,10 +679,3 @@ services: ``` Then `stunt up` and make requests to the served address. - -## Concurrency note - -`POST /v1/refunds` (no path id to key on) is not serialized per charge the -way `/v1/charges/{id}/refund` is; under concurrent refunds of the same -charge the over-refund guard can be raced. Prefer the charge-scoped route -in concurrency-sensitive tests. From 6188971c8d38b4bf69dc0591ad4855ed98c72ccb Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 16 Aug 2026 09:44:21 +0300 Subject: [PATCH 3/4] fix(stripe-style): address review findings on full-coverage PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Majors: - _refunded_total now excludes canceled refunds (a canceled refund no longer locks the charge-level /refund route or credit notes out of the remaining balance); refunds.star local twin deleted - concurrency_key added to 5 read-modify-write routes (transfer reversals, PM attach/detach, customer + account update) - setup-mode checkout sessions keep payment_status no_payment_required through completion (real Stripe reserves paid for funds received) - invoice lines render in a real list envelope (object/data/has_more/ url) incl. the upcoming preview — typed SDKs read invoice.lines.data - application_fee.created is now emitted when a charge records its fee Minors: - _bad_body + _coupon_public hoisted into lib.star (14 and 2 identical local twins collapsed) - dispute ids dp_* -> du_* matching real Stripe - 404 messages quote the id (No such charge: 'ch_1'); hand-built 404s collapsed onto _not_found; starting_after message quoted too - Idempotency-Key honored on POST transfers/payouts/files/ webhook_endpoints - _dispute_submit docstring matches behavior; stale digit-run invariant comment corrected --- adapters/stripe-style/README.md | 2 +- adapters/stripe-style/adapter.yaml | 5 ++ adapters/stripe-style/scripts/charges.star | 6 +- adapters/stripe-style/scripts/checkout.star | 16 ++--- adapters/stripe-style/scripts/coupons.star | 20 +------ .../stripe-style/scripts/credit_notes.star | 13 +--- adapters/stripe-style/scripts/customers.star | 6 +- adapters/stripe-style/scripts/disputes.star | 11 +--- adapters/stripe-style/scripts/files.star | 5 ++ .../stripe-style/scripts/invoice_items.star | 11 +--- adapters/stripe-style/scripts/invoices.star | 23 +++---- adapters/stripe-style/scripts/lib.star | 60 +++++++++++++++---- adapters/stripe-style/scripts/payouts.star | 5 ++ adapters/stripe-style/scripts/prices.star | 11 +--- adapters/stripe-style/scripts/products.star | 11 +--- .../stripe-style/scripts/promotion_codes.star | 23 +------ adapters/stripe-style/scripts/refunds.star | 32 ++-------- .../scripts/subscription_items.star | 13 +--- .../stripe-style/scripts/subscriptions.star | 13 +--- adapters/stripe-style/scripts/tax_rates.star | 11 +--- .../stripe-style/scripts/test_clocks.star | 11 +--- adapters/stripe-style/scripts/transfers.star | 5 ++ .../scripts/webhook_endpoints.star | 1 + internal/engine/stripe_checkout_test.go | 4 ++ internal/engine/stripe_disputes_test.go | 18 +++--- internal/engine/stripe_groundwork_test.go | 8 +-- internal/engine/stripe_invoices_test.go | 8 +-- internal/engine/stripe_subscriptions_test.go | 2 +- 28 files changed, 146 insertions(+), 208 deletions(-) diff --git a/adapters/stripe-style/README.md b/adapters/stripe-style/README.md index 60ac7c26..348f938a 100644 --- a/adapters/stripe-style/README.md +++ b/adapters/stripe-style/README.md @@ -468,7 +468,7 @@ Deliberate simplifications (all also commented in the scripts): - Checkout's hosted page runs 3DS for SCA cards (succeeds) rather than challenging. - `POST /v1/files` returns `201` (adapter-wide create convention). -- Dispute ids are `dp_*` (real Stripe currently mints `du_*`). + - Webhook delivery goes to the single configured sink; endpoint registration gates *which* event types deliver (not per-endpoint URLs). diff --git a/adapters/stripe-style/adapter.yaml b/adapters/stripe-style/adapter.yaml index 0024f7fc..68be6b97 100644 --- a/adapters/stripe-style/adapter.yaml +++ b/adapters/stripe-style/adapter.yaml @@ -117,9 +117,11 @@ endpoints: - route: /v1/payment_methods/{id}/attach method: POST handler: scripts/payment_methods.star#on_attach_payment_method + concurrency_key: id # read-modify-write on the PM doc - route: /v1/payment_methods/{id}/detach method: POST handler: scripts/payment_methods.star#on_detach_payment_method + concurrency_key: id - route: /v1/payment_methods/{id} method: GET handler: scripts/payment_methods.star#on_retrieve_payment_method @@ -155,6 +157,7 @@ endpoints: - route: /v1/customers/{id} method: POST handler: scripts/customers.star#on_update_customer + concurrency_key: id # read-modify-write, serialized per customer - route: /v1/customers/{id} method: DELETE handler: scripts/customers.star#on_delete_customer @@ -521,6 +524,7 @@ endpoints: - route: /v1/accounts/{id} method: POST handler: scripts/accounts.star#on_update_account + concurrency_key: id # read-modify-write on the account doc - route: /v1/accounts method: GET handler: scripts/accounts.star#on_list_accounts @@ -591,6 +595,7 @@ endpoints: - route: /v1/transfers/{id}/reversals method: POST handler: scripts/transfers.star#on_reverse_transfer + concurrency_key: id # partial reversals accumulate amount_reversed - route: /v1/transfers/{id}/reversals method: GET handler: scripts/transfers.star#on_list_transfer_reversals diff --git a/adapters/stripe-style/scripts/charges.star b/adapters/stripe-style/scripts/charges.star index fa6443c3..cd594730 100644 --- a/adapters/stripe-style/scripts/charges.star +++ b/adapters/stripe-style/scripts/charges.star @@ -118,7 +118,7 @@ def on_retrieve_charge(req): c = store_collection("charges") doc = c.get(id) if doc == None: - return respond(404, {"error": {"message": "No such charge: " + id, "type": "invalid_request_error"}}) + return _not_found("charge", id) return respond(200, doc) # GET /v1/charges — list all charges. @@ -163,7 +163,7 @@ def on_capture_charge(req): c = store_collection("charges") doc = c.get(id) if doc == None: - return respond(404, {"error": {"message": "No such charge: " + id, "type": "invalid_request_error"}}) + return _not_found("charge", id) body = req["body"] if body == None: @@ -198,7 +198,7 @@ def on_refund_charge(req): c = store_collection("charges") doc = c.get(id) if doc == None: - return respond(404, {"error": {"message": "No such charge: " + id, "type": "invalid_request_error"}}) + return _not_found("charge", id) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/checkout.star b/adapters/stripe-style/scripts/checkout.star index 9c41253c..9a9659c7 100644 --- a/adapters/stripe-style/scripts/checkout.star +++ b/adapters/stripe-style/scripts/checkout.star @@ -220,7 +220,7 @@ def on_create_checkout_session(req): if cached != None: return respond(cached["status"], _ck_public(cached["doc"])) - if _ck_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] @@ -298,15 +298,8 @@ def on_create_checkout_session(req): _idempotent_remember(req, "checkout_sessions", 201, sid) return respond(201, _ck_public(doc)) -# _ck_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is # the source of truth. -def _ck_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # GET /v1/checkout/sessions — list sessions (filters customer, status, # payment_intent, subscription, created; newest first; cursor pagination). def on_list_checkout_sessions(req): @@ -697,7 +690,7 @@ def on_pay_checkout_session(req): id = req["params"]["id"] doc = _ck_get(id) if doc == None: - return respond(404, {"error": {"message": "No such checkout_session: " + id, "type": "invalid_request_error"}}) + return _not_found("checkout_session", id) doc = _ck_advance(doc) status = doc.get("status", "") @@ -742,7 +735,10 @@ def on_pay_checkout_session(req): doc["payment_intent"] = pi["id"] doc["status"] = "complete" - doc["payment_status"] = "paid" + # setup mode never moves money: the session stays no_payment_required + # (real Stripe's enum reserves "paid" for funds received). + if doc.get("mode", "payment") != "setup": + doc["payment_status"] = "paid" doc["url"] = None doc["_paid_pm"] = pm store_collection("checkout_sessions").update(id, doc) diff --git a/adapters/stripe-style/scripts/coupons.star b/adapters/stripe-style/scripts/coupons.star index 63dab01b..ec16e445 100644 --- a/adapters/stripe-style/scripts/coupons.star +++ b/adapters/stripe-style/scripts/coupons.star @@ -24,22 +24,6 @@ def _coupon_err(msg, param): e["param"] = param return respond(400, {"error": e}) -# _coupon_bad_body reports a malformed JSON body authoritatively. -def _coupon_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - -# _coupon_public renders a stored coupon (internal keys stripped). -def _coupon_public(doc): - out = {} - for k in doc: - if k.startswith("_"): - continue - out[k] = doc[k] - return out - # POST /v1/coupons — create a coupon. # # Exactly one of percent_off / amount_off(+currency) is required. duration @@ -53,7 +37,7 @@ def on_create_coupon(req): if cached != None: return respond(cached["status"], _coupon_public(cached["doc"])) - if _coupon_bad_body(req): + if _bad_body(req): return _coupon_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -174,7 +158,7 @@ def on_update_coupon(req): if doc.get("deleted", False) == True: return _coupon_err("This coupon has been deleted and can no longer be updated.", None) - if _coupon_bad_body(req): + if _bad_body(req): return _coupon_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/credit_notes.star b/adapters/stripe-style/scripts/credit_notes.star index ed3235fe..d02dfabe 100644 --- a/adapters/stripe-style/scripts/credit_notes.star +++ b/adapters/stripe-style/scripts/credit_notes.star @@ -26,14 +26,7 @@ def _cn_err(msg, param): e["param"] = param return respond(400, {"error": e}) -# _cn_bad_body reports a malformed JSON body authoritatively (an unparseable # body arrives as an EMPTY dict via req.body; req.raw_body is the truth). -def _cn_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _cn_reason normalizes the credit-note reason: the four documented values # pass through, the legacy duplicated/product_unacceptable spellings map to # their modern forms, anything else is a 400 (or None when absent). @@ -80,7 +73,7 @@ def _cn_build_lines(invoice, body): il = inv_lines[j] break if il == None: - return [], 0, _cn_err("No such line_item: " + str(ref), "lines") + return [], 0, _cn_err("No such line_item: '" + str(ref) + "'", "lines") unit = _num(il.get("amount", 0)) qty = _num(entry.get("quantity", il.get("quantity", 1))) if qty < 1: @@ -294,7 +287,7 @@ def on_create_credit_note(req): if cached != None: return respond(cached["status"], _cn_public(cached["doc"])) - if _cn_bad_body(req): + if _bad_body(req): return _cn_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -401,7 +394,7 @@ def on_update_credit_note(req): if doc == None: return _not_found("credit_note", id) - if _cn_bad_body(req): + if _bad_body(req): return _cn_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/customers.star b/adapters/stripe-style/scripts/customers.star index 44b93b2f..46ca0734 100644 --- a/adapters/stripe-style/scripts/customers.star +++ b/adapters/stripe-style/scripts/customers.star @@ -40,7 +40,7 @@ def on_retrieve_customer(req): c = store_collection("customers") doc = c.get(id) if doc == None: - return respond(404, {"error": {"message": "No such customer: " + id, "type": "invalid_request_error"}}) + return _not_found("customer", id) return respond(200, doc) # GET /v1/customers — list all customers. @@ -92,7 +92,7 @@ def on_update_customer(req): c = store_collection("customers") doc = c.get(id) if doc == None or doc.get("deleted", False) == True: - return respond(404, {"error": {"message": "No such customer: " + id, "type": "invalid_request_error"}}) + return _not_found("customer", id) body = req["body"] if body != None: @@ -116,7 +116,7 @@ def on_delete_customer(req): c = store_collection("customers") doc = c.get(id) if doc == None: - return respond(404, {"error": {"message": "No such customer: " + id, "type": "invalid_request_error"}}) + return _not_found("customer", id) doc["deleted"] = True c.update(id, doc) diff --git a/adapters/stripe-style/scripts/disputes.star b/adapters/stripe-style/scripts/disputes.star index d46f7dd7..94324add 100644 --- a/adapters/stripe-style/scripts/disputes.star +++ b/adapters/stripe-style/scripts/disputes.star @@ -57,15 +57,8 @@ _DISP_EVIDENCE_FIELDS = [ # can never collide with it). _DISP_ABSENT = {} -# _disp_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is the # source of truth. -def _disp_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _disp_known_field reports whether key is one of the accepted evidence # fields. Unknown evidence fields are ignored (Stripe rejects unknown params # with parameter_unknown; this adapter's convention is to ignore). @@ -226,7 +219,7 @@ def on_update_dispute(req): if cached != None: return respond(cached["status"], _dispute_public(cached["doc"])) - if _disp_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -288,7 +281,7 @@ def on_close_dispute(req): if cached != None: return respond(cached["status"], _dispute_public(cached["doc"])) - if _disp_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] diff --git a/adapters/stripe-style/scripts/files.star b/adapters/stripe-style/scripts/files.star index aba91301..c35b7660 100644 --- a/adapters/stripe-style/scripts/files.star +++ b/adapters/stripe-style/scripts/files.star @@ -96,6 +96,10 @@ def on_create_file(req): if err != None: return err + cached = _idempotent_lookup(req, "files") + if cached != None: + return respond(cached["status"], _files_public(cached["doc"])) + h = req.get("headers") ct = "" if h != None: @@ -152,6 +156,7 @@ def on_create_file(req): "_sha256": crypto.sha256(data), } store_collection("files").insert(doc) + _idempotent_remember(req, "files", 201, doc["id"]) return respond(201, _files_public(doc)) # GET /v1/files — list uploads (filter purpose; newest first). diff --git a/adapters/stripe-style/scripts/invoice_items.star b/adapters/stripe-style/scripts/invoice_items.star index ad898fd0..950a3405 100644 --- a/adapters/stripe-style/scripts/invoice_items.star +++ b/adapters/stripe-style/scripts/invoice_items.star @@ -24,14 +24,7 @@ def _ii_err(msg, param): def _ii_missing(param): return _ii_err("Missing required param: " + param + ".", param) -# _ii_bad_body reports a malformed JSON body authoritatively (an unparseable # body arrives as an EMPTY dict via req.body; req.raw_body is the truth). -def _ii_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _ii_price resolves the price dict for an item: an inline price_data object # or a stored price id (read from the prices collection the subscriptions # domain owns; unknown ids yield None). Internal _ keys are stripped. @@ -77,7 +70,7 @@ def on_create_invoice_item(req): if cached != None: return respond(cached["status"], _ii_public(cached["doc"])) - if _ii_bad_body(req): + if _bad_body(req): return _ii_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -209,7 +202,7 @@ def on_update_invoice_item(req): if doc == None: return _not_found("invoiceitem", id) - if _ii_bad_body(req): + if _bad_body(req): return _ii_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/invoices.star b/adapters/stripe-style/scripts/invoices.star index c2d90ddc..9b2bd346 100644 --- a/adapters/stripe-style/scripts/invoices.star +++ b/adapters/stripe-style/scripts/invoices.star @@ -21,15 +21,8 @@ _INV_COLLECTION = "invoices" -# _inv_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is # the source of truth. -def _inv_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _inv_err builds the real Stripe error envelope with status 400. def _inv_err(msg, param): e = {"type": "invalid_request_error", "message": msg} @@ -322,7 +315,7 @@ def on_create_invoice(req): if cached != None: return respond(cached["status"], _invoice_public(cached["doc"])) - if _inv_bad_body(req): + if _bad_body(req): return _inv_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -487,7 +480,7 @@ def on_update_invoice(req): if doc == None: return _not_found("invoice", id) - if _inv_bad_body(req): + if _bad_body(req): return _inv_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -684,7 +677,7 @@ def on_pay_invoice(req): if cached != None: return respond(cached["status"], _invoice_public(cached["doc"])) - if _inv_bad_body(req): + if _bad_body(req): return _inv_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -888,7 +881,15 @@ def _inv_preview_doc(customer, subscription, sub_lines, item_lines, collection_m "status": "open", "collection_method": collection_method, "currency": currency, - "lines": lines, + # same list envelope as _invoice_public — upcoming previews are + # invoice-shaped responses too + "lines": { + "object": "list", + "data": lines, + "has_more": False, + "total_count": len(lines), + "url": "/v1/invoices/upcoming", + }, "subtotal": subtotal, "discount": discount, "tax": tax, diff --git a/adapters/stripe-style/scripts/lib.star b/adapters/stripe-style/scripts/lib.star index e54ae641..4122e6f6 100644 --- a/adapters/stripe-style/scripts/lib.star +++ b/adapters/stripe-style/scripts/lib.star @@ -163,6 +163,25 @@ def _get_query(req, key): return "" return v +# _coupon_public renders a stored coupon (internal keys stripped). +def _coupon_public(doc): + out = {} + for k in doc: + if k.startswith("_"): + continue + out[k] = doc[k] + return out + + +# _bad_body reports a malformed JSON body authoritatively: undecodable bodies +# arrive as EMPTY DICTS via req.body, so the raw bytes are the only reliable +# signal (json_safe_decode returns None on malformed). Empty body = not bad. +def _bad_body(req): + raw = req.get("raw_body", "") + if raw == None or raw == "": + return False + return json_safe_decode(raw) == None + # _created_filters maps Stripe's `created` / `created[gt|gte|lt|lte]` query # params (exact timestamp or bracketed range, form-encoded) to query_select # triples against the int `created` field. Appends to the clause list in @@ -259,7 +278,7 @@ def _set_balance(acct_id, amount): # _not_found returns a standard Stripe-style 404 error response. def _not_found(resource, id): - return respond(404, {"error": {"message": "No such " + resource + ": " + id, "type": "invalid_request_error"}}) + return respond(404, {"error": {"message": "No such " + resource + ": '" + id + "'", "type": "invalid_request_error"}}) # _list_page applies Stripe cursor pagination (limit + starting_after) to a list # of docs via the paginate builtin. Returns (page, has_more, error_response). @@ -291,7 +310,7 @@ def _list_page(req, docs, resource): found = True break if not found: - err = respond(400, {"error": {"type": "invalid_request_error", "message": "No such " + resource + ": " + sa, "param": "starting_after"}}) + err = respond(400, {"error": {"type": "invalid_request_error", "message": "No such " + resource + ": '" + sa + "'", "param": "starting_after"}}) return None, False, err page, nxt = paginate(docs, limit, offset) return page, nxt != None, None @@ -360,7 +379,7 @@ def _num(v): # Real Stripe reserves specific test card numbers for deterministic outcomes: # declines (with the real decline_code) and SCA cards that force 3DS # authentication. The digit strings are assembled at runtime from <=4-digit -# chunks so no literal in this file ever contains 5+ consecutive digits. +# chunks so no card number ever appears as a contiguous literal. _DECLINE_CARDS = { "4000" + "0000" + "0000" + "0002": {"code": "card_declined", "decline_code": "generic_decline", "message": "Your card was declined."}, @@ -472,13 +491,18 @@ def _refunds_for(field, val): docs = store_collection("refunds").list() return query_select(docs, [[field, "=", val]]) -# _refunded_total sums the amounts of every non-failed refund (pending -# refunds count — Stripe reserves the unrefunded balance immediately). +# _refunded_total sums the amounts of every refund that still counts against +# the unrefunded balance: pending (Stripe reserves it immediately) and +# succeeded. failed refunds never counted; canceled ones are rolled back +# (funds returned), so they must not count either — otherwise a canceled +# refund permanently locks the remaining balance out of re-refund. def _refunded_total(docs): total = 0 for r in docs: - if r.get("status") != "failed": - total = total + _num(r.get("amount", 0)) + st = r.get("status", "") + if st == "failed" or st == "canceled": + continue + total = total + _num(r.get("amount", 0)) return total # _usd renders integer cents as a "$dollars.cents" string for error messages. @@ -671,6 +695,7 @@ def _maybe_record_fee(ch, body): "created": _now(), } store_collection("application_fees").insert(doc) + _signed_emit("application_fee.created", doc) return doc # ============================================================================ @@ -678,7 +703,7 @@ def _maybe_record_fee(ch, body): # ============================================================================ # The documented dispute test cards (docs.stripe.com/testing): charging with # these SUCCEEDS and immediately raises a dispute. Real Stripe now mints du_* -# dispute ids; this simulator uses the dp_* prefix shared across the billing +# dispute ids; this simulator uses the du_* prefix shared across the billing # domains' doc contracts. # 4000 0000 0000 0259 -> reason fraudulent # 4000 0000 0000 2685 -> reason product_not_received @@ -711,7 +736,7 @@ def _maybe_create_dispute(ch, number): now = _now() due_by = now + 7 * 24 * 3600 # evidence window: created + 7 days dp = { - "id": _next_id("dp"), + "id": _next_id("du"), "object": "dispute", "amount": _num(ch.get("amount", 0)), "balance_transactions": [], @@ -840,10 +865,10 @@ def _dispute_close(doc): return doc # _dispute_submit records an evidence submission (the dispute-update endpoint -# calls this). winning True schedules the merchant-favor ruling for -# _settle_at = submit time + 1 day; losing evidence resolves immediately via -# _dispute_close. The needs_response -> under_review transition is derived -# right away so the submit response reflects it. +# calls this). It schedules the ruling for _settle_at = submit time + 1 day; +# the mock always rules in the merchant's favor when evidence is submitted. +# The needs_response -> under_review transition is derived right away so the +# submit response reflects it. def _dispute_submit(doc, winning, evidence): now = _now() doc["_submit_at"] = now @@ -1026,10 +1051,19 @@ def _subscription_invoice(sub, line_dicts, discount_amt, tax_cents, inclusive): return inv # _invoice_public renders a stored invoice doc, stripping internal "_" keys. +# lines is stored as a bare array but rendered in a list envelope — real +# Stripe's invoice object wraps it, and typed SDKs read invoice.lines.data. def _invoice_public(doc): out = {} for k in doc: if k.startswith("_"): continue out[k] = doc[k] + out["lines"] = { + "object": "list", + "data": doc.get("lines", []), + "has_more": False, + "total_count": len(doc.get("lines", [])), + "url": "/v1/invoices/" + doc.get("id", "") + "/lines", + } return out diff --git a/adapters/stripe-style/scripts/payouts.star b/adapters/stripe-style/scripts/payouts.star index 607b6bed..dd2b41ff 100644 --- a/adapters/stripe-style/scripts/payouts.star +++ b/adapters/stripe-style/scripts/payouts.star @@ -136,6 +136,10 @@ def on_create_payout(req): if err != None: return err + cached = _idempotent_lookup(req, "payouts") + if cached != None: + return respond(cached["status"], _payout_view(cached["doc"])) + body = req["body"] if body == None: body = {} @@ -191,6 +195,7 @@ def on_create_payout(req): # Emit webhook event (fire-and-forget). _signed_emit("payout.created", _payout_view(doc)) + _idempotent_remember(req, "payouts", 201, doc["id"]) return respond(201, _payout_view(doc)) # GET /v1/payouts — list all payouts (optionally ?destination=/?status=). diff --git a/adapters/stripe-style/scripts/prices.star b/adapters/stripe-style/scripts/prices.star index 556536d4..0b9fbd4f 100644 --- a/adapters/stripe-style/scripts/prices.star +++ b/adapters/stripe-style/scripts/prices.star @@ -10,14 +10,7 @@ # metered prices bill reported usage (see subscription_items usage_records). # Shared helpers are in lib.star (see products.star header for the list). -# _price_bad_body reports a malformed JSON body authoritatively (req.body # arrives as an empty dict for unparseable bodies; req.raw_body is the truth). -def _price_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - def _price_missing(param): return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) @@ -54,7 +47,7 @@ def on_create_price(req): if cached != None: return respond(cached["status"], _price_public(cached["doc"])) - if _price_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -203,7 +196,7 @@ def on_update_price(req): if err != None: return err - if _price_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] doc = store_collection("prices").get(id) diff --git a/adapters/stripe-style/scripts/products.star b/adapters/stripe-style/scripts/products.star index e078f887..ed8a5fe1 100644 --- a/adapters/stripe-style/scripts/products.star +++ b/adapters/stripe-style/scripts/products.star @@ -9,15 +9,8 @@ # _created_filters, _created_check, _newest_first, _list_page, _idempotent_lookup, # _idempotent_remember, _now, _num, _signed_emit) are in lib.star. -# _prod_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is # the source of truth. -def _prod_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - def _prod_missing(param): return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) @@ -49,7 +42,7 @@ def on_create_product(req): if cached != None: return respond(cached["status"], _prod_public(cached["doc"])) - if _prod_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -145,7 +138,7 @@ def on_update_product(req): if err != None: return err - if _prod_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] doc = _prod_get(id) diff --git a/adapters/stripe-style/scripts/promotion_codes.star b/adapters/stripe-style/scripts/promotion_codes.star index 0f7bb82e..ba1deb8a 100644 --- a/adapters/stripe-style/scripts/promotion_codes.star +++ b/adapters/stripe-style/scripts/promotion_codes.star @@ -21,13 +21,6 @@ def _promo_err(msg, param): e["param"] = param return respond(400, {"error": e}) -# _promo_bad_body reports a malformed JSON body authoritatively. -def _promo_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _promo_gen_code mints a Stripe-style code: 8 uppercase alphanumerics # derived from an HMAC of the KV sequence (runtime data, no long literals). def _promo_gen_code(): @@ -35,16 +28,6 @@ def _promo_gen_code(): h = crypto.hmac_sha256("stunt-promo", str(seq)) return h[0:8].upper() -# _promo_coupon_public renders the embedded coupon (strips internal keys). -# Local twin of coupons.star's _coupon_public — hoist candidate for lib.star. -def _promo_coupon_public(doc): - out = {} - for k in doc: - if k.startswith("_"): - continue - out[k] = doc[k] - return out - # _promo_public renders a stored promotion code with the coupon EXPANDED # (internal keys stripped). def _promo_public(doc): @@ -55,7 +38,7 @@ def _promo_public(doc): if k == "coupon": coupon = store_collection("coupons").get(doc["coupon"]) if coupon != None: - out["coupon"] = _promo_coupon_public(coupon) + out["coupon"] = _coupon_public(coupon) else: out["coupon"] = doc["coupon"] else: @@ -73,7 +56,7 @@ def on_create_promotion_code(req): if cached != None: return respond(cached["status"], _promo_public(cached["doc"])) - if _promo_bad_body(req): + if _bad_body(req): return _promo_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -201,7 +184,7 @@ def on_update_promotion_code(req): if doc == None: return _not_found("promotion_code", id) - if _promo_bad_body(req): + if _bad_body(req): return _promo_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/refunds.star b/adapters/stripe-style/scripts/refunds.star index 504c1d0e..269113b2 100644 --- a/adapters/stripe-style/scripts/refunds.star +++ b/adapters/stripe-style/scripts/refunds.star @@ -32,30 +32,8 @@ def _ref_public(doc): out["failure_balance_transaction"] = doc["failure_balance_transaction"] return out -# _ref_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is the # source of truth. -def _ref_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - -# _ref_active_total sums the amounts of every refund that still counts against -# the unrefunded balance: pending (Stripe reserves it immediately) and -# succeeded. failed refunds never counted; canceled ones are rolled back by -# on_cancel_refund, so they must not count either. (lib._refunded_total treats -# everything non-failed as active — kept for other callers; this local copy -# adds the canceled case for this file's guard.) -def _ref_active_total(docs): - total = 0 - for r in docs: - st = r.get("status", "") - if st == "failed" or st == "canceled": - continue - total = total + _num(r.get("amount", 0)) - return total - # _ref_apply_charge_recompute recomputes a charge's refund bookkeeping from the # still-active refunds (used after a cancel rolls one back). Mirrors lib's # _apply_charge_refund flags: fully refunded -> refunded True + status @@ -124,7 +102,7 @@ def on_create_refund(req): if cached != None: return respond(cached["status"], _ref_public(cached["doc"])) - if _ref_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -151,7 +129,7 @@ def on_create_refund(req): if pi.get("status", "") == "requires_capture": return respond(400, {"error": {"code": "payment_intent_unexpected_state", "type": "invalid_request_error", "message": "This PaymentIntent could not be refunded because it has a status of requires_capture. You can cancel it instead with the PaymentIntents API.", "param": "payment_intent"}}) base = _num(pi.get("amount", 0)) - remaining = base - _ref_active_total(_refunds_for("payment_intent", pi_id)) + remaining = base - _refunded_total(_refunds_for("payment_intent", pi_id)) if amount == 0: amount = remaining if amount > remaining or amount <= 0: @@ -163,7 +141,7 @@ def on_create_refund(req): if ch == None: return _not_found("charge", charge_id) base = _num(ch.get("amount", 0)) - already = _ref_active_total(_refunds_for("charge", charge_id)) + already = _refunded_total(_refunds_for("charge", charge_id)) remaining = base - already if amount == 0: amount = remaining @@ -246,7 +224,7 @@ def on_cancel_refund(req): if cached != None: return respond(cached["status"], _ref_public(cached["doc"])) - if _ref_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] @@ -282,7 +260,7 @@ def on_cancel_refund(req): chs = store_collection("charges") ch = chs.get(ch_id) if ch != None: - _ref_apply_charge_recompute(ch, _ref_active_total(_refunds_for("charge", ch_id))) + _ref_apply_charge_recompute(ch, _refunded_total(_refunds_for("charge", ch_id))) chs.update(ch_id, ch) _signed_emit("refund.updated", _ref_public(doc)) diff --git a/adapters/stripe-style/scripts/subscription_items.star b/adapters/stripe-style/scripts/subscription_items.star index 9762765a..6e657535 100644 --- a/adapters/stripe-style/scripts/subscription_items.star +++ b/adapters/stripe-style/scripts/subscription_items.star @@ -24,14 +24,7 @@ # _newest_first, _list_page, _idempotent_lookup, _idempotent_remember, _now, # _num, _signed_emit) are in lib.star. -# _si_bad_body reports a malformed JSON body authoritatively (req.body # arrives as an empty dict for unparseable bodies; req.raw_body is the truth). -def _si_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - def _si_missing(param): return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) @@ -88,7 +81,7 @@ def on_create_subscription_item(req): if err != None: return err - if _si_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -152,7 +145,7 @@ def on_update_subscription_item(req): if err != None: return err - if _si_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] sub, idx = _si_find(id) @@ -231,7 +224,7 @@ def on_create_usage_record(req): if cached != None: return respond(cached["status"], cached["doc"]) - if _si_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/subscriptions.star b/adapters/stripe-style/scripts/subscriptions.star index c9518082..b25e692e 100644 --- a/adapters/stripe-style/scripts/subscriptions.star +++ b/adapters/stripe-style/scripts/subscriptions.star @@ -36,15 +36,8 @@ # _card_number_for, _card_outcome, _charge_settle_hooks, _subscription_invoice, # _invoice_public, _add_months, _signed_emit) are in lib.star. -# _sub_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is # the source of truth. -def _sub_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - def _sub_missing(param): return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) @@ -610,7 +603,7 @@ def on_create_subscription(req): if cached != None: return respond(cached["status"], _sub_public(cached["doc"])) - if _sub_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -829,7 +822,7 @@ def on_update_subscription(req): if err != None: return err - if _sub_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] doc = _sub_get(id) @@ -1010,7 +1003,7 @@ def on_cancel_subscription(req): if err != None: return err - if _sub_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] doc = _sub_get(id) diff --git a/adapters/stripe-style/scripts/tax_rates.star b/adapters/stripe-style/scripts/tax_rates.star index 0dc5fc1c..205ac92e 100644 --- a/adapters/stripe-style/scripts/tax_rates.star +++ b/adapters/stripe-style/scripts/tax_rates.star @@ -25,13 +25,6 @@ def _txr_err(msg, param): def _txr_missing(param): return _txr_err("Missing required param: " + param + ".", param) -# _txr_bad_body reports a malformed JSON body authoritatively. -def _txr_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _txr_percentage parses a percentage into a float: numbers pass through, # numeric strings ("16", "8.875") are split manually (Starlark float() # raises on bad input and there is no try/except). Returns None when the @@ -91,7 +84,7 @@ def on_create_tax_rate(req): if cached != None: return respond(cached["status"], _txr_public(cached["doc"])) - if _txr_bad_body(req): + if _bad_body(req): return _txr_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: @@ -186,7 +179,7 @@ def on_update_tax_rate(req): if doc.get("deleted", False) == True: return _txr_err("This tax rate has been deleted and can no longer be updated.", None) - if _txr_bad_body(req): + if _bad_body(req): return _txr_err("Invalid request body: could not parse as JSON.", None) body = req["body"] if body == None: diff --git a/adapters/stripe-style/scripts/test_clocks.star b/adapters/stripe-style/scripts/test_clocks.star index 8097ce79..a4b0ed75 100644 --- a/adapters/stripe-style/scripts/test_clocks.star +++ b/adapters/stripe-style/scripts/test_clocks.star @@ -29,15 +29,8 @@ def _tc_public(doc): out[k] = doc[k] return out -# _tc_bad_body reports a malformed JSON body authoritatively: a body that # fails to parse arrives as an EMPTY dict via req.body, so req.raw_body is # the source of truth. -def _tc_bad_body(req): - raw = req.get("raw_body", "") - if raw == None or raw == "": - return False - return json_safe_decode(raw) == None - # _tc_missing_param is the real Stripe 400 for a missing required param. def _tc_missing_param(param): return respond(400, {"error": {"type": "invalid_request_error", "message": "Missing required param: " + param + ".", "param": param}}) @@ -67,7 +60,7 @@ def on_create_test_clock(req): if cached != None: return respond(cached["status"], _tc_public(cached["doc"])) - if _tc_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) body = req["body"] if body == None: @@ -144,7 +137,7 @@ def on_advance_test_clock(req): out["status"] = "advancing" return respond(cached["status"], out) - if _tc_bad_body(req): + if _bad_body(req): return respond(400, {"error": {"type": "invalid_request_error", "message": "Invalid request body: could not parse as JSON."}}) id = req["params"]["id"] diff --git a/adapters/stripe-style/scripts/transfers.star b/adapters/stripe-style/scripts/transfers.star index 41a8e989..5f4f00f8 100644 --- a/adapters/stripe-style/scripts/transfers.star +++ b/adapters/stripe-style/scripts/transfers.star @@ -83,6 +83,10 @@ def on_create_transfer(req): if err != None: return err + cached = _idempotent_lookup(req, "transfers") + if cached != None: + return respond(cached["status"], _transfer_view(cached["doc"])) + body = req["body"] if body == None: body = {} @@ -134,6 +138,7 @@ def on_create_transfer(req): # Emit webhook event (fire-and-forget). _signed_emit("transfer.created", _transfer_view(doc)) + _idempotent_remember(req, "transfers", 201, transfer_id) return respond(201, _transfer_view(doc)) # GET /v1/transfers/{id} — retrieve a single transfer. diff --git a/adapters/stripe-style/scripts/webhook_endpoints.star b/adapters/stripe-style/scripts/webhook_endpoints.star index acddf87b..555c9af4 100644 --- a/adapters/stripe-style/scripts/webhook_endpoints.star +++ b/adapters/stripe-style/scripts/webhook_endpoints.star @@ -83,6 +83,7 @@ def on_create_webhook_endpoint(req): "url": url, } store_collection("webhook_endpoints").insert(doc) + _idempotent_remember(req, "webhook_endpoints", 201, doc["id"]) return respond(201, _we_public(doc)) # GET /v1/webhook_endpoints — list registered endpoints (newest first). diff --git a/internal/engine/stripe_checkout_test.go b/internal/engine/stripe_checkout_test.go index f4f4c063..48b60b7a 100644 --- a/internal/engine/stripe_checkout_test.go +++ b/internal/engine/stripe_checkout_test.go @@ -561,6 +561,10 @@ func TestStripeCkCheckoutSetupMode(t *testing.T) { if cs["status"] != "complete" { t.Fatalf("setup session status = %v, want complete", cs["status"]) } + // setup mode never moves money — payment_status must SURVIVE completion. + if cs["payment_status"] != "no_payment_required" { + t.Fatalf("setup payment_status after completion = %v, want no_payment_required", cs["payment_status"]) + } setiID, _ := cs["setup_intent"].(string) if !strings.HasPrefix(setiID, "seti_") { t.Fatalf("session.setup_intent = %v, want seti_*", cs["setup_intent"]) diff --git a/internal/engine/stripe_disputes_test.go b/internal/engine/stripe_disputes_test.go index da471bc1..855d4c2a 100644 --- a/internal/engine/stripe_disputes_test.go +++ b/internal/engine/stripe_disputes_test.go @@ -17,7 +17,7 @@ import ( // stripeDisputeOnCard charges `amount` cents with a raw card number and // returns the parsed charge doc (the dispute test cards raise a dispute on -// capture, so the charge carries a dp_* id). +// capture, so the charge carries a du_* id). func stripeDisputeOnCard(t *testing.T, base, number string, amount float64) map[string]any { t.Helper() tok := mintStripeCardToken(t, base, number) @@ -61,8 +61,8 @@ func TestStripeDisputeSurface(t *testing.T) { ch := stripeDisputeOnCard(t, base, stripeCardNum("4000", "0000", "0000", "0259"), 4400) dpID, _ := ch["dispute"].(string) - if dpID == "" || !strings.HasPrefix(dpID, "dp_") { - t.Fatalf("charge dispute = %v, want dp_*", ch["dispute"]) + if dpID == "" || !strings.HasPrefix(dpID, "du_") { + t.Fatalf("charge dispute = %v, want du_*", ch["dispute"]) } // ===== List: newest first, list envelope, the fresh dispute on top ===== @@ -113,8 +113,8 @@ func TestStripeDisputeSurface(t *testing.T) { } // 404 with the real message. - body, status = getAuth(t, base+"/v1/disputes/dp_nope", devToken) - if status != 404 || !strings.Contains(body, "No such dispute: dp_nope") { + body, status = getAuth(t, base+"/v1/disputes/du_nope", devToken) + if status != 404 || !strings.Contains(body, "No such dispute: 'du_nope'") { t.Fatalf("GET unknown dispute -> %d %s, want 404 with real message", status, body) } @@ -323,8 +323,8 @@ func TestStripeDisputeClose(t *testing.T) { } // 404s keep the real message. - body, status = postJSONAuth(t, base+"/v1/disputes/dp_nope/close", devToken, map[string]any{}) - if status != 404 || !strings.Contains(body, "No such dispute: dp_nope") { + body, status = postJSONAuth(t, base+"/v1/disputes/du_nope/close", devToken, map[string]any{}) + if status != 404 || !strings.Contains(body, "No such dispute: 'du_nope'") { t.Fatalf("close unknown dispute -> %d %s, want 404 with real message", status, body) } @@ -396,7 +396,7 @@ func TestStripeDisBalanceTransactions(t *testing.T) { // 404 with the real resource name. body, status = getAuth(t, base+"/v1/balance_transactions/txn_nope", devToken) - if status != 404 || !strings.Contains(body, "No such balance_transaction: txn_nope") { + if status != 404 || !strings.Contains(body, "No such balance_transaction: 'txn_nope'") { t.Fatalf("GET unknown bt -> %d %s, want 404 with real message", status, body) } @@ -683,7 +683,7 @@ func TestStripeDisRefundCancel(t *testing.T) { // 404 + malformed body. body, status = postJSONAuth(t, base+"/v1/refunds/re_nope/cancel", devToken, map[string]any{}) - if status != 404 || !strings.Contains(body, "No such refund: re_nope") { + if status != 404 || !strings.Contains(body, "No such refund: 're_nope'") { t.Fatalf("cancel unknown refund -> %d %s, want 404 with real message", status, body) } if _, status = stripeGroundPostRaw(t, base+"/v1/refunds/"+re3ID+"/cancel", devToken, `{"refund": `); status != 400 { diff --git a/internal/engine/stripe_groundwork_test.go b/internal/engine/stripe_groundwork_test.go index 367af498..014920d0 100644 --- a/internal/engine/stripe_groundwork_test.go +++ b/internal/engine/stripe_groundwork_test.go @@ -296,8 +296,8 @@ func TestStripeGroundworkDisputes(t *testing.T) { t.Fatalf("dispute-card charge = %v, want succeeded+captured", ch) } dpID, _ := ch["dispute"].(string) - if dpID == "" || !strings.HasPrefix(dpID, "dp_") { - t.Fatalf("charge dispute = %v, want dp_* id", ch["dispute"]) + if dpID == "" || !strings.HasPrefix(dpID, "du_") { + t.Fatalf("charge dispute = %v, want du_* id", ch["dispute"]) } if bt, _ := ch["balance_transaction"].(string); bt == "" || !strings.HasPrefix(bt, "txn_") { t.Fatalf("charge balance_transaction = %v, want txn_* id", ch["balance_transaction"]) @@ -414,8 +414,8 @@ func TestStripeGroundworkPaymentIntentCharge(t *testing.T) { if pich["payment_intent"] != pi["id"] || pich["status"] != "succeeded" { t.Fatalf("PI charge = %v", pich) } - if dp, _ := pich["dispute"].(string); dp == "" || !strings.HasPrefix(dp, "dp_") { - t.Fatalf("PI charge dispute = %v, want dp_*", pich["dispute"]) + if dp, _ := pich["dispute"].(string); dp == "" || !strings.HasPrefix(dp, "du_") { + t.Fatalf("PI charge dispute = %v, want du_*", pich["dispute"]) } if bt, _ := pich["balance_transaction"].(string); !strings.HasPrefix(bt, "txn_") { t.Fatalf("PI charge balance_transaction = %v, want txn_*", pich["balance_transaction"]) diff --git a/internal/engine/stripe_invoices_test.go b/internal/engine/stripe_invoices_test.go index 94d0d3d9..cc9a0d48 100644 --- a/internal/engine/stripe_invoices_test.go +++ b/internal/engine/stripe_invoices_test.go @@ -141,7 +141,7 @@ func TestStripeInvManualLifecycle(t *testing.T) { if inv["subscription"] != nil { t.Fatalf("manual invoice subscription = %v, want null", inv["subscription"]) } - lines, _ := inv["lines"].([]any) + lines, _ := inv["lines"].(map[string]any)["data"].([]any) if len(lines) != 2 { t.Fatalf("draft lines = %d, want 2", len(lines)) } @@ -355,7 +355,7 @@ func TestStripeInvManualLifecycle(t *testing.T) { // Unknown invoice -> the real Stripe 404 message. body, status = getAuth(t, base+"/v1/invoices/in_nope", devToken) - if status != 404 || stripeInvErr(t, body)["message"] != "No such invoice: in_nope" { + if status != 404 || stripeInvErr(t, body)["message"] != "No such invoice: 'in_nope'" { t.Fatalf("missing invoice -> %d; body %s", status, body) } } @@ -603,7 +603,7 @@ func TestStripeInvUpcomingPreview(t *testing.T) { t.Fatalf("upcoming %s = %d, want %d", k, got, want) } } - upLines, _ := up["lines"].([]any) + upLines, _ := up["lines"].(map[string]any)["data"].([]any) if len(upLines) != 2 { t.Fatalf("upcoming lines = %d, want 2", len(upLines)) } @@ -644,7 +644,7 @@ func TestStripeInvUpcomingPreview(t *testing.T) { if got := stripeInvInt(sup, "total"); got != 6270 { t.Fatalf("subscription upcoming total = %d, want 6270", got) } - supLines, _ := sup["lines"].([]any) + supLines, _ := sup["lines"].(map[string]any)["data"].([]any) if len(supLines) != 3 { t.Fatalf("subscription upcoming lines = %d, want 3", len(supLines)) } diff --git a/internal/engine/stripe_subscriptions_test.go b/internal/engine/stripe_subscriptions_test.go index c108d962..d8a7f737 100644 --- a/internal/engine/stripe_subscriptions_test.go +++ b/internal/engine/stripe_subscriptions_test.go @@ -301,7 +301,7 @@ func TestStripeSubMeteredUsage(t *testing.T) { if sub2["latest_invoice"] != inv2["id"] { t.Fatalf("metered latest_invoice = %v", sub2["latest_invoice"]) } - lines, _ := inv2["lines"].([]any) + lines, _ := inv2["lines"].(map[string]any)["data"].([]any) if len(lines) != 1 { t.Fatalf("metered invoice#2 lines = %v", inv2["lines"]) } From bea3e7540c3f05f63796a40f65be060e89bf5b80 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sun, 16 Aug 2026 11:12:06 +0300 Subject: [PATCH 4/4] test(braze): de-flake scheduled-lifecycle race RFC3339 truncation made a now+1s schedule effectively 0-1s out, so the 'before send' read could already see it sent (CI flake). Schedule 2-3s out on a rounded-up boundary and poll to the sent transition instead of sleeping past a guessed deadline. --- internal/engine/braze_style_test.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/engine/braze_style_test.go b/internal/engine/braze_style_test.go index 7301e0f7..26d02208 100644 --- a/internal/engine/braze_style_test.go +++ b/internal/engine/braze_style_test.go @@ -895,8 +895,12 @@ func TestBrazeStyleScheduledLifecycle(t *testing.T) { t.Fatalf("schedule unknown campaign message = %v, want Invalid Campaign ID", resp["message"]) } - // Schedule a real send 1s out. - scheduleTime := time.Now().Add(1 * time.Second).UTC().Format(time.RFC3339) + // Schedule a real send 2-3s out. RFC3339 truncates sub-seconds, so a bare + // now+1s is really 0-1s out — when creation lands late in a second, the + // "before send" read below already sees it sent. Rounding up to the next + // second boundary keeps the upcoming window comfortably wider than the + // two intervening roundtrips. + scheduleTime := time.Now().UTC().Add(2 * time.Second).Truncate(time.Second).Add(1 * time.Second).Format(time.RFC3339) body, status = brazePost(t, base+"/messages/schedule/create", token, map[string]any{ "campaign_id": "cmp001", "external_user_ids": []string{"user001"}, @@ -957,13 +961,26 @@ func TestBrazeStyleScheduledLifecycle(t *testing.T) { } // After the send time, reads derive scheduled -> sent: the broadcast - // leaves the upcoming list and the webhook fires exactly once. - time.Sleep(2200 * time.Millisecond) - for i := 0; i < 3; i++ { + // leaves the upcoming list and the webhook fires exactly once. Poll to + // the transition instead of sleeping past a guessed deadline — under CI + // load the fixed sleep raced the (2-3s-out) send time. + empty := false + for i := 0; i < 80; i++ { body, status = brazeGet(t, base+"/messages/scheduled?"+endTime.Encode(), token) if status != 200 { t.Fatalf("scheduled (post-send read %d) -> status %d; body %s", i, status, body) } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal scheduled post-send: %v (body %s)", err, body) + } + if bcs, ok := resp["scheduled_broadcasts"].([]any); ok && len(bcs) == 0 { + empty = true + break + } + time.Sleep(100 * time.Millisecond) + } + if !empty { + t.Fatalf("schedule never derived to sent: %v", resp["scheduled_broadcasts"]) } if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatalf("unmarshal scheduled post-send: %v (body %s)", err, body)