Skip to content

feat: service-block eligibility gate (migrations 038-040)#50

Open
I-am-nothing wants to merge 1 commit into
mainfrom
feat/service-block-gate
Open

feat: service-block eligibility gate (migrations 038-040)#50
I-am-nothing wants to merge 1 commit into
mainfrom
feat/service-block-gate

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

What

A service-block eligibility gate for billing-engine: block an account's services when its payment standing is unsafe. An account is eligible iff all hold:

  1. it has ≥ 1 non-fraud card on file,
  2. its first charge did not fail (go uncollectible), and
  3. its consecutive failed-charge streak is < 2.

Any failing gate blocks. This is the enforcement surface the platform reads to stop delinquent/uncarded accounts from consuming paid service — without it the platform is an unbounded AI broker for free module calls.

Design (mirrors the pure collection policy)

  • internal/account/eligibility — pure Evaluate(Signals) → Verdict, no DB/Stripe/clock, exhaustively table-tested. Grace edges (decided with the product owner): a brand-new account (no charge yet) and one whose first invoice is still retrying pass gate 2; only an uncollectible first charge blocks. Gate 1 (a card) is unconditional. < 2 blocks at a streak of exactly 2.
  • Migrations 038–040 (+ init-db.sql wiring): payment_methods_mirror.fraud_blocked (+reason/flagged_at; defaults non-fraud so the gate works immediately — the fraud-setting webhook is a follow-up), invoices.ever_failed (sticky per-invoice latch), accounts.failed_charge_streak (recoverable counter).
  • Webhook streak maintenance: invoice.payment_failed advances the streak once per distinct invoice (the ever_failed latch dedups Stripe's per-retry events); invoice.paid resets it to 0 (auto-cure — a blocked account self-heals on its next successful charge). The latch + increment run in one transaction so a mid-way failure can't durably under-count (caught in adversarial review — see below).
  • billing.GetServiceStatus RPC (internal-secret): gathers the three signals in one round-trip and returns {blocked, reason, reasons}. No account yet ⇒ blocked on the card gate.

Product decisions baked in

Question Decision
Recovery from a block Auto-cure: streak resets to 0 on the next successful charge
New account, card, no charge yet Graced (eligible) — avoids an arrears bootstrap deadlock
Card required for All usage (0 cards ⇒ blocked)
Fraud detection Column ships now (defaults false); the Radar/dispute webhook that sets it is a follow-up PR

Testing

  • Pure-package table tests (every gate + boundary + grace edge + multi-gate priority).
  • Webhook handler tests (payment_failed → record, paid → reset, drift/error → 500, only-these-events).
  • Integration tests against real Postgres (testcontainers, migrations 038–040 applied): full streak lifecycle (dedup, distinct-invoice increment, reset, idempotent no-op) + ServiceBlockSignals reflecting the fraud-excluded card count and first-charge status.
  • Migrations applied to the local dev DB and the ServiceBlockSignals query smoke-tested against live data.

Reviewed

Ran a 4-dimension adversarial review (charge-logic / idempotency / migration / RPC) with per-finding verification. It caught one real HIGH: the latch+increment were originally two non-atomic autocommits, so a crash between them could permanently under-count the streak and let a should-be-blocked account read eligible. Fixed by wrapping both in a single pgx transaction (WithTx), with an integration test for the lifecycle. The other candidate findings were verified as false positives.

Follow-ups (not in this PR)

  • The radar.early_fraud_warning.created + charge.dispute.created webhook that sets fraud_blocked.
  • The api-platform consumer (client GetServiceStatus + install-time 402 gate) — separate PR against api-platform.

🤖 Generated with Claude Code

Block an account's services when its payment standing is unsafe, per the
product gate: an account is ELIGIBLE iff (1) it has >= 1 non-fraud card on
file, (2) its first charge did not fail, and (3) its consecutive
failed-charge streak is < 2. Any failing gate blocks.

Mirrors the pure-policy posture of internal/account/collection:

- internal/account/eligibility — pure Evaluate(Signals) -> Verdict, no
  DB/Stripe/clock, exhaustive table tests. Grace edges: a brand-new
  account (no charge yet) and one whose first invoice is still retrying
  pass gate 2; only an uncollectible first charge blocks. Gate 1 (a card)
  is unconditional.
- migrations 038-040 (+ init-db wiring): payment_methods_mirror.fraud_blocked
  (+reason/flagged_at, defaults non-fraud so the gate works immediately;
  the fraud-setting webhook is a follow-up), invoices.ever_failed (sticky
  per-invoice latch), accounts.failed_charge_streak (recoverable counter).
- webhook: invoice.payment_failed advances the streak once per distinct
  invoice (ever_failed latch dedups Stripe's per-retry events);
  invoice.paid resets it to 0 (auto-cure). Both idempotent under replay.
- billing.GetServiceStatus RPC (internal-secret) — gathers the three
  signals in one read and returns {blocked, reason, reasons} for the
  platform's gate. No account yet => blocked on the card gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant