@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-payment

npm · Source on GitHub

How 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.ts JSDoc, 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 by planKey.

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-resource
  • zod

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):

  1. Verify the provider token/receipt server-side via the bonded payments provider (@molecule/api-payments — read its docs). Trust only its normalized result.
  2. Record it with paymentRecordService``.store, which enforces UNIQUE(platformKey, transactionId) so one receipt binds to exactly ONE account.
  3. Resolve the plan from the verified product/price id against the registered plans (match BOTH productId and priceId), 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``.store THROWS when a transactionId is 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 registerPlans using YOUR price/product ids (the env-configured price_…), 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 verified data — 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 bare subscriptionId) 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.