Commit d0ec58b
feat(auto-topup): Stripe auto top-up for opted-in users (#448)
* docs(billing): add auto top-up implementation plan
Self-contained design doc covering goal, architecture, Postgres schema,
endpoint topology (dashboard endpoints behind existing billing_auth.rs on
lit-api-server, lit-payments internal-only), trigger handler flow, webhook
handler with raw-body HMAC verification, three-layer concurrency model
(mutex + Stripe Idempotency-Key + Postgres unique constraint), edge cases,
build order, and explicit soft-cap trade-offs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): add implementation phases and local testing to auto top-up plan
Replaces the 9-item build order with an 8-phase breakdown including
dependency chain, per-phase tasks, time estimates, gates, and parallelization
notes. Adds a fully self-contained local development & testing section
covering Postgres via Docker, env var templates for both services, Stripe
CLI webhook forwarding, the full test-card matrix, and per-phase acceptance
checklists. Adds a handoff checklist so an implementing agent can pick up
the doc cold. Records the Stripe Billing Meters alternative as considered
and rejected, with the relevant Stripe doc link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): spec SCA recovery flow for auto top-up
Adds the dashboard resolution path for off-session PaymentIntents that
return authentication_required (3DS/SCA). Previously the plan only said
"user re-authenticates next visit" without describing how. Now spelled out:
- Two new columns on auto_topup_config: pending_action_pi_id, pending_action_at
- New endpoint POST /billing/auto_topup_resume_pending returns the pending
PI's client_secret so the dashboard can call stripe.handleNextAction
- Trigger handler sets the pending fields and sends the "action required"
email when Stripe returns authentication_required
- Webhook handler clears the pending fields on payment_intent.succeeded
or payment_intent.payment_failed for the matching PI
- Three email templates listed (action required, payment failed, auto-disable)
- Phase 6 dashboard tasks updated; Phase 6 gate now includes the SCA path
- New SCA test scenarios added to the local testing checklist
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): rewrite auto top-up plan for webhook-driven architecture
Replaces the internal-trigger architecture with a webhook-driven one,
empirically validated against Stripe test mode. Key changes:
- Trigger source: Stripe customer.updated webhook (filtered by
previous_attributes.balance) instead of an internal HTTP call from
lit-api-server after every deduction.
- lit-api-server is unchanged except for one tiny internal endpoint:
POST /internal/invalidate_balance_cache.
- All dashboard-facing endpoints and webhook handlers live on
lit-payments. Auth uses the existing billing_auth module extracted
from lit-api-server into a shared crate so lit-payments can verify
wallet sig + API key identically.
- Empirical validation included in section 20: 21 balance_transactions
fired against a test customer (slow loop + parallel burst) produced
exactly 21 customer.updated events, 1:1, no coalescing, balance
correct in every payload.
- New 9-phase build plan reflecting the simplified scope (foundation,
auth extraction, setup_intent, config CRUD, trigger handler, credit
webhook, dashboard UI, hardening, rollout).
- Updated local testing section with the new webhook subscription set
and per-phase acceptance criteria.
Net architecture: TEE narrowed to key-usage only. lit-payments owns
all auto-top-up logic. Two new Postgres tables (auto_topup_config,
auto_topup_credits) unchanged from prior plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): switch auto top-up to sync-credit design
Replaces the webhook-driven credit path with synchronous charge-and-credit
inside the customer.updated handler. Reconciler cron becomes load-bearing
as the recovery path for HTTP timeouts and partial writes.
Key changes from prior version:
- Drop payment_intent.succeeded and payment_intent.payment_failed webhooks.
Only customer.updated remains subscribed.
- Sync flow in the trigger handler: paymentIntents.create returns
succeeded synchronously for US cards, then immediately INSERT
auto_topup_credits + balance_transactions credit + cache invalidation.
- Failure handling moves to the synchronous response branches: declined,
expired, insufficient_funds, requires_action all send email and exit.
- Reconciliation cron added (default 15-min interval). Scans recent
succeeded auto-topup PIs and recovers any missing credit rows or
NULL balance_transaction_ids using the same idempotency keys.
- Partial index on auto_topup_credits.stripe_balance_transaction_id IS NULL
for fast reconciler scans.
- Drops SCA recovery flow entirely. US cards only. requires_action treated
as failure with a "card requires authentication" email.
- Schema: removes pending_action_pi_id and pending_action_at columns since
there's no SCA resume flow now.
- New env var RECONCILER_INTERVAL_SECS (default 900).
- Phase 5 becomes the load-bearing webhook+sync-credit phase. New Phase 6
is reconciler implementation with explicit kill-and-recover acceptance
test. Total still ~2 weeks.
Validated by Codex consult: sync credit is defensible under US-card +
no-SCA + accept-rare-manual-recovery constraints, provided idempotency
keys on the credit write and a reconciler are in place. Both are in the
design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): drop recent-PI short-circuit, add dashboard banner to error paths
Removes the 10-minute recent-PI check from the trigger handler per
explicit product preference: never miss a legitimate top-up. The mutex,
fresh balance fetch, and cap check together still prevent duplicate
processing in normal cases. The rare case where customer.balance briefly
lags after a credit could result in an extra top-up, which aligns with
the "bias toward more top-ups, never less" stance.
Also clarifies that requires_action and decline paths send both an
email and a dashboard banner (not email only). Notes in §13 that
the existing one-time top-up flow does support SCA cards because the
user is on-session in the dashboard; only auto-top-up is US-only due
to off-session constraints.
Renumbered trigger-handler steps 8-15 to 8-14 after removing the
recent-PI step. Updated sequence diagram in §16 and phase 5 task
description to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(billing): add SCA recovery flow (EU card support without webhooks)
Adds full SCA / 3DS support for auto-top-up while keeping the
sync-credit + reconciler architecture. The recovery uses on-session
stripe.confirmCardPayment which returns synchronously after the
bank's 3DS challenge — no payment_intent.succeeded webhook needed.
Three-stage SCA story:
1. Save card with stripe.confirmCardSetup (already in Phase 3) creates
the MIT prior-auth record. Most future off-session charges skip 3DS.
2. Off-session charge with confirm=true + off_session=true. Issuer
often grants exemption.
3. When issuer demands SCA anyway: paymentIntents.create errors with
authentication_required. We save pending_action_pi_id + a one-time
recovery_token on auto_topup_config, send the user a tokenized
email link. User clicks link → dashboard recovery page calls
GET /billing/auto_topup_resume?token=... → backend returns the PI's
client_secret → dashboard runs stripe.confirmCardPayment which
renders the bank's 3DS challenge inside Stripe's iframe →
confirmCardPayment returns synchronously → dashboard calls
POST /billing/auto_topup_resume/complete → backend credits via the
normal sync path and clears the pending state.
Schema additions to auto_topup_config:
- pending_action_pi_id, pending_action_at (resume target)
- recovery_token, recovery_token_expires_at (single-use, 24h TTL)
New endpoints on lit-payments:
- GET /billing/auto_topup_resume?token=... (token → client_secret)
- POST /billing/auto_topup_resume/complete (sync credit after 3DS)
Other doc updates:
- Sequence diagrams: setup flow now shows 3DS prior-auth, new SCA
recovery diagram.
- Phase 3 gate now includes SCA test card; Phase 7 gate adds the
recovery flow test.
- Edge cases: authentication_required row now describes the recovery
flow instead of "treated as failure."
- Trade-offs: removed "EU cards not supported" row; added "user
abandons SCA recovery" row.
About 1 day of additional implementation work, mostly the recovery
page and email template. No new webhooks, no architectural changes
to sync + reconciler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(lit-payments): add concise auto top-up architecture section to README
Adds a self-contained summary of the auto top-up design at the top of
lit-payments/README.md: components and their roles, where data lives,
component interaction diagram, left-to-right runtime flow, three-layer
dedup model, and key invariants. Points to plans/auto-top-up.md for the
full detail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auto-topup): phase 1 foundation — schema, env vars, cache-invalidation endpoint
* Postgres migration: auto_topup_config + auto_topup_credits with CHECK
constraint + 2 partial indexes (reconciler null-bt_id scan; SCA
recovery_token lookup).
* lit-payments config: LIT_API_SERVER_BASE_URL, LIT_INTERNAL_SHARED_SECRET,
STRIPE_WEBHOOK_SECRET, optional RECONCILER_INTERVAL_SECS. Outbound
reqwest helper for the Phase 5 cache-invalidation callback.
* lit-api-server: X-Internal-Secret Rocket guard (constant-time compare
via subtle::ConstantTimeEq) + POST /internal/invalidate_balance_cache
endpoint behind it. Public StripeState::invalidate_balance_cache shim.
Tests: 4 Rocket integration tests using rocket::local::asynchronous::Client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auto-topup): phase 2 — shared lit-billing-auth crate + lit-payments wiring
Extracts the BillingAuth Rocket guard into a new lit-billing-auth crate so
both lit-api-server and lit-payments authenticate with identical headers
(EIP-712 wallet signature OR API key). Per-service plumbing is supplied via
an AuthResolver trait pulled from Rocket state.
* lit-billing-auth: new crate, ~600 lines incl. tests. Holds the BillingAuth
enum, the request guard with full CPL-285 / CPL-286 hardening
(rejects precomputed-hash shapes in API-key headers), and the
AuthResolver trait. is_precomputed_hash_shape lives here (self-contained
copy from utils/parse_with_hash.rs, no ApiStatus dep). Behind a default-off
`openapi` feature, ships the OpenApiFromRequest impl for okapi consumers.
* lit-api-server: deletes core/v1/guards/billing_auth.rs (the new crate is
the source of truth). New auth_resolver::LocalAuthResolver bridges the
trait to the existing in-process eip712.rs verifier and the on-chain
accounts::get_billing_wallet_address resolver. Two new internal endpoints
for lit-payments to delegate auth verification:
- POST /internal/verify_wallet_auth
- POST /internal/resolve_api_key
Both behind the X-Internal-Secret guard. 216→223 lib tests (existing
flows still pass — full regression sweep clean).
* lit-payments: new auth_resolver::HttpAuthResolver forwards verification to
lit-api-server over the existing X-Internal-Secret channel — keeps the
EIP-712 verifier and on-chain plumbing in exactly one place (the TEE).
Adds throwaway GET /_authping behind the guard so the wiring is
reachable end-to-end without dashboard work.
Tests added:
* lit-billing-auth: 18 unit + integration tests with a mock AuthResolver
covering the full guard decision tree (valid wallet sig, BadCredentials,
Transient, malformed-header fallthrough, valid API key both Bearer and
X-Api-Key, CPL-285 precomputed-hash rejection in both header positions,
empty key, no headers, missing resolver fails closed).
* lit-api-server: 7 new integration tests on verify_wallet_auth /
resolve_api_key (auth required, success returns identity, BadCredentials
→ 401, Transient → 503).
Architectural deviation from plan §17 (option B in discussion): the
on-chain `allApiKeyHashesToMaster` resolver stays in lit-api-server rather
than moving into the shared crate, since it pulls in TEE-specific
plumbing (signer_pool, chain bindings). lit-payments delegates via the
new internal endpoints instead. Same end-user behavior; avoids ~800 lines
of unrelated code moving into the lean side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 2 P1 — guard resolves api key + classify resolver errors
* feat(auto-topup): phase 3 — POST /billing/setup_intent on lit-payments
Dashboard save-card flow entry point. Behind the lit-billing-auth guard,
returns a Stripe SetupIntent client_secret + publishable_key so the
dashboard can run stripe.confirmCardSetup (3DS prior-auth happens
client-side in Phase 8).
* POST /billing/setup_intent:
- Wallet-sig callers: resolved wallet → find_by_wallet on Stripe.
If no customer: 400 "make a manual top-up first" (deliberate bootstrap
requirement — does not auto-create customers, matches plan §5).
If customer: create SetupIntent (usage=off_session) → return client_secret.
- API-key callers: 501 (Phase 3 ships wallet-sig only; API-key resolver hop
lands in a later phase if needed for dashboard).
- Stripe lookup / SetupIntent failures: 503 (retriable) not 400.
* lit-payments Config gains STRIPE_PUBLISHABLE_KEY (plan §14).
* lit-billing-core::StripeClient pins Stripe-Version: 2020-08-27 globally.
Required because Customer Search needs >=2020-08-27 and the test account's
default was 2020-03-02. Pinning protects against drift from Stripe's
per-account default version.
Tests (3 new, real-Stripe-backed, skip silently if STRIPE_SECRET_KEY absent):
* setup_intent_returns_client_secret_when_customer_exists — uses dedup-safe
ensure_unique_customer helper to handle search-lag duplicate-creation
races. Verifies returned SetupIntent has usage=off_session and points at
the right customer.
* setup_intent_returns_400_when_no_customer — wallet with no Stripe
customer yields the bootstrap error.
* setup_intent_returns_501_for_api_key_caller — explicit unsupported path.
Regression: 47 lit-payments tests + 223 lit-api-server lib + 14 integration +
18 lit-billing-auth + 15 lit-billing-core = 317 tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(auto-topup): phase 4 — GET/PUT /billing/auto_topup_config + validation
Adds the dashboard CRUD endpoints for the per-user auto top-up rule:
* lit-payments/src/auto_topup/{mod,types,db}.rs — Postgres model + queries
for `auto_topup_config`. UPSERT respects the CHECK constraint; the
disable transition NULLs `pending_action_pi_id`, `pending_action_at`,
`recovery_token`, `recovery_token_expires_at` so a re-enable can't
re-trigger a charge against stale SCA state (closes codex gap #15).
* lit-payments/src/billing/auto_topup_config.rs — GET + PUT handlers
behind BillingAuth (wallet-sig only; API-key returns 501 until the
on-chain resolver hop lands in Phase 5).
- Server-side validation: cap >= topup_amount, topup >= $5
(MIN_TOPUP_CENTS), threshold > 0, payment_method_id present, consent
present. DB CHECK constraint covers the same invariants as a
belt-and-braces.
- Cross-tenant guard: GET /v1/payment_methods?customer=cus_xxx
confirms the requested pm_xxx belongs to the caller (closes codex
gap #14).
- Map sqlx error code 23514 → 400 invalid_config; other DB errors → 503.
Tests added (9 new integration tests, real Stripe test mode + real local
Postgres, gated on STRIPE_SECRET_KEY and DATABASE_URL env vars — silent
skip if absent):
- GET returns null when no row exists
- PUT enabled=false roundtrips through GET with disabled_reason='manual'
- PUT enabled=true with all fields persists every column
- PUT enabled=true with cap < topup → 400
- PUT enabled=true with topup < $5 floor → 400
- PUT enabled=true with null threshold → 400
- PUT with pm_xxx owned by a different customer → 400
- Disable transition clears pending_action_pi_id + recovery_token
- API-key caller → 501
Test infrastructure:
* serial_test dev-dep added; all Stripe/DB-touching tests use `#[serial]`
to prevent the search-lag-duplicate-creation race the Phase 4
development cycle exposed.
* `ensure_unique_customer` stopped deleting "duplicate" Stripe customers
— that was producing orphan auto_topup_config rows under
UNIQUE(wallet_address). The helper now accepts whichever id Stripe
search returns first and trusts test order.
* reset_config_row_for_wallet keys cleanup by wallet_address instead of
customer_id so any prior-run orphan row is purged before each test.
Regression: 56 lit-payments tests (47 + 9 new) + 18 lit-billing-auth +
15 lit-billing-core + 223 lit-api-server lib + 14 integration = 326
tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 4 P1 — upsert preserves pending SCA fields on enabled=true
* feat(auto-topup): phase 5 — customer.updated webhook + sync charge + sync credit
Load-bearing trigger phase. Implements the full §6 flow synchronously
inside the webhook request: HMAC verification → quick exits → per-customer
mutex → fresh balance fetch → PI list / failure / cap → off-session
PaymentIntent create → INSERT auto_topup_credits ON CONFLICT DO NOTHING →
balance_transactions write with credit:{pi.id} idempotency key →
UPDATE row with bt_id → fire-and-forget cache invalidation.
* auto_topup/webhook/signature.rs — Stripe-Signature HMAC-SHA256 verifier
with subtle::ConstantTimeEq, ±300s timestamp skew, multi-v1 rotation
support. 11 unit tests.
* auto_topup/webhook/mutex.rs — moka-backed PerCustomerMutex (5-min TTL).
Optimization-only; correctness rests on the Postgres UNIQUE constraint
+ Stripe Idempotency-Keys (plan §11).
* auto_topup/webhook/sca.rs — 32-byte URL-safe recovery token generator
for the SCA `authentication_required` handoff (Phase 7 consumer).
* auto_topup/db.rs — disable_after_failures, set_pending_action,
clear_pending_action, try_insert_credit (ON CONFLICT DO NOTHING),
mark_credit_completed.
* auto_topup/webhook/handler.rs — the POST /stripe/webhook route.
Raw Data handler (NOT Json — HMAC must verify exact bytes), bounded
to 1 MiB. Filters event.type=customer.updated AND
previous_attributes.balance present. Walks recent PIs with metadata
filter client-side, derives consecutive failure count, enforces
monthly cap. Handles paymentIntents.create succeeded / declined /
authentication_required / timeout paths distinctly. SCA path saves
pending_action_pi_id + recovery_token. Returns 5xx on transient
errors so Stripe retries; 200 only after credit commits.
Tests (9 integration tests, real Stripe test mode + local Postgres,
silent-skip when STRIPE_SECRET_KEY / DATABASE_URL absent):
- rejects_tampered_signature → 401
- rejects_stale_timestamp → 401 (codex gap #4)
- ignores_non_customer_updated_event → 200 (cheap reject)
- ignores_event_without_balance_change → 200 (codex gap #3)
- short_circuits_when_config_disabled → no Stripe call
- ignores_when_payload_balance_above_threshold → no Stripe call
- happy_path_charges_and_credits → real off-session PI + credit row
with non-null stripe_balance_transaction_id (codex gap #1 happy)
- replay_of_same_event_is_safe → second delivery doesn't double-credit
(codex gaps #2, #6)
- cap_reached_skips_charge → second top-up refused after cap hit
(codex gap #7)
Plus 19 unit tests on signature, sca, and handler helpers (event-shape
parsing, consecutive-failure derivation, month-spend sum, PI-id
extraction).
Test infrastructure: attach_test_card promoted from
billing/auto_topup_config_tests.rs to billing/setup_intent_tests.rs so
the webhook tests can share it.
Cargo: subtle, thiserror direct deps added; moka "sync" feature enabled.
Regression: 84 lit-payments tests + 18 lit-billing-auth + 15
lit-billing-core + 237 lit-api-server = 354 total, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 5 P1 — config re-read under mutex, stable PI idempotency key, 5xx as Transient
* feat(auto-topup): phase 6 — reconciler tokio task
Tokio task spawned at startup, runs every RECONCILER_INTERVAL_SECS.
Three branches per PI: row+bt_id skip; row+null bt_id retry with the
credit:{pi.id} idempotency key; no row → full credit dance.
Tests: 3 integration tests (orphan PI, partial credit, completed-row
skip) against real Stripe + Postgres.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 6 P1 — reconciler unions enabled+partial customers; age-gate partial retries
* feat(auto-topup): phase 7 — SCA resume endpoints (codex P2 #3 fix folded in)
GET /billing/auto_topup_resume?token=... does a non-consuming lookup,
calls Stripe, then atomically clears the token only after Stripe
succeeds. POST /complete re-fetches PI from Stripe, runs the sync
credit path, clears pending state.
Tests: 6 integration tests including the codex P2 #3 regression
(get_resume_preserves_token_on_stripe_failure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 7 P1 — /complete retries credit when prior row exists with NULL balance_tx_id
* feat(auto-topup): phase 8 — dashboard auto-recharge UI + SCA recovery page
Lands the user-facing surface for Phases 1-7. Matches the OpenAI
Platform auto-recharge UX (per attached design spec) using the
dashboard's existing Bootstrap-style component vocabulary.
* lit-static/dapps/dashboard/auto_recharge.js — new module. Talks
directly to lit-payments (not the legacy api-server client). Surfaces:
- Status banner with four states: not-configured / enabled-healthy
(full description with threshold + topup + cap) / SCA-pending /
auto-disabled-after-3-failures. Banner renders on page load via
GET /billing/auto_topup_config and re-renders after every Save.
- "Auto recharge" modal: toggle, "When balance drops to" + "Restore
balance to" inputs (recharge amount auto-computed as the
difference), monthly cap toggle + amount input, card picker,
consent checkbox. Validates server-side invariants client-side
too (cap >= topup, topup >= $5) for friendlier errors. Save fires
PUT /billing/auto_topup_config.
- Save-card sub-flow: POST /billing/setup_intent, mounts Stripe
Payment Element in setup mode, stripe.confirmSetup handles 3DS
automatically for SCA cards (CPL-329 MIT prior-auth). Returns the
new pm_xxx into the parent modal.
* lit-static/dapps/dashboard/recover_topup.html + recover_topup.js —
standalone SCA recovery page reached from the tokenized email link
(added by the Phase 5 webhook handler when an off-session PI returns
authentication_required). GET /billing/auto_topup_resume returns the
PI's client_secret without consuming the token; stripe.confirmPayment
renders the bank's 3DS challenge in the Stripe iframe; on
succeeded, POST /billing/auto_topup_resume/complete applies the sync
credit and clears the pending state. The recovery_token is
invalidated atomically by lit-payments after the GET's Stripe call
succeeds — codex P2 #3's preserved-on-failure semantics in action.
* lit-static/dapps/dashboard/index.html — adds the auto-recharge
banner host inside the Overview section, the "Auto recharge" topbar
button (visible to authed users), and the two new modals (config +
save-card).
* lit-static/dapps/dashboard/auth.js — adds getLitPaymentsBaseUrl()
alongside getBaseUrl(). Dev: http://localhost:8001 (lit-payments now
moves to 8001 to leave 8000 for lit-api-server, matching the
dashboard's existing baseUrl assumption). Production: build-time
__LIT_PAYMENTS_BASE_URL__ substitution, mirroring __LIT_API_BASE_URL__.
* lit-static/dapps/dashboard/billing.js — exports getWalletAuthHeader()
so the new auto-recharge module reuses the existing EIP-712
wallet-sig builder. Zero behavioral change.
* lit-static/dapps/dashboard/styles.css — appends auto-recharge banner +
modal styles. Uses the existing --primary / --success / --danger
tokens; matches the .modal-* / .btn-* vocabulary already in the
dashboard. Custom toggle switch in the modal header.
* lit-payments/Cargo.toml + src/main.rs — adds rocket_cors so the
dashboard (different origin / port) can call the new /billing/*
endpoints. Same `AllowedOrigins::all()` shape lit-api-server uses.
* lit-billing-auth/src/guard.rs — DEV-ONLY bypass: when
LIT_DEV_WALLET_BYPASS=1 is set and the request carries
`X-Dev-Wallet: 0x...`, the guard accepts it as a synthetic
`BillingAuth::WalletSigned`. Used by the QA harness to drive the
dashboard end-to-end without lit-api-server (which owns the on-chain
resolver) running. Logs a clear `tracing::warn!` so the bypass is
visible. The env var is read at request time, never cached.
Browser QA (verified against real Stripe test mode + real local
Postgres via gstack browse + 8001/8002 dev ports):
* Banner empty state: "Set up" CTA.
* Modal opens, pre-populated from DB row (real Stripe customer +
attached pm_card_visa).
* Save → PUT /billing/auto_topup_config → row updated in DB → banner
re-renders to "Modify" with full description.
* Banner SCA-pending state: red left border, "Action required",
"Manage" CTA. (Seeded by UPDATE auto_topup_config SET
disabled_reason='requires_action', pending_action_pi_id='pi_test',
recovery_token='qa-token').
* Banner auto-disabled-after-failures state: "Auto recharge paused".
* Save-card modal mounts Stripe Payment Element (visible card form,
Stripe Link option, country/ZIP).
* Recovery page (/recover_topup.html?token=...) successfully calls
Stripe and mounts the Payment Element bound to a real PI's
client_secret.
* Recovery token single-use verified live: reload after first call
returns 404 "Recovery link is invalid or expired." DB row shows
recovery_token = NULL, pending_action_pi_id intact.
Lit-payments regression: 94 lib tests pass (was 92 before; +2 for the
Phase 8 P1 #2 pending-action regression test from the codex-fixes
commit). lit-billing-auth: 18 pass. lit-billing-core: 16. lit-api-server:
223 lib + 14 integration. Total backend: 365 tests, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): phase 8 P1 — tighten CORS to exact-match allowlist + dev port doc
* fix(auto-topup): glitch PR review fixes + Phase 7 idempotency expiry + clippy
Addresses glitch003's review on PR #448 (the 4 items in scope this
round) plus two related correctness items and the clippy lints that
were blocking CI on all 8 phase PRs.
* **Feature-flag the dev wallet bypass** (`lit-billing-auth/src/guard.rs`).
`LIT_DEV_WALLET_BYPASS` + `X-Dev-Wallet` is now gated behind
`#[cfg(debug_assertions)]`. `cargo build --release` strips the code
entirely; the env var becomes inert in prod builds. Removes the
runtime-flag liability glitch and codex both flagged as security-audit
bait.
* **Wire SCA recovery email** (`auto_topup/webhook/handler.rs`).
`handle_sca_required` now fetches the customer's email from Stripe
and dispatches the tokenized recovery link via `Mailer` (Resend).
Fire-and-forget — a Resend outage logs loudly but doesn't fail the
webhook (Stripe would otherwise retry and stage a second pending PI).
Without this the 3DS challenge would sit forever and auto top-up
would be permanently paused, which is what the engineer was worried
about for EU cards.
* **DB-driven reconciler partial repair** (`auto_topup/reconciler.rs`,
`auto_topup/db.rs::list_partial_credit_rows`). New Pass A walks
`auto_topup_credits WHERE stripe_balance_transaction_id IS NULL`
directly — no Stripe time-window scan — and replays the
balance_transactions write per row. Stripe is hit by PI id, which has
no lookback restriction. Pass B (the legacy 7-day list scan) is kept
only for the rarer "PI succeeded at Stripe but service crashed before
DB INSERT" case. Pre-fix, any partial older than 7 days was stranded
forever.
* **Max-topup cap + topup>=threshold** (`billing/auto_topup_config.rs`,
new migration `20260608000001_auto_topup_max_topup.sql`).
`MAX_TOPUP_CENTS=20000` ($200). Off-session charging makes a hard
ceiling per-charge meaningful: a compromised account or corrupt row
is now bounded per-charge in addition to per-month. Requiring
`topup >= threshold` ensures one charge actually brings the balance
back above threshold — otherwise the next `customer.updated` would
immediately fire another charge. DB CHECK constraint mirrors the
handler validation.
* **Phase 7 idempotency-key expiry guard** (`billing/sca_resume.rs`).
Stripe drops a given `Idempotency-Key` after 24h, so reusing
`credit:{pi_id}` after that window posts a new balance_tx instead of
hitting dedup. Two layers: refuse the retry path with HTTP 409
`partial_credit_too_old` if `credited_at` is >=23h, and just before
posting re-check the row in case a concurrent path already completed
it.
* **Delete `_authping` route** (`lit-payments/src/main.rs`). Phase 2
throwaway. Codex P3.
* **Clippy + fmt fixes blocking CI**:
- `db.rs` doc_lazy_continuation (rewrote a `+ reconciler,` continuation
that clippy interpreted as a list item)
- `webhook/handler.rs` identity_op (`1 * 1024 * 1024` → `1024 * 1024`)
- `webhook/handler.rs` collapsible_if (merged the pre-mutex quick-exit
`if let Some {...} { if ... }` into a single `if let && ...`)
- `billing/auto_topup_config.rs` manual_range_contains (rewrote the
`topup` validation as `!(MIN..=MAX).contains(&topup)`)
- `billing-auth/guard.rs` needless_lifetimes (`extract_api_key` lost
its explicit `'r`)
- `webhook/handler.rs` too_many_arguments allow attribute on
`process_event` (8 params, all distinct — splitting into a struct
is the next refactor pass)
CI was failing on all 8 phase PRs at the `cargo clippy --locked
--all-features -- -D warnings` step on `lit-payments` and
`lit-billing-auth`. After this commit: `cargo test --lib` clean (94 +
18), `cargo clippy --locked --all-features -- -D warnings` clean for
both crates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): cargo fmt — lit-api-server/auth_resolver.rs
* chore(deny): ignore RUSTSEC-2026-0173 (proc-macro-error2 unmaintained, transitive)
* chore(deny): ignore RUSTSEC-2026-0174 (http-types 2.12 transitive in lit-actions)
* fix(auto-topup): 11 codex P1 follow-ups across phases 2-8
Apply 11 P1 fixes flagged by an independent codex review of PR #448.
All landed atomically in this commit so the PR's invariants hold.
Tier A — security / plan-spec compliance:
1. setup_intent + auto_topup_config: API-key callers used to 501.
Plan §5 says these endpoints sit "behind the shared auth module"
which already verifies API keys via resolve_api_key. The handler
now re-resolves the key through the same AuthResolver state (1h
cache on api-server) to pull the wallet and continues the normal
flow. Tests updated: the old "501 for ApiKey" assertions now
verify the API-key path lands at the same status as the
wallet-sig path (200 or 400 no_stripe_customer).
2. internal/guard.rs: constant-time compare must not short-circuit
on length mismatch (§15 violation). Pad both inputs to the
longer length with 0u8, ct_eq the equal-length buffers, then &
with a length-equality Choice. Added 5 unit tests covering
equal, content-diff, length-diff, both-empty, one-empty.
Tier B — correctness gaps:
3. SCA recovery URL: use /recover_topup.html (explicit) instead of
/recover_topup so the link works regardless of host-side
extension stripping.
4. reconciler::repair_partial_credits: add 23h MAX_PARTIAL_RETRY_AGE
cap mirroring the /complete endpoint's guard. Prevents Stripe
idempotency-key TTL expiry from posting a duplicate balance_tx.
5. reconciler::repair_partial_credits: cross-verify Stripe PI
`customer` and `amount` against the DB row before crediting.
Defence against stale / tampered DB rows.
6. handle_sca_required: if the SCA recovery email fails to send,
roll back the pending state in the DB so the next webhook tick
re-stages SCA from scratch. Pre-fix the user was stranded — no
link, and webhook short-circuited on pending_action_pi_id.
7. reconciler::run_once Pass B: union enabled customers with
recently-active (last 7d) customers via new
db::list_recently_active_customers. Catches the "off-session PI
charged but service crashed pre-INSERT and auto-disabled
flipped" case which was previously invisible.
8. db::upsert: clear pending_action_pi_id / recovery_token et al
when the PUT changes payment_method_id, so a card switch
mid-SCA doesn't leave a stale pending PI blocking new charges.
9. New migration 20260609000001_auto_topup_disabled_reason_check:
CHECK constraint enumerating the four valid disabled_reason
values ('manual', 'failures', 'card_invalid', 'requires_action').
10. month_spend_cents: exclude requires_action PIs from the monthly
cap calculation. They're abandoned SCA challenges, not spend;
pre-fix a single 3DS drop-off could exhaust the budget without
any successful charge.
Tier C — fail-closed:
11. lit-api-server internal/config.rs: panic at startup in
production builds if LIT_INTERNAL_SHARED_SECRET is missing or
empty. Test / debug builds still return None so local cargo run
/ cargo test continue to work without the var set.
Tests:
- lit-payments lib: 95 passed
- lit-billing-auth lib: 18 passed
- lit-api-server internal:: 16 passed
cargo fmt + clippy --lib clean on all three crates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(billing-auth): collapse HTTP-hop into shared crate (glitch follow-up) (#466)
* refactor(auto-topup): move EIP-712 verifier into lit-billing-core
Glitch's PR #448 review flagged that lit-payments hits lit-api-server
over HTTP just to verify a wallet signature when the verifier is pure
Rust with no on-chain dependency. Folding it into the shared crate lets
both services call it in-process.
What moved:
- `lit-api-server/src/core/eip712.rs` (full implementation, ~25 tests)
→ `lit-billing-core/src/eip712.rs`
- `verify_eip712_signature` now takes `chain_id: u64` as a parameter
instead of reading lit-api-server's `GLOBAL_NODE_CONFIG`; each service
supplies its own chain_id.
- Replaces lit-api-server-specific `ApiStatus` returns with a new
`Eip712Error` enum (BadRequest / Internal) so the shared crate has no
knowledge of Rocket response types.
Adapter on lit-api-server side: `core::eip712` is now a thin wrapper
that reads chain_id from GLOBAL_NODE_CONFIG and translates Eip712Error
back to ApiStatus, so existing callers in `account_management.rs` keep
their signatures unchanged.
Cites glitch's comment on `lit-api-server/src/internal/routes.rs:90`
(/internal/verify_wallet_auth should not exist).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(auto-topup): add OnChainBillingResolver to lit-billing-core
Lifts the lit-api-server `get_billing_wallet_address` lookup into the
shared crate so lit-payments can resolve API key → billing wallet in
process instead of via the `/internal/resolve_api_key` HTTP hop.
To avoid making the shared crate depend on lit-api-server's 23k-LOC
`sol!`-generated AccountConfig bindings, the resolver hand-rolls the ABI
encoding for `getBillingWalletAddress(bytes32)`:
- selector = keccak256("getBillingWalletAddress(bytes32)")[..4]
- argument = 32-byte keccak256(key) (or pass-through for precomputed hash)
- response = 32-byte left-padded address
Each service constructs its own `OnChainBillingResolver` from its own
env vars — `lit-api-server` from `GLOBAL_NODE_CONFIG`, `lit-payments`
from `ALCHEMY_HTTPS_URL` + a new `LIT_BILLING_ACCOUNTS_CONTRACT` env.
The resolver is dependency-light by design — no global state, no signer
pool, just `reqwest` + bare JSON-RPC. Matches the established lit-payments
LITKEY pattern (`chain.rs::HttpGatewayRpc`).
Cites glitch's comment on `lit-api-server/src/internal/routes.rs:107`
(/internal/resolve_api_key should not exist) and the comment on
`lit-payments/src/auth_resolver.rs:99` (HttpAuthResolver should be
replaced with a direct in-process call).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(auto-topup): fold lit-billing-auth into lit-billing-core
Glitch's PR #448 review correctly flagged that maintaining
`lit-billing-auth` as its own crate added ceremony with zero payoff:
- Used by exactly two callers (lit-api-server, lit-payments) — the same
two consumers of `lit-billing-core`.
- The auth pieces and the Stripe pieces always ride together in any
consumer; no third caller has ever needed just the auth half.
What moved:
- `lit-billing-auth/src/lib.rs::is_precomputed_hash_shape`
→ moved earlier to `lit-billing-core/src/on_chain.rs`, re-exported
from `billing_auth::` for backward-compatible import paths.
- `lit-billing-auth/src/guard.rs` (BillingAuth Rocket guard, tests)
→ `lit-billing-core/src/billing_auth/guard.rs`
- `lit-billing-auth/src/resolver.rs` (AuthResolver, ResolvedIdentity,
WalletAuthPayload, AuthError)
→ `lit-billing-core/src/billing_auth/resolver.rs`
Cargo plumbing:
- `lit-billing-core` gains an `openapi` feature flag (was on
lit-billing-auth) plus rocket / base64_light / rocket_okapi /
schemars deps. async-trait and thiserror added (used by resolver).
- `lit-api-server` drops `lit-billing-auth` dep; enables `openapi`
feature on `lit-billing-core`.
- `lit-payments` drops `lit-billing-auth` dep.
- The whole `lit-billing-auth/` directory is removed.
All consumer source imports rewritten from `lit_billing_auth::*` to
`lit_billing_core::billing_auth::*`. All tests passing on lit-billing-core
(61 tests including the 13 guard tests previously in lit-billing-auth).
Cites glitch's comment on `lit-billing-auth/src/lib.rs:1`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(auto-topup): replace HttpAuthResolver with in-process LocalAuthResolver
The final piece of glitch's PR #448 architectural concern. With both
the EIP-712 verifier and the on-chain billing-wallet lookup now in the
shared `lit-billing-core` crate, lit-payments no longer needs an HTTP
hop to lit-api-server for either operation.
Changes:
- `lit-payments/src/auth_resolver.rs` rewritten: `HttpAuthResolver` →
`LocalAuthResolver`. Same `AuthResolver` impl shape as lit-api-server's
local resolver — same primitives (`eip712::verify_eip712_signature` +
`on_chain::OnChainBillingResolver`), same error classification
(`BadRequest` → `BadCredentials`/401, `Internal`/`Transient` → 503),
so behaviour is identical across both services.
- `lit-payments/src/config.rs` gains three required env vars:
- `LIT_ACCOUNTS_RPC_URL` — RPC endpoint for the AccountConfig chain
- `LIT_ACCOUNTS_CHAIN_ID` — chain id for EIP-712 signature pinning
- `LIT_ACCOUNTS_CONTRACT_ADDRESS` — deployed AccountConfig address
(Same values lit-api-server reads from NodeConfig.toml; lit-payments
has no equivalent file-based config so they come via env.)
- `lit-payments/src/main.rs` constructs `LocalAuthResolver` from the
parsed config. Removed `HttpAuthResolver::new` call.
- `LIT_API_SERVER_BASE_URL` is intentionally kept — still used by the
post-credit cache-invalidation callback to `/internal/invalidate_balance_cache`.
- Test fixtures updated to include the three new Config fields.
- README updated with the new env var block and a note that the old
`LIT_API_SERVER_BASE_URL` is now scoped to cache invalidation only.
All 95 lit-payments lib tests passing.
Cites glitch's comment on `lit-payments/src/auth_resolver.rs:99`
(HttpAuthResolver should be a direct in-process call).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(billing-auth): drop /internal HTTP auth endpoints (glitch PR review)
Addresses glitch003's architectural feedback on PR #448 by completing
the in-process refactor: lit-payments no longer reaches into lit-api-server
to verify wallet signatures or resolve API keys.
Removed:
- POST /internal/verify_wallet_auth — replaced by direct call into
lit-billing-core::eip712 verifier (pure signature recovery, no
TEE-bound state — no reason for an HTTP hop).
- POST /internal/resolve_api_key — replaced by direct call into
lit-billing-core on-chain resolver (just reads a public contract
mapping).
- Associated request/response DTOs, MockResolver, and 7 route tests.
Preserved:
- POST /internal/invalidate_balance_cache — genuinely cross-service
(lit-payments needs to nudge lit-api-server's in-memory Stripe
balance cache after a sync credit).
- The X-Internal-Secret guard, still used by the cache-invalidation
call.
Earlier commits in this branch (b9e164a7..HEAD) already:
- Moved the EIP-712 verifier into lit-billing-core::eip712
- Moved the on-chain resolver into lit-billing-core::on_chain
- Folded lit-billing-auth crate into lit-billing-core::billing_auth
- Replaced HttpAuthResolver in lit-payments with LocalAuthResolver
Net result: one less HTTP hop per authenticated lit-payments request,
one less shared-secret surface for auth, one less crate.
Tests: lit-billing-core 61 / lit-api-server 198 / lit-payments 95
all passing. cargo fmt + clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(billing-auth): on-chain selector — uint256 not bytes32 (codex P1)
The Solidity contract declares `getBillingWalletAddress(uint256)` with
selector 0x7249a9b6 (see lit-api-server's alloy-generated binding at
accounts/contracts/account_config_contract.rs:1676 / :11815). The
refactor introduced in PR #466 was computing the selector from
keccak256("getBillingWalletAddress(bytes32)"), which produces a
different 4-byte hash. The EVM dispatcher would not match it and
every API-key resolve call from lit-payments would revert →
collapsing to "no wallet address" / transient errors → API-key auth
totally broken on lit-payments after this PR merges.
Wire encoding of the argument is identical between uint256 and
bytes32 (both 32 bytes big-endian), so only the selector string
needed to change. No calldata layout change.
Caught by codex post-refactor review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): UI polish + brand/last4 + optional cap + SCA fixes
UX polish from manual browser testing:
* $1 step on threshold/restore/cap inputs (was $0.01)
* Removed "~" tilde from monthly cap display in the on-banner
* Cap-toggle row visually grays out (opacity 0.45) when unchecked +
bigger 18×18px checkbox with indigo accent
* Modal card picker shows "Visa •••• 4242" instead of leaking
pm_xxxxx — new `GET /billing/payment_method?pm_id=…` endpoint
fetches brand+last4 from Stripe; AutoTopupConfigRow carries them
in the existing config GET response. Frontend looks them up right
after confirmCardSetup so the new card shows brand/last4 immediately.
Validation relaxations (matching OpenAI/Anthropic UX):
* Drop the `topup_amount >= threshold` check — restored as a hard
constraint by an earlier codex round but it rejects perfectly
reasonable configs like "drops below $20, restore to $30" (top-up
$10). Migration `20260610000001` drops the DB CHECK clause too.
* Make `monthly_cap_cents` truly optional. Handler validation +
webhook step 8 cap check both treat None as "no monthly ceiling"
(per-charge MAX_TOPUP_CENTS still bounds individual charges).
Migration `20260610000002` adjusts the DB CHECK to allow NULL cap.
Frontend sends null when the toggle is off.
SCA recovery fixes:
* `recover_topup.js` now uses `stripe.handleNextAction({clientSecret})`
instead of mounting a fresh Payment Element + `confirmPayment`.
The PI already has a card attached — we just need to trigger the
bank's 3DS challenge, not collect new card details. Codex flagged
this in an earlier per-phase review.
* Stale-pending escape hatch in the webhook handler: if
`pending_action_pi_id` is set AND `recovery_token_expires_at` is
in the past, presume the user never completed 3DS (lost email,
bank timeout, etc.) and clear pending state so the next
customer.updated can fire a fresh SCA cycle. Without this, an
expired recovery handoff permanently froze auto top-up with no
operator-free recovery path. New `db::clear_pending_action_force`
helper (unconditional clear, used only by the webhook escape hatch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): SCA email — show amount, card, merchant + stale-pending escape hatch
Two follow-ups from manual browser QA:
1. SCA recovery email body was anonymous (no amount, no card, no
"from"). Reworked to show:
- Subject: "Action required: confirm your $X Lit Protocol auto top-up"
- Body: amount, "Card: Visa •••• 4242", styled call-to-action button
- New `fetch_pi_details(stripe, pi_id)` helper expands `payment_method`
on the PI retrieve so we get brand/last4 in one Stripe call
- New `prettify_brand()` matches the dashboard's `prettyBrand` JS
helper for consistent display ("visa" → "Visa")
2. Stale-pending escape hatch in the webhook handler. If
`pending_action_pi_id` is set AND `recovery_token_expires_at` is in
the past, presume the user never completed 3DS (lost email, bank
timeout, etc.) and clear pending state via the new
`db::clear_pending_action_force` helper, then continue processing
the current event to fire a fresh SCA cycle. Without this, an
expired recovery handoff permanently froze auto top-up — no
operator-free recovery path. Stripe research confirms:
`requires_action` PIs do NOT auto-cancel; need to either prompt
user on-session or cancel + create a new PI. We do the latter via
"ignore the old one + create new" — Layer 2 (proactively cancel
the old PI) is deferred.
CI checks all green locally before push:
- cargo fmt --all -- --check: clean (4 crates)
- cargo clippy --locked --all-features -- -D warnings: clean (4 crates)
- cargo test --lib: lit-payments 95, lit-billing-core 61,
lit-api-server 198 — all passing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): stale-pending escape hatch also checks Stripe PI status
The previous escape hatch only triggered when `recovery_token_expires_at`
was in the past (>24h since SCA was staged). That left a real
prod-reachable bug: any time Stripe moves the PI out of
`requires_action` *without* going through our `/complete` endpoint
(bank rejects 3DS, user cancels the popup, card expires between PI
create and email click, Stripe internal timeout, admin cancel via
dashboard, etc.), our DB still thinks the PI is pending but
`handleNextAction` errors with "PI not in requires_action."
Customer was then permanently frozen with no operator-free recovery.
Fix: when `pending_action_pi_id` is set, GET the PI from Stripe and
treat any non-`requires_action` status as a signal to clear our
pending state and let the current webhook fire a fresh SCA cycle.
Costs one extra Stripe call per webhook for customers that actually
have pending state — bounded and only on the slow path we'd otherwise
short-circuit anyway.
If the Stripe call itself fails, leave pending as-is and try again
next tick — don't escalate (webhook should still 200).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): SCA recovery — use confirmCardPayment, not handleNextAction
Empirical: when Stripe gets an off-session `paymentIntents.create` for
a card that requires 3DS, it can't authenticate off-session, so it
returns the error AND moves the PI directly to `requires_payment_method`
(with `last_payment_error.code = authentication_required`) — NOT
`requires_action`. The PI never sits in `requires_action` waiting
for handleNextAction.
handleNextAction therefore errors with "PaymentIntent supplied is not
in the requires_action state" the moment the user clicks Confirm.
Correct primitive: `stripe.confirmCardPayment(clientSecret)`. It
re-confirms the existing PI with its already-saved payment method
on-session, runs Stripe.js's 3DS challenge UI, and moves the PI
through requires_action → succeeded. The saved card stays attached;
no new card entry.
Note: we still don't mount a Payment Element — the PI already has the
card on it, and re-confirming just needs the client_secret. The old
"mount Payment Element + confirmPayment" version was the one that
broke things by submitting empty card data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): SCA recovery — pass saved pm_id to confirmCardPayment
When an off-session paymentIntents.create returns authentication_required
for an SCA card, Stripe DETACHES the payment_method from the PI as it
moves it to `requires_payment_method`. Subsequent on-session
confirmCardPayment(clientSecret) alone errors with "A payment method
of type card was expected to be present, but this PaymentIntent does
not have a payment method and none was provided."
Fix: pass the saved pm_id explicitly via
`confirmCardPayment(clientSecret, { payment_method: pm_xxx })`. The
backend's GET /billing/auto_topup_resume response now carries the
customer's `payment_method_id` from our config row alongside the
existing client_secret + publishable_key.
CI checks all clean locally before push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auto-topup): SCA escape hatch only fires on terminal PI or 24h token expiry
Previous version cleared pending state on ANY non-`requires_action` PI
status. But `requires_payment_method + last_payment_error =
authentication_required` is the LIVE state the recovery email + the
`/recover_topup.html` page are built to handle (off-session SCA
failure detaches the payment_method, PI lands there). Clearing it on
every subsequent `customer.updated` invalidated the in-flight email
and re-sent a duplicate one each time the user ran a Lit Action —
verified by manual QA where two balance adjustments produced two
emails before completion.
Narrowed: only clear when the PI is `canceled` / `succeeded`
(terminal) OR the recovery token is past its 24h expiry. Everything
else (including the off-session SCA failure state) is treated as
live and left alone. Worst-case: a truly-abandoned recovery sits up
to 24h before auto-clearing. No fix for the "user never returns"
case beyond the 24h bound — acceptable.
CI checks clean locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent d182753 commit d0ec58b
64 files changed
Lines changed: 14639 additions & 771 deletions
File tree
- lit-api-server
- src
- core/v1
- endpoints
- guards
- internal
- lit-billing-core
- src
- billing_auth
- lit-payments
- migrations
- src
- auto_topup
- webhook
- billing
- internal
- lit-static/dapps/dashboard
- plans
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
46 | | - | |
| 47 | + | |
47 | 48 | | |
48 | 49 | | |
49 | 50 | | |
| |||
76 | 77 | | |
77 | 78 | | |
78 | 79 | | |
| 80 | + | |
79 | 81 | | |
80 | 82 | | |
81 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
4 | 3 | | |
5 | 4 | | |
6 | 5 | | |
7 | 6 | | |
8 | 7 | | |
9 | 8 | | |
10 | 9 | | |
| 10 | + | |
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| |||
0 commit comments