Skip to content

Vendor membership program (spec)

Vendor membership is a tenant-scoped program: each vendor owns a catalog of membership plans (membership_products, audience vendor_customer) and active subscriptions (membership_subscriptions) that link store customers to those plans. It is intentionally separate from admin SaaS vendor (platform) plans (platform_vendor).

The mental model is a chain retailer: one legal vendor account, many physical or logical stores, many end customers. Benefits can be defined vendor-wide or per store; checkout only applies benefits when the current POS store, logged-in customer, and plan + subscription scopes all align.

Operational detail lives in Membership products and Membership subscriptions; this page is the single-page spec tying scope, data, and POS behavior together.


ActorRole
VendorTenant key on plans and subscriptions (vendor_id). All CRM and POS resolution is under this boundary.
StoreOptional scope on a plan (membership_products.store_id) and inherited on subscriptions. POS uses the order’s store when matching.
Customerusers row associated with the vendor (user_id on subscription). POS passes user_id on place_order for membership resolution.

flowchart LR
  subgraph vendor["Vendor (tenant)"]
    MP["membership_products\n(plans)"]
    MS["membership_subscriptions\n(customer ↔ plan)"]
    U["Customer user"]
  end
  MP --> MS
  U --> MS
  MP -->|"store_id null = all stores"| S["Stores"]
  MP -->|"store_id set"| S
  MS -->|"store_id from plan"| MP
  • membership_products: sellable plan; audience = vendor_customer; optional benefits_json (array of typed benefit rows).
  • membership_subscriptions: binds user_id + membership_product_id for that vendor; store_id on the row follows the plan at create time (see Membership subscriptionsStore scoping under APIs).

Plans and subscriptions use store_id to mean where the program applies.

Plan store_idMeaning
NULLPlan is not limited to one store; valid for any store of the vendor (chain-wide catalog entry).
SetPlan is only for that store; employees must be allowed that store to manage it.

At POS, the backend loads an open subscription with:

  • Subscription status in trialing or active.
  • Subscription store_id is NULL or equals the checkout store ((ms.store_id IS NULL OR ms.store_id = :storeId)).
  • Plan status is active, same vendor_id, audience = vendor_customer, and plan store_id is NULL or equals the checkout store ((mp.store_id IS NULL OR mp.store_id = :storeId)).

Implementation reference: apps/backend/src/modules/pos/pos-membership-resolve.util.ts (loadActiveVendorCustomerMembershipSubscriptionForPos).


If more than one row satisfies the query, TypeORM returns a single row ordered by membership_subscriptions.created_at descending — i.e. the newest qualifying subscription is used for benefit rollup. Vendors should avoid overlapping open subscriptions for the same customer + store + overlapping plans; when they exist, behavior follows that last-created rule.


Only a subset of benefits_json kinds is enforced in POS (see Membership subscriptions — POS):

  • order_discount_percent — max percent across rows; after store-level discounts, before coupon.
  • order_discount_fixed_cents — summed fixed cents; combined with percent; total membership discount capped to subtotal.
  • loyalty_points_multiplier / loyalty_points_bonus_per_order — affect earn calculation.

Finance: membership discount flows into vendor expense / tax allocation as documented on the subscriptions page; orders.indochina_pos_place_snapshot may record membership_discount_amount, membership_subscription_id, membership_free_delivery_waived, membership_free_delivery_threshold_cents, membership_catalog_early_access_order, and membership_catalog_early_access_item_ids when discount > 0, free delivery was applied, or the order includes early-access catalog items under qualified membership preview context.

free_delivery_threshold_cents is enforced in POS for any order_type whenever a positive delivery_charge is included and the customer is not on customer pays shipping (see Membership subscriptions — POS). catalog_early_access is now enforced in POS (visibility + preview pricing + snapshot marker), and vendor-web emits analytics events:

  • pos_member_preview_exposure
  • pos_member_preview_add_to_cart
  • pos_member_preview_conversion

price_cents and duration_days on membership_products are catalog / billing metadata (list price and term length). They are not stored in benefits_json. POS custom membership lines must match price_cents; the benefit rollup still ignores them for discount math.


catalog_early_access implementation contract

Section titled “catalog_early_access implementation contract”

This section documents the normalized contract used by backend + vendor web. The contract is implemented and remains the source of truth for future extensions.

{
"kind": "catalog_early_access",
"value": {
"mode": "allowlist",
"starts_at": "2026-06-01T00:00:00.000Z",
"ends_at": "2026-06-03T00:00:00.000Z",
"category_ids": [12, 15],
"product_ids": [1012, 1044],
"member_price_override": {
"type": "percent",
"value": 10
}
}
}
  • value must be an object for enforceable behavior.
  • mode: allowlist | global_preview.
  • starts_at / ends_at: ISO datetime, UTC; if both present then ends_at > starts_at.
  • category_ids / product_ids: integer arrays, deduplicated, max size bounded (protect query fan-out).
  • member_price_override (optional):
    • type: percent | fixed_cents,
    • percent in range 0..100,
    • fixed_cents >= 0.

Legacy string | null inputs may remain accepted for backward compatibility, but must be treated as non-enforced/no-op values until migrated.

For catalog read endpoints under member context:

  1. Resolve effective subscription (same vendor/store/user constraints and newest-row rule as POS membership resolver).
  2. Parse catalog_early_access.
  3. Check active time window (starts_at / ends_at).
  4. Build eligible set by:
    • global_preview: all products in vendor/store scope,
    • allowlist: union of listed category_ids and product_ids.
  5. Apply optional member_price_override as response projection only.

If any step fails, fall back to normal public catalog behavior (no hard failure for end user listing).

When product is returned because of early access, include explicit fields:

  • is_member_preview: boolean
  • member_price_preview_cents?: number
  • preview_window_ends_at?: string

These flags are required for deterministic frontend rendering (badge, countdown, member price) and avoid duplicated business logic in UI.

  • Add shared typed model for this value in packages/shared/src/membership/membership-model.ts.
  • Reuse shared enum/tuple for:
    • catalog_early_access.mode,
    • member_price_override.type.
  • Required automated coverage when implemented:
    • parser/validator unit tests,
    • resolver unit tests (store scope + window + allowlist logic),
    • endpoint integration tests for catalog visibility and response flags.

For rollout and product UX checklist, see Membership playbook.


Membership discount / loyalty rollup runs only when:

  1. place_order includes a non-empty user_id (logged-in store customer on the order).
  2. The order has a real store context (storeId and vendor on the store) so tenant + store filters apply.

If the customer checks out guest or user_id is missing, no vendor-customer membership resolution runs (even if a subscription exists in CRM).


  • Vendor dashboard: module customerCustomers, Membership products, profile Membership subscriptions (see subscriptions guide).
  • APIs: vendor JWT, paths under /vendor/membership-products and /vendor/membership-subscriptions (client modules in apps/vendor-web/src/services/vendor-membership-*.api.ts).