Skip to content

Enterprise SEPA mandates with no org_id are silently discarded, and the card keeps being charged #84

Description

@sre-helmcode

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_failedpast_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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions