CRM (Trading Partners) — customers & suppliers as the party master

The whole CRM domain reduces to one idea:

A customer and a supplier are the same thing — a User row — discriminated by kind. Both are Mongoose discriminators of the shared users collection (kind=Customer / kind=Supplier), sharing one common profile shape (CommonUserInfo / CommonUserInput). Each partner carries no stored balance: its outstanding receivable/payable is derived from the finance General Ledger by looking up the partner's control account (accountId) and summing the POSTED legs whose payeeId == partner._id. A customer's control account is an Accounts-Receivable (debtor) account; a supplier's is an Accounts-Payable (creditor) account. Everything else — orders, trade entries, cashbook payments, aged reports — references these party rows by customerId / supplierId and posts to the party's control account.

Source: BE src/modules/customer, src/modules/supplier (both discriminators of src/modules/user) · Admin src/modules/customers, src/modules/suppliers

Sub-modules:

  • customers.md — customer master (contacts, addresses, RC/TIN, banking, price level, multi-currency sub-accounts, receivable control account, GL-derived balance & statement).
  • suppliers.md — supplier master (contacts, addresses, RC/TIN, banking, multi-currency sub-accounts, payable control account, GL-derived balance & statement).

Related domains: inventory/sales (customerId → Stock OUT + AR) · inventory/purchases (supplierId → Stock IN + AP) · finance/trade (manual receivable/payable postings) · finance/account (the control accounts & balance engine) · finance/transaction (the GL legs) · finance/cashbook (settlement) · platform/permissions-access.


1. Entity map

                        users  (collection)
                          │   kind discriminator
        ┌─────────────────┼─────────────────────────┐
   kind=Customer      kind=Supplier            kind=Staff/Admin/Company …
        │                  │
   Customer doc        Supplier doc        (extra profile fields per discriminator)
   • accountId  ───────────┐  • accountId ──────────┐
   • currencyId            │  • currencyId          │
   • priceLevelId          │  • rcNumber/tinNumber  │
   • rcNumber/tinNumber    │  • bank* fields        │
   • bank* fields          │  • parentId (sub-acct) │
   • parentId (sub-acct)   │                        │
        │                  ▼                        ▼
        │       finance_accounts (control acct)  finance_accounts (control acct)
        │       category = ACCOUNTS RECEIVABLE   category = ACCOUNTS PAYABLE
        │       (a "debtor" account)             (a "creditor" account)
        │                  │                        │
        ▼                  ▼                        ▼
   ─────────────── finance_account_transactions (GL legs) ───────────────
              legs stamped payeeId = partner._id, accountId = control acct
              balance = Σ DR/CR over POSTED legs  (see finance/account)

Key collections:

Collection Discriminator Represents Doc
users (kind=Customer) Customer A buyer / debtor party. customers.md
users (kind=Supplier) Supplier A vendor / creditor party. suppliers.md
finance_accounts The per-partner control account (accountId). AR for customers, AP for suppliers. finance/account
finance_account_transactions The GL legs that, summed by payeeId, give the partner balance. finance/transaction

There is no customers / suppliers collection — both schemas register against the users collection via model.discriminator(...) (customer/customer.module.ts, supplier/supplier.module.ts). The kind field is what tells them apart and drives the AR-vs-AP routing in AccountService.getUserAccount() (finance/account/account.service.ts).


2. The shared party shape

Both Customer and Supplier GraphQL types extend CommonUserInfo (user/user.dto.ts); both create inputs extend CommonUserInput. The common fields:

