Customers — debtor party master (receivable, price level, statements)
The whole customer module reduces to one idea:
A customer is a
Userdiscriminator (kind=Customer) that maps to an Accounts-Receivable control account. The document stores only the profile (contacts, address, RC/TIN, banking, price level, currency) and a pointer to its receivableaccountId; the outstanding balance is derived from the General Ledger as the sum of POSTED legs whosepayeeId == customer._idagainst that control account. Sales invoices, trade entries, and cashbook receipts all reference the customer bycustomerIdand post against this same account.
Source: BE src/modules/customer (discriminator of src/modules/user) · Admin src/modules/customers
Related: _overview.md · suppliers.md · inventory/sales · finance/account · finance/trade · finance/cashbook · platform/permissions-access
1. Purpose & scope
Owns the customer (debtor) master record:
- Profile: name, contacts, address, company registration (
rcNumber/tinNumber), banking. - Receivable control account mapping (
accountId, anACCOUNTS RECEIVABLEaccount) + transactingcurrencyId. - Price level (
priceLevelId) used by sales pricing resolution. - Multi-currency sub-accounts (one child customer per currency).
- Read-only balance and statement views derived from the GL.
- CRUD, search/paginate, summary counters, XLSX/PDF export, and bulk import.
It does NOT: post any GL legs itself (sales/trade/cashbook do — see those docs); move stock; store a balance or aging; define price levels or currencies (those are master records); or own the order/invoice lifecycle (inventory/sales).
2. Data model
users (discriminator Customer) — customer/customer.schema.ts
The Customer schema extends BaseSchema but registers as a discriminator on the User model (customer.module.ts: model.discriminator(Customer.name, CustomerSchema)), so documents live in the shared users collection with kind=Customer. Soft-deleted via mongoose-delete.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes (create) | Customer name. Uniqueness-checked across all users (validateExist). |
email |
string | no | Uniqueness-checked; trimmed. |
phoneNumber |
string | no | Uniqueness-checked; special chars stripped. |
address, country, city, postalCode |
string | no | Address block. |
password |
string | no | Hashed (DEFAULT_PASSWORD fallback). Never returned by GraphQL. |
groupId |
string | — | Set to the "Customer" access-group _id at create. |
keywords |
string[] | — | name tokenized for prefix search. |
accountId |
ObjectId | yes (create) | Receivable control account (debtor). Set via BaseSchema.toObjectId. |
currencyId |
ObjectId | no | Master (key=currency). |
parentId |
ObjectId | no | Parent customer when this is a per-currency sub-account. |
priceLevelId |
ObjectId | no | Master (key=price-level, e.g. Retail/Wholesale/VIP) — drives sales pricing. |
rcNumber |
string | no | Company registration number. |
tinNumber |
string | no | Tax identification number. |
bankName, bankAccountNo, bankAccountName |
string | no | Banking details. |
currency |
any | — | Transient (populated), not persisted as a real field. |
Inherited from BaseSchema: _id, companyId, branchId, ref, documentCode, documentDate, createdAt/By, updatedAt/By, soft-delete fields.
@ApSchema()
export class Customer extends BaseSchema {
@Prop() address?, country?, city?, postalCode?, name?, email?, phoneNumber?: string;
@Prop() password: string;
@Prop() groupId: string;
@Prop() keywords: string[];
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) accountId: Types.ObjectId; // receivable acct
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) currencyId: Types.ObjectId;
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) parentId: Types.ObjectId;
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) priceLevelId?: Types.ObjectId; // price level
@Prop() rcNumber?, tinNumber?, bankName?, bankAccountNo?, bankAccountName?: string;
}
CustomerSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });
CustomerSchema.index({ name: "text", email: "text", phoneNumber: "text" });Indexes: text index on name/email/phoneNumber. Soft delete: yes (deletedAt, deletedBy). Tenant/branch scoping: companyId/branchId via BaseSchema; the page query is not branch-filtered (the branchId page field is marked // TODO remove field).
Enums
No customer-specific enum. The discriminator value is UserKindTypes.Customer = "Customer" (user/user.schema.ts). See _overview.md §5.
Derived / computed (resolve-fields, customer.resolver.ts)
| Field | How computed |
|---|---|
account |
accountSvc.findById(accountId) — the receivable control account. |
balance (AccountBalance) |
acct = getUserAccount(_id) (→ debtor account) → balanceWithDrAnCrPosted(acct._id, { payeeId: _id }). POSTED-only, payee-scoped. |
currency (Master) |
masterSvc.findById(currencyId). |
priceLevel (Master) |
masterSvc.findById(priceLevelId). |
subAccounts ([Customer]) |
customerSvc.find({ parentId: _id }). |
canDelete (Boolean) |
true only when derived balance.balance === 0. |
kyc |
from the KYC module (@Field(KYC)); not populated in the standard fragment. |
3. API surface
GraphQL (customer/customer.resolver.ts). The Customer resolver is @ApGqlAuthorize(); the CustomerPageResolver adds a summary resolve-field on CustomerPageResult.
| Operation | Type | Input | Returns | Auth / Audit |
|---|---|---|---|---|
createCustomer |
mutation | CreateCustomerInput |
Customer |
@AuditMeta(customer, CREATE) |
updateCustomer |
mutation | id, UpdateCustomerInput |
Customer |
@AuditMeta(customer, UPDATE) |
deleteCustomer |
mutation | _id |
Boolean | @AuditMeta(customer, DELETE) |
deleteManyCustomers |
mutation | ids: [String] |
Boolean | @AuditMeta(customer, DELETE) |
customerPage |
query | CustomerPageInput |
CustomerPageResult |
— |
findOneCustomer |
query | CustomerQueryInput |
Customer |
— |
currentCustomer |
query | (from token) | Customer |
— |
importCustomers/confirmCustomerImportmutations are referenced by the admin (gql/query.ts) but are not defined incustomer.resolver.ts— bulk import is wired through the shared user import path (ImportUsersBtn kind=Customer), not a customer-specific resolver. Treat the customer-named import ops as admin-side aliases over the user importer (or unimplemented on this resolver).
REST (customer/customer.controller.ts): GET /api/customer/download?downloadType=xlsx — XLSX of customers with Name, Account Type, Phone, Email, Currency, Balance, Document Date (balance fetched per row via getUserAccount + balanceWithDrAnCrPosted).
Input DTOs (customer/customer.dto.ts)
@InputType() class CustomerCommonInput extends CommonUserInput { // name, phone, email, idNumber,
priceLevelId?: string; // address, country, city,
rcNumber?, tinNumber?, bankName?, bankAccountNo?, bankAccountName?: string; // postalCode, currencyId, parentId
}
@InputType() class CreateCustomerInput extends CustomerCommonInput {
accountId!: string; // @IsNotEmpty "Account Category is required" (receivable acct)
password: string;
subAccounts?: SubAccountInput[]; // [{ currencyId!, accountId! }]
}
@InputType() class UpdateCustomerInput extends PartialType(CustomerCommonInput) {
accountId?: string;
subAccounts?: SubAccountInput[];
}
@InputType() class CustomerPageInput { skip!, take!: number; keyword?, branchId?, status?, sortBy?: string; fromDate?, toDate?: number; sortOrder?: SortOrder }
@InputType() class CustomerQueryInput { _id?, name?, branchId?: string; fromDate?, toDate?: number }Output types
type CustomerPageResult { summary: CustomerSummary, totalRecords: Float!, data: [Customer!]! }
type CustomerSummary { totalCount: Float!, registerThisMonth: Float!, registerToday: Float! }
type AccountBalance { type: String, balance: Float, debits: Float, credits: Float,
openingBalance: AccountBalance, closingBalance: AccountBalance }4. Business rules & calculations
4.1 Create — CustomerService.create() (in withRetryTransaction("create_customer"))
- Split off
subAccountsfrom the model. userSvc.validateExist(model)— reject duplicate email/username/phone/idNumber/name.- If
name:keywords = removeSpecialChar(name).split(' '). - Resolve the
"Customer"access group (accessGroupSvc.findOne({ group: 'Customer' })); throw"Customer group not found"if absent. customerRepo.create({ ...model, password: hash(password || DEFAULT_PASSWORD), groupId, currencyId }).- If
subAccounts.length→createSubAccounts(parent, subAccounts, groupId)(§4.3).
4.2 Update — CustomerService.update() (in withRetryTransaction("update_customer"))
userSvc.validateUpdateExist(id, model)— reject collisions with other users.- Recompute
keywordsifnamechanged. - Load current doc; if
model.accountIddiffers fromuser.accountId:accountMigrationSvc.migratePayeeAccount({ fromAccountId, toAccountId, payeeId: id })→ re-points every existing GL leg{ accountId: from, payeeId }to the new receivable account so the customer's history follows the account change. customerRepo.update(id, model)→ return fresh doc.
4.3 Multi-currency sub-accounts — createSubAccounts()
For each { currencyId, accountId }: reject duplicate currency/account within the list; verify the currency master exists; clone the parent into a child with name = "{parent.name} - {currency.name}", suffixed email/phone, parentId = parent._id, accountId/currencyId from the entry, fresh _id, same groupId; validateExist; persist. The child is itself a kind=Customer row.
4.4 Balance (derived) — resolver balance
const acct = await accountSvc.getUserAccount(customer._id); // → debtor account (kind=Customer)
return acct ? accountSvc.balanceWithDrAnCrPosted(acct._id, { payeeId: customer._id }) : null;balanceWithDrAnCrPosted (finance/account/account.service.ts) sums POSTED debit/credit legs for the account, scoped to payeeId, mapped to the account's normal balance (debtor → debit-positive), with optional opening/closing split when a fromDate is given. No balance is stored on the customer. See finance/account.
4.5 Summary counters — countSummary()
{ totalCount, registerThisMonth, registerToday } = three customerRepo.count(...) over createdAt ranges (whole table, current calendar month, current day). Surfaced as customerPage.summary.
4.6 State / transactionality
No status state machine — a customer is just active or soft-deleted. create/update run inside a retry transaction (setSession propagates the Mongo session to accountMigrationSvc + loggerSvc). Delete is soft; deleteManyCustomers loops single deletes.
4.7 Side effects
| Trigger | Side effect |
|---|---|
| create | hashes password, stamps groupId, builds keywords, optional sub-account rows |
| update (accountId change) | migratePayeeAccount re-points GL legs |
| any mutation | @AuditMeta audit snapshot (module=customer, collection=customers) |
| delete | soft delete; only allowed when derived balance is 0 (canDelete) |
The customer module writes no GL legs and no stock. All receivable postings originate in inventory/sales, finance/trade, and finance/cashbook.
5. Permissions
Module customers (USER_ACCESS.CUSTOMERS): view, create, update, delete, import-customers, view-customer-details. Pages guard via ApGuardBuilder.haveAccess('customers', '<action>') in getServerSideProps; the Add button uses permission={{ module: 'customers', action: 'create' }}; import is wrapped in ApAccessGuard(action='import-customers'). BE mutations are @ApGqlAuthorize() + @AuditMeta. See platform/permissions-access.
6. Flows
6.1 Create a customer (happy path)
- Customers page → Add Customer →
CreateCustomermodal (components/create.tsx). - Pick Currency (
ApMasterSelectInput masterKey=currency) and Receivable Account (ApAccountSelectionfilteredcategories=[ACCOUNTS_RECEIVABLE], optionally by currency); fill name/contacts/RC/TIN/banking; optionally a Price Level (masterKey=price-level). - Submit →
saveCustomer(undefined, payload)→createCustomermutation →CustomerService.create. - Validate uniqueness → hash password → stamp
Customergroup → persist → optional sub-accounts →fetchCustomerPagerefresh.
6.2 Update / migrate account
Edit modal pre-fills from the row. Changing the receivable account triggers migratePayeeAccount so all prior GL legs move to the new account; the displayed balance is unchanged.
6.3 View statement
Detail page Account tab → AccountsDetailPage payeeId={customer._id} renders the customer's GL statement (every leg with payeeId == customer._id) with opening/closing balance.
6.4 Unhappy paths
- Duplicate name/email/phone/idNumber →
NOT_ACCEPTABLEfromvalidateExist. - Missing account selection → Yup
accountrequired +toastSvc.error('Please select an account'). "Customer group not found"→ seed/access-group misconfiguration (CustomError).- Delete with non-zero balance →
canDelete=falsehides the delete action. - Missing/invalid control account →
balanceresolvesnull(error swallowed).
7. Admin UI
- Module
src/modules/customers/:page.tsx,context.tsx,model.ts,detail.tsx,layout/index.tsx,order/detail.tsx,gql/{query,fragment}.ts,components/{create,select,customerTemplate}.tsx. - Routes: list at
pages/customers/index.tsx(MainLayoutkeyscustomersAndSales/customers); detail atpages/customer/[_id]/{index,orders,account}.tsx(Profile / Orders / Account tabs viaCustomerLayout→UserLayout). Each route guards oncustomersaccess ingetServerSidePropsand SSR-loads the record viafindOneCustomerAsync. - Context (
useCustomerState):fetchCustomerPage,saveCustomer(create-or-update dispatcher),deleteCustomer,deleteManyCustomers,importCustomer,confirmCustomerImport,downloadReport(POSTcustomer/report→ XLSX), pluscustomers,summary,totalRecords,filter,selectedRowKeysstate. Single consumer ofuseCustomerQuery(); refetches after mutations. - List page (
page.tsx): AntApTablewith Ref/Name links (→/customer/{_id}),USER_COLUMNS, created-at, row edit/delete (gated oncanUpdate/canDelete),ApViewDetailBtn→CustomerDetailPage; search, duration/date filters, bulk delete toolbar, and a download menu (PDF + XLSX/customer/download). - Create form (
components/create.tsx): Formik + Yup (name,account,currencyrequired). Sections: Account Info (Ref auto, Currency, Receivable Account), User Info, Address, Business & Banking (RC/TIN/bank*), Pricing (Price Level). Payload mapsaccount._id→accountId,currency._id→currencyId,priceLevel?._id→priceLevelId. - Selector (
components/select.tsx,ApCustomerSelection): async-creatable react-select that searches both Customer and Supplier kinds (via the user page query), labelsname (kind), and on inline-create opens a segmented Supplier/Customer modal. Used by sales/order screens. - Detail (
detail.tsx): read-only Personal Information + Address & Registration sections. - Order detail (
order/detail.tsx): renders a sales-invoice detail (items, totals, margin, customer block) fromuseSalesOrderState. - Report template (
components/customerTemplate.tsx): printable table (Name, Phone, Currency, Balance, Email, Created At) for the "Account Receivable Summary Report".
8. Dependencies & integrations
- user —
UserService.validateExist/validateUpdateExist; Customer is a User discriminator; bulk import uses the user importer. - finance/account —
getUserAccount(→ debtor),findById,balanceWithDrAnCrPosted;AccountMigrationService.migratePayeeAccount. - master — currency and price-level lookups (
masterSvc.findById). - permission/group — resolves the
"Customer"access group at create. - inventory/sales — consumes
customerId+priceLevelIdfor invoicing and price resolution. - audit-trail / log —
@AuditMetasnapshots;AuditLogServicesession-bound. - KYC — optional
kycfield (module wired incustomer.module.ts). - Cron/jobs/external: none in this module.
9. Gotchas & project-specific rules
- Lives in
users, notcustomers— it's a discriminator (kind=Customer). The collection name in audit metadata iscustomersfor grouping only. - Balance is derived & POSTED-only — never stored; draft transactions are invisible to it; a missing control account silently yields
null. accountIdmust be a receivable (debtor) account — the BE only validates presence (@IsNotEmpty), not category; the UI enforcesACCOUNTS RECEIVABLE. A wrong category mapped via API would mis-route postings.- Changing the account migrates history —
migratePayeeAccountre-points all prior legs, so the new account inherits the full statement. - Price level drives sales pricing —
priceLevelIdfeedsItemPriceLevelService.resolvePriceat sales checkout (see inventory/sales §4.4); it does nothing here on its own. - Page query is not branch-scoped —
CustomerPageInput.branchIdis a leftover (// TODO remove); the repopage()filters only by keyword/status/date. import*ops are admin aliases — not defined oncustomer.resolver.ts; bulk import flows through the shared user importer keyed bykind=Customer.canDeleteblocks non-zero balances — and delete is soft, so "deleted" customers still exist with their GL history intact.