Suppliers — creditor party master (payable, banking, statements)
The whole supplier module reduces to one idea:
A supplier is a
Userdiscriminator (kind=Supplier) that maps to an Accounts-Payable control account. The document stores only the profile (contacts, address, RC/TIN, banking, currency) and a pointer to its payableaccountId; the outstanding balance is derived from the General Ledger as the sum of POSTED legs whosepayeeId == supplier._idagainst that control account. Purchase invoices, trade entries, and cashbook payments reference the supplier bysupplierIdand post against this same account.
Source: BE src/modules/supplier (discriminator of src/modules/user) · Admin src/modules/suppliers
Related: _overview.md · customers.md · inventory/purchases · finance/account · finance/trade · finance/cashbook · platform/permissions-access
1. Purpose & scope
Owns the supplier (creditor / vendor) master record:
- Profile: name, contacts, address, company registration (
rcNumber/tinNumber), banking. - Payable control account mapping (
accountId, anACCOUNTS PAYABLEaccount) + transactingcurrencyId. - Multi-currency sub-accounts (one child supplier per currency).
- Read-only balance and statement views derived from the GL.
- A generated document
refper supplier. - CRUD, search/paginate, summary counters, XLSX/PDF export, and bulk import.
It does NOT: post any GL legs itself (purchases/trade/cashbook do); move stock; store a balance or aging; or own the purchase order/invoice lifecycle (inventory/purchases). Unlike Customer, Supplier has no price level and no KYC field.
2. Data model
users (discriminator Supplier) — supplier/supplier.schema.ts
The Supplier schema extends BaseSchema and registers as a discriminator on the User model (supplier.module.ts: model.discriminator(Supplier.name, SupplierSchema)), so documents live in the shared users collection with kind=Supplier. Soft-deleted via mongoose-delete.
| Field | Type | Required | Description |
|---|---|---|---|
ref |
string | — | Generated document number, set in supplier.repository.create() via generateRef(). |
name |
string | yes (create) | Supplier 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 "Supplier" access-group _id at create. |
keywords |
string[] | — | name tokenized for prefix search. |
accountId |
ObjectId | yes (create) | Payable control account (creditor). Set via BaseSchema.toObjectId. |
currencyId |
ObjectId | no | Master (key=currency). |
parentId |
ObjectId | no | Parent supplier when this is a per-currency sub-account. |
rcNumber |
string | no | Company registration number. |
tinNumber |
string | no | Tax identification number. |
bankName, bankAccountNo, bankAccountName |
string | no | Banking details (remittance). |
createdAt, updatedAt |
number | — | Set explicitly in create() (Date.now()), alongside BaseSchema timestamps. |
currency |
any | — | Transient (populated), not a persisted field. |
Inherited from BaseSchema: _id, companyId, branchId, documentCode, documentDate, createdBy/updatedBy, soft-delete fields. create() explicitly stamps createdBy = contextSvc.user._id and branchId = contextSvc.user.branchId.
@ApSchema()
export class Supplier extends BaseSchema {
@Prop() ref: string; // generated per supplier
@Prop() address, country, city, postalCode: string;
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) accountId: Types.ObjectId; // payable acct
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) currencyId: Types.ObjectId;
@Prop({ set: (v) => BaseSchema.toObjectId(v) }) parentId: Types.ObjectId;
@Prop() rcNumber?, tinNumber?, bankName?, bankAccountNo?, bankAccountName?: string;
groupId, name, email, phoneNumber, password: string; keywords: string[];
}
SupplierSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });
SupplierSchema.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; branchId is stamped from the acting user on create. The page query is not branch-filtered.
Enums
No supplier-specific enum. The discriminator value is UserKindTypes.Supplier = "Supplier" (user/user.schema.ts). See _overview.md §5.
Derived / computed (resolve-fields, supplier.resolver.ts)
| Field | How computed |
|---|---|
account |
accountSvc.findById(accountId) — the payable control account. |
balance (AccountBalance) |
acct = getUserAccount(_id) (→ creditor account) → balanceWithDrAnCrPosted(acct._id, { payeeId: _id }). POSTED-only, payee-scoped. |
currency (Master) |
masterSvc.findById(currencyId). |
subAccounts ([Supplier]) |
supplierSvc.find({ parentId: _id }). |
canDelete (Boolean) |
true only when derived balance.balance === 0. |
3. API surface
GraphQL (supplier/supplier.resolver.ts). Both the Supplier resolver and the SupplierPageResolver (which adds the summary field) are @ApGqlAuthorize().
| Operation | Type | Input | Returns | Auth / Audit |
|---|---|---|---|---|
createSupplier |
mutation | CreateSupplierInput |
Supplier |
@AuditMeta(supplier, CREATE) |
updateSupplier |
mutation | id, UpdateSupplierInput |
Supplier |
@AuditMeta(supplier, UPDATE) |
deleteSupplier |
mutation | _id |
Boolean | @AuditMeta(supplier, DELETE) |
deleteManySuppliers |
mutation | ids: [String] |
Boolean | @AuditMeta(supplier, DELETE) |
supplierPage |
query | SupplierPageInput |
SupplierPageResult |
— |
findSupplier |
query | SupplierQueryInput |
Supplier |
delegates to userSvc.findOne |
currentSupplier |
query | (from token) | Supplier |
— |
Note:
findSupplierresolves viauserSvc.findOne(supplier)(notsupplierSvc), so it can match any user by the given query — pass_idto scope to a specific supplier.
REST (supplier/supplier.controller.ts): GET /api/supplier/download?downloadType=xlsx — XLSX of suppliers with Name, Account Type, Phone, Email, Currency, Balance, Document Date (balance per row via getUserAccount + balanceWithDrAnCrPosted).
There is no supplier-specific import resolver — bulk import uses the shared user importer (ImportUsersBtn kind=Supplier).
Input DTOs (supplier/supplier.dto.ts)
@InputType() class SupplierCommonInput extends CommonUserInput { // name, phone, email, idNumber,
accountId!: string; // @IsNotEmpty "Account Category is required" (payable acct)
rcNumber?, tinNumber?, bankName?, bankAccountNo?, bankAccountName?: string; // + address/currency/parent
}
@InputType() class CreateSupplierInput extends SupplierCommonInput {
password: string;
subAccounts?: SubAccountInput[]; // [{ currencyId!, accountId! }]
}
@InputType() class UpdateSupplierInput extends PartialType(SupplierCommonInput) {
subAccounts?: SubAccountInput[];
}
@InputType() class SupplierPageInput { skip!, take!: number; keyword?, branchId?, status?, sortBy?: string; fromDate?, toDate?: number; sortOrder?: SortOrder }
@InputType() class SupplierQueryInput { _id?, name?, branchId?: string; fromDate?, toDate?: number }
accountIdis@IsNotEmptyonSupplierCommonInput(so both create and update demand it), whereas on the customer sideaccountIdis required only on create. In the generated schema, however,UpdateSupplierInput.accountIdis nullable (PartialType), so update tolerates omission at the GraphQL layer.
Output types
type SupplierPageResult { summary: SupplierSummary, totalRecords: Float!, data: [Supplier!]! }
type SupplierSummary { totalCount: Float!, registerThisMonth: Float!, registerToday: Float! }
type AccountBalance { type, balance, debits, credits, openingBalance, closingBalance }4. Business rules & calculations
4.1 Create — SupplierService.create() (in withRetryTransaction("create_supplier"))
- Split off
subAccounts. userSvc.validateExist(model)— reject duplicate email/username/phone/idNumber/name.- If
name:keywords = removeSpecialChar(name).split(' '). - Resolve the
"Supplier"access group (accessGroupSvc.findOne({ group: 'Supplier' })); throw"Supplier group not found"(NOT_ACCEPTABLE) if absent. supplierRepo.create({ ...model, createdBy, branchId, password: hash(password||DEFAULT_PASSWORD), groupId, createdAt, updatedAt, currencyId }). The repocreate()first stampsref = generateRef().- If
subAccounts.length→createSubAccounts(parent, subAccounts, groupId)(§4.3).
4.2 Update — SupplierService.update() (in withRetryTransaction("update_customer") — name reused)
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 payable account. supplierRepo.update(id, model)→ return fresh doc.
4.3 Multi-currency sub-accounts — createSubAccounts()
Identical mechanics to customers: per { currencyId, accountId }, reject duplicate currency/account in the list, verify the currency master, clone the parent with name = "{parent.name} - {currency.name}", suffixed email/phone, parentId = parent._id, the entry's accountId/currencyId, fresh _id, same groupId; validateExist; persist as a kind=Supplier row.
4.4 Balance (derived) — resolver balance
const acct = await accountSvc.getUserAccount(supplier._id); // → creditor account (non-Customer kind)
return acct ? accountSvc.balanceWithDrAnCrPosted(acct._id, { payeeId: supplier._id }) : null;getUserAccount routes any non-Customer kind to getCreditorAccount(accountId). balanceWithDrAnCrPosted sums POSTED legs scoped to payeeId, mapped to the payable account's normal balance (creditor → credit-positive). No balance is stored on the supplier. See finance/account.
4.5 Summary counters — countSummary()
{ totalCount, registerThisMonth, registerToday } from three supplierRepo.count(...) over createdAt ranges. Surfaced as supplierPage.summary.
4.6 State / transactionality
No status state machine — active or soft-deleted. create/update run inside a retry transaction (setSession propagates the session to accountMigrationSvc). Delete is soft; deleteManySuppliers loops single deletes.
4.7 Side effects
| Trigger | Side effect |
|---|---|
| create | generates ref, hashes password, stamps groupId/createdBy/branchId, builds keywords, optional sub-accounts |
| update (accountId change) | migratePayeeAccount re-points GL legs |
| any mutation | @AuditMeta audit snapshot (module=supplier, collection=suppliers) |
| delete | soft delete; only allowed when derived balance is 0 (canDelete) |
The supplier module writes no GL legs and no stock. Payable postings originate in inventory/purchases, finance/trade, and finance/cashbook.
5. Permissions
Module vendors (USER_ACCESS.VENDORS): view, create, update, delete, import-suppliers, view-supplier-details. Pages guard via ApGuardBuilder.haveAccess('vendors', '<action>') in getServerSideProps; the Add button uses permission={{ module: 'vendors', action: 'create' }}; import is wrapped in ApAccessGuard(action='import-suppliers'). BE mutations are @ApGqlAuthorize() + @AuditMeta. See platform/permissions-access.
The admin "Suppliers" surface uses the
vendorspermission module (not asuppliersmodule).
6. Flows
6.1 Create a supplier (happy path)
- Suppliers page → Add Supplier →
CreateSuppliermodal (components/create.tsx). - Pick Currency and Payable Account (
ApAccountSelectionfilteredcategories=[ACCOUNTS_PAYABLE], optionally by currency); fill name/contacts/RC/TIN/banking. - Submit →
saveSupplier(undefined, payload)→createSuppliermutation →SupplierService.create. - Validate uniqueness → generate
ref→ hash password → stampSuppliergroup → persist → optional sub-accounts →fetchSupplierPagerefresh.
6.2 Update / migrate account
Edit modal pre-fills from the row. Changing the payable account triggers migratePayeeAccount so all prior GL legs move to the new account; the displayed balance is unchanged.
6.3 View statement & purchase orders
Detail page Account tab → AccountsDetailPage payeeId={supplier._id} renders the supplier's GL statement. The Orders tab (order/page.tsx → SupplierOrderTable) lists the supplier's orders via useOrderState.orderPage({ supplierId }).
6.4 Unhappy paths
- Duplicate name/email/phone/idNumber →
NOT_ACCEPTABLEfromvalidateExist. - Missing account selection → Yup
account._idrequired. "Supplier group not found"→ access-group misconfiguration (HttpException 406).- Delete with non-zero balance →
canDelete=falsehides the delete action. - Missing/invalid control account →
balanceresolvesnull(error swallowed).
7. Admin UI
- Module
src/modules/suppliers/:page.tsx,context.tsx,model.ts,detail.tsx,layout/index.tsx,order/{page,detail}.tsx+order/components/table.tsx,gql/{query,fragment}.ts,components/{create,index,suppliersTemplate}.tsx. - Routes: list at
pages/suppliers.tsx(MainLayoutkeysvendorsAndPurchases/vendors); detail atpages/supplier/[_id]/{index,orders,account}.tsx(Profile / Orders / Account tabs viaSupplierLayout→UserLayout). Each route guards onvendorsaccess; detail SSR-loads viafindSupplierAsync. - Context (
useSupplierState):fetchSupplierPage,saveSupplier(create-or-update dispatcher),deleteSupplier,deleteManySuppliers, plussuppliers,summary,totalRecords,filter,selectedRowKeysstate. Single consumer ofuseSupplierQuery(); refetches after mutations. - List page (
page.tsx): AntApTablewith Ref/Name links (→/supplier/{_id}),USER_COLUMNS, created-at, row edit/delete (gated oncanUpdate/canDelete),ApViewDetailBtn→SupplierDetailPage; search, duration/date filters, bulk delete toolbar, download menu (PDF + XLSX/supplier/download, "Account Payable Summary Report"). - Create form (
components/create.tsx): Formik + Yup (name,account._id,currencyrequired). Sections: Account Info (Ref auto, Currency, Payable Account), User Info, Address, Business & Banking (RC/TIN/bank*). Payload mapsaccount._id→accountId,currency._id→currencyId. (No price level / KYC.) - Detail (
detail.tsx): read-only Personal Information + Address & Registration sections. - Orders (
order/page.tsx+order/components/table.tsx): supplier order list driven byuseOrderState, columns date/customer/totals/margin/items/status with a per-row link to/order/{_id}. - Report template (
components/suppliersTemplate.tsx): printable "Account Payable Summary Report".
8. Dependencies & integrations
- user —
UserService.validateExist/validateUpdateExist/findOne; Supplier is a User discriminator; bulk import uses the user importer. - finance/account —
getUserAccount(→ creditor),findById,balanceWithDrAnCrPosted;AccountMigrationService.migratePayeeAccount. - master — currency lookups (
masterSvc.findById). - permission/group — resolves the
"Supplier"access group at create. - inventory/purchases — consumes
supplierIdfor purchase invoicing/orders. - config —
ApConfigServiceinjected (DEFAULT_PASSWORD / error messages). - audit-trail / log —
@AuditMetasnapshots. - Cron/jobs/external: none in this module.
9. Gotchas & project-specific rules
- Lives in
users, notsuppliers— discriminator (kind=Supplier); collection name in audit metadata issuppliersfor grouping only. - Admin permission module is
vendors, notsuppliers— a frequent mismatch when wiring guards. - Balance is derived & POSTED-only — never stored; drafts invisible; missing control account →
null. accountIdmust be a payable (creditor) account — BE validates presence only; the UI enforcesACCOUNTS PAYABLE.getUserAccountroutes any non-Customer kind to the creditor branch.- Supplier has a generated
ref; Customer does not — set in the repositorycreate(), unlike the customer side which relies on theBaseSchemaref. updatetransaction is mislabeled"update_customer"inSupplierService.update(copy-paste) — harmless but confusing in logs.- Changing the account migrates history —
migratePayeeAccountre-points all prior legs to the new payable account. findSupplierusesuserSvc.findOne— it isn't kind-restricted; always pass_idto be safe.- No price level / no KYC — those exist only on Customer; suppliers carry banking + RC/TIN for remittance and tax instead.
canDeleteblocks non-zero balances — and delete is soft, so history survives.