Skip to content

Membership playbook (Vendor)

This page aligns LionPOS vendor → customer membership with a large-format retail chain mental model: one account, many stores, member savings + loyalty). It complements:

The canonical kind strings and validation live in packages/shared/src/membership/membership-model.ts and apps/backend/src/database/utils/membership-benefits-json.util.ts. The vendor dashboard modal shows the same playbook in a collapsible table.


List price and term length live on the product row: membership_products.price_cents and membership_products.duration_days. They are not benefits_json rows. POS validates custom membership lines against price_cents; subscription ends_at defaults use duration_days.

Benefit kind → retail lever → POS today

Section titled “Benefit kind → retail lever → POS today”
kindValue (summary)Retail member program analogyEnforced in POS checkout
order_discount_percent0–100Instant basket / “member price” savings on subtotal (after store discounts, before coupon)Yes
order_discount_fixed_centsMinor units (cents)Fixed member dollars-offYes
loyalty_points_multiplier> 1.0Higher earn rate on rewardsYes
loyalty_points_bonus_per_orderInteger ≥ 0Bonus points per tripYes
catalog_early_accessObject, string, or nullEarly access / gated dropsYes (Phase A/B/C implemented: visibility + preview pricing + analytics/snapshot)
free_delivery_threshold_centsInteger ≥ 0Free delivery / shipping threshold (merchandise subtotal in USD cents vs included fee)Yes (any order_type with included delivery_charge; not when customer pays shipping)

JSON shape: benefits_json is a JSON array of objects, each { "kind": "<kind>", "value": <typed> }. It is not { "benefits": [ ... ] }.


Rollout checklist (vendor operator + engineering)

Section titled “Rollout checklist (vendor operator + engineering)”
  • membership_products / membership_subscriptions migrations applied on the target DB.
  • DB_ENABLED=true and TypeORM repositories available (otherwise vendor APIs return vendor_requires_database).
  • At least one vendor_customer plan with status = active and valid benefits_json (or empty).
  • Store scope intentional: plan store_id null = all stores; set = single store only.
  • Test inactive plan cannot be assigned (API product_inactive).
  • Customer has user_id on POS orders when membership savings should apply.
  • At most one open subscription per vendor + customer (any plan; customer_open_membership_exists if another is draft / trialing / active / past_due).
  • Cancel path sets canceled / canceled_at and POS stops picking the row.
  • Place order with logged-in customer: verify membership_discount_amount / membership_subscription_id in indochina_pos_place_snapshot when discount > 0.
  • Any order type with included delivery_charge and threshold: subtotal meets plan → delivery_charge zeroed, snapshot membership_free_delivery_waived true (when fee is included, not customer-pays-shipping).
  • Verify expense row / tax allocation per Membership subscriptions — POS.
  • Vendor → Customer membership (assign plan on customer profile): collapsible playbook table + benefits_json array example (shared VENDOR_CUSTOMER_MEMBERSHIP_BENEFITS_JSON_EXAMPLE), aligned with Membership products modal.
  • Vendor → Membership products modal: same playbook reference (catalog editor).

IDScenarioStepsExpected
WM-POS-01Member percent discountActive plan with order_discount_percent; customer subscribed; place_order with user_idSubtotal reduced after store discounts; snapshot contains discount when > 0
WM-POS-02Member fixed + percent capPlan with both percent and fixed cents; subtotal smallTotal membership discount ≤ subtotal after store discounts
WM-POS-03Loyalty earn multiplierPlan with loyalty_points_multiplier > 1Earned points reflect multiplier (see POS earn path)
WM-POS-04Guest checkoutSame plan + subscription but no user_id on orderNo membership resolution
WM-POS-05Wrong storePlan scoped to store A; order at store BNo membership row selected
WM-CRM-01Second open membershipPOST subscription while customer already has active / trialing / draft / past_due on any plan409 customer_open_membership_exists
WM-CRM-02Inactive planPOST subscription to inactive plan409 product_inactive
WM-CRM-03JSON validationPATCH plan with invalid benefits_json kind400 invalid_benefit_kind or invalid_benefits_json
WM-CRM-04Newest winsTwo overlapping qualifying subscriptions (avoid in prod)POS uses newest created_at (see program spec)
WM-POS-06Free delivery thresholdAny order_type, positive included fee, benefits_json has free_delivery_threshold_cents ≤ merchandise subtotal (after member + coupon, before tax), not customer-pays-shippingdelivery_charge = 0, original_delivery_charge unchanged; snapshot membership_free_delivery_waived true

