Customers — debtor party master (receivable, price level, statements)

The whole customer module reduces to one idea:

A customer is a User discriminator (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 receivable accountId; the outstanding balance is derived from the General Ledger as the sum of POSTED legs whose payeeId == customer._id against that control account. Sales invoices, trade entries, and cashbook receipts all reference the customer by customerId and 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, an ACCOUNTS RECEIVABLE account) + transacting currencyId.
  • 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 / confirmCustomerImport mutations are referenced by the admin (gql/query.ts) but are not defined in customer.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"))

  1. Split off subAccounts from the model.
  2. userSvc.validateExist(model) — reject duplicate email/username/phone/idNumber/name.
  3. If name: keywords = removeSpecialChar(name).split(' ').
  4. Resolve the "Customer" access group (accessGroupSvc.findOne({ group: 'Customer' })); throw "Customer group not found" if absent.
  5. customerRepo.create({ ...model, password: hash(password || DEFAULT_PASSWORD), groupId, currencyId }).
  6. If subAccounts.lengthcreateSubAccounts(parent, subAccounts, groupId) (§4.3).

4.2 Update — CustomerService.update() (in withRetryTransaction("update_customer"))

  1. userSvc.validateUpdateExist(id, model) — reject collisions with other users.
  2. Recompute keywords if name changed.
  3. Load current doc; if model.accountId differs from user.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.
  4. 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)

  1. Customers page → Add CustomerCreateCustomer modal (components/create.tsx).
  2. Pick Currency (ApMasterSelectInput masterKey=currency) and Receivable Account (ApAccountSelection filtered categories=[ACCOUNTS_RECEIVABLE], optionally by currency); fill name/contacts/RC/TIN/banking; optionally a Price Level (masterKey=price-level).
  3. Submit → saveCustomer(undefined, payload)createCustomer mutation → CustomerService.create.
  4. Validate uniqueness → hash password → stamp Customer group → persist → optional sub-accounts → fetchCustomerPage refresh.

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_ACCEPTABLE from validateExist.
  • Missing account selection → Yup account required + toastSvc.error('Please select an account').
  • "Customer group not found" → seed/access-group misconfiguration (CustomError).
  • Delete with non-zero balance → canDelete=false hides the delete action.
  • Missing/invalid control account → balance resolves null (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 (MainLayout keys customersAndSales/customers); detail at pages/customer/[_id]/{index,orders,account}.tsx (Profile / Orders / Account tabs via CustomerLayoutUserLayout). Each route guards on customers access in getServerSideProps and SSR-loads the record via findOneCustomerAsync.
  • Context (useCustomerState): fetchCustomerPage, saveCustomer (create-or-update dispatcher), deleteCustomer, deleteManyCustomers, importCustomer, confirmCustomerImport, downloadReport (POST customer/report → XLSX), plus customers, summary, totalRecords, filter, selectedRowKeys state. Single consumer of useCustomerQuery(); refetches after mutations.
  • List page (page.tsx): Ant ApTable with Ref/Name links (→ /customer/{_id}), USER_COLUMNS, created-at, row edit/delete (gated on canUpdate/canDelete), ApViewDetailBtnCustomerDetailPage; search, duration/date filters, bulk delete toolbar, and a download menu (PDF + XLSX /customer/download).
  • Create form (components/create.tsx): Formik + Yup (name, account, currency required). Sections: Account Info (Ref auto, Currency, Receivable Account), User Info, Address, Business & Banking (RC/TIN/bank*), Pricing (Price Level). Payload maps account._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), labels name (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) from useSalesOrderState.
  • Report template (components/customerTemplate.tsx): printable table (Name, Phone, Currency, Balance, Email, Created At) for the "Account Receivable Summary Report".

8. Dependencies & integrations

  • userUserService.validateExist/validateUpdateExist; Customer is a User discriminator; bulk import uses the user importer.
  • finance/accountgetUserAccount (→ 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 + priceLevelId for invoicing and price resolution.
  • audit-trail / log@AuditMeta snapshots; AuditLogService session-bound.
  • KYC — optional kyc field (module wired in customer.module.ts).
  • Cron/jobs/external: none in this module.

9. Gotchas & project-specific rules

  • Lives in users, not customers — it's a discriminator (kind=Customer). The collection name in audit metadata is customers for grouping only.
  • Balance is derived & POSTED-only — never stored; draft transactions are invisible to it; a missing control account silently yields null.
  • accountId must be a receivable (debtor) account — the BE only validates presence (@IsNotEmpty), not category; the UI enforces ACCOUNTS RECEIVABLE. A wrong category mapped via API would mis-route postings.
  • Changing the account migrates historymigratePayeeAccount re-points all prior legs, so the new account inherits the full statement.
  • Price level drives sales pricingpriceLevelId feeds ItemPriceLevelService.resolvePrice at sales checkout (see inventory/sales §4.4); it does nothing here on its own.
  • Page query is not branch-scopedCustomerPageInput.branchId is a leftover (// TODO remove); the repo page() filters only by keyword/status/date.
  • import* ops are admin aliases — not defined on customer.resolver.ts; bulk import flows through the shared user importer keyed by kind=Customer.
  • canDelete blocks non-zero balances — and delete is soft, so "deleted" customers still exist with their GL history intact.