Suppliers — creditor party master (payable, banking, statements)

The whole supplier module reduces to one idea:

A supplier is a User discriminator (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 payable accountId; the outstanding balance is derived from the General Ledger as the sum of POSTED legs whose payeeId == supplier._id against that control account. Purchase invoices, trade entries, and cashbook payments reference the supplier by supplierId and 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, an ACCOUNTS PAYABLE account) + transacting currencyId.
  • Multi-currency sub-accounts (one child supplier per currency).
  • Read-only balance and statement views derived from the GL.
  • A generated document ref per 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: findSupplier resolves via userSvc.findOne(supplier) (not supplierSvc), so it can match any user by the given query — pass _id to 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 }

accountId is @IsNotEmpty on SupplierCommonInput (so both create and update demand it), whereas on the customer side accountId is required only on create. In the generated schema, however, UpdateSupplierInput.accountId is 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"))

  1. Split off subAccounts.
  2. userSvc.validateExist(model) — reject duplicate email/username/phone/idNumber/name.
  3. If name: keywords = removeSpecialChar(name).split(' ').
  4. Resolve the "Supplier" access group (accessGroupSvc.findOne({ group: 'Supplier' })); throw "Supplier group not found" (NOT_ACCEPTABLE) if absent.
  5. supplierRepo.create({ ...model, createdBy, branchId, password: hash(password||DEFAULT_PASSWORD), groupId, createdAt, updatedAt, currencyId }). The repo create() first stamps ref = generateRef().
  6. If subAccounts.lengthcreateSubAccounts(parent, subAccounts, groupId) (§4.3).

4.2 Update — SupplierService.update() (in withRetryTransaction("update_customer") — name reused)

  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 payable account.
  4. 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 vendors permission module (not a suppliers module).


6. Flows

6.1 Create a supplier (happy path)

  1. Suppliers page → Add SupplierCreateSupplier modal (components/create.tsx).
  2. Pick Currency and Payable Account (ApAccountSelection filtered categories=[ACCOUNTS_PAYABLE], optionally by currency); fill name/contacts/RC/TIN/banking.
  3. Submit → saveSupplier(undefined, payload)createSupplier mutation → SupplierService.create.
  4. Validate uniqueness → generate ref → hash password → stamp Supplier group → persist → optional sub-accounts → fetchSupplierPage refresh.

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.tsxSupplierOrderTable) lists the supplier's orders via useOrderState.orderPage({ supplierId }).

6.4 Unhappy paths

  • Duplicate name/email/phone/idNumber → NOT_ACCEPTABLE from validateExist.
  • Missing account selection → Yup account._id required.
  • "Supplier group not found" → access-group misconfiguration (HttpException 406).
  • 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/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 (MainLayout keys vendorsAndPurchases/vendors); detail at pages/supplier/[_id]/{index,orders,account}.tsx (Profile / Orders / Account tabs via SupplierLayoutUserLayout). Each route guards on vendors access; detail SSR-loads via findSupplierAsync.
  • Context (useSupplierState): fetchSupplierPage, saveSupplier (create-or-update dispatcher), deleteSupplier, deleteManySuppliers, plus suppliers, summary, totalRecords, filter, selectedRowKeys state. Single consumer of useSupplierQuery(); refetches after mutations.
  • List page (page.tsx): Ant ApTable with Ref/Name links (→ /supplier/{_id}), USER_COLUMNS, created-at, row edit/delete (gated on canUpdate/canDelete), ApViewDetailBtnSupplierDetailPage; 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, currency required). Sections: Account Info (Ref auto, Currency, Payable Account), User Info, Address, Business & Banking (RC/TIN/bank*). Payload maps account._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 by useOrderState, 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

  • userUserService.validateExist/validateUpdateExist/findOne; Supplier is a User discriminator; bulk import uses the user importer.
  • finance/accountgetUserAccount (→ creditor), findById, balanceWithDrAnCrPosted; AccountMigrationService.migratePayeeAccount.
  • master — currency lookups (masterSvc.findById).
  • permission/group — resolves the "Supplier" access group at create.
  • inventory/purchases — consumes supplierId for purchase invoicing/orders.
  • configApConfigService injected (DEFAULT_PASSWORD / error messages).
  • audit-trail / log@AuditMeta snapshots.
  • Cron/jobs/external: none in this module.

9. Gotchas & project-specific rules

  • Lives in users, not suppliers — discriminator (kind=Supplier); collection name in audit metadata is suppliers for grouping only.
  • Admin permission module is vendors, not suppliers — a frequent mismatch when wiring guards.
  • Balance is derived & POSTED-only — never stored; drafts invisible; missing control account → null.
  • accountId must be a payable (creditor) account — BE validates presence only; the UI enforces ACCOUNTS PAYABLE. getUserAccount routes any non-Customer kind to the creditor branch.
  • Supplier has a generated ref; Customer does not — set in the repository create(), unlike the customer side which relies on the BaseSchema ref.
  • update transaction is mislabeled "update_customer" in SupplierService.update (copy-paste) — harmless but confusing in logs.
  • Changing the account migrates historymigratePayeeAccount re-points all prior legs to the new payable account.
  • findSupplier uses userSvc.findOne — it isn't kind-restricted; always pass _id to be safe.
  • No price level / no KYC — those exist only on Customer; suppliers carry banking + RC/TIN for remittance and tax instead.
  • canDelete blocks non-zero balances — and delete is soft, so history survives.