Tiago Fortunato
ProjectsOdysUser Journeys

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.

  1. The professional sets their PIX key in /dashboard/settings.
  2. On a confirmed appointment with paymentType: "upfront", the booking widget renders a <PixQR> component — an EMV BR Code payload generated by src/lib/pix.ts. Pure-TS implementation: no external dependencies, inline CRC-CCITT (polynomial 0x1021, initial 0xffff), ID-length-value fields per Banco Central's "Manual de Padrões para Iniciação do Pix".
  3. The customer scans the QR with their Brazilian bank app and pays bank-to-bank, directly to the professional.
  4. The professional manually marks the appointment paid from the dashboard — PATCH /api/appointments/[id] with action: "paid" — which updates appointments.paymentStatus to captured.

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

  1. Professional clicks Upgrade on /dashboard/plans.
  2. Client POSTs to /api/stripe/checkout/route.ts.
  3. Handler authenticates via supabase.auth.getUser(), looks up the professional row.
  4. 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",
      ...
    })
  5. If the professional is inside the 14-day Pro trial window, subscription_data.trial_end is set and payment_method_collection: "if_required" means no card is demanded up front — reduces trial-activation friction.
  6. The route returns session.url; the browser window.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:

  1. Critical first step — read the raw body via req.text() (never req.json() — parsing strips whitespace and breaks the HMAC).
  2. Grab the stripe-signature header. If missing → 400 before any constructEvent call.
  3. 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, constructEvent throws and the handler returns 400 rather than silently accepting.
  4. Dispatch by event.type — three branches handled:
EventWhat happens
checkout.session.completedWrite new plan + stripeCustomerId + stripeSubscriptionId to the professional row (using session metadata to resolve)
customer.subscription.updatedMatch the new price ID against PLANS entries to derive the new plan key; update professional — only if sub.status === "active"
customer.subscription.deletedDrop 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.updated only updates the professional's plan when sub.status === "active". Transient states (past_due, unpaid, paused, incomplete) leave the professional on their previous plan until Stripe either transitions back to active or fully cancels via customer.subscription.deleted. A past_due professional 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 edited professional.plan between the two retries, the second would overwrite. Low-probability, pre-launch deferred. Fix path: an idempotency_keys table keyed by event.id, checked before dispatch.

  • Refunds are not implemented in-product. When a subscription is canceled Stripe-side, the professional's plan reverts to free via customer.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

On this page