Payments and order rails
Two order rails — an in-person handoff that never touches Stripe, and a Stripe Connect direct charge with a 0% application fee. How the money moves, and where it never goes.
Marka has exactly two order rails, and the difference between them is not a payment method setting — it is a different code path with different guarantees:
DIRECT | CHECKOUT | |
|---|---|---|
| What it is | In-person handoff | Card payment |
| Stripe involved | No. Not at all. | Yes — Connect direct charge |
| Fulfilment | Pickup only | Pickup or shipping |
| Shipping possible | Never | Yes |
| Initial order status | created | checkout_pending |
| Protection tier | DIRECT_BASIC | CHECKOUT_PICKUP / CHECKOUT_SHIPPING |
| Completion proof | QR scan or 6-digit OTP | Stripe webhook |
A listing declares which rails it accepts with directEnabled and checkoutEnabled, and which
fulfilment options with pickupEnabled and shippingEnabled. Publishing requires at least one of
each.
That shipping is CHECKOUT-only is a domain guard, not a convention:
assertDomain(
!(input.rail === "DIRECT" && input.fulfillmentMode === "SHIPPING"),
"direct_shipping_forbidden",
"Direct orders cannot use shipping.",
);The seller is the merchant of record
Card payments are Stripe Connect direct charges on the seller's own connected account. The
connected account is passed as the request-option stripeAccount, which is what makes the charge
belong to the seller rather than to Marka:
checkoutSession = await stripe.checkout.sessions.create(
{
mode: "payment",
line_items: lineItems,
payment_intent_data: { /* … */ },
expires_at: Math.floor(Date.now() / 1000) + 30 * 60,
/* … */
},
{
stripeAccount: sellerPayment.stripeAccountId, // <- direct charge
idempotencyKey: `checkout:${order.id}`,
},
);There is no on_behalf_of and no transfer_data anywhere on this path — those are the shapes you
would use for destination or separate-transfer charges, where the platform takes the payment first.
Marka does not. The money lands in the seller's Stripe account, and Marka never holds a balance on
behalf of a seller.
The 0% fee is structural
The marketplace fee is not a promotional rate that a config change could raise:
const MARKETPLACE_APPLICATION_FEE_AMOUNT: number = 0;and it is applied through a conditional spread, so at zero the parameter is omitted from the
Stripe call entirely rather than sent as application_fee_amount: 0:
payment_intent_data: {
...(MARKETPLACE_APPLICATION_FEE_AMOUNT > 0
? { application_fee_amount: MARKETPLACE_APPLICATION_FEE_AMOUNT }
: {}),
/* … */
}The same zero appears in every order's frozen price breakdown, where the field carries a comment saying what it is: "Always 0 on Marka — sellers are merchant of record and the marketplace fee is 0%."
Stripe's own processing fees still apply, charged to the seller's connected account, because that is the account receiving the money.
So how does Marka make money?
Seller subscriptions, billed through RevenueCat — Free at CA$0, Pro at CA$29/month and Business at CA$99/month. See Plans & limits.
Price is frozen at order time
Two snapshots are written onto every order and never updated afterwards: listingSnapshot, a
complete copy of the listing as it was, and priceSnapshot, the money breakdown. So a seller
editing the listing afterwards cannot change what an existing order was for.
export function derivePriceSnapshot(
listing: Listing,
quantity: number,
fulfillmentMode: FulfillmentMode,
): MoneyBreakdown {
const itemSubtotal = listing.price * quantity;
// Flat per-order shipping: charged once regardless of quantity.
const shippingAmount = fulfillmentMode === "SHIPPING" ? (listing.shippingFlatAmount ?? 0) : 0;
return {
itemSubtotal,
shippingAmount,
taxAmount: 0,
applicationFeeAmount: 0,
currency: listing.currency,
};
}Pure integer arithmetic — listing.price is already in minor units, and it goes straight into
Stripe's unit_amount with no conversion. Shipping is flat and per-order, charged once no
matter how many units are bought, and it appears as its own line item with quantity: 1.
When Stripe reports the captured amount, it is reconciled against the snapshot: if
itemSubtotal + shippingAmount + taxAmount does not equal Stripe's amount_total, the payment is
recorded as a lump sum and a warning is raised rather than the mismatch being written as though it
matched.
The DIRECT rail: QR and OTP
A direct order involves no payment processor at all. What it needs instead is proof that the handoff actually happened, from both sides, in person.
When a pickup is scheduled, the session mints a token — qr_<uuid> — and the six-digit code is
derived from that token, not stored separately:
export function derivePickupOtp(qrSessionToken: string): string {
const hash = createHash("sha256").update(qrSessionToken).digest("hex");
const n = parseInt(hash.slice(0, 8), 16) % 1_000_000;
return String(n).padStart(6, "0");
}The buyer's device renders the raw token as a QR code and the derived OTP beside it. The seller either scans the QR or types the six digits; both verify with a timing-safe comparison, and a hash of whatever was presented is persisted onto the session as the audit record of the confirmation.
The sequence:
- Seller accepts the order.
- A pickup session is scheduled; the token is minted.
- Both parties check in → the order moves to
inspecting. - The buyer inspects the item and accepts.
- The buyer's screen shows the QR and OTP.
- The seller scans or types → the order is
completedand inventory is finalised.
Completion requires the order to be in inspecting and the buyer to have accepted. Those
preconditions are what make the confirmation idempotent: a second scan finds the order already
completed and does nothing.
If it goes wrong instead — the buyer rejects the item at the meetup, or someone does not turn up — the reservation is released. On the CHECKOUT rail that also marks the payment for refund; on DIRECT there is no payment to refund, because there never was one. A cron checks for overdue sessions every five minutes against a 45-minute grace period.
Refunds
Refunds are not issued synchronously. The domain marks the payment refund_pending, and a
cron running every five minutes performs the actual money movement:
await stripe.refunds.create(
{ payment_intent: payment.providerPaymentIntentId },
{
stripeAccount: payment.connectedAccountId,
idempotencyKey: `pickup-refund:${payment.orderId}`,
},
);The idempotency key is derived from the order id, so a re-run of the sweep cannot double-refund.
Deliberately, the sweep does not set the terminal state. That is done when Stripe's
charge.refunded webhook arrives, so the recorded outcome always reflects what Stripe actually
did rather than what Marka asked for.
Partial and full refunds are distinguished:
const isFull = refund.amountRefunded >= refund.amountCaptured;
// …
captureState: isFull ? "refunded" : "partially_refunded";A partial refund updates the payment's refundedAmount and sets partially_refunded, and
leaves the order's fulfilment status alone — money truth lives on the payment, not on the order.
Only a full refund flips the order to refunded. The handler is also cumulative-idempotent: it
returns early if the already-recorded refunded amount is at or above the incoming one, so a
redelivered webhook is a no-op.
Stripe webhooks and the observer
Stripe events land on the web app, which verifies the signature, records the raw event with a processed-at idempotency marker, and republishes a typed topic to the background engine. A durable worker consumes those topics.
| Stripe event | Published topic |
|---|---|
checkout.session.completed (only when payment_status is paid) | payment.captured |
checkout.session.async_payment_succeeded | payment.captured |
checkout.session.async_payment_failed | checkout.expired |
checkout.session.expired | checkout.expired |
charge.refunded | payment.refunded |
charge.dispute.created | dispute.opened |
charge.dispute.updated | dispute.updated |
charge.dispute.closed | dispute.closed |
account.updated | (none — handled in the route, updating the seller's payment-account status) |
A completed session is not necessarily a paid one: a delayed-notification payment method completes
the session while payment_status is still unpaid. Marka acks that event without capturing, so
the order stays awaiting payment, and captures (or releases the reservation) only when Stripe sends
checkout.session.async_payment_succeeded (or ..._failed). A charge whose amount does not match
the order's price snapshot is never captured either.
Checkout sessions carry a 30-minute expires_at — Stripe's minimum — so an abandoned checkout
releases its inventory reservation promptly. Two mechanisms cover that: the checkout.expired
webhook, and a sweeper cron every ten minutes that releases stale reservations as a backstop for a
webhook that never arrived.
Other scheduled work on the same worker: overdue pickup no-show checks every five minutes, the pending-refund sweep every five minutes, and a daily append-only log purge.
Subscriptions
Seller subscriptions run through RevenueCat, which posts to a webhook authenticated by a
timing-safe comparison against a shared secret. Two entitlement identifiers map to tiers —
seller_business and seller_pro — and anything else resolves to free.
Event handling: INITIAL_PURCHASE, RENEWAL, PRODUCT_CHANGE, UNCANCELLATION,
SUBSCRIPTION_EXTENDED and REFUND_REVERSED mark the subscription active at the tier its
entitlements name, or trialing when the period is a trial (RevenueCat sends a trial as an
INITIAL_PURCHASE whose period_type is TRIAL); BILLING_ISSUE marks it past due;
CANCELLATION cancelled; EXPIRATION drops the tier to free. SUBSCRIPTION_PAUSED,
TEMPORARY_ENTITLEMENT_GRANT, TRANSFER and any other type change no subscription state: the
event is recorded and acknowledged, and access changes only on the event that follows it, such as
the EXPIRATION that ends a pause.
A subscription counts as effective while it is trialing, active, or past due — a billing problem does not instantly revoke a seller's entitlements. A cancelled subscription has only turned auto-renew off, so it stays effective until the end of the period the seller paid for; a refund ends it at once. Tier is resolved from the subscription at call time rather than stamped onto an API key, so a plan change takes effect for keys you already hold.
Every per-tier limit derives from one table, TIER_LIMITS, which is why
GET /v1/me can report your exact entitlement list rather than a plan name you have
to interpret.
Shipping
Shipping exists only on the CHECKOUT rail. The price a buyer pays is the listing's
shippingFlatAmount — a flat per-order figure the seller sets, not a live carrier quote.
Rates, labels and tracking are designed to go through Easyship, on a bring-your-own-key model: a seller connects their own Easyship account, with a platform token as fallback. That path is built but not live: label purchase is switched off in production, so today the seller ships with any carrier and adds the tracking number to the order. Easyship is label-and-tracking plumbing behind the seller dashboard; it does not price the buyer's checkout.
Where to look
| Concern | File |
|---|---|
| Rails, protection tiers, order creation guards | packages/domain/src/orders.ts |
| Checkout session creation | apps/web/app/api/checkout/route.ts |
| QR / OTP derivation and verification | apps/web/lib/pickup-tokens.ts |
| Pickup session lifecycle | packages/domain/src/pickups.ts |
| Refund state transitions | packages/domain/src/payments.ts |
| Webhook handlers, crons, refund sweep | apps/iii-engine/workers/payment-observer/src/ |
| RevenueCat webhook | apps/web/app/api/revenuecat/webhook/route.ts |
| Per-tier limits | packages/shared/src/tier-limits.ts |
Listings and channels
One canonical listing, projected onto external marketplaces as ChannelListings — how authority, drift detection, sell-through and cross-channel decrements actually work.
Search
Typesense hybrid lexical + vector search, a fire-and-forget index write with a reconciling cron behind it, and the 200-listing candidate ceiling — including which surfaces it binds.