Commit Graph
2 Commits
Author SHA1 Message Date
Evan JarrettandClaude Opus 5 fa6473a896 appview/holdclient: cover the tier fan-out itself, and its failure path
The existing tests covered updateCrewTierWithRetry and UpdateCrewTierOnHold.
UpdateCrewTierOnAllHolds -- the function the Stripe webhook actually calls, and
whose error decides whether a paid upgrade is retried or dropped -- had none.

Three cases: the joined error names every failing hold and not the one that
succeeded; a hold that accepts and never answers does not starve the holds
after it (mutation-verified by making the fan-out serial, which leaves the
healthy hold contacted zero times); and a context deadline aborts the retry
loop rather than running to tierUpdateMaxAttempts.

That last one records a real mismatch rather than an intent. Three attempts at
a 5s client timeout need ~15s, and the webhook allows the whole fan-out 10s, so
under a hang the budget funds two attempts and never three -- confirmed against
a blackholed hold on the dev stack, which failed at exactly 10.0s with a bare
context error rather than the "after N attempts" wrapper. If either constant or
the deadline moves, that test is where the arithmetic gets re-checked.

Also covers the other half in pkg/billing: a fan-out failure has to reach
Stripe as a 5xx and leave stripe_processed_events empty. A hold that is briefly
down otherwise costs the customer their tier permanently -- the same shape of
loss as the customer-lookup hole, one layer further out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwxF2N3HuZ8xSkx6nkirgB
2026-08-25 16:34:26 -05:00
Evan JarrettandClaude Opus 5 4b9d4bcbeb billing: retry a failed customer lookup instead of dropping the subscription
getCustomerDID returned "" for a FAILED customer.Get exactly as it does for a
customer carrying no user_did. handleSubscriptionChange read that as "not our
customer" and returned nil, so HandleWebhook recorded the event as processed
and answered 200. Stripe never redelivered. A transient Stripe API error
therefore dropped a paid upgrade permanently — the precise "paid but never
received tier" hole 12c55ed was written to close, left open one level down.

It now returns (string, error) so the two cases are distinguishable, and only
the subscription path propagates it. The invoice-failed and dispute handlers
use the DID for logging alone, so a lookup failure there is not worth failing a
webhook over and they ignore it deliberately.

Also fixed: handleSubscriptionChange dereferenced sub.Customer.ID four lines
after an ordering guard that explicitly checks sub.Customer != nil. Confirmed a
real panic, not a theoretical one — the new test panics against the old code.

Five tests, all mutation-verified, and all of them new ground: pkg/billing had
one test file and test/stripe-integration builds its manager with a nil
database, so every `m.db != nil` branch — which is all of the idempotency and
ordering work — was dead there. These use a real database.

The idempotency and ordering tests assert on whether Stripe was CALLED again,
not on row counts. That distinction matters: RecordStripeEvent is an idempotent
upsert, so deleting either guard outright leaves the table looking identical
and a row-count assertion passes. Counting API calls is the only thing that
separates "short-circuited" from "re-applied". My first draft got this wrong
and passed against both mutations.

Makefile: `make test` never ran any of this. The only -tags billing in the file
was a build line, so gate_test.go and checkout_gate_test.go had never executed
in the default target or in CI, and `make lint` never linted the package
either. Both now do; both are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
2026-08-25 16:34:26 -05:00