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
Userrow — discriminated bykind. Both are Mongoose discriminators of the shareduserscollection (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 whosepayeeId == 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 bycustomerId/supplierIdand 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'skind:Customer → getDebtorAccount(accountId), anything else (incl.Supplier) →getCreditorAccount(accountId). Both justfindById(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 anACCOUNTS RECEIVABLEaccount ("Receivable Account"), supplier picks anACCOUNTS PAYABLEaccount ("Payable Account").Balance is GL-derived, POSTED-only, payee-scoped. Every
balanceresolve-field callsaccountSvc.balanceWithDrAnCrPosted(controlAcct._id, { payeeId: party._id })→balanceWithDrAnCr(..., { status: POSTED }). SAVED/draft transactions do not affect the displayed balance. There is nobalancecolumn on the party document.Account migration on change. If
update()changesaccountId, the service callsaccountMigrationSvc.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 withparentId = parent._id, a per-currency control account, and a name suffixed with the currency ("Acme - USD"). Duplicate currency/account in the list is rejected. ThesubAccountsresolve-field returnsfind({ parentId }).canDeletegate. A party can only be deleted when its derived balance is zero (canDeleteresolve-field). Delete is soft (mongoose-delete), anddeleteMany*loops single deletes.Uniqueness on write.
UserService.validateExist(create) /validateUpdateExist(update) reject duplicateemail,username,phoneNumber,idNumber, and (create-only) duplicatenameacross the wholeuserscollection.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 acct6. 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/supplierscollection — both live inusers, separated bykind. Queries that forget thekind/groupIdfilter can leak other user types. Theauditcollection metadata still labels themcustomers/suppliersfor 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 tracegetUserAccountfor 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
getUserAccountfalls through to creditor for any non-Customer kind — the routing isCustomer → debtor, else → creditor, so it iskind-correct only because suppliers are the only other party type that maps anaccountId. Don't reusegetUserAccountfor staff/admin users. - Customer-vs-supplier select is unified in the UI —
ApCustomerSelection(customers/components/select.tsx) searches both kinds and its inline-create modal offers a Customer / Supplier segmented tab; the label showsname (kind).