DSL building blocks
Purchase models commerce using a small set of primitives:
featureFlag(...)quotaFeature(...)creditUnit(...)plan(...)subscriptionProduct(...)oneTimeProduct(...)creditPackProduct(...)
Design rules
The catalog DSL is intended to produce stable commercial identifiers.
- product ids identify the product family
- plan ids identify the commercial option inside the product
- offer ids are the stable external runtime ids, typically
productId:planId - provider ids map your commercial catalog to Stripe or Paddle objects
Example
const noteSyncEnabled = featureFlag({ id: "note_sync_enabled" })
const noteItems = quotaFeature({ id: "note_items" })
const aiCredits = creditUnit({ id: "ai_credits", unit: "AI credits" })
const subscriptionPlans = [
plan({
id: "notes_free",
group: "base",
default: true,
includes: [noteItems({ limit: 50, reset: "month" })]
}),
plan({
id: "notes_pro_monthly",
group: "base",
price: { amount: 9, interval: "month" },
includes: [noteSyncEnabled(), noteItems({ limit: 10_000, reset: "month" })],
provider: {
stripe: "notes_pro_monthly",
paddle: "notes_pro_monthly"
}
})
] as const
const creditPlans = [
plan({
id: "ai_credits_500",
group: "credits",
price: { amount: 10, interval: "one_time" },
includes: [aiCredits({ amount: 500, reset: "year" })],
provider: {
stripe: "ai_credits_500",
paddle: "ai_credits_500"
}
})
] as constThe example catalog in this repository intentionally mixes all three commercial families:
- subscriptions
- one-time purchases
- credit packs
That is a useful signal about the intended scope of the package.
What to keep stable
Treat these as long-lived ids:
- feature ids
- product ids
- plan ids once they are used in production
- provider mapping keys
Changing human-facing names is cheap. Changing commercial ids is not.
Modeling guidance
- Use subscriptions for ongoing access and renewable quotas
- Use one-time products for perpetual purchases
- Use credit packs for prepaid balances and usage-style systems
- Keep provider mappings explicit rather than deriving them from display copy
Why the model is split this way
The DSL separates product families from concrete offers because real applications need both views:
- browsing and upgrade UI cares about the product family
- checkout and mutation workflows care about the exact offer
That is why most runtime APIs operate on offerId, while projections often also expose productId.
Current scope
The existing DSL already covers:
- feature flags
- quotas with reset behavior
- credit units with amount and expiry metadata
- subscription, one-time, and credits product modes
More advanced catalog authoring ergonomics may still evolve, but the core modeling shape is already visible in the current codebase and tests.
Normalized catalog shape
At runtime, the DSL is normalized into:
- commercial products
- commercial offers
- benefit records
- provider mappings
- checkout targets
Application code should prefer those normalized commercial ids over raw DSL internals once the SDK is running.