Skip to content

Customers (Vendor)

Customers provides list (filters, sort, pagination, KPIs) and profile detail. Module customer applies; mutations need write where route rules say so.


GoalsNon-goals
CRM-style store customer opsEmail marketing automation
Respect PII handlingSync to external CRM without integration

  • Prefix /dashboard/customers → module customer; owners bypass checks.
  • APIs documented in-page (APIs section).

  1. Open Customers → filter → open profile.
  2. Create/update per UI if enabled and write allowed.

  • PII — minimize export; comply with local privacy law.

IDScenarioExpected
CU1List KPI + rowsLoads for allowed module
CU2DetailShows customer fields
CU3Employee read-onlyMutations disabled or API 403
CU4Customer category: set wholesale defaults; assign customer to categoryCreate-order / wholesale-credit-status reflects category → store merge
CU5Customer wholesale overridesOverrides win over category and store

  • /dashboard/customers — list with filters, sort, pagination, KPI overview
  • /dashboard/customers/:id — profile detail
  • /dashboard/customers/categoriescustomer segments / labels (CRM customer_categories): list, create/edit modal, import/export Excel. Used to group customers and to set wholesale (B2B) credit defaults per segment (see below).

Prefix /dashboard/customers maps to module customer. Mutations (create/update/delete) require write where enforced by route rules. Owners always pass.

Implemented in apps/vendor-web/src/services/vendor-customers.api.ts.

List and overview use the auth context base path /auth/vendor/context (see VENDOR_AUTH_CONTEXT_API in lib/api.ts):

  • GET /auth/vendor/context/customers/overview
  • GET /auth/vendor/context/customers — query: page, limit, search, status, store_id, sort, date range, etc.
  • GET /auth/vendor/context/customers/:id/loyalty — loyalty summary (loyalty_points, tier_key).
  • GET /auth/vendor/context/customers/:id/loyalty-transactions — loyalty ledger (paginated).
  • GET /auth/vendor/context/customers/:id/wallet — wallet summary (wallet_balance).
  • GET /auth/vendor/context/customers/:id/wallet-transactions — wallet ledger (paginated).
  • POST /auth/vendor/context/customers/:id/wallet/adjust — manual wallet credit. Body: amount, note, credit_type (cash | bank_transfer | check | card | other), optional evidence_paths (up to 5 vendor gallery public_path values, e.g. photos of checks or transfer receipts). Ledger row uses transaction_type = vendor_manual_credit and JSON metadata (credit_type, evidence[]).
  • GET /auth/vendor/context/customers/segments/overview — segmentation counters (vip, loyal, new, dormant, plus tier counters).
  • GET /auth/vendor/context/customers/:id/timeline — blended customer timeline (orders + loyalty events, newest first).
  • GET /auth/vendor/context/customers/crm/kpis — CRM performance KPIs (repeat_rate, average_order_value, open_membership_customers, memberships_expiring_soon, redemption_rate_30d, …).
  • GET /auth/vendor/context/customers/:id — detail (same :id semantics as GET /api/v1/vendor/customers/:id). id is always CRM customers.id (same value as list rows and Excel export). The row must fall in an authorized store, or vendor owners may load CRM rows with store_id null for that vendor.
  • GET /vendor/customers/:id/wholesale-credit-status?store_id= — returns resolved effective wholesale credit policy for that CRM customer in the given store, plus open/overdue wholesale debt round counts and a placement preview (whether a new wholesale debt order would warn or be denied). store_id is required; must be in the vendor’s authorized stores.

