fix(subscriptions): prepare for the Stripe webhook version cutover - #21122
fix(subscriptions): prepare for the Stripe webhook version cutover#21122david1alvarez wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds backward-compatible Stripe webhook handling during the API-version transition.
Changes:
- Normalizes previous plan/price webhook shapes.
- Prevents renewal events from triggering upgrade emails.
- Adds Stripe API-version telemetry and subscription tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
stripe-webhook.ts |
Tags legacy webhook telemetry with API version. |
stripe.ts |
Derives previous pricing across Stripe payload shapes. |
stripe.spec.ts |
Tests payload normalization and renewal filtering. |
stripe-webhooks.service.ts |
Tags NestJS webhook telemetry with API version. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| interval: planOldDiff.interval ?? planNew.interval, | ||
| interval_count: planOldDiff.interval_count ?? planNew.interval_count, |
| request.payload, | ||
| request.headers['stripe-signature'] | ||
| ); | ||
| Sentry.setTag('stripe_api_version', event.api_version); |
| Sentry.setTag( | ||
| 'stripe_api_version', | ||
| webhookEventResponse.event.api_version | ||
| ); |
8846b27 to
73ccaf8
Compare
|
Is the check firestore stripe sync script the correct home for this migration? I think this deserves a separate script and shouldn't bear the load of this logic. |
| // Acacia sent the previous price as the top-level `plan`; basil removed | ||
| // that field and reports item changes under `items.data[]` instead — | ||
| // including the billing-period bump on every renewal, so only a moved | ||
| // price id is an actual upgrade. | ||
| // |
There was a problem hiding this comment.
[nit] Mind removing these past-present looking comments? I think these types of comments should live in tickets rather than in code
There was a problem hiding this comment.
Generally agreed, though in this particular case it may be worth keeping despite its verbosity. We're going to be revisiting this to remove the dual-shape-handling code, and having this context at that point may be useful.
Want me to keep it with a // TODO: remove comment alongside code after Firestore-Stripe Sync script run, or to just remove it?
73ccaf8 to
aa3fd5e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical migration race conditions and moderate pagination and billing-cadence issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
packages/fxa-auth-server/lib/payments/stripe.ts:2462
- When the previous price diff omits
recurring, these fallbacks copy the new plan's cadence rather than the old plan's cadence. For a monthly-to-annual change, the upgrade email will therefore describe the previous billing cycle as annual. Resolveintervalandinterval_countfrom the old price ID (for example viafindAbbrevPlanById) instead of falling back toplanNew; the new omission test should assert those old-plan values.
interval: planOldDiff.interval ?? planNew.interval,
interval_count: planOldDiff.interval_count ?? planNew.interval_count,
packages/fxa-auth-server/scripts/update-firestore-acacia-records/update-firestore-acacia-records.ts:250
- A missing subscription is counted but its identifiers are never logged. This breaks the PR's stated reconciliation contract that every non-clean bucket names its record, and an operator cannot scope a follow-up from the completion tally. Emit a warning here containing
customerId,uid, andstripeSubscription.id.
if (!subscriptionDoc.exists) {
this.subscriptionsMissingDoc++;
} else if (isAcaciaShape(subscriptionDoc.data())) {
- Files reviewed: 11/11 changed files
- Comments generated: 3
- Review effort level: Balanced
aa3fd5e to
a7ec259
Compare
Because: * The Stripe webhook endpoint's own api_version sets inbound payload shape, so the version flip has to be revertible without breaking parsing. * Basil removed Subscription.plan, which several consumers still read straight off the Firestore mirror. * The mirror is written only from SDK reads, so it already holds both shapes and the cold docs need a migration of their own. This commit: * Reads the previous price from previous_attributes.plan, items.data[].plan or items.data[].price, and only when the price id moved. * Tags both Stripe webhook receivers with stripe_api_version. * Reads the subscription plan through singlePlan() in subscriptionsToResponse and the admin-server subscription list. * Adds update-firestore-acacia-records, a one-off script that rewrites mirror docs still carrying the top-level fields basil dropped, behind a --dry-run defaulting to true. * Tallies that run so every record lands in exactly one bucket, with the failures named in the log for follow-up. Addresses: PAY-3900
a7ec259 to
e907764
Compare
Because
payloads don't match the shapes our code reads. We need to flip it and be able
to roll back.
Subscription.plan, and three consumers still read it — theupgrade/downgrade email off the webhook payload, and
/v1/accountplus theadmin-panel subscription list off the Firestore mirror.
stripe.*.retrieve, so it has been emittingbasil docs since the SDK pin landed and already holds both shapes. The cold
ones need a migration.
This pull request
previous_attributes.plan(acacia) or thesubscription item's
plan/price(basil), treating an item diff as an upgradeonly when the price id actually moved — basil reports the billing-period bump
on every renewal, so without that guard every subscriber gets an upgrade notice
each cycle.
singlePlan()insubscriptionsToResponseand the admin-server subscription list, retiring an
as anyand a@ts-ignoreover the removed top-level
plan.stripe_api_version, covered on eachside including that it fires only after signature verification.
update-firestore-acacia-records, a one-off script that spots mirrorsubscription and invoice docs still carrying the top-level fields basil
dropped, and rewrites them through
StripeFirestore, behind a--dry-runthatdefaults to true.
check-firestore-stripe-syncis left alone — it stays adrift reporter that nudges Stripe into re-emitting a webhook.
in exactly one bucket, resync outcomes are counted apart from detections, and
each term is counted where it happens rather than derived — so a sum that does
not balance is itself a finding. Every term that is not a clean pass also names
its record in a log line.
Issue that this pull request solves
Addresses: PAY-3900
Checklist
Put an
xin the boxes that applyHow to review (Optional)
planOldinextractSubscriptionUpdateEventDetailsForEmail, and the shape detection plusresync path in
update-firestore-acacia-records/.stripe.tsblock and its spec cases, then the twosinglePlan()call sites, then the new script end to end.planOldis the only thing that sends anupgrade/downgrade email, and the id comparison is what keeps renewals out of
that path. The new script is the migration vehicle for production mirror data,
so its failure modes matter more than its happy path — errors are logged and
swallowed per record rather than failing the run. Its resync calls leave
ignoreErrorsat the default soStripeFirestorethrows: an orphaned invoicesubtree is reachable by our path traversal but invisible to the customer lookup
inside
fetchAndInsertInvoice, and should land in the failed column rather thancount as a write that never happened.
Screenshots (Optional)
Please attach the screenshots of the changes made in case of change in user interface.
Other information (Optional)
version, running the migration, and removing the dual-format code are still
open — hence
Addresses:rather thanCloses:. The endpoint version has norepo artifact; it's a Stripe Dashboard setting.
grep -rn "PAY-3900"returns the complete step-4 deletion list;update-firestore-acacia-recordsgoes wholesale.the mirror's shape follows the SDK pin, so the flip can't pollute it and a
rollback can't leave mixed-shape docs behind. What it does mean is that the
mirror is mixed today, and only records untouched since the SDK pin are stale.
run()usesautoPagingEach, whose page fetches bypass the script's Stripe rate limiter,so
--rate-limitisn't a hard ceiling.payment_methodsmirror docs get noshape check, since basil barely moved that object. A mirror doc missing
altogether is drift rather than an outdated shape, so the new script leaves it
to
check-firestore-stripe-sync.