Automated coverage today includes parseAndValidateMembershipBenefitsJson, pos-membership-resolve.util rollup math, POS snapshot builder, and PosService.placeOrder waiver path — extend with integration tests when adding new enforced kinds.


catalog_early_access has moved beyond storage-only:

  • Phase A (visibility gating): implemented.
  • Phase B (member preview price projection + UI rendering): implemented.
  • Phase C (analytics + order traceability marker): implemented.

catalog_early_access should let vendors publish products or categories to members earlier than the public catalog while preserving:

  • existing subscription resolution rules (vendor + user + store scope),
  • current product status workflow,
  • backward-compatible benefits_json parsing.

This benefit is for catalog visibility and optional member pricing, not checkout discount math (that remains in order-level benefits).

Current parser accepts object / string / null. To make behavior deterministic across backend + web, standardize on an object payload and keep legacy values as soft-compatible input:

{
"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
}
}
}

Field intent

  • mode: allowlist (only listed categories/products) or global_preview (all products in scope during window).
  • starts_at / ends_at: UTC gate window. If omitted, window is always open while subscription is active.
  • category_ids: category gates for preview.
  • product_ids: explicit SKU gates.
  • member_price_override: optional member-only preview price policy:
    • type: percent (0–100),
    • type: fixed_cents (minor units),
    • absent => no price override (visibility-only early access).

For each catalog read request:

  1. Resolve effective membership subscription using existing rules (newest active/trialing for vendor + customer + store).
  2. Parse catalog_early_access from plan benefits_json.
  3. Compute access window (starts_at / ends_at).
  4. Build allowed catalog set from mode, category_ids, product_ids.
  5. Apply optional member_price_override to response projection (do not mutate base product price row).

When no valid benefit or no active subscription exists, fallback to current public catalog behavior.

Keep endpoint path unchanged; gate via query context + auth:

  • Vendor web customer context (selected customer on POS/catalog view) provides user_id.
  • Backend returns only preview-eligible rows for member context during active window.
  • Response should include explicit flags for UI:
    • is_member_preview: boolean
    • member_price_preview_cents?: number
    • preview_window_ends_at?: string

This avoids implicit frontend guessing and supports badges/countdown UX.

  • Catalog cards/list rows:
    • badge: Member Early Access,
    • optional member price with crossed public price.
  • Membership product modal:
    • structured editor for catalog_early_access object (no raw JSON required for common flow),
    • preview summary text generated from chosen scope/window.
  • POS/cart:
    • if product added under early access context, snapshot should mark source as membership preview for audit (new snapshot key when implemented).
  • No new table required in phase 1 (derive from benefits_json).
  • Keep parser backward compatibility:
    • string/null accepted but treated as “disabled/no-op” unless mapped by migration policy,
    • object is canonical for enforcement.
  • Add shared typed model in packages/shared/src/membership/membership-model.ts for exact value shape.

Test matrix extension (for implementation phase)

Section titled “Test matrix extension (for implementation phase)”

Add these IDs when coding starts:

  • WM-CAT-01: member with active window sees gated products; guest does not.
  • WM-CAT-02: window expired => preview rows hidden.
  • WM-CAT-03: store mismatch => no preview access even with active subscription.
  • WM-CAT-04: category allowlist + explicit product allowlist union behavior.
  • WM-CAT-05: member_price_override.percent computes preview price correctly and never below zero.
  • WM-CAT-06: malformed object falls back safely (validation error on write, no runtime crash on read).
  1. Phase A (visibility only): enforce gating, no price override.
  2. Phase B (member preview price): add response projection + UI badges/price.
  3. Phase C (analytics): track preview conversion and attach source marker in order snapshot.

Each phase must include unit tests in parser/resolver + integration tests for catalog listing contracts.

Implementation checklist by phase (execution runbook)

Section titled “Implementation checklist by phase (execution runbook)”

This checklist maps docs design to concrete implementation touchpoints for backend + frontend.

Backend scope

  • Add canonical typed value contract for catalog_early_access in packages/shared/src/membership/membership-model.ts.
  • Extend membership benefit parser/validator in apps/backend/src/database/utils/membership-benefits-json.util.ts:
    • mode,
    • starts_at / ends_at,
    • category_ids / product_ids.
  • Add resolver utility in vendor catalog read flow to compute preview eligibility from:
    • vendor + user + store subscription scope,
    • active preview window,
    • allowlist/global mode.
  • Keep normal catalog response unchanged for non-member context.