Create/update/delete call /vendor/customers directly:

  • POST /vendor/customers — create (optional store_id to scope the customer to a store that belongs to the vendor). Uniqueness: email and phone are enforced per vendor (and legacy rows tied only by store within the vendor’s stores), so the same email or phone may exist for customers of other vendors. Optional body fields mirror Laravel users columns for extended profile: bussiness_name, bussiness_type, city, street_address, state, zip_code, ref_code, limit_amount_debt, limit_order_amount_owed, maximum_amount_debt. Duplicate email/phone for the same vendor returns 409 with { success: false, errors: [{ code, message }] } (not a generic 500).
  • GET /vendor/customers/:id — detail; :id is CRM customers.id only (matches list/export).
  • PATCH /vendor/customers/:id:id is CRM customers.id. Excel id column is the same. Rows matched only by phone must already have a CRM row for that retail login (otherwise import reports an error for that row). Login-backed creates that write a CRM mirror return customers.id as the public identifier for detail/PATCH. For CRM-only rows, sending create_user: true with email and password creates and links a retail login to the existing CRM customer; status updates are limited to active (1) or blocked (0). Optional category_id (CRM segment id) links the row to customer categories for wholesale defaults.

Optional wholesale (B2B) credit overrides on create/patch (nullable; omitted = no change):

  • wholesale_credit_term_mode0 none, 1 N days after invoice, 2 fixed day of next month.
  • wholesale_credit_deadline_days — used when mode is 1 (positive integer).
  • wholesale_credit_due_day_of_month — used when mode is 2 (1–28).
  • wholesale_max_open_debt_rounds — cap on concurrent open wholesale debt “rounds” (receivable-based; not a money cap).
  • Sending explicit null on patch clears that override column so the customer inherits from category and/or store again.

Customers may reference a category_id (CRM segment). Effective policy resolution is documented in Wholesale (B2B) credit policy.

  • DELETE /vendor/customers/:id
  • POST /vendor/customers/:id/loyalty/adjust — manual loyalty adjustment (delta, optional reason).

Implemented in apps/vendor-web/src/services/vendor-customer-categories.api.ts.

  • GET /vendor/customer-categories — paginated list (page, limit, search, store_id).
  • POST /vendor/customer-categories — create (name, optional store_id, description, image, plus optional wholesale credit fields below).
  • GET /vendor/customer-categories/:id
  • PATCH /vendor/customer-categories/:id
  • DELETE /vendor/customer-categories/:id
  • POST /vendor/customer-categories/batch/delete — max 200 ids
  • GET /vendor/customer-categories/export — Excel (columns include base label fields; wholesale columns are persisted on the entity for API/UI)
  • POST /vendor/customer-categories/import — Excel import (existing behavior for name/store/description/image; wholesale fields are not part of the simple import sheet—set them in the UI or via PATCH)

Optional wholesale credit defaults for the segment (same semantics as customer overrides; null clears a stored default):

  • wholesale_credit_term_mode, wholesale_credit_deadline_days, wholesale_credit_due_day_of_month, wholesale_max_open_debt_rounds, wholesale_max_open_debt_rounds_rule (0 allow / 1 warn / 2 deny when max open rounds would be exceeded on a new wholesale debt order).

DB: Migration adds prefixed columns on customer_categories (e.g. 1782900000000-CustomerCategoryWholesaleCredit).

Applies only to wholesale sales (OrderSaleType.Wholesale / sale type 1) when the order is recorded on account (debt). Retail checkout does not use store debt caps (ops_max_debt_amount, ops_max_customer_debt_amount, sale_debt_over_limit_rule).

Store debt caps (Operations + Sales): on wholesale debt placement, evaluatePosStoreDebtPlacement compares the new unpaid amount and projected customer total against ops_max_debt_amount / ops_max_customer_debt_amount, using existing wholesale-only open receivable totals. sale_debt_over_limit_rule (allow / warn / deny) controls whether over-limit placement is blocked or only warned. Preview: GET /vendor/customers/:id/wholesale-credit-status?store_id=&order_outstanding= (optional projected unpaid amount).

Precedence (effective values):

  1. Customer — non-null wholesale credit fields on customers win for that field.
  2. Customer category — for any field still unset on the customer, use non-null values on customer_categories linked by customers.category_id.
  3. Storestore_configurations provides store-wide defaults for remaining fields.

Term modes: 0 no automatic deadline rule, 1 due N days after invoice date, 2 due on day X of the next calendar month (X clamped to last day of that month when needed).

