Found while reviewing #54 (enterprise-api). Pre-existing, not introduced there.
The bug
internal/handlers/webhook.go:503 acks a checkout.session.completed in mode=setup when it carries no org_id, and returns without doing anything.
That was assumed to be the same cross-tenant discard as the subscription branch: foreign traffic from the NaN community, which shares this Stripe account. It is not. cloud-api creates no setup-mode session anywhere (git grep over origin/main: zero matches); enterprise-api/internal/stripe/client.go:404 is the only place on the platform that does.
So every setup-mode event arriving here is ours, and this branch silently discards one of our own. mode == setup routes there on its own, without needing the kind=sepa_switch marker, so a Dashboard-minted mandate falls in too — the same operator path that produced evt_1TVqclBF7qtPjBKwuHL7XlOm.
Why it is worse than "the SEPA switch didn't take"
That was the first read and it understates the tail. The customer completes a SEPA mandate, we drop it, and the subscription keeps its card as the default payment method. Renewals keep charging the card. If the customer then removes the card — reasonably, believing SEPA is now active — the next renewal fails:
invoice.payment_failed → past_due → dunning → keys blocked.
Involuntary churn and a service interruption for a customer who paid and did everything right.
What softens it
billing.go:547-612 (PaymentMethodResponse{Type, Last4, SepaEligible}) reads the customer live from Stripe, so the console still shows card and an attentive customer can see the switch did not take.
- Existing dunning notices fire before keys are blocked.
- It is a pure no-op: nothing is double-charged, nothing is granted wrongly, no bad write.
So it degrades gradually and is recoverable. It is not urgent. It is also not correct.
Fix
Same shape as #54's: distinguish "not ours" from "ours and broken". A setup-mode session with no org_id is by construction ours, so it should not be acked silently — either fail it so Stripe retries and it stays visible as an unprocessed row, or ack it with the Slack notice that branch now raises for unclassifiable subscription sessions.
Prefer resolving the root cause with it: docs/manual-onboarding.md §1.1 now requires metadata[org_id] on the session, which covers the subscription path. The setup path needs the same requirement wherever a mandate is minted by hand.
Acceptance
A SEPA mandate that cannot be bound to an org is never discarded without a trace, and the customer is never left believing a switch took effect when it did not.
Found while reviewing #54 (enterprise-api). Pre-existing, not introduced there.
The bug
internal/handlers/webhook.go:503acks acheckout.session.completedinmode=setupwhen it carries noorg_id, and returns without doing anything.That was assumed to be the same cross-tenant discard as the subscription branch: foreign traffic from the NaN community, which shares this Stripe account. It is not.
cloud-apicreates no setup-mode session anywhere (git grepoverorigin/main: zero matches);enterprise-api/internal/stripe/client.go:404is the only place on the platform that does.So every setup-mode event arriving here is ours, and this branch silently discards one of our own.
mode == setuproutes there on its own, without needing thekind=sepa_switchmarker, so a Dashboard-minted mandate falls in too — the same operator path that producedevt_1TVqclBF7qtPjBKwuHL7XlOm.Why it is worse than "the SEPA switch didn't take"
That was the first read and it understates the tail. The customer completes a SEPA mandate, we drop it, and the subscription keeps its card as the default payment method. Renewals keep charging the card. If the customer then removes the card — reasonably, believing SEPA is now active — the next renewal fails:
invoice.payment_failed→past_due→ dunning → keys blocked.Involuntary churn and a service interruption for a customer who paid and did everything right.
What softens it
billing.go:547-612(PaymentMethodResponse{Type, Last4, SepaEligible}) reads the customer live from Stripe, so the console still showscardand an attentive customer can see the switch did not take.So it degrades gradually and is recoverable. It is not urgent. It is also not correct.
Fix
Same shape as #54's: distinguish "not ours" from "ours and broken". A setup-mode session with no
org_idis by construction ours, so it should not be acked silently — either fail it so Stripe retries and it stays visible as an unprocessed row, or ack it with the Slack notice that branch now raises for unclassifiable subscription sessions.Prefer resolving the root cause with it:
docs/manual-onboarding.md§1.1 now requiresmetadata[org_id]on the session, which covers the subscription path. The setup path needs the same requirement wherever a mandate is minted by hand.Acceptance
A SEPA mandate that cannot be bound to an org is never discarded without a trace, and the customer is never left believing a switch took effect when it did not.