Docs

Billing Products

Product catalog model and admin management workflow.

Catalog Model

Billing products are stored in billing_products with non-secret metadata:

  • product key

  • type (subscription or one_time)

  • plan slug

  • interval (monthly/yearly when applicable)

  • optional entitlement slug

  • Stripe price ID, managed internally after sync

  • mirrored Stripe Product/Price metadata

  • active flag

  • sort order

Admins manage product prices in Yayaw. When a product amount is saved, Yayaw creates or updates the matching Stripe Product, creates a new Stripe Price when the amount, currency, or interval changes, archives the previous Price for new checkouts, and stores the resulting Stripe price ID plus mirrored Product/Price metadata. Stripe secrets stay in environment variables.

Products & Services is the source of truth for billing product names, prices, types, intervals, Stripe sync status, and checkout availability. CMS data models must not duplicate those commercial fields.

Stripe coupons and promotion codes are mirrored in:

  • billing_stripe_coupons

  • billing_stripe_promotion_codes

Stripe remains the source of truth for discounts; Yayaw mirrors them for CMS variables and MCP inspection.

Admin Workflow

Use the dashboard admin pages:

Current V1 capabilities:

  • toggle product active state

  • edit product amount and currency

  • create and mirror the Stripe Product/Price through the Stripe API

  • edit sort order

  • keep product keys stable

Billing settings currently cover:

  • grace period days

  • GitHub code-access repository settings

Plan feature limits live in billing_plans and are read by runtime billing services. Media quotas and seat limits should be changed through the runtime billing plan model, not hard-coded into product cards.

The billing catalog screen

/dashboard/admin/billing-products ("Products & Services", route permission billing-product:manage) renders the screen dashboard.admin.billing-catalog. The catalog is the platform's, so it is a global screen: one copy for the whole platform, which only Super Admins customize (see Dashboard). Its default shows:

  • five numbers, counts only, since prices are in several currencies and are never added up: the products, those on sale, the subscriptions, the one-time products, and Synchronization to review (the products whose Stripe sync failed, is pending or has no price, the table's work queue of the same name);

  • Plans: each billing plan's seat limit, largest file and storage quota, shown to whoever holds billing-plan:read globally and edited by whoever holds billing-plan:manage globally;

  • the products as a full-page table on the system:billing-products source, in the catalog's order, 20 a page.

The numbers and the table list every product to whoever holds billing-product:list globally, whatever organization is active, and an organization's grant never opens them. Whoever also holds billing-product:manage globally (Super Admins) edits a product from its row: a click opens the form with its price and currency, whether it is on sale, and its sort order; the product key is read-only. The server checks every edit again (billing-product:update, globally) and syncs a new price to Stripe before saving it.

The table keeps the list's columns (name, plan, product type, interval, price, currency, Stripe sync and on sale shown; product key, sort order, Stripe price ID and Stripe product one click away), its saved views and favorites (billing-products), and its work queues after the screen's own view: On sale, Not on sale and Synchronization to review. Prices show in their own currency. It offers Table, List, a Kanban by Stripe sync status, whose cards cannot be dragged (a product changes through its form), and charts that count products.

The screen never reads the text of a Stripe sync failure (only whether there is one, for the sync status), the Stripe product ID or any Stripe secret, which stays in environment variables. The Stripe price ID is shown: it names a price in Stripe and grants nothing.

  • Search reads text only: the name, product key, plan, type, interval, Stripe price ID and product name, currency and sync status, never a boolean, a sort order or a price.

  • Paging: at most 100 products a page (10, 20 or 50 offered).

  • Order: the catalog's order by default (sort order, then id); names sort as people read them ("Pro 2" before "Pro 10"), in the database.

  • Prices are never added, averaged or compared, in a number, a chart or a table footer.

  • URL parameters: the table's parameters are keyed by its source (system:billing-products-…), and view=<id> opens a saved view or a work queue.

  • Plans: when the server refuses a plan's limits, the plan's editor says why.

An assistant preparing a draft of this screen reads the source's columns only, never a row's id or whether the viewer may change it; assistants list and update products with the tools below.

CMS Product Content

The seed creates one global billing-product-content data model with one entry per billing catalog product. Each entry is linked by the locked catalog_product_key field and can hold only editorial additions such as badge, summary, feature bullets, CTA label, or highlight state.

Use this model to complement catalog variables in page or section content. Use {billing.product.<productKey>.*} and {billing.price.<productKey>.*} for product names, prices, Stripe price IDs, and checkout availability.

Organization Workflow

Use the organization billing page:

  • /dashboard/organization/billing

  • /dashboard/organization/plans

Current V1 capabilities:

  • /billing focuses on active plan summary and internal activity

  • /plans focuses on plan selection and checkout actions

  • start subscription checkout

  • start one-time checkout (pro_lifetime)

  • open Stripe billing portal

  • view disabled checkout reasons before clicking actions

The billing portal action is disabled until the organization has a recorded Stripe customer. This prevents a confusing portal failure before the first successful checkout creates the customer in Stripe.

Operational Notes

  • If a product has no synced Stripe price ID, inactive mirrored Stripe Price/Product, or a sync error, checkout is disabled for that product.

  • Subscription and one-time Stripe Checkout sessions allow Stripe promotion codes.

  • Seed only creates missing product rows and does not overwrite operator-managed catalog changes.

  • Product keys are stable contracts. Add a new product key for a new commercial offer rather than repurposing an existing key with different semantics.

  • Stripe Price IDs are immutable for amount/currency/interval. Amount changes create a new active Price and archive the old Price for new checkouts.

  • Discount data is mirrored from Stripe for read-side usage only. Create and govern coupons/promotion codes in Stripe.

  • One-time lifetime checkout grants pro_lifetime and creates a durable code-access grant keyed by Stripe checkout session.

  • Subscription checkout creates a durable code-access grant keyed by Stripe subscription ID, then refreshes or revokes that grant on subscription events.

MCP Workflow

Control-plane tools expose the catalog and Stripe discount mirror:

  • yayaw_billing_products_list

  • yayaw_billing_product_update

  • yayaw_stripe_discounts_list

  • yayaw_stripe_discounts_sync

Writes require control-plane:admin, Yayaw billing-product:update authorization, and a reason.

Validation

Useful checks after billing catalog changes:

bun test src/lib/server/services/billing/billing-products.test.ts
bun test src/lib/server/services/billing/stripe-catalog-sync.test.ts
bun test src/lib/server/services/billing/billing-organization-view.test.ts
bun run test