Field Type Notes
name string Party name. Uniqueness-checked on create (UserService.validateExist).
phoneNumber, email, idNumber string Each is uniqueness-checked across all users; phone is stripped of special chars.
address, country, city, postalCode string Contact / address block.
accountId ObjectId Control account — required on create. AR for customer, AP for supplier.
currencyId ObjectId Master (key=currency) — the party's transacting currency.
parentId ObjectId Self-ref → parent party when this is a per-currency sub-account (see §4).
rcNumber, tinNumber string Company registration number / tax identification number.
bankName, bankAccountNo, bankAccountName string Banking details (for remittance).
keywords string[] Search index — name split into tokens (helper.removeSpecialChar(name).split(' ')). Text index on name/email/phoneNumber.
password string Hashed DEFAULT_PASSWORD if none supplied (parties can sign in to a portal). Never exposed in GraphQL.
groupId string The access-group id ("Customer" / "Supplier" group) stamped at create.

Customer adds: priceLevelId (+ resolved priceLevel) and kyc. Supplier carries its own generated ref (Supplier.ref, set in supplier.repository.create() via generateRef()); Customer relies on the BaseSchema ref. Both expose resolve-fields account, balance, currency, subAccounts, canDelete.


3. Cross-module flows

3.1 Sell to a customer (receivable created & settled)

1. Sales Invoice   ─ Order(kind=SalesInvoice, customerId) → Stock OUT + GL:
                         DR Accounts-Receivable (customer control acct, payeeId=customer._id)
                         CR Revenue / CR Inventory / DR COGS / CR Output-Tax
                     (see inventory/sales.md §4.3)
2. Customer balance  ─ derived: AccountService.balanceWithDrAnCrPosted(controlAcct, {payeeId})
                         → debtor balance rises by the invoice total
3. Receipt           ─ Cashbook RECEIPT (or trade settlement) credits the control acct
                         → debtor balance falls (see finance/cashbook.md)
4. Aged Receivable   ─ report ages the *invoices* (not the party) off orderDate into
                         0-30 / 31-60 / 61-90 / 90+ buckets (see finance/trade.md §4.5-bis)

3.2 Buy from a supplier (payable created & settled)

1. Purchase Invoice  ─ Order(kind=PurchaseInvoice, supplierId) → Stock IN + GL:
                         CR Accounts-Payable (supplier control acct, payeeId=supplier._id)
                         DR Inventory / DR Input-Tax  (see inventory/purchases.md)
2. Supplier balance  ─ derived: AccountService.balanceWithDrAnCrPosted(controlAcct, {payeeId})
                         → creditor balance rises by the invoice total
3. Payment           ─ Cashbook PAYMENT (or trade settlement) debits the control acct
                         → creditor balance falls
4. Aged Payable      ─ report ages purchase invoices off orderDate into the same buckets

3.3 Manual obligation (no inventory)

A receivable/payable that does not flow through the Order pipeline is booked with a trade entry (type=SALES for a receivable, type=PURCHASE for a payable) against the party's control account, then settled via cashbook. Trade entries post pure GL legs and move no stock.

3.4 Per-partner statement (drill-down)

The admin Account tab on a customer/supplier detail page renders AccountsDetailPage payeeId={party._id} (finance/account/detail). It shows every GL leg posted to the party's control account filtered by payeeId — i.e. the partner's running statement of invoices, payments, and trade entries with opening/closing balance. This is the "statement" surface; it is read entirely from finance_account_transactions, never from a stored ledger on the party.


