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 (
subscriptionorone_time)plan slug
interval (
monthly/yearlywhen 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_couponsbilling_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:
/dashboard/admin/billing-products, the billing catalog (see The billing catalog screen)/dashboard/admin/billing-settings
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:readglobally and edited by whoever holdsbilling-plan:manageglobally;the products as a full-page table on the
system:billing-productssource, 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-…), andview=<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:
/billingfocuses on active plan summary and internal activity/plansfocuses on plan selection and checkout actionsstart 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_lifetimeand 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_listyayaw_billing_product_updateyayaw_stripe_discounts_listyayaw_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