feat: service-block eligibility gate (migrations 038-040)#50
Open
I-am-nothing wants to merge 1 commit into
Open
feat: service-block eligibility gate (migrations 038-040)#50I-am-nothing wants to merge 1 commit into
I-am-nothing wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
collectionpolicy)internal/account/eligibility— pureEvaluate(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 anuncollectiblefirst charge blocks. Gate 1 (a card) is unconditional.< 2blocks at a streak of exactly 2.init-db.sqlwiring):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).invoice.payment_failedadvances the streak once per distinct invoice (theever_failedlatch dedups Stripe's per-retry events);invoice.paidresets 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.GetServiceStatusRPC (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
Testing
ServiceBlockSignalsreflecting the fraud-excluded card count and first-charge status.ServiceBlockSignalsquery 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)
radar.early_fraud_warning.created+charge.dispute.createdwebhook that setsfraud_blocked.GetServiceStatus+ install-time 402 gate) — separate PR againstapi-platform.🤖 Generated with Claude Code