4. Shared mechanics (apply to both sub-modules)

  • Control-account routing. getUserAccount(userId) (finance/account/account.service.ts) reads the user's kind: Customer → getDebtorAccount(accountId), anything else (incl. Supplier) → getCreditorAccount(accountId). Both just findById(accountId) and throw a helpful "map a debtor/creditor account" error if missing. The admin create forms enforce the right category at selection time: customer picks an ACCOUNTS RECEIVABLE account ("Receivable Account"), supplier picks an ACCOUNTS PAYABLE account ("Payable Account").

  • Balance is GL-derived, POSTED-only, payee-scoped. Every balance resolve-field calls accountSvc.balanceWithDrAnCrPosted(controlAcct._id, { payeeId: party._id })balanceWithDrAnCr(..., { status: POSTED }). SAVED/draft transactions do not affect the displayed balance. There is no balance column on the party document.

  • Account migration on change. If update() changes accountId, the service calls accountMigrationSvc.migratePayeeAccount({ fromAccountId, toAccountId, payeeId }) (finance/account/account.migration.ts), which re-points every existing GL leg ({ accountId: from, payeeId }) to the new control account — so history follows the party.

  • Multi-currency sub-accounts. A subAccounts: [{ currencyId, accountId }] array on create spawns child party rows (one per currency), each with parentId = parent._id, a per-currency control account, and a name suffixed with the currency ("Acme - USD"). Duplicate currency/account in the list is rejected. The subAccounts resolve-field returns find({ parentId }).

  • canDelete gate. A party can only be deleted when its derived balance is zero (canDelete resolve-field). Delete is soft (mongoose-delete), and deleteMany* loops single deletes.

  • Uniqueness on write. UserService.validateExist (create) / validateUpdateExist (update) reject duplicate email, username, phoneNumber, idNumber, and (create-only) duplicate name across the whole users collection.

  • Transactional create/update. Both services wrap create/update in withRetryTransaction(...) so the party row, sub-accounts, and (on update) account migration commit atomically.


5. Shared enums

// user/user.schema.ts — the discriminator key values
export enum UserKindTypes {
  Admin = "Admin", SuperAdmin = "SuperAdmin", Company = "Company",
  StoreAdmin = "StoreAdmin", Staff = "Staff",
  Customer = "Customer",   // ← CRM
  Supplier = "Supplier",   // ← CRM
}

export enum UserRoleTypes {
  Admin, SuperAdmin, Customer, StoreAdmin, Staff, Employee, SalesMan, Supplier
}
// admin: finance/account/model.ts — the control-account categories the forms filter on
ACCOUNT_CATEGORIES.ACCOUNTS_PAYABLE    = 'ACCOUNTS PAYABLE'    // supplier control acct
ACCOUNT_CATEGORIES.ACCOUNTS_RECEIVABLE = 'ACCOUNTS RECEIVABLE' // customer control acct

6. Permissions

Surface Module Actions
Customers (admin) customers view, create, update, delete, import-customers, view-customer-details
Suppliers / vendors (admin) vendors view, create, update, delete, import-suppliers, view-supplier-details

Constants in zerp-admin/src/constants/UserAccess.ts (USER_ACCESS.CUSTOMERS, USER_ACCESS.VENDORS). Page-level access is enforced server-side via ApGuardBuilder.haveAccess(MODULE, ACTION) in each route's getServerSideProps; buttons use the ApAccessGuard / permission props. BE mutations carry @ApGqlAuthorize() + @AuditMeta({ module: 'customer'|'supplier', collection: 'customers'|'suppliers', … }). See platform/permissions-access.


7. Gotchas (domain-wide)

  • No customers/suppliers collection — both live in users, separated by kind. Queries that forget the kind/groupId filter can leak other user types. The audit collection metadata still labels them customers / suppliers for snapshot grouping, which can mislead.
  • Balance never lives on the party — it is recomputed per-read from the GL. A party with a missing or wrong control account silently shows null/0 (the resolve-fields swallow the error). Always trace getUserAccount for the actual account.
  • POSTED-only balance — draft (SAVED) invoices/entries do not move the displayed balance; the partner can look "clear" while drafts are outstanding.
  • Aged Receivable/Payable ages invoices, not parties — and off orderDate, with the whole outstanding amount in a single bucket (no partial aging). The party has no due-date term of its own. See finance/trade.md §4.5-bis.
  • Supplier getUserAccount falls through to creditor for any non-Customer kind — the routing is Customer → debtor, else → creditor, so it is kind-correct only because suppliers are the only other party type that maps an accountId. Don't reuse getUserAccount for staff/admin users.
  • Customer-vs-supplier select is unified in the UIApCustomerSelection (customers/components/select.tsx) searches both kinds and its inline-create modal offers a Customer / Supplier segmented tab; the label shows name (kind).