EasyStarter logoEasyStarter

Credits

Configure web credit packages, Stripe / Creem checkout, usage, and ledger rules

Credits

EasyStarter ships a credit system backed by a server-side ledger. On the web app, users buy credit packages through your web payment provider — Stripe or Creem:

  • Sell credit packages through Stripe / Creem checkout
  • Grant free credits on signup, with optional expiration
  • Abuse protection: signup grants require a verified email and are rate-limited per email, IP, and user agent
  • Consume credits per feature with idempotent, race-safe accounting
  • Ready-made UI: balance indicator, purchase page, transaction history

The ledger is the single source of truth. The client never changes a balance directly — only the payment webhook (on purchase) and server-side consumption write to it.

Building the mobile app too? Credits share one server and config — see Mobile · Credits for the app setup.

Architecture

LayerLocation
Configpackages/app-config/src/app-config.ts
Server serviceapps/server/src/credits/*
API routesapps/server/src/routers/common/credits.ts, apps/server/src/routers/web/credits.ts
Web UIapps/web/src/routes/_authed/(dashboard)/credits/*, apps/web/src/hooks/use-credits.ts

The credit tables:

TablePurpose
credit_accountCurrent balance and aggregate counters per user
credit_transactionImmutable ledger rows; the (sourceProvider, sourceType, sourceId) tuple is the idempotency key
credit_orderWeb purchase order lifecycle
credit_signup_grant_claimSignup grant eligibility and abuse checks

1. Configure credit packages

Everything is configured in packages/app-config/src/app-config.ts.

Enable web credits

packages/app-config/src/app-config.ts
web: {
  credits: {
    enabled: true,
    signupGrant: creditSignupGrant,
    packages: webCreditPackages,
  },
},

Configure the signup grant

Grants free credits to a new user on first balance read. Set expiresInDays: null (or omit) for credits that never expire.

packages/app-config/src/app-config.ts
const creditSignupGrant = {
  enabled: true,
  amount: 100,          // credits granted on signup
  expiresInDays: 30,    // null = never expires
} satisfies NonNullable<AppCreditsConfig["signupGrant"]>;

Create the one-time product

Credit packages are one-time payments — never a subscription. Create the product in your provider's dashboard, then copy its price / product ID for the next step.

Stripe

  1. Dashboard → Products → Add product, name it (e.g. 100 Credits).
  2. Under Pricing, choose One time (not Recurring), set the amount and currency, then save.
  3. Open the price and copy its Price ID (price_xxx).
  4. Toggle Test mode on/off to create a price in each environment — you need one ID for test and one for prod.

Creem

  1. Dashboard → Products → Create product, name it (e.g. 100 Credits).
  2. Set the billing type to One time and set the amount.
  3. Save, then copy the Product ID (prod_xxx) — Creem uses the Product ID as the price id.
  4. Repeat in both the test and live environments.

Full provider setup (API keys, webhooks) lives in the Stripe and Creem guides.

Configure web packages

Put the price / product IDs from the previous step into web for each package. The active environment is picked from NODE_ENV.

packages/app-config/src/app-config.ts
const webCreditPackages = [
  {
    id: "starter",       // internal package id
    amount: 100,         // credits delivered after purchase
    web: {
      provider: "stripe", // "stripe" | "creem"
      test: { providerPriceId: "price_xxx" },
      prod: { providerPriceId: "price_xxx" },
      currency: "usd",
      amountCents: 499,  // $4.99
      status: "active",
    },
  },
] satisfies AppCreditsConfig["packages"];

Add package labels

Add a title and description for each package id so the purchase UI can render it.

packages/i18n/src/messages/web/en.json
"credits": {
  "packages": {
    "starter": {
      "title": "Starter pack",
      "description": "{count} credits for light usage."
    }
  }
}

Configuration rules

  • amount and amountCents must be positive integers.
  • Both test and prod provider price IDs are required and must be unique.
  • Set status: "archived" to hide a package without deleting history.
  • If the same package is also sold in the app, reuse the same id (with matching amount and status) — see Mobile · Credits.

2. Set up the server

Run migrations

pnpm db:migrate:local   # local D1
pnpm db:migrate         # remote D1

Configure payment secrets

Credits reuse your web payment provider, so no extra secrets are needed beyond what Stripe / Creem already require:

ProviderSecrets
StripeSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
CreemCREEM_API_KEY, CREEM_WEBHOOK_SECRET

Confirm the maintenance cron

apps/server/src/index.ts runs runCreditMaintenance on a daily schedule defined in apps/server/wrangler.jsonc. It expires free credits past their window and cancels stale pending orders.

apps/server/wrangler.jsonc
"triggers": {
  "crons": ["10 16 * * *"]
}

3. Consume credits

Spending credits is the part you wire into your own features. Prefer the server-side service from inside a route, so balance checks can't be bypassed by the client.

server route
await context.credits.consumeCredits({
  user: { userId: context.session.user.id },
  amount: 1,
  idempotencyKey: `image-generate:${recordId}`,
  metadata: { feature: "image-generate", recordId },
});

From the browser, call the API directly:

await orpc.credits.consume.call({
  amount: 1,
  idempotencyKey: `image-generate:${recordId}`,
  metadata: { feature: "image-generate", recordId },
});

idempotencyKey must identify one real usage event (8–120 chars). Retrying with the same key returns the current balance without charging twice. Consumption spends soonest-to-expire credits first; an insufficient balance throws Insufficient credits.

Ledger rules

  • Signup credits are granted lazily on first balance / transaction read or consume. They require a verified email and are rate-limited per email, IP, and user agent.
  • Purchased credits never expire (expiresAt = null).
  • Free grants expire after expiresInDays; the daily cron sweeps them.
  • Consumption spends soonest-to-expire credits first, then permanent paid credits.
  • Refunds revoke only the unspent remainder of the original purchase.