docs/postmortems/2026-03-16_onboarding-currency-regression.md

Onboarding Zod transform silently broken — web signups assigned wrong checkout currency

2026-03-16 | Rajiv | Resolved

Onboarding Zod transform silently broken — web signups assigned wrong checkout currency

2026-03-16 | Rajiv | Resolved

TL;DR

During a refactor of the onboarding flow, an unobtrusive inconspicuous call to the handleSubmit function of the Vee-Validate form validation library was removed. This function was vital for triggering the Zod transformations on onboarding data, including mapping countries to currencies. This caused all web-signups to get CHF currencies, and get wrong prices presented in the store. This ultimately led to a lower conversion rate for 2 weeks.

Main Mistakes & Lessons

MistakeLesson
PR author deleted existing function calls without understanding the underlying purpose. Red flags could have included that the function was imported from a library the author did not recognize.Don't remove what you don't understand. Unfamiliar library imports are a signal to investigate, not delete.
The original code author failed to recognize the importance of adding inline documentation to snippets using libraries that other team members might be unfamiliar with.Document non-obvious library interactions — especially when a function does more than its name suggests.
Mapping logic lived inside client-side form logic, tightly coupled to the onboarding carousel — a "hot" code path that is frequently touched and refactored.Critical business logic (what currency a user pays in) should not live in a fragile, frequently-changed code path.
Original author underestimated the criticality of this transformation was underestimated.
An integration test would not have realistically caught this, as the failure was silent.Stricter AI code review would have flagged that removing handleSubmit disables the Zod transform chain.

Next steps:

  • Introduce postmortems as a standard practice
  • Take integration of AI code reviews more seriously

Machine-generated structured context below.

Summary

PR #1539 ("fix: onboarding data integrity", merged March 3) removed handleSubmit from the portal onboarding form to implement per-slide validation. This inadvertently disabled the Zod .transform() that derives currency (and other fields) from user input. Since the profile table defaults currency to CHF, ~99% of web signups in DE/NL (and likely all non-Swiss markets) have been assigned the wrong checkout currency for 13 days.

Impact

  • 4,248 confirmed DE/NL web signups received currency = CHF instead of EUR since March 3.
  • These users were charged Swiss prices at Stripe checkout (e.g. 16 CHF/month instead of 12 EUR/month).
  • Pricing experiment contaminated: 32 out of 186 conversions (17%) in the DE/NL experiment period were charged in CHF. Their PostHog revenue_in_eur property records the EUR list price, not what they actually paid.
  • All Zod transforms broken, not just currency:
    • subject_of_studies normalization (economicsbusiness) stopped running.
    • newsletter_opt_in override for Apple/Google OAuth users stopped running.
  • Scope extends beyond DE/NL — any non-Swiss, non-USD web signup in this period got CHF.

Root Causes

The onboarding form used vee-validate's handleSubmit to submit the form. handleSubmit internally runs Zod validation and the .transform() chain, then passes the transformed output to the callback. The PR replaced handleSubmit with a manual function that reads the reactive values object directly. In vee-validate, values contains raw form input — not Zod-transformed output. The transform that computes currency = countryToCurrency(country) became dead code.

Because Supabase's .update() ignores undefined fields, the currency: undefined in the payload simply didn't touch the column, leaving the DB default (CHF) intact.

Trigger

PR #1539 merged March 3, 2026 at 10:58 CET.

Resolution

  1. Code fix: PR #1590 — calls schema.parse(values) before updateProfile so the Zod transform runs. Merged 2026-03-16 09:28 UTC.
  2. Currency mapping inverted: PR #1594countryToCurrency() now lists CHF countries (Switzerland only) and defaults to EUR, instead of listing EUR countries and defaulting to CHF. Merged 2026-03-16 14:37 UTC.

Detection

Discovered during a pricing experiment data integrity audit. Cross-referencing PostHog subscription:create events with Stripe transactions in Metabase revealed 57 DE/NL conversions charged in CHF instead of EUR. Tracing back to the profile currency field exposed the regression.

Action Items

ActionTypeOwnerStatus
Restore Zod transform on final submit (#1590)fixDone
Invert currency mapping to default EUR (#1594)fixDone
Re-run Zod transforms for other broken fields (subject, newsletter)fixTODO
Add monitoring/alert on currency distribution anomaliespreventTODO
Assess financial impact and determine if price-difference refunds are neededmitigateTODO
Clean up experiment data (exclude CHF-currency conversions or annotate)mitigateTODO

Lessons Learned

What went well

  • The pricing experiment audit caught this before experiment results were presented.
  • The Zod transform logic itself was correct — it just wasn't being called.

What went wrong

  • handleSubmit was removed without understanding it was the only thing triggering the Zod transform.
  • CodeRabbit's auto-generated summary ("Currency now auto-selects based on chosen country") was misleading — it described the existing transform as if it were a new feature, masking that it was actually broken.
  • This wasn't realistically preventable by a test — the failure was silent (no error, no crash). A test would only catch it if someone specifically asserted "currency must not be undefined in the update payload", which is an unlikely test to write. Stricter AI code review could have flagged that removing handleSubmit would break the transform chain.
  • No monitoring on the currency distribution of new signups. A simple alert on "% of DE/NL signups with CHF" would have caught this on day one.

Where we got lucky

  • The bug was discovered 13 days in, not months later.
  • The affected users can be precisely identified via _created >= '2026-03-03' AND signup_via = 'web'.
  • The data migration to correct currency is straightforward since country was saved correctly.

Timeline

Time (CET)Event
2026-03-03 10:58PR #1539 merged to main
2026-03-03 ~11:00Regression goes live — web onboarding stops computing currency from country
2026-03-03 → 03-16~4,248 DE/NL web signups (and unknown number of other non-CH signups) receive CHF currency
2026-03-16Discovered during pricing experiment data audit — Stripe transactions in CHF for DE/NL users flagged
2026-03-16Root cause traced to PR #1539 removing handleSubmit
2026-03-16 09:28Fix merged: PR #1590 restores Zod transform on submit
2026-03-16 14:37Fix merged: PR #1594 inverts currency mapping to default EUR

Supporting information

  • PR: https://github.com/studyflash-ai/studyflash/pull/1539
  • Commit: 6a469a89a
  • Affected file: apps/portal/app/pages/onboarding/index.vue
  • DB default: profiles.currency column defaults to 'CHF'::text
  • The countryToCurrency() function in packages/common/src/mappers.ts is correct — it was just never invoked.

Verification query

SELECT country, currency, count(*)
FROM profiles
WHERE country IN ('Germany', 'Netherlands')
  AND _created >= '2026-03-03'
  AND signup_via = 'web'
GROUP BY country, currency;
-- Before fix: ~99% CHF
-- After fix: should show 0 CHF rows