Payment Flow
How money moves in Odys — PIX for client-to-professional (off-Stripe), Stripe Checkout for professional-to-Odys (subscription).
Payment Flow
Odys has two entirely separate payment flows. Understanding the split is key to reading the code.
Flow 1 — Client pays professional (PIX, off-Stripe)
The Brazilian-native channel for session payments. Stripe is not involved.
- The professional sets their PIX key in
/dashboard/settings. - On a confirmed appointment with
paymentType: "upfront", the booking widget renders a<PixQR>component — an EMV BR Code payload generated bysrc/lib/pix.ts. Pure-TS implementation: no external dependencies, inline CRC-CCITT (polynomial0x1021, initial0xffff), ID-length-value fields per Banco Central's "Manual de Padrões para Iniciação do Pix". - The customer scans the QR with their Brazilian bank app and pays bank-to-bank, directly to the professional.
- The professional manually marks the appointment
paidfrom the dashboard — PATCH/api/appointments/[id]withaction: "paid"— which updatesappointments.paymentStatustocaptured.
Money never touches Odys's infrastructure or Stripe's. The platform is just a QR generator and a status flag.
Why PIX-first for Brazilian SMBs
- Zero processing fees (PIX is free bank-to-bank in Brazil)
- Instant settlement (seconds, 24/7)
- Universal adoption (~90% of the Brazilian population uses PIX)
- Avoids Stripe's Brazilian fee structure (~4-6% + FX) on small-ticket service payments
Refunds are marked as refunded status in the DB but the actual money movement is the professional's manual bank transfer — Odys doesn't move funds.
Flow 2 — Professional pays Odys (Stripe Checkout, subscription)
This is where Stripe lives. The professional subscribes to Basic / Pro / Premium plans.
Plan definition
src/lib/stripe/plans.ts is the single source of truth for plan pricing, limits, and features:
- Free — 10 clients, 20 appointments/month,
priceId: null(no Stripe product) - Basic / Pro / Premium — each maps to
process.env.STRIPE_{PLAN}_PRICE_ID ?? null(missing env yields null, not undefined — explicit fail)
Each plan carries a limits object and human-readable features for the /dashboard/plans page.
Checkout
- Professional clicks Upgrade on
/dashboard/plans. - Client POSTs to
/api/stripe/checkout/route.ts. - Handler authenticates via
supabase.auth.getUser(), looks up the professional row. - Calls:
stripe.checkout.sessions.create({ mode: "subscription", payment_method_types: ["card"], line_items: [{ price: PLANS[plan].priceId!, quantity: 1 }], metadata: { professionalId, plan }, customer_email: user.email, locale: "pt-BR", subscription_data: trialEnd ? { trial_end: trialEnd } : undefined, payment_method_collection: trialEnd ? "if_required" : "always", ... }) - If the professional is inside the 14-day Pro trial window,
subscription_data.trial_endis set andpayment_method_collection: "if_required"means no card is demanded up front — reduces trial-activation friction. - The route returns
session.url; the browserwindow.location.hrefs to the Stripe-hosted checkout page.
Hosted, not embedded. Embedded would require Odys to touch card data and enter PCI scope. Hosted keeps Odys out of PCI entirely. The locale: "pt-BR" ensures Stripe renders the checkout page in Portuguese for Brazilian professionals.
Webhook — the state-update mechanism
/api/stripe/webhook/route.ts receives Stripe events:
- Critical first step — read the raw body via
req.text()(neverreq.json()— parsing strips whitespace and breaks the HMAC). - Grab the
stripe-signatureheader. If missing → 400 before any constructEvent call. stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET)inside a try/catch. On signature failure → 400 before any DB write. Fails closed: if the webhook secret env var is missing,constructEventthrows and the handler returns 400 rather than silently accepting.- Dispatch by
event.type— three branches handled:
| Event | What happens |
|---|---|
checkout.session.completed | Write new plan + stripeCustomerId + stripeSubscriptionId to the professional row (using session metadata to resolve) |
customer.subscription.updated | Match the new price ID against PLANS entries to derive the new plan key; update professional — only if sub.status === "active" |
customer.subscription.deleted | Drop plan back to free, null out stripeSubscriptionId |
Every update sets updatedAt. Always returns { received: true } so Stripe stops retrying.
Webhook — known limits
Three honest flags worth documenting, none urgent at pre-launch scale:
-
Non-active subscription states are silently ignored.
customer.subscription.updatedonly updates the professional's plan whensub.status === "active". Transient states (past_due,unpaid,paused,incomplete) leave the professional on their previous plan until Stripe either transitions back toactiveor fully cancels viacustomer.subscription.deleted. Apast_dueprofessional keeps premium features for ~3-4 weeks of Stripe's retry cadence. For production scale, a grace-period state machine with in-app notifications would handle this gracefully. -
No event-id deduplication. Each DB update is shape-idempotent (
UPDATE ... SET plan = ?), so Stripe's retries on 5xx produce no net change. But if someone manually editedprofessional.planbetween the two retries, the second would overwrite. Low-probability, pre-launch deferred. Fix path: anidempotency_keystable keyed byevent.id, checked before dispatch. -
Refunds are not implemented in-product. When a subscription is canceled Stripe-side, the professional's plan reverts to
freeviacustomer.subscription.deleted— but there's no in-product refund button. Pre-launch, Stripe dashboard handles the rare case.
Why this separation
- Regulatory — touching client-to-professional money would make Odys a payment facilitator under Brazilian regulation (BACEN). Staying as a QR generator keeps it classified as software, not a financial service.
- Economic — Stripe fees on small Brazilian service payments (R$ 80-200 haircut, R$ 150-400 therapy session) would eat 4-6% per transaction. PIX is free.
- Simplicity — the two flows never interact. Code stays clean. No edge cases where a PIX payment needs to reconcile with a Stripe invoice.
Diagram
┌────────────────────────────────────────────────────────────────────┐
│ Client books appointment │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────┴───────────────────────┐
│ │
Client pays professional Professional pays Odys
(session fee) (SaaS subscription)
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ PIX QR (EMV) │ │ Stripe Checkout │
│ Bank-to-bank │ │ (hosted, out of │
│ No Stripe │ │ PCI scope, pt-BR) │
│ No platform fee │ │ │
└──────────────────┘ └──────────┬───────────┘
│ │
▼ ▼
Professional marks Stripe webhook →
appointment `paid` update professional.plan
in dashboard via event dispatch