Home Projects Blog About
← All projects

Guapocado - Config-First Billing for Product Teams

Billing logic that starts in your repo. Define plans, entitlements and usage meters in a typed config file, sync it to Stripe, and get typed access checks on your server instead of plan logic scattered across conditionals.

Beta Updated
  • Billing
  • Cloudflare Workers
  • Durable Objects
  • Hono
  • Stripe Connect
  • npm
Visit guapocado.dev

The Problem Statement

Billing starts simple and then metastasises. You add a plan in the Stripe dashboard, hardcode a price ID, and write if (user.plan === "pro"). Six months later that conditional has thirty siblings scattered across the codebase, nobody can answer “what does Pro actually include” without reading source, and the answer in staging is different from production because someone configured them by hand on different days.

The root problem is that the source of truth lives in a dashboard while the logic that depends on it lives in your repo. The two drift, and nothing catches it - there’s no type error when a plan changes shape, no review when someone edits pricing in a web UI, and no way to diff what changed.

The Solution

Guapocado inverts it. Plans, entitlements, usage meters and pricing are defined in a billing.config.ts in your repository:

export default defineBilling({
  entitlements: {
    reports: { type: "feature" },
    api_calls: { type: "meter" },
  },
  // ...
});

That config is the source of truth. The guap CLI syncs it to Stripe, and your server gets typed access checks against it - has(), limit(), usage.balance() - instead of string comparisons against plan names. Pricing changes arrive as pull requests with diffs and review, and they’re identical across environments because they come from the same committed file.

For the hot path, the SDK ships a store-backed local read model so entitlement checks skip the network round trip entirely, plus typed webhook hooks so you can react to billing events without polling for them.

Two Repos, on Purpose

The project is deliberately split, with a small meta-repo tying the two together:

  • guapocado/ is public. It holds every @guapocado/* package - the SDK, CLI, React hooks, and the Better Auth, Hono and Supabase adapters. Being public means the code running inside your auth and billing path is auditable, and every version is published to npm with provenance, so each release is cryptographically linked to the exact commit and CI run that produced it. If you’re going to ask people to trust a package with their billing, that’s the bar.
  • guap/ is private. The dashboard, API and platform internals. It consumes the packages as pinned published npm versions rather than workspace links, which means the platform eats exactly the same artifacts customers install.

They are intentionally not a single pnpm workspace. The friction is the point - it makes it impossible to accidentally depend on unpublished package code.

Multi-Tenancy on Durable Objects

Tenant data doesn’t sit in one shared database. A Turso control plane holds the global picture, and each tenant gets a TenantCell Durable Object per environment mode, born when the org is created. Tenant data is physically isolated rather than isolated by a WHERE tenant_id = ? that one missing clause can defeat, reads stay close to where the object lives, and archival to R2 happens per cell.

The whole platform - Astro dashboard and Hono API - runs as a single Cloudflare Worker serving two hostnames, routed by host and path, with dynamic imports keeping cold starts honest.

The Stack

  • Astro dashboard + Hono API composed into one Cloudflare Worker.
  • Turso control plane, with per-tenant data on Durable Objects (TenantCell) and R2 archival.
  • Better Auth for authentication, including CLI device auth and a key-gated admin view.
  • Stripe Connect (per-environment) for payments, with the platform dogfooding its own self-billing.
  • tRPC between dashboard and API, Biome for lint and format.
  • Seven published @guapocado/* packages: sdk, shared, react, better-auth, hono, supabase, cli.