Skip to content
weftGitHub

LearnFull-stack apps

Payments

Selling something is mostly bookkeeping that's easy to get subtly wrong. A price typed as 9.99 rounds badly. A button that marks the user as paid when they click it gives Pro to anyone who opens the console. A webhook handler that doesn't check signatures accepts forged events, and one that isn't idempotent grants twice when the provider retries.

weft's answer is a plugin, @weft-js/pay. It's a plugin in the plain sense: nothing in weft names it, and an app that doesn't use it carries none of its code. It owns one ledger of purchases, verifies every event from the payment provider, and turns what's in the ledger into entitlements: short names like 'pro' that your server functions check. The provider is a rail, and today's rail is Stripe.

Pro, one plan

Your plan: Free

    import { derived, state } from '@weft-js/core'import { Badge, Button, Card } from '@weft-js/warp'type Step = 'idle' | 'hosted' | 'waiting'export default function Paywall() {  const step = state<Step>('idle')  // What the ledger says this user holds: the only thing the page renders access from.  const held = state<string[]>([])  const pro = derived(() => held.value.includes('pro'))  const log = state<{ n: number; text: string }[]>([])  const note = (text: string) => {    log.value = [...log.value, { n: log.value.length, text }]  }  const applied = new Set<string>()  // Stands in for the rail posting a signed event to /_weft/x/pay/stripe. Applying one twice changes nothing.  const deliver = (id: string) => {    if (applied.has(id)) {      note(`${id} delivered again: already applied, nothing changes.`)      return    }    applied.add(id)    held.value = ['pro']    note(`${id} verified and applied: the purchase is active, entitlements poked.`)  }  const checkout = () => {    step.value = 'hosted'    note('checkout() opens the hosted page. Nothing is granted yet.')  }  const pay = () => {    step.value = 'waiting'    note('The buyer pays on the rail’s page and comes back.')    setTimeout(() => {      deliver('evt_1')      step.value = 'idle'    }, 1200)  }  const close = () => {    step.value = 'idle'    note('The buyer closes the page. No event, so nothing happens.')  }  return (    <Card title="Pro, one plan">      <p>Your plan: <Badge tone={pro.value ? 'positive' : 'neutral'}>{pro.value ? 'Pro' : 'Free'}</Badge></p>      {step.value === 'idle' && !pro.value && <Button onClick={checkout}>Go Pro</Button>}      {step.value === 'hosted' && (        <section aria-label="The rail's hosted checkout page">          <p>The provider’s checkout page (simulated)</p>          <Button onClick={pay}>Pay with a test card</Button>{' '}          <Button emphasis="ghost" tone="neutral" onClick={close}>Close it</Button>        </section>      )}      {step.value === 'waiting' && <p>Back on your site, waiting for the webhook…</p>}      {pro.value && <Button tone="neutral" emphasis="ghost" onClick={() => deliver('evt_1')}>Redeliver the event</Button>}      <ol>        {log.value.map(l => <li key={l.n}>{l.text}</li>)}      </ol>    </Card>  )}

    Try this: Press Go Pro, then close the page: still Free. Go again and pay: Pro arrives with the event, not the click. Then redeliver it and see that nothing changes.

    Adding payments

    Payments need a database and sign-in, so add those first (Database, Auth). Then:

    bash
    weft add pay

    That adds the Stripe rail to plugins and writes five files, which this page walks through:

    FileWhat it holds
    src/pay/catalog.tsWhat you sell. It reaches the client, so names and amounts only.
    src/pay.server.tsThe ledger: definePay({ … }). Its webhook is served at /_weft/x/pay/stripe.
    src/server/pay.tsThe server functions your pages call: entitlements, checkout, portal.
    src/db/pay.schema.tsThe purchases and pay_events tables, re-exported from src/db/schema.ts.
    .envSTRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, as placeholders.

    Then run weft db generate for the two tables, put your Stripe test keys in .env, and give Stripe the webhook URL https://<your app>/_weft/x/pay/stripe, subscribed to customer.subscription.*, checkout.session.*, charge.refunded and charge.dispute.*. Nothing calls Stripe until a user checks out, and no page loads Stripe.js.

    What you sell: the catalog

    App code names products, never a provider's ids. A product says how it's paid for, what it is, what it costs and which entitlements it grants:

    ts
    // src/pay/catalog.tsimport { product } from '@weft-js/pay'export const pro = product({  id: 'pro',  kind: 'subscription',  use: 'digital',  name: 'Pro',  plans: { month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } },  grants: ['pro'],})export const credits = product({  id: 'credits',  kind: 'consumable',  use: 'digital',  name: '100 credits',  price: { usd: 500, eur: 450 },})
    • kind is 'subscription' (priced per plan in plans), 'one-time' or 'consumable' (priced with price).
    • use is 'digital', 'physical' or 'service'; shipping: true collects an address for physical goods.
    • Amounts are whole minor units with a lowercase currency: { usd: 999 } is $9.99, and JPY has no minor unit at all. A float or an amount with no currency is PAY008, both in weft check and when the module loads.
    • A plan named month or year is its own interval; any other name needs interval.
    • grants lists the entitlements a live purchase gives.

    To show a price, never type it into the page (that's PAY005): ask the catalog. price(product, plan) formats it for the reader's locale, by the currency's own number of decimals:

    tsx
    import { price } from '@weft-js/pay'import { pro } from '#/pay/catalog'export function Plans() {  const year = price(pro, 'year')  return (    <p>      {price(pro, 'month').display} a month, or {year.display} a year      {year.trialDays ? ` with ${year.trialDays} days free` : ''}    </p>  )}

    The ledger and the server functions

    src/pay.server.ts is the one ledger of the app. Here it is as weft add pay writes it for a Drizzle app:

    ts
    // src/db/pay.schema.tsimport { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'export const purchases = sqliteTable('purchases', {  id: text().primaryKey(),  rail: text().notNull(),  user: text(),  product: text(),  plan: text(),  state: text().notNull(),  at: integer().notNull(),  expires: integer(),  customer: text(),})export const payEvents = sqliteTable('pay_events', {  id: text().primaryKey(),  at: integer().notNull(),})
    ts
    // src/pay.server.tsimport { drizzlePayStore } from '@weft-js/pay/drizzle'import { definePay } from '@weft-js/pay/server'import { db } from '#/db/index.server'import { payEvents, purchases } from '#/db/pay.schema'import * as catalog from './pay/catalog'export default definePay({ catalog, store: drizzlePayStore(db, { purchases, payEvents }), urls: { success: '/', cancel: '/' } })

    (weft add pay imports the tables from #/db/schema, which re-exports them; either path works.)

    urls are where a buyer comes back to after paying or giving up. They're set here, on the server, and never come from the client. definePay also takes leeway (how long access lasts past a paid period while a late renewal arrives, 3 days by default), origin for absolute URLs behind a proxy, and user if your user id isn't ctx.user.id.

    Pages don't import the ledger. They call three server functions:

    ts
    // src/server/pay.tsimport { entitlementTag } from '@weft-js/pay'import { mutation, query, redirect } from '@weft-js/start'import pay from '../pay.server'export const entitlements = query({  auth: 'user',  tags: (_, ctx) => [entitlementTag(ctx.user.id)],  run: ({ ctx }) => ctx.entitlements.list(),})export const checkout = mutation({  auth: 'user',  input: pay.input,  run: async ({ input, ctx }) => redirect(await pay.checkout(ctx, input), 303),})export const portal = mutation({  auth: 'user',  run: async ({ ctx }) => redirect(await pay.portal(ctx), 303),})

    pay.input is a schema built from the catalog: the client may send a product id, a plan, a currency and a quantity, and every amount comes from the catalog on the server. A checkout mutation without auth: 'user' is PAY010, because a purchase has to belong to someone.

    Checkout grants nothing

    Here's a pricing page. The buttons call the mutations, and what the page shows comes from entitlements:

    tsx
    // src/routes/pricing.tsximport { price } from '@weft-js/pay'import type { Meta } from '@weft-js/start'import { pro } from '#/pay/catalog'import { checkout, entitlements, portal } from '#/server/pay'export const meta: Meta = { title: 'Pricing', description: 'Pro unlocks reports and exports, billed monthly or yearly.' }export default function Pricing() {  const mine = entitlements.live()  return (    <section>      <h1>Pricing</h1>      {mine.value?.includes('pro') ? (        <>          <p aria-label="plan">Pro</p>          <button type="button" onClick={() => portal()}>Manage your plan</button>        </>      ) : (        <button type="button" onClick={() => checkout({ product: 'pro', plan: 'year' })}>          Go Pro · {price(pro, 'year').display}        </button>      )}    </section>  )}

    checkout sends the buyer to the provider's hosted page, and that's all it does. When they finish paying, the provider posts an event to /_weft/x/pay/stripe, and that is what grants Pro:

    1. The event is verified against the exact bytes received, with STRIPE_WEBHOOK_SECRET. A bad signature answers 400.
    2. It's applied to the purchase in the ledger.
    3. It pokes the tag entitlements:<user>, so every .live() reader of entitlements for that user refetches, on every tab. The page above flips to "Pro" by itself, a moment after the buyer comes back.

    Providers deliver at least once and in any order. A repeated event changes nothing, and an older event than the one already applied changes nothing, by the provider's own clock. If applying fails, the webhook answers 500 so the event is sent again.

    Deep diveWhy there's no webhook handler to write

    In most stacks you write the webhook route yourself: read the raw body before anything parses it, check the signature with the right secret and tolerance, look up whether you've seen this event, apply it in order, and answer the right status so the provider retries what failed. Each of those steps has a well-known way to go wrong, and PAY011 flags a handler of payment events with no signature check.

    definePay is a server extension: the one way a plugin reaches the server, under /_weft/x/<key>/. It sees the raw request before anything reads the body, and it can invalidate tags like a mutation, which is how a verified event reaches your live readers. An app with no src/pay.server.ts has no such route and none of this code.

    Enforce on the server

    What the client shows is decoration. Anyone can call a server function directly, so the server decides. There are two ways, and you'll use both.

    requires on a query or mutation refuses the call unless the user holds every entitlement listed. The client gets a typed 403 with the code ENTITLEMENT_REQUIRED:

    ts
    // src/server/reports.tsimport { query } from '@weft-js/start'import { eq } from 'drizzle-orm'import { todos } from '#/db/schema'export const report = query({  tags: ['todos'],  auth: 'user',  requires: ['pro'],  run: async ({ ctx }) => {    const rows = await ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id))    return { total: rows.length, done: rows.filter(t => t.done).length }  },})

    ctx.entitlements works in any query, mutation or load, for decisions that aren't a plain yes or no. In a layout's load it guards a whole section:

    tsx
    // src/routes/reports/_layout.tsximport { redirect, type LoadArgs } from '@weft-js/start'export const load = async ({ ctx }: LoadArgs) => {  if (!ctx.user) redirect('/login')  if (!(await ctx.entitlements.has('pro'))) redirect('/pricing')  return {}}export default function ReportsLayout(props: { children?: unknown }) {  return <section>{props.children}</section>}

    An entitlement checked only on the client, with nothing on the server enforcing it, is PAY013.

    A query with cache: { life } shares its value across users, so it can't read ctx.entitlements unless it also says scope: 'user'. And a query's tags can take ctx, which is how entitlements above gets one tag per user.

    Purchase states

    Every rail's events land on the same states:

    StateAccess
    active, trial, graceYes
    pending, billing-retry, paused, expired, refunded, revokedNo

    Access also ends leeway (3 days) after a paid period if no renewal came, so a missed event can't keep someone on Pro forever. A refund or a revoke is final, and wins a tie with a renewal in the same second.

    Test mode

    Two kinds of testing, for two jobs.

    Stripe's test mode, for trying the real flow in a browser. Put your sk_test_… key and the test webhook secret in .env, run the app, and pay with Stripe's test cards. To reach your machine from Stripe, use the Stripe CLI's forwarding to localhost:<port>/_weft/x/pay/stripe and use the signing secret it prints as STRIPE_WEBHOOK_SECRET.

    fakePay, for automated tests. It's a rail with no key and no network. Its events are signed and go through your own webhook and ledger, so a test sees exactly what a user would. net.context sets who the test's requests come from:

    tsx
    import { net, render, screen, user } from '@weft-js/core/test'import { fakePay } from '@weft-js/pay/test'import { expect, test } from 'vitest'import pay from '#/pay.server'import Pricing from '#/routes/pricing'test('Pro arrives with the event, not the click', async () => {  const fake = fakePay(pay)  net.context({ user: { id: 'u1' } })  render(Pricing)  await user.click(await screen.findByRole('button', { name: /Go Pro/ }))  expect(screen.queryByLabelText('plan')).toBeNull()  await fake.complete()  expect((await screen.findByLabelText('plan')).textContent).toBe('Pro')  await fake.simulate('refund')  expect(await screen.findByRole('button', { name: /Go Pro/ })).toBeTruthy()})

    fake.complete() is the buyer finishing the hosted page; fake.abandon() is them closing it. fake.simulate(…) plays what a rail does later: renewal, renewal-failed, recovered, grace-expired, cancel, pause, resume, refund and revoke. fake.redeliver() and fake.deliver(event, { forged: true }) check that a repeat is harmless and a forgery is refused. The Testing page covers render, user and the fake network.

    A live key in a test is PAY031: tests never touch real money. And memoryPayStore() is for tests only; a production server refuses to start with it (PAY016).

    Another provider

    The catalog, the server functions and your pages never name Stripe, so another provider is one line: replace stripe() in plugins with any plugin for the pay.rail slot. A rail implements PayRail from @weft-js/pay (checkout, portal, verify, and optionally refund) and passes payRailSuite from @weft-js/pay/test. verify gets the exact bytes received and throws PayError('PAY011', …) for anything it can't prove.

    Stripe is the only rail that ships today. App-store rails, store policy and a weft pay sync command aren't built.

    Recap

    • weft add pay writes a catalog, a ledger, three server functions and two tables. Keys live in the server's environment, never in source.
    • Products are whole minor units with a currency. Show prices with price(product, plan), never typed into the page.
    • checkout only opens the provider's page. A verified event at /_weft/x/pay/<rail> is the only grant, and it pokes entitlements:<user>, so .live() readers update by themselves.
    • Enforce on the server: requires: ['pro'] on a function, or await ctx.entitlements.has('pro') in a load.
    • Try the real flow with Stripe's test keys; test it automatically with fakePay, which runs your real webhook and ledger with no network.

    Try it

    Challenge 1

    Sell credits once

    Add a one-time product, lifetime, that costs $149 or €139 and grants 'pro' forever. Then show its price in euros on the pricing page.

    Show the solution
    ts
    // src/pay/lifetime.tsimport { product } from '@weft-js/pay'export const lifetime = product({  id: 'lifetime',  kind: 'one-time',  use: 'digital',  name: 'Pro for life',  price: { usd: 14900, eur: 13900 },  grants: ['pro'],})

    Add it to src/pay/catalog.ts (an export there is what import * as catalog picks up). On the page, price(lifetime, undefined, { currency: 'eur' }).display gives "€139.00" in an English locale. The amounts are whole cents, and price divides by the currency's own exponent.

    Challenge 2

    Find the hole

    The export page hides its button from free users. Is the export protected?

    ts
    import { mutation } from '@weft-js/start'import { eq } from 'drizzle-orm'import { todos } from '#/db/schema'export const exportCsv = mutation({  auth: 'user',  run: async ({ ctx }) => {    const rows = await ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id))    return rows.map(t => `${t.id},${JSON.stringify(t.title)}`).join('\n')  },})
    Show the solution

    No. Anyone signed in can call exportCsv directly, whatever the page shows, which is PAY013's point. Enforce it on the server:

    ts
    import { mutation } from '@weft-js/start'import { eq } from 'drizzle-orm'import { todos } from '#/db/schema'export const exportCsv = mutation({  auth: 'user',  requires: ['pro'],  run: async ({ ctx }) => {    const rows = await ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id))    return rows.map(t => `${t.id},${JSON.stringify(t.title)}`).join('\n')  },})

    A free user now gets a 403 with the code ENTITLEMENT_REQUIRED, which the page can turn into "Go Pro to export".

    Challenge 3

    Test a failed renewal

    Write the steps of a test that buys Pro for a user, makes the next renewal fail, and checks what they hold. Should they still have Pro?

    Show the solution
    tsx
    const fake = fakePay(pay)await fake.buy('u1', 'pro', 'month')await fake.simulate('renewal-failed')expect(await fake.entitlements('u1')).toEqual(['pro'])await fake.simulate('grace-expired')expect(await fake.entitlements('u1')).toEqual([])

    A failed renewal puts the purchase into grace, which still gives access while the provider retries the card. Only when grace runs out (grace-expired) does Pro go. fake.buy skips the hosted page, which is what you want when the test is about what happens after.