Catalog & subscription data model
How products, prices, benefits, coupons, subscriptions, one-time purchases, and entitlements relate — every entity scoped to an app and mode.
The catalog is what your app sells and what a purchase grants. It is a small set of entities that build on each other:
- A product is a sellable offering (≈ Stripe Product).
- A price is one way to buy a product —
RECURRING(a subscription cadence) orONE_TIME(a single up-front charge). - A benefit is a feature your app gates on; it attaches to the product.
- A coupon is a reusable discount applied to a subscription's cycles.
- A subscription bills a customer on a recurring price.
- A one-time purchase is an invoice with a one-time price pinned.
- Entitlements are derived — the union of benefits a customer currently holds.
Every catalog entity carries an applicationId and a mode. The mode (TEST
or LIVE) is derived from the API credential that authenticated the request —
never from the request body. One app never sees another app's catalog, and a
test credential never sees live rows. See Test & live modes
and App isolation.
All amounts below are integer minor units (paisa): 150000 means 1,500.00
BDT. Currency is BDT-only in v1. See Money & amounts.
How the entities relate
The "one-time purchase" is not its own table — it is an invoice with the
bought price_id pinned. Everything else is a real table.
Each entity, its table, and its scoping columns
| Entity | DB table | Scoping columns | Notable immutable columns |
|---|---|---|---|
Product | product | application_id, mode | — (status moves DRAFT → ACTIVE → ARCHIVED) |
Price | price | application_id, mode | type, amount, billing_interval, interval_count, trial_days |
Benefit | benefit | application_id, mode | lookup_key (unique per app + mode) |
ProductBenefit | product_benefit | application_id, mode | product_id, benefit_id (unique pair uq_product_benefit) |
Subscription | subscription | application_id, mode | product_id, price_id, customer_ref_id, start_date |
Coupon | coupon | application_id, mode | code, type, value, duration, duration_cycles |
SubscriptionCoupon | subscription_coupon | inherited via its subscription_id | subscription_id, coupon_id, applied_at |
| One-time purchase | no own table — an invoice with price_id pinned | application_id, mode (on the invoice) | the pinned price_id |
SubscriptionCoupon deliberately stores subscription_id and coupon_id as
plain id columns (not entity associations) — it carries no application_id/mode
of its own and is scoped through its parent subscription. The database foreign
keys are declared in the migration.
Products & prices
A product is created in DRAFT (freely editable, not sellable), published to
ACTIVE (sellable), and eventually ARCHIVED (accepts no new
subscriptions/purchases; existing subscriptions keep billing at their captured
terms). A product is sellable only while ACTIVE — and only its active prices
are purchasable.
A product holds one or more prices and may expose several active prices at once. A price is typed:
PriceType | Meaning |
|---|---|
ONE_TIME | A single up-front purchase — no interval, no trial. Reuses the v1 invoice + payment flow; creates no subscription. |
RECURRING | Billed on a cadence (interval × intervalCount, e.g. MONTH × 3 = quarterly) with an optional trialDays. A subscription references a recurring price. |
PriceInterval is one of DAY, WEEK, MONTH, YEAR (null for a one-time
price).
Prices are immutable by design. A price's amount, type, interval,
intervalCount, and trialDays are set once and never updated. You do not
reprice a live price — you add a new price and archive the old one. Archiving
a price (active = false) stops new sales but lets existing subscriptions keep
billing at their captured terms.
PriceDto
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | always | The price id. |
productId | number | always | The owning product. |
type | string | always | ONE_TIME or RECURRING. |
amountMinor | number | always | Integer minor units (paisa). |
currency | string | always | ISO-4217; BDT in v1. |
interval | string | recurring only | DAY/WEEK/MONTH/YEAR; omitted for one-time. |
intervalCount | number | recurring only | Units per cycle; omitted for one-time. |
trialDays | number | optional | Free-trial length in days; omitted when absent / one-time. |
active | boolean | always | false once archived. |
createdAt | string | always | ISO-8601 timestamp. |
See the Products & prices guide for the endpoints that create and manage them.
Benefits & entitlements
A benefit is a per-app catalogue entry answering "what does buying a product
grant the customer?" Each benefit has a stable lookupKey — the app's own gating
identifier — that your client code keys on.
Benefits attach to the product, not the price. That is why entitlements
survive repricing: when you add a new price and archive the old one, the product
still grants the same benefits, so subscribers keep their access. The display
name can change freely; the lookupKey is immutable (re-keying is treated
as a new benefit), so gating never breaks.
The attachment is the product_benefit join (one benefit may attach to many
products; the pair is unique). Detaching a benefit revokes it from that product's
holders at the next entitlement read.
BenefitDto
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | always | The benefit id. |
lookupKey | string | always | Stable gating identifier (≤80 chars), unique per app + mode, immutable. |
name | string | always | Display name; may be renamed without affecting the key. |
metadata | string | optional | Structured value as a JSON string (see below); omitted when absent. |
active | boolean | always | Only the benefit's active flag gates entitlement. |
mode | string | always | TEST or LIVE. |
createdAt | string | always | ISO-8601 timestamp. |
updatedAt | string | always | ISO-8601 timestamp. |
metadata serializes as a JSON string, not a nested JSON object. The
benefit stores it as jsonb, but the DTO field is a raw string, so on the wire
you receive "metadata": "{\"seats\": 10}" — parse it yourself if you need the
inner object. The same applies to the metadata on an entitlement view.
Entitlements are derived, never stored
There is no entitlement table. A customer's active entitlements are computed on the fly as the union of:
- the benefits granted by the customer's entitling subscriptions (status
TRIALINGorACTIVE— a paused, past-due, or canceled subscription does not entitle), and - the benefits granted by the customer's paid one-time purchases (a paid
purchase grants durable entitlements; the price's current
activeflag is irrelevant to it),
then deduplicated by lookupKey (first wins) and returned in stable
lookupKey order.
Two reads expose this:
GET /entitlements?customerReference=…lists the active entitlements (EntitlementView[]). An unknown customer yields an empty list, not a 404.GET /entitlements/check?customerReference=…&lookupKey=…is the gate (EntitlementCheckDto). It runs the full derivation and tests membership; an unknown customer readsentitled: false, never an error.
/entitlements/check re-derives the customer's entitlements every call — it is
a correctness-first membership test, not a cached or short-circuiting lookup.
Don't rely on it being cheaper than the list read.
EntitlementView
| Field | Type | Required | Notes |
|---|---|---|---|
lookupKey | string | always | The benefit's stable gating identifier. |
name | string | always | Display name. |
metadata | string | optional | JSON string (same as BenefitDto.metadata); null when absent. |
EntitlementCheckDto
| Field | Type | Required | Notes |
|---|---|---|---|
entitled | boolean | always | Whether the customer holds the entitlement. |
lookupKey | string | always | Echoes the gating identifier asked about. |
customerReference | string | always | Echoes the external customer id asked about. |
curl "http://localhost:8080/api/v1/entitlements/check?customerReference=cus_84217&lookupKey=seats" \
-H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6"{
"data": {
"entitled": true,
"lookupKey": "seats",
"customerReference": "cus_84217"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}See the Benefits and Entitlements guide for more.
Subscriptions
A subscription bills a customer on a recurring price of an ACTIVE product.
It captures the chosen priceId at create time so a later reprice never silently
moves a live subscriber, and it tracks the billing calendar (startDate,
currentPeriodStart, nextBillingDate, and the optional trialEndDate).
Whether a subscription trials is decided by the price's trialDays, not the
request:
- trial price → created
TRIALING; the first invoice falls at trial end, - no trial → created
ACTIVE; billed fromstartDate.
A TRIALING or ACTIVE subscription is entitling; PAUSED, PAST_DUE, and
CANCELED are not. CANCELED is terminal. The billing sweep generates each
cycle's invoice, advances the calendar, and drives the dunning transitions.
CreateSubscriptionRequest
| Field | Type | Required | Notes |
|---|---|---|---|
priceId | number | yes (@NotNull) | A RECURRING price of an ACTIVE product. |
customerReference | string | yes (@NotBlank) | The app's own external customer id (find-or-created). |
customerName | string | no | Optional display name; refreshes the customer reference when supplied. |
customerEmail | string | no | Optional contact email. |
customerPhone | string | no | Optional contact phone. |
startDate | string (date) | no | When billing begins; defaults to today when absent. |
couponCode | string | no | Redemption code of a coupon to apply; an unknown/non-redeemable code rolls back the create. |
The mode is fixed by the authenticating credential, never the body. Validation:
the owning product must be ACTIVE (else 422) and the price must be RECURRING
(else 400).
curl -X POST http://localhost:8080/api/v1/subscriptions \
-H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
-H "Idempotency-Key: 9f1c2e44-7b3a-4f2e-9c1d-2a6b8e0f4d11" \
-H "Content-Type: application/json" \
-d '{
"priceId": 1001,
"customerReference": "cus_84217",
"customerEmail": "[email protected]",
"couponCode": "WELCOME20"
}'Every endpoint returns the standard { data, meta, pagination } envelope; the
rest of this page shows just data. Because the price carries a 14-day trial, the
subscription is created TRIALING and returns 201:
{
"data": {
"id": 5001,
"status": "TRIALING",
"productId": 42,
"priceId": 1001,
"customerReference": "cus_84217",
"startDate": "2026-06-30",
"currentPeriodStart": "2026-06-30",
"nextBillingDate": "2026-07-14",
"trialEndDate": "2026-07-14",
"cancelAtPeriodEnd": false,
"mode": "TEST",
"createdAt": "2026-06-30T12:00:00Z",
"updatedAt": "2026-06-30T12:00:00Z"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}SubscriptionDto
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | always | The subscription id. |
status | string | always | TRIALING/ACTIVE/PAST_DUE/PAUSED/CANCELED. |
productId | number | always | The subscribed product. |
priceId | number | always | The captured recurring price (fixed for the lifetime). |
customerReference | string | always | The app's external customer id (resolved by the use case). |
startDate | string (date) | always | When the subscription begins. |
currentPeriodStart | string (date) | always | Start of the current billing period. |
nextBillingDate | string (date) | always | When the next invoice is due. |
trialEndDate | string (date) | optional | End of the free trial; omitted when no trial. |
cancelAtPeriodEnd | boolean | always | A cancel scheduled for the period end. |
canceledAt | string | optional | When canceled (immediate cancel); omitted while not canceled. |
mode | string | always | TEST or LIVE. |
createdAt | string | always | ISO-8601 timestamp. |
updatedAt | string | always | ISO-8601 timestamp. |
The create accepts an Idempotency-Key; a repeat
with the same key and body replays the first response. See the
Subscriptions guide for the cancel/pause/resume
endpoints.
Coupons
A coupon is a reusable discount an app authors once and attaches to a
subscription by its code at create time (see the couponCode field above). The
discount is applied to each generated cycle invoice by the billing engine.
| Dimension | Values |
|---|---|
CouponType | PERCENT (a 1..100 percentage) or FIXED (an amount in minor units). |
CouponDuration | ONCE (first cycle), REPEATING (the next durationCycles cycles), or FOREVER (every cycle). |
The discount math (integer arithmetic, never floating point):
PERCENT→discount = grossMinor * value / 100(integer floor).FIXED→discount = min(value, grossMinor)— it never exceeds the cycle amount.
Consumption is tracked per cycle on the subscription_coupon link via its
remainingCycles (initialised ONCE → 1, REPEATING → durationCycles,
FOREVER → null/unlimited). The billing engine decrements it one cycle at a time;
when it reaches zero the link is deactivated. Coupon redeemability is also checked
per cycle (it must be active and not past expiresAt).
A fully-covering coupon that brings the cycle to net zero (e.g. a 100%
PERCENT) skips invoice issuance for that cycle — nothing is owed, so no
invoice is created. The subscription still advances its billing calendar.
CreateCouponRequest
| Field | Type | Required | Notes |
|---|---|---|---|
code | string | yes (@NotBlank, ≤64) | Stable redemption handle, unique per app + mode, immutable. |
name | string | yes (@NotBlank, ≤255) | Display name. |
type | string | yes (@NotNull) | PERCENT or FIXED. |
value | number | yes (@NotNull, @Positive) | PERCENT must be 1..100; FIXED is minor units > 0 (business-validated). |
duration | string | yes (@NotNull) | ONCE/REPEATING/FOREVER. |
durationCycles | number | conditional | Required (≥1) for REPEATING; must be absent for ONCE/FOREVER (business-validated). |
expiresAt | string | no | Optional expiry instant; after it the coupon is no longer redeemable. |
CouponDto echoes these fields plus id, active, mode, createdAt, and
updatedAt. See the Coupons guide.
One-time purchases
A one-time purchase sells a product through one of its ONE_TIME prices. It
owns no table of its own — it is an invoice with the bought price_id pinned,
plus a payable link the customer settles through the existing v1 hosted-checkout
flow. No subscription is created and there is no billing calendar.
The pinned price is what entitlement derivation reads later: once the invoice is paid, the customer durably holds the benefits of the price's product.
CreateOneTimePurchaseRequest
| Field | Type | Required | Notes |
|---|---|---|---|
priceId | number | yes (@NotNull) | A ONE_TIME price. |
customerReference | string | yes (@NotBlank) | The app's own external customer id (find-or-created). |
customerName | string | no | Optional display name. |
customerEmail | string | no | Optional contact email. |
customerPhone | string | no | Optional contact phone. |
There is no start date and no coupon — a one-time purchase has no billing calendar. The mode is fixed by the authenticating credential, never the body.
curl -X POST http://localhost:8080/api/v1/purchases \
-H "X-Api-Key: oi_test_2f9d8c1a7b6e4f30" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
-H "Idempotency-Key: 7c2a9e10-4d6b-4a1f-8e22-b1f0c3d4e5a6" \
-H "Content-Type: application/json" \
-d '{
"priceId": 1002,
"customerReference": "cus_84217",
"customerEmail": "[email protected]"
}'{
"data": {
"invoiceId": 9100,
"invoiceNumber": "INV-2026-000042",
"status": "ISSUED",
"totalMinor": 500000,
"currency": "BDT",
"priceId": 1002,
"productId": 42,
"payableLink": {
"payableUrl": "http://localhost:8080/checkout/pl_7c2a9e104d6b"
}
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}OneTimePurchaseDto
| Field | Type | Required | Notes |
|---|---|---|---|
invoiceId | number | always | The issued invoice's internal id. |
invoiceNumber | string | always | The issued invoice's per-app number. |
status | string | always | ISSUED on creation. |
totalMinor | number | always | The purchase total in minor units. |
currency | string | always | ISO-4217; BDT in v1. |
priceId | number | always | The bought ONE_TIME price (pinned on the invoice). |
productId | number | always | The owning product. |
payableLink | object | always | { payableUrl } — the hosted-checkout link the customer settles. |
See the One-time purchases guide.
A fully-assembled product
A product read returns the product with its prices and attached benefits
inlined (both lists are assembled by the use case after mapping). This ProductDto
has one RECURRING price with a trial, one ONE_TIME price, and one benefit:
{
"id": 42,
"name": "Pro plan",
"description": "Full access for growing teams",
"status": "ACTIVE",
"mode": "TEST",
"prices": [
{
"id": 1001,
"productId": 42,
"type": "RECURRING",
"amountMinor": 150000,
"currency": "BDT",
"interval": "MONTH",
"intervalCount": 1,
"trialDays": 14,
"active": true,
"createdAt": "2026-06-30T10:00:00Z"
},
{
"id": 1002,
"productId": 42,
"type": "ONE_TIME",
"amountMinor": 500000,
"currency": "BDT",
"active": true,
"createdAt": "2026-06-30T10:05:00Z"
}
],
"benefits": [
{
"id": 7,
"lookupKey": "seats",
"name": "Team seats",
"metadata": "{\"seats\": 10}",
"active": true,
"mode": "TEST",
"createdAt": "2026-06-29T09:00:00Z",
"updatedAt": "2026-06-29T09:00:00Z"
}
],
"createdAt": "2026-06-30T10:00:00Z",
"updatedAt": "2026-06-30T10:05:00Z"
}Notice the one-time price omits interval, intervalCount, and trialDays
(they are null and dropped from the response), and the benefit's metadata is a
JSON string ("{\"seats\": 10}"), not a nested object.
ProductDto
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | always | The product id. |
name | string | always | Product name. |
description | string | optional | Omitted when absent. |
status | string | always | DRAFT/ACTIVE/ARCHIVED. |
mode | string | always | TEST or LIVE. |
prices | PriceDto[] | always | The product's prices (assembled by the use case). |
benefits | BenefitDto[] | always | The product's attached benefits (assembled by the use case). |
createdAt | string | always | ISO-8601 timestamp. |
updatedAt | string | always | ISO-8601 timestamp. |
How it ties back to payments, invoices & webhooks
The catalog reuses the v1 money machinery rather than inventing parallel flows:
- A subscription cycle and a one-time purchase both produce an invoice with a payable link, settled through the same hosted-checkout payment flow.
- Settlement, refunds, and the double-entry ledger behave exactly as for any other
payment — there are no new webhook event types; you react to the existing
payment.*andinvoice.*webhooks. - A paid one-time purchase and an entitling subscription both feed
entitlement derivation, so your app gates features
off
lookupKeyregardless of how the customer paid.
Products & prices
Create products, add immutable prices, publish and archive.
Subscriptions
Create, cancel, pause, and resume recurring subscriptions.
Coupons
Author discounts and redeem them on a subscription.
One-time purchases
Sell a one-time price as a payable invoice.
Entitlements
Read and gate on a customer's active benefits.
App isolation
How app_id + mode keep every catalog scoped.