Frontend scope (vendor web)

  • Add optional member context input to catalog query flow (selected customer where applicable).
  • Render preview badge when backend marks row as member-preview.
  • Keep public price behavior unchanged (no override yet).

Required tests

  • Unit: parser accepts valid object, rejects malformed object.
  • Unit: resolver enforces store scope + window + allowlist union.
  • Integration: same catalog endpoint returns different visibility for member vs guest context.
    • Suggested target: add/extend POS catalog listing integration spec (backend) to assert:
      • member context returns preview-only rows in active window,
      • guest/no-user context hides those rows,
      • store mismatch still hides preview rows.
    • Pass gate: one spec covers both positive + negative visibility with deterministic fixtures.
    • Implemented in: apps/backend/src/modules/pos/pos.service.spec.ts.

Done criteria

  • Member can see preview-only items in active window.
  • Guest/non-qualified member cannot see those items.
  • No regression on existing catalog list for non-members.

Backend scope

  • Support member_price_override validation (percent / fixed_cents).
  • Compute member_price_preview_cents in response projection (never mutate source price).
  • Include explicit response flags:
    • is_member_preview,
    • member_price_preview_cents,
    • preview_window_ends_at.

Frontend scope (vendor web)

  • Show crossed public price + member preview price.
  • Show countdown/end hint using preview_window_ends_at.
  • Keep fallback rendering stable when flags are absent.

Required tests

  • Unit: price override math (percent, fixed_cents, floor at 0).
  • Integration: API response includes preview flags for qualified rows.
  • UI test: badge + preview price render correctly from API contract.
    • Integration target (backend): assert row payload includes:
      • is_member_preview,
      • member_price_preview_cents,
      • preview_window_ends_at for qualified member context only.
    • UI target (vendor web): cover row/card rendering when:
      • preview flags exist (badge + crossed price + preview price + end hint),
      • preview flags are absent (fallback public rendering unchanged).
    • Pass gate: response contract + render behavior both verified by automated tests.
    • Implemented (backend integration): apps/backend/src/modules/pos/pos.service.spec.ts.
    • Implemented (vendor-web UI contract mapping): apps/vendor-web/e2e/pos-catalog-member-preview.spec.ts.

Done criteria

  • Qualified member sees deterministic preview pricing.
  • Non-qualified users never receive preview price fields.

Phase C — Analytics and order traceability

Section titled “Phase C — Analytics and order traceability”

Backend scope

  • Add event/metrics capture for preview impressions and conversions.
  • Add order snapshot marker for early-access source when item purchased under preview context.
  • Document new snapshot keys in membership docs if introduced.
    • Implementation notes:
      • Emit analytics events with stable names and vendor/store/customer dimensions:
        • pos_member_preview_exposure
        • pos_member_preview_add_to_cart
        • pos_member_preview_conversion
      • Add snapshot key only when item came from member preview context (no-op for normal flow).
      • Keep key naming aligned with existing membership_* snapshot fields.

Frontend scope (vendor web)

  • Track preview exposure events from catalog view.
  • Track click/add-to-cart actions for preview items.
    • Implementation notes:
      • Fire exposure once per visible preview row set (avoid duplicate spam on rerender).
      • Fire action events with product id + preview context + current customer context.
      • Keep payload schema shared with backend analytics contract.

Required tests

  • Unit: analytics payload builder.
  • Integration: order snapshot includes preview marker when applicable.
  • Regression: normal purchase flow unaffected without preview context.
    • Pass gate:
      • payload builder test validates event schema and required dimensions,
      • integration test asserts snapshot marker appears only for preview-origin purchase,
      • regression test confirms non-preview orders keep existing snapshot behavior.

Done criteria

  • Team can report preview-to-purchase funnel.
  • Finance/support can audit preview-origin orders via snapshot.

When implementing, add unit tests next to the util or service, update this playbook table (posEnforced column), and update the vendor modal (data comes from shared VENDOR_CUSTOMER_MEMBERSHIP_BENEFIT_PLAYBOOK).

  • Analytics contract + payload builders: apps/vendor-web/src/lib/pos-member-preview-analytics.util.ts.
  • POS event emit points:
    • exposure on visible preview set,
    • add-to-cart for preview item,
    • conversion on successful order.
  • UI rendering assertion expanded to full card check:
    • apps/vendor-web/e2e/pos-catalog-member-preview.spec.ts.
  • Snapshot non-preview regression:
    • apps/backend/src/modules/pos/pos-place-order-snapshot.util.spec.ts.