Overdue wholesale receivables: always produce a warning in placement checks; they do not block checkout by themselves.

Max open debt rounds: if placing a new wholesale debt order would push the customer above the configured max, behavior is driven by wholesale_max_open_debt_rounds_rule: allow (no block), warn, or deny (default at store level is typically deny; category/customer can override the rule).

Implementation references: resolveWholesaleCreditPolicy / evaluateWholesaleCreditPlacement / computeWholesalePaymentDeadline in apps/backend/src/modules/pos/wholesale-credit-policy.util.ts; POS enforcement in PosService.placeOrderWithStock; shared constants in packages/shared/src/constants/wholesale-credit.ts.

Vendor UI: store defaults under Stores → Configuration; category defaults in Customers → Categories modal; per-customer overrides in add/edit customer modals and on create wholesale order (credit status query + banners).

  • Auto earn: when a POS order is marked as paid (manual mark success or terminal webhook success), earn applies to customers.loyalty_point when the order has a CRM customer_id, otherwise to users.loyalty_point. The ledger row uses transaction_type = "earn" and reference = "order:<order_id>"; CRM-path rows may have loyalty_point_transactions.user_id null.
  • Auto reversal on refund: when terminal webhook marks an order as refunded, earned points for that order are reversed with transaction_type = "refund_reversal".
  • Idempotency: earn/reversal uses the order reference key to prevent duplicate credits/debits from repeated callbacks.
  • Vendor visibility: customer detail exposes loyalty summary and paginated loyalty transactions. GET …/loyalty-transactions and GET …/timeline include CRM-linked ledger rows (matching reference to orders for this customers.id in authorized stores), not only rows keyed by a linked retail users.id.
  • Wallet refund credit: when a refund is moved to refunded and refund_method = "wallet", backend credits users.wallet_balance and writes a wallet_transactions ledger row with transaction_type = "order_refund".
  • Point redemption on checkout: POS accepts optional loyalty_points_redeem in place-order payload and applies redemption once payment is captured.
  • Redeem idempotency: redemption writes a ledger row with transaction_type = "redeem" and reference = "order:<order_id>"; repeated paid callbacks do not double-debit.
  • Refund restore: when refunded, redeemed points are restored with transaction_type = "redeem_reversal" (alongside earn reversal).
  • Segmentation API: vendor can query segments/overview for quick CRM targeting in dashboard.
  • Customer timeline API: customer detail can render unified history across order and loyalty events.
  • CRM KPI API: backend exposes aggregated metrics for vendor dashboard:
    • purchasing_customers, repeat_customers, repeat_rate
    • average_order_value
    • open_membership_customers — distinct customers with at least one membership subscription in draft, trialing, active, or past_due for the scoped store(s)
    • memberships_expiring_soon — open-status subscriptions with ends_at set, ending within the next 30 days (same store scope)
    • redemption_customers_30d, redemption_rate_30d
  • Dashboard visibility: vendor customer list page consumes overview plus crm/kpis and shows four KPI cards: total customers (MoM trend), average order value, membership customers, and memberships expiring soon.
  • Store scope: all KPI endpoints respect vendor store authorization and optional store_id filter.

Treat customer PII (name, email, phone, addresses) according to your organization’s privacy policy; the UI is a standard dashboard CRUD surface.

If the legacy schema still has global unique indexes on users.phone / users.email (common Laravel names users_phone_unique, users_email_unique), inserts can fail even when another vendor already uses that phone or email. Migration 1778200000001-DropUsersGlobalPhoneEmailUniqueIndexes drops those index names on the prefixed users table when they exist (MySQL information_schema). If your database uses different index names, adjust or drop them manually, then add composite uniques (vendor_id, phone) / (vendor_id, email) if your DBA requires DB-level enforcement.

  • Orders: customers appear on order list/detail via the orders APIs (Orders (Vendor)).
  • Receivables: finance receivable lines reference user_id and order_id (Finance).