@molecule/api-resource-payment
API resource · payments · API (Node) · v1.0.3 · Apache-2.0
Payment resource with subscription plan management
npm install @molecule/api-resource-paymentHow it works
@molecule/api-resource-payment is an API resource: the routes, validation and storage for payments, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { registerPlans, stripeMonthly, stripeYearly } from '@molecule/api-resource-payment'
// Register your plan catalogue at startup. The keys are YOUR plan ids; the ready-made
// Plan objects carry the env-configured Stripe price/product ids (also register the
// apple and google plan exports when you support those providers):
registerPlans({ monthly: stripeMonthly, yearly: stripeYearly })
// Then grant a plan ONLY after a SERVER-VERIFIED payment — never from a client field.
// The full verify → record (replay-guarded) → resolve → grant flow lives in the scaffolded
// user `verifyPayment` handler; verify receipts against @molecule/api-payments and store
// them with `paymentRecordService.store` (it THROWS on a replayed transactionId — reject).Works with: @molecule/api-bond, @molecule/api-database, @molecule/api-i18n, @molecule/api-locales-payment, @molecule/api-logger, @molecule/api-payments, @molecule/api-resource
Live demos
Running apps built with Molecule that install this package — open the app or study the template.
Reference
Payment resource with subscription plan management.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { registerPlans, stripeMonthly, stripeYearly } from '@molecule/api-resource-payment'
// Register your plan catalogue at startup. The keys are YOUR plan ids; the ready-made
// Plan objects carry the env-configured Stripe price/product ids (also register the
// apple and google plan exports when you support those providers):
registerPlans({ monthly: stripeMonthly, yearly: stripeYearly })
// Then grant a plan ONLY after a SERVER-VERIFIED payment — never from a client field.
// The full verify → record (replay-guarded) → resolve → grant flow lives in the scaffolded
// user `verifyPayment` handler; verify receipts against @molecule/api-payments and store
// them with `paymentRecordService.store` (it THROWS on a replayed transactionId — reject).
Type
resource
Installation
npm install @molecule/api-resource-payment @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-locales-payment @molecule/api-logger @molecule/api-payments @molecule/api-resource zod
API
Types
Props
Full payment record properties (userId, platform, transactionId, productId, data, receipt).
type Props = z.infer<typeof propsSchema>
CreateProps
Fields required when creating a new payment record.
type CreateProps = z.infer<typeof createPropsSchema>
UpdateProps
Updatable payment record fields (data and receipt).
type UpdateProps = z.infer<typeof updatePropsSchema>
Classes
PaymentRecordConflictError
Error thrown by paymentRecordService.store when inserting a payment
record violates the UNIQUE(platformKey, transactionId) constraint — i.e. the
transaction is already bound to an account.
This is surfaced (not swallowed) so callers such as the user resource's
verifyPayment handler can enforce first-claim-wins ownership and reject a
replayed receipt instead of silently granting the plan to a second account.
class PaymentRecordConflictError extends Error {
/** The platform key (e.g. `'apple'`, `'google'`, `'stripe'`). */
readonly platformKey: string
/** The transaction id that is already bound. */
readonly transactionId: string
/**
* Builds the conflict error, naming the platform + transaction already bound
* so the caller can report which record it collided with.
*
* @param platformKey - The platform key of the conflicting record.
* @param transactionId - The transaction id that is already bound.
* @param options - Standard error options (e.g. `{ cause }`).
*/
constructor(platformKey: string, transactionId: string, options?: ErrorOptions) {
super(`Payment record already exists for ${platformKey} transaction ${transactionId}`, options)
this.name = 'PaymentRecordConflictError'
this.platformKey = platformKey
this.transactionId = transactionId
}
}
Functions
registerPlans(customPlans)
Registers (or replaces) plans in the shared registry consumed by
planService — keyed by planKey, merged into the defaults above.
The built-in entries carry PLACEHOLDER platform product ids
(price_test_id / price_prod_id), so findPlanByProductId can never
match a real platform identifier until the app registers its own catalogue
— typically derived from its pricing tiers with the real Stripe price ids
from env (@molecule/api-bonds-default-express's createBillingRouter
does this automatically). Without registration, payment verification and
webhook plan grants fail with "unknown plan" even though the charge
succeeded.
Idempotent: re-registering the same planKey overwrites that entry.
function registerPlans(customPlans: Record<string, Plan>): void
customPlans— Plans to merge into the registry, keyed byplanKey.
getPeriodTime(period)
Get the duration in milliseconds for a billing period.
function getPeriodTime(period: PlanPeriod): number
period— The billing period ('month' or 'year').
Returns: The period duration in milliseconds.
Constants
i18nRegistered
The i18n registered.
const i18nRegistered: true
planService
PlanService implementation for the bond system.
Provides plan lookup operations that other resources
can use through get('plans') / require('plans').
const planService: PlanService
stripeMonthly
Stripe monthly subscription plan ($5/month, auto-renews).
const stripeMonthly: Plan
stripeYearly
Stripe yearly subscription plan ($55/year, auto-renews).
const stripeYearly: Plan
appleMonthly
Apple App Store monthly subscription plan ($5.99/month, auto-renews).
const appleMonthly: Plan
appleYearly
Apple App Store yearly subscription plan ($64.99/year, auto-renews).
const appleYearly: Plan
googleMonthly
Google Play monthly subscription plan ($5.99/month, auto-renews).
const googleMonthly: Plan
googleYearly
Google Play yearly subscription plan ($64.99/year, auto-renews).
const googleYearly: Plan
plans
All available plans indexed by planKey. The empty string key '' maps to the default free plan.
const plans: Record<string, Plan>
paymentRecordService
PaymentRecordService implementation for the bond system.
Provides payment record CRUD operations that other resources
can use through get<PaymentRecordService>('paymentRecords').
const paymentRecordService: PaymentRecordService
resource
Payment resource definition with JSON schema for validation.
const resource: types.Resource
propsSchema
Validation schema for props.
const propsSchema: z.ZodObject<
{
id: z.ZodString
createdAt: z.ZodString
updatedAt: z.ZodString
userId: z.ZodString
platformKey: z.ZodDefault<
z.ZodEnum<{ '': ''; stripe: 'stripe'; apple: 'apple'; google: 'google' }>
>
transactionId: z.ZodOptional<z.ZodString>
productId: z.ZodString
data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
receipt: z.ZodOptional<z.ZodString>
},
z.core.$strip
>
createPropsSchema
Zod schema for creating a payment record (picks fields required at creation time).
const createPropsSchema: z.ZodObject<
{
createdAt: z.ZodString
updatedAt: z.ZodString
userId: z.ZodString
platformKey: z.ZodDefault<
z.ZodEnum<{ '': ''; stripe: 'stripe'; apple: 'apple'; google: 'google' }>
>
transactionId: z.ZodOptional<z.ZodString>
productId: z.ZodString
data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
receipt: z.ZodOptional<z.ZodString>
},
z.core.$strip
>
updatePropsSchema
Zod schema for updating a payment record (data and receipt only).
const updatePropsSchema: z.ZodObject<
{
data: z.ZodOptional<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>>
receipt: z.ZodOptional<z.ZodOptional<z.ZodString>>
},
z.core.$strip
>
Namespaces
types
Re-exported as a namespace — reach every member below through it (e.g. types.<name>).
types.PlatformKey
Every available platform.
Open string type so new payment platforms can be added without modifying this package. Well-known values include 'stripe', 'apple', and 'google'.
type PlatformKey = string
types.PlanAlias
Every available plan alias.
type PlanAlias = '' | 'monthly' | 'yearly'
types.PlanPeriod
Every available plan period.
type PlanPeriod = '' | 'month' | 'year'
types.Props
The payment's properties.
interface Props {
id: string
createdAt: string
updatedAt: string
userId: string
platformKey: PlatformKey
transactionId?: string
productId: string
data?: Record<string, unknown>
receipt?: string
}
types.CreateProps
Properties when creating a payment.
type CreateProps = Pick<
Props,
| 'createdAt'
| 'updatedAt'
| 'userId'
| 'platformKey'
| 'transactionId'
| 'productId'
| 'data'
| 'receipt'
>
types.UpdateProps
Properties when updating a payment.
type UpdateProps = Partial<Pick<Props, 'data' | 'receipt'>>
types.Plan
A plan's properties.
interface Plan {
planKey: string
platformKey: PlatformKey
platformProductId: string
/**
* The platform's PRICE identifiers that grant this plan (e.g. Stripe
* `price_…` ids). Apps configure prices — not products — in their env
* (`STRIPE_<APP>_PRO_MONTHLY` etc.), so plan resolution matches an
* incoming platform identifier against `platformProductId` OR membership
* in this list.
*/
platformPriceIds?: string[]
alias: PlanAlias
period: PlanPeriod
price: string
autoRenews?: boolean
title: string
/** i18n key for `Plan.title`. Optional — plans registered cross-package (e.g. by a billing router bridging its tiers) carry plain text only. */
titleKey?: string
description: string
/** i18n key for `Plan.description`. Optional — see `Plan.titleKey`. */
descriptionKey?: string
shortDescription?: string
shortDescriptionKey?: string
highlightedDescription?: string
highlightedDescriptionKey?: string
capabilities: {
premium: boolean
}
}
types.Resource
Resource type.
interface Resource {
name: string
tableName: string
schema: unknown
}
Services
This package exports services that should be registered with the bond system:
import { bond } from '@molecule/api-bond'
import { planService, paymentRecordService } from '@molecule/api-resource-payment'
bond('plans', planService)
bond('paymentRecords', paymentRecordService)
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-database^1.0.1@molecule/api-i18n^1.0.1@molecule/api-locales-payment^1.0.1@molecule/api-logger^1.0.1@molecule/api-payments^1.0.1@molecule/api-resource^1.0.1
Runtime Dependencies
@molecule/api-bond@molecule/api-database@molecule/api-i18n@molecule/api-locales-payment@molecule/api-logger@molecule/api-payments@molecule/api-resourcezod
A plan/entitlement is granted ONLY after a SERVER-VERIFIED payment — never from anything
the client reports. The flow (implemented by the user resource's verifyPayment handler):
- Verify the provider token/receipt server-side via the bonded payments provider
(
@molecule/api-payments— read its docs). Trust only its normalized result. - Record it with
paymentRecordService``.store, which enforcesUNIQUE(platformKey, transactionId)so one receipt binds to exactly ONE account. - Resolve the plan from the verified product/price id against the registered
plans(match BOTHproductIdandpriceId), then grant it.
Weak-integration mistakes to avoid:
- Never trust a client-sent plan / subscription / "isPro". The plan is derived from the verified provider result, not a request field.
- Never swallow the duplicate-record error.
paymentRecordService``.storeTHROWS when atransactionIdis already claimed — that is the replay guard (first-claim-wins). Catch it and REJECT; do not grant the plan to a second account. - Register your catalogue with
registerPlansusing YOUR price/product ids (the env-configuredprice_…), and resolve by BOTH product and price id — providers report the product on a subscription while checkout uses the price. - Re-verify entitlement server-side wherever it gates access; never cache "is subscribed" somewhere the client can set.
The payments table ships in src/__setup__/payments.sql — an mlcl-scaffolded
API copies and replays it automatically on migrate; anywhere else run it once.
Missing it surfaces as relation "payments" does not exist on the first
billing call — nothing at runtime creates the table.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt
each item to this app's actual billing screens/flows, and check every box
off one by one. A box you can't check is an integration bug to fix — not a
skip. This resource RECORDS a server-verified provider result (a receipt or
subscription id verified via @molecule/api-payments) and resolves the plan
— it does NOT charge cards and has no amount/status column, so drive the
provider's real verify path; never fabricate a record or a status:
- A completed purchase/subscribe flow reflects in the account: the paid tier's capabilities unlock and a payment record binds to THIS user with the right platformKey (stripe/apple/google), productId/priceId, and transactionId — and it appears in the user's own billing/subscription view.
- The plan and price shown match what was actually purchased: the tier is
resolved from the VERIFIED product/price id against the registered plans,
and any amount/currency shown comes from that plan (its
price) or the provider's verifieddata— never a client-sent field. An amount renders formatted with its currency, not as a raw smallest-unit integer. - Status follows the provider, not the client: a verified payment grants
the plan (pending is granted only once it succeeds), and a provider
cancellation/refund/expiry webhook (
handlePaymentNotification) REVOKES it so the user loses premium. No request field (planKey,isPro, a baresubscriptionId) lets the client set or keep an active/paid status. - Replay and re-complete guard: one receipt/subscription transaction binds to exactly ONE account (UNIQUE(platformKey, transactionId), first-claim-wins) — replaying the same receipt into a second account is REJECTED, re-verifying your own is idempotent (no double grant), and a refunded/canceled entitlement can't be reclaimed without a NEW verified payment.
- Authorization — a user sees only THEIR OWN payments: guessing another user's payment/transaction id returns nothing (records scope by userId), and NO endpoint lets a user mark their own payment succeeded/refunded or set their own plan/isPro to spoof having paid — only the verified provider result grants the tier.
- The provider secret (e.g. STRIPE_SECRET_KEY / APPLE_SHARED_SECRET / GOOGLE_API_SERVICE_KEY_OBJECT) is used only server-side for verification and never reaches the browser bundle or a client request — this resource is SERVER-ONLY.