Payments & payouts
This page follows the money after checkout: the provider abstraction that runs every charge, how funds settle to your connected account, how installments and refunds behave, and the guarantee that no one is ever charged without getting access. For setting a price, running checkout, or issuing coupons, see Pricing & checkout.
Every monetary value is stored and transmitted in the currency's smallest unit (cents), the same format Stripe uses. A price of 9990 in BRL means R$99.90.
The pluggable payment provider
All commerce goes through a provider-agnostic contract (PaymentProvider) — never through a concrete SDK in business code. The current implementation is Stripe; a future provider (Mercado Pago, PayPal) is a new class and a new branch in resolvePaymentProvider, without touching the call sites.
The contract exposes only what's essential to sell a piece of content:
| Method | Role |
|---|---|
createCheckout | Starts a one-time checkout; returns the redirect URL |
createInstallmentPlan | Starts an installment plan (N recurring charges); returns the URL |
finalizeInstallmentPlan | Caps the subscription at exactly N charges (a backstop on the return) |
verifyCheckout | Re-reads the session and returns the facts only when paid; null otherwise |
refund | Fully refunds the charges behind a purchase (idempotent by key) |
resolvePaymentProvider(orgId) reads the organization's integration row (kind payments): if there's a connected Stripe account, charges are routed through it; otherwise everything falls back to the platform account, with no change in behavior until Connect is configured.
Stripe Connect: destination charges and the platform fee
When an organization connects its own Stripe account, sales start settling to it, and the platform can retain an application fee (PLATFORM_FEE_BPS). To connect the payout account, see Integrations.
The model is destination charges: checkout always runs on the platform account — so verification, refunds, and webhooks stay platform-scoped, with no change — and, when the organization is connected, the money is transferred to its account via transfer_data.destination, with the optional fee. A direct platform sale is simply that without the destination.
- Express account. The organization connects an Express account (onboarding and dashboard hosted by Stripe), with a default country of
BRand only thetransferscapability — the lightest onboarding, since the account only receives transfers. - No per-organization secret. Only the
acct_…id is kept in theintegrationrow (kindpayments, providerstripe); there's no per-organization secret key — charges use the platform key plus the account id. That's why the id isn't sensitive and its status can be read by any member. - Destination refund. If the charge was a destination charge (the money was transferred to the connected organization), the refund reverses the transfer and also returns the platform fee (
reverse_transfer,refund_application_fee) — a full refund leaves no one holding the buyer's money. - Connected installments. Each installment is transferred (less the platform percentage) to the connected account via
transfer_data.destinationon the subscription.
Installments and payment methods
An installment plan is a Stripe subscription (subscription mode) split into N equal monthly charges — not the card network's native installment feature. Each installment is a recurring charge; the plan is closed right after the last one so Stripe never opens invoice N+1.
- Minimum installments: a plan needs at least 2 monthly charges. The
priceyou provide is the total, divided here. - Amount per installment:
ceil(total price / N)— rounded up, so N installments never fall below the total price (the leftover cents land on the first installment). - Recorded revenue: the first session's
amount_totalis only the first installment; Cursiva records the plan's full amount (kept in metadata asfullPrice) inpurchase.amount, so revenue totals count the entire sale and not a single installment. - N-charge cap: after payment, the plan is capped at exactly N installments via
cancel_at, scheduled one day after the last installment. This closeout is idempotent and runs reliably through the webhook.
Payment methods differ by kind. A one-time checkout uses Stripe's automatic payment methods, so in Brazil the buyer can pay by card, pix, or boleto without extra setup. Installment checkout, being a recurring subscription, is card only (payment_method_types: ["card"]).
Freeze, thaw, and cancellation
The learner's access follows the plan's state, driven by Stripe's invoice webhook (which has no actor — it's server-only):
past_due— a charge failed: freezes access (the learner becomespast_dueand can't get in).active— the next invoice was paid: thaws access. A purchase that has already been refunded is not resurrected.canceled— the plan ended: revokes access.
A delinquent installment plan therefore grants no access: only an unrefunded one-time purchase, or a plan that's current, unlocks the content.
Reconciling a stuck past_due
If the reactivation webhook (invoice.paid) is lost, the learner can get stuck in past_due even though the plan is already current in Stripe. To close that trap, Cursiva re-reads the subscription directly from Stripe as the source of truth and maps the status to the internal vocabulary:
| Stripe status | Purchase state |
|---|---|
active, trialing | active (current) |
past_due, unpaid, incomplete | past_due (in arrears) |
canceled, incomplete_expired | canceled (ended) |
When the learner is unenrolled, the installment plan is canceled without a refund — access ends now, so future installments also have to stop. The subscription lives on the platform account (destination charges), and the cancellation is idempotent.
Refunds and the money-before-access guarantee
A refund is recorded by marking refundedAt on the purchase. The operation is idempotent and revokes access.
- Once only. A purchase that's already refunded is rejected — each purchase refunds exactly once, protected by the transition of
refundedAt. - Effects. Marking as refunded sets
refundedAt, moves the learner torefunded(the row remains, with progress and history, but every access predicate excludes it), decrements the coupon redemption, and fires thepurchase.refundedwebhook. - Provider idempotency. The refund's idempotency key is the purchase id, so retries and duplicate sends collapse into a single refund.
- Who can refund. Refunding requires pricing rights (finance/admin on the team that owns the content, or the organization's owner/admin). If the content has been deleted since the sale, the fallback is the organization's owner/admin — never an ordinary member.
Automatic refund when enrollment fails
The golden rule is never take money without delivering access. When a paid checkout closes out, if enrollment fails after payment — for example, on a time-based course whose cohort filled up or closed during the window — Cursiva refunds the charge automatically and marks the purchase as refunded, returning an ‘unavailable’ result. The same automatic refund fires when a pay-first request is rejected.
Refunding an installment plan
Refunding an installment purchase first cancels the subscription (to stop future charges) and then refunds each paid invoice, returning to the buyer exactly what they've paid so far. An already-canceled subscription doesn't block the refunds.
Financial state lifecycle
Two states move together: the purchase state (the financial record) and the learner state (access). Every access predicate considers only active learners.
| Event | Purchase state | Learner state | Access |
|---|---|---|---|
| One-time purchase verified paid | active, refundedAt null | active | Granted |
Installment fails (past_due) | past_due | past_due | Frozen |
| Next invoice paid | active | active | Thawed |
| Plan ended / canceled | canceled | refunded | Revoked |
Refund (markRefunded) | refundedAt set | refunded | Revoked |
| Pay-first rejection | refunded | refunded | Never granted |
The purchase is a financial record: it has no foreign key to the content and survives its deletion. The learner row also remains after a refund (preserving progress and history), just with a refunded status.