Claims, Loans & Advances — employee money-out requests

The whole sub-domain reduces to one shape: an employee asks the company for money; a workflow approves it; the GL records the liability/expense; the money is settled (reimbursed, disbursed, repaid). All three documents (Claim, Loan, Advance) are near-identical request records — employeeId, amount, a status state machine (PENDING_APPROVAL → APPROVED | REJECTED | CANCELLED), and a workflow task. They differ only in what happens on approval:

  • Claim = expense reimbursement → on approval just flips status (no GL, no money tracking).
  • Advance = short-term cash advance → on approval posts a disbursement GL entry; later disbursement/repayment transactions track outstanding.
  • Loan = long-term advance with an installment plan → on approval posts a disbursement GL entry and computes a flat installment schedule; repayments are recorded manually and decrement remainingBalance.

Source: BE src/modules/hr/claim, src/modules/hr/loan, src/modules/hr/advance · Admin src/modules/hr/claim, src/modules/hr/loan, src/modules/hr/advance (+ src/modules/hr/claim/group)


1. Purpose & scope

This sub-domain owns three employee-initiated "money request" documents and their settlement:

Document What it is On approval Settlement
Claim Expense reimbursement request (travel, medical, etc.) with receipts status → APPROVED only Out of scope — no GL, no payment record in code (see §9)
Advance Short-term cash advance against future salary DR advance-receivable / CR payment account (GL) Manual AdvanceTransaction rows (disbursement / repayment); outstanding = disbursed − repaid
Loan Long-term advance repaid over N installments DR loan-receivable / CR payment account (GL) + flat schedule computed Manual LoanTransaction rows (repayment); each repayment decrements remainingBalance

It also owns Claim Groups (claim_groups) — per-claim-type spending-limit policies (a master-data lookup; not enforced in the claim create path, see §9).

It explicitly does not:

  • Run the approval state machine itself — it delegates entirely to the workflow approval engine via the HR approval orchestrator. Claim/Loan/Advance services only register approve/reject callbacks and react to them.
  • Auto-deduct loan/advance repayments from payroll. There is no payroll integration — repayment is a manual admin action (confirmed: no references to remainingBalance, loanTransaction, or installmentAmount anywhere under hr/payroll/). See §9.
  • Define accounts — the GL account ids come from the company config (loanAccountId, loanPaymentAccountId, advanceAccountId, advancePaymentAccountId).

2. Data model

All three request schemas use @HrSchema({ collection: "<x>s" }) (the hr_ prefix is applied by the decorator → physical collections hr_claims, hr_loans, hr_advances; the audit collection metadata on resolvers also uses these names). All extend BaseSchema (gives _id, companyId, branchId, ref, documentDate, createdAt/By, etc.) and register mongoose-delete (deletedAt) for soft-delete. Every *Id is coerced from a 24-char hex string to ObjectId via BaseSchema.toObjectId.

2.1 hr_claims — expense claim

claim/claim.schema.ts

field type required description
employeeId ObjectId → employees yes The claimant.
companyId ObjectId → companies Stamped from employee.companyId at create. Tenant boundary.
amount number yes Claim amount to reimburse.
description string yes What the expense was.
claimDate number (unix ms) yes Date the expense was incurred. The repository's date-range filter key (dateKey: "claimDate").
attachments string[] Uploaded receipt URLs (default []).
note string Free-text note.
status enum ClaimStatus Default PENDING_APPROVAL.

Note there is no approverId/approvalId column persisted on the claim despite CreateClaimInput accepting an optional approverId — it is ignored by the service (approvers are resolved by the policy, not passed in). The DTO exposes approverId/approvalId fields but they are never written.

2.2 hr_loans — staff loan

loan/loan.schema.ts

field type required description
employeeId ObjectId → employees yes Borrower.
companyId ObjectId Stamped from employee.
amount number yes Principal.
purpose string yes Reason for the loan.
attachments string[] Supporting docs (default []).
numberOfInstallments number yes How many installments the principal is split over.
installmentAmount number yes Computed = amount / numberOfInstallments (flat, no interest — see §4.2).
startDate number (unix ms) yes When repayment begins (informational; schedule is not date-driven in code).
remainingBalance number Default 0; set to amount at create; decremented by each repayment transaction.
status enum LoanStatus Default PENDING_APPROVAL.
journalEntryId ObjectId → finance_journal_entries The disbursement journal entry posted on approval (if accounts configured).

2.3 hr_advances — salary advance

advance/advance.schema.ts

field type required description
employeeId ObjectId → employees yes Requestor.
companyId ObjectId Stamped from employee.
amount number yes Advance amount.
purpose string yes Reason.
attachments string[] Supporting docs (default []).
status enum AdvanceStatus Default PENDING_APPROVAL.
journalEntryId ObjectId Disbursement journal entry posted on approval (if accounts configured).

The advance has no installment fields — it is a lump sum. Repayment is tracked purely through AdvanceTransaction rows (disbursed vs repaid), surfaced as the derived disbursed/repaid/outstanding fields on the Advance DTO (computed in the repository's page() aggregation, §4.5).

2.4 Enums (status state machine)

The three status enums are identical in shape (claim.schema.ts, loan.schema.ts, advance.schema.ts):

export enum ClaimStatus   { PENDING="PENDING", PENDING_APPROVAL="PENDING_APPROVAL", APPROVED="APPROVED", REJECTED="REJECTED", CANCELLED="CANCELLED" }
export enum LoanStatus    { PENDING="PENDING", PENDING_APPROVAL="PENDING_APPROVAL", APPROVED="APPROVED", REJECTED="REJECTED", CANCELLED="CANCELLED" }
export enum AdvanceStatus { PENDING="PENDING", PENDING_APPROVAL="PENDING_APPROVAL", APPROVED="APPROVED", REJECTED="REJECTED", CANCELLED="CANCELLED" }

PENDING is declared but unused in the create paths — every document is created as PENDING_APPROVAL. All three enums are registered in GraphQL (registerEnumType).

2.5 hr_claim_groups — claim-type spending policy

claim/group/group.schema.ts. A named policy that caps spend per claim type.

export class ClaimGroupType {
  claimTypeId: ObjectId;   // → masters (a "Claim Type" master-data row)
  limit: number;           // spend cap for this type
}

@HrSchema({ collection: "claim_groups" })
export class ClaimGroup extends BaseSchema {
  name: string;            // REQUIRED
  types: ClaimGroupType[]; // [{ claimTypeId, limit }]
}

$lookupClaimGroupTypes (exported from the schema) joins masters to populate each types[].claimType { _id, name } for the admin. An employee is assigned a claim group via Employee.claimGroupId (see employee). The limit is not enforced at claim-create time in the BE code (see §9).

2.6 Settlement transactions — LoanTransaction / AdvanceTransaction

These are not standalone collections. They are discriminators on the finance AccountTransaction collection (finance_account_transactions), so a loan/advance repayment is a real GL transaction line, not a separate ledger.

// loan/transaction/transaction.schema.ts
export enum LoanTransactionType { DISBURSEMENT="DISBURSEMENT", REPAYMENT="REPAYMENT" }

@ApSchema()
export class LoanTransactionEntry extends AccountTransactionEntity {  // discriminator of AccountTransaction
  loanId: ObjectId;
  employeeId: ObjectId;
  loanType: string;          // "DISBURSEMENT" | "REPAYMENT"
  attachments: string[];
}
// registered as a discriminator value: AccountTransactionKind.LoanRepayment

// advance/transaction/transaction.schema.ts — same shape, value AccountTransactionKind.AdvanceTransaction
export enum AdvanceTransactionType { DISBURSEMENT="DISBURSEMENT", REPAYMENT="REPAYMENT" }
@ApSchema()
export class AdvanceTransactionEntry extends AccountTransactionEntity {
  advanceId: ObjectId; employeeId: ObjectId; advanceType: string; attachments: string[];
}

The transaction services return a synthetic LoanTransaction/AdvanceTransaction GraphQL shape mapped from the underlying journal entry + the receivable-side AccountTransaction line; only the receivable side carries ref2Id = loanId/advanceId so that "one event = one record" when listing (findByLoan / findByAdvance filter on ref2Id + kind). See §4.3–4.5.


3. API surface

All resolvers are guarded by @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature('HR_MODULE'). Mutations carry @AuditMeta (see audit-trail).

3.1 Claim (claim/claim.resolver.ts)

Operation Type Input Returns Audit
createClaim Mutation CreateClaimInput { employeeId, approverId?, amount, description, claimDate, attachments?, note? } Claim CREATE
updateClaim Mutation id, UpdateClaimInput (all optional) Claim UPDATE
cancelClaim Mutation id Claim STATUS_CHANGE
claimById Query id Claim (with employeeName lookup)
claimPage Query ClaimPageInput { skip, take, status?, employeeId?, fromDate?, toDate? } ClaimPageResult
myClaims Query ClaimPageInput + @GqlCurrentUser ClaimPageResult (forces employeeId = user._id)
myClaimSummary Query employeeId? (defaults to user.employeeId ?? user._id) MyClaimSummary { total/pending/approved × amount/count }

@ResolveField employee resolves { _id, name } via employeeSvc.getNameById. There is no deleteClaim mutation (claims are only cancellable, not deletable).

3.2 Loan (loan/loan.resolver.ts + loan/transaction/transaction.resolver.ts)

Operation Type Input Returns Audit
createLoan Mutation CreateLoanInput { employeeId, amount, purpose, numberOfInstallments, startDate, attachments? } Loan CREATE
cancelLoan Mutation id Loan STATUS_CHANGE
deleteLoan Mutation id Boolean (cascades: deletes ref'd journal entries then the loan) DELETE
loanById Query id Loan (+ employeeName)
loanPage Query LoanPageInput { skip, take, status?, employeeId? } LoanPageResult
loanSummary Query employeeId LoanSummary { totalAmount, totalCount, pendingCount, activeCount, totalRemainingBalance }
createLoanTransaction Mutation CreateLoanTransactionInput { loanId, amount, date, description?, attachments?, type? } LoanTransaction CREATE (finance_account_transactions)
loanTransactions Query loanId [LoanTransaction]

@ResolveField journalEntry on Loan resolves the approval disbursement entry via journalSvc.findOne({ refId: loan._id }). There is no myLoans query (admin-driven module).

3.3 Advance (advance/advance.resolver.ts + advance/transaction/transaction.resolver.ts)

Operation Type Input Returns Audit
createAdvance Mutation CreateAdvanceInput { employeeId, amount, purpose, attachments? } Advance CREATE
cancelAdvance Mutation id Advance STATUS_CHANGE
deleteAdvance Mutation id Boolean (cascade: journals → advance) DELETE
advanceById Query id Advance (+ employeeName)
advancePage Query AdvancePageInput { skip, take, status?, employeeId? } AdvancePageResult (+ derived disbursed/repaid/outstanding)
myAdvances Query AdvancePageInput + @GqlCurrentUser AdvancePageResult
myAdvanceSummary Query employeeId? MyAdvanceSummary { totalAmount, totalCount, pendingCount, approvedCount, totalOutstanding }
createAdvanceTransaction Mutation CreateAdvanceTransactionInput { advanceId, amount, date, description?, attachments?, type? } AdvanceTransaction CREATE
advanceTransactions Query advanceId [AdvanceTransaction]

3.4 Claim group (claim/group/group.resolver.ts)

createClaimGroup / updateClaimGroup / deleteClaimGroup mutations + claimGroupPage / findOneClaimGroup queries, all over ClaimGroup (standard CRUD, CreateClaimGroupInput { name, types: [{ claimTypeId, limit }] }).


4. Business rules & calculations

4.1 Create flow (identical skeleton for all three)

createClaim / createLoan / createAdvance (*.service.ts):

  1. Load the employee (employeeSvc.findById); throw BadRequestException("Employee not found") if missing.
  2. Validate the approval chain BEFORE writing anythingorchestrator.validateAndResolveStages(employeeId, kind). This resolves the approval policy + approvers; if no policy / no levels / no resolvable approvers, it throws and nothing is persisted (see §6 unhappy paths).
  3. In withRetryTransaction(...) (single Mongo session — see architecture):
    • repo.create({ ...input, companyId: employee.companyId, status: PENDING_APPROVAL, <loan: installmentAmount + remainingBalance> }).
    • orchestrator.resolveAndSubmit({ refId: created._id, kind, employeeId, submitterId: employeeId, ref: created.ref }) — creates the workflow task(s) and routes to the first approver.
  4. Return the created document.

Loan-only at step 3: installmentAmount = input.amount / input.numberOfInstallments and remainingBalance = input.amount are set at create time.

4.2 Loan repayment-schedule math (flat, no interest)

The "schedule" is a flat split of principal — there is no interest, no reducing-balance amortization anywhere in the code:

installmentAmount = amount / numberOfInstallments          // computed once at createLoan
remainingBalance  = amount                                 // at create

There is no generated installment table / due-date collection. startDate and installmentAmount are descriptive. Actual repayment is event-driven:

on each REPAYMENT transaction (createLoanTransaction, type omitted → REPAYMENT):
  remainingBalance = max(0, remainingBalance − transaction.amount)

So the schedule is implicit: the employee is expected to pay installmentAmount per period for numberOfInstallments periods, but the system only tracks the running remainingBalance against ad-hoc repayment amounts. The admin loan detail shows progress as repaidPct = (amount − remainingBalance) / amount × 100.

Example: amount 1200, installments 12 → installmentAmount = 100, remainingBalance = 1200. A 250 repayment → remainingBalance = 950. No rounding/last-installment adjustment exists; installmentAmount may be fractional (e.g. 1000/3 = 333.33…) and is stored as a raw float.

4.3 Loan approval → disbursement GL (LoanService.workflowCompleted)

When the workflow engine finishes all stages, it calls workflowCompleted(loanId):

  1. Load loan + company config.
  2. In a transaction, set status = APPROVED.
  3. If config.loanAccountId AND config.loanPaymentAccountId are set, post a JournalEntryTypes.GENERAL entry (refId = loan._id) with two legs:
DR  config.loanAccountId          loan.amount   "Employee loan receivable - <ref>"
CR  config.loanPaymentAccountId   loan.amount   "Loan disbursement - <ref>"

and stamp journalEntryId = entry._id on the loan. 4. If the accounts are not configured, status still flips to APPROVED but no GL entry is posted.

workflowRejected(loanId)status = REJECTED (no GL).

4.4 Advance approval → disbursement GL (AdvanceService.workflowCompleted)

Identical to loan but with the advance accounts:

DR  config.advanceAccountId          advance.amount   "Employee advance receivable - <ref>"
CR  config.advancePaymentAccountId   advance.amount   "Advance disbursement - <ref>"

status = APPROVED always; GL only when both advanceAccountId + advancePaymentAccountId configured. workflowRejectedREJECTED.

Claim has no workflowCompleted GL. ClaimService.workflowCompleted only does status = APPROVED; workflowRejected only does status = REJECTED. Claims post nothing to the GL on approval (confirmed: no journal/account references in claim.service.ts). Reimbursement payment is not modeled in code — see §9.

4.5 Settlement transactions — double-entry math (LoanTransactionService / AdvanceTransactionService)

createLoanTransaction / createAdvanceTransaction:

  1. Load the loan/advance; require the two config accounts (else BadRequestException("... accounts not configured")).
  2. Decide direction (type defaults to REPAYMENT):
type Debit account Credit account Effect
DISBURSEMENT <x>AccountId (receivable) <x>PaymentAccountId (cash/bank) money goes out; debt rises
REPAYMENT <x>PaymentAccountId (cash/bank) <x>AccountId (receivable) money comes in; debt falls
  1. Post a balanced JournalEntry (refId = loan/advance._id) with both legs tagged kind = LoanRepayment / AdvanceTransaction. Only the receivable side carries ref2Id = loan/advance._id and the loanType/advanceType label so findByLoan/findByAdvance return exactly one row per event.
  2. Loan only: if REPAYMENT, decrement loan.remainingBalance = max(0, remainingBalance − amount). (Advance does not mutate any balance field — outstanding is purely derived.)

Advance outstanding (derived, in advance.repository.ts page()): aggregates the advance's AdvanceTransaction lines grouped by advanceType:

disbursed   = Σ amount where advanceType = DISBURSEMENT
repaid      = Σ amount where advanceType = REPAYMENT
outstanding = disbursed − repaid

4.6 Status state machine

                 createClaim/Loan/Advance
                          │
                          ▼
                  PENDING_APPROVAL ──── cancelX ───▶ CANCELLED
                  │   (workflow task)              (only allowed from PENDING_APPROVAL)
        workflow  │
   ┌──────────────┼──────────────┐
   ▼                              ▼
 APPROVED                       REJECTED
 (loan/advance: + disbursement GL)
  • cancelX is rejected unless status === PENDING_APPROVAL ("Cannot cancel a <x> with status <s>"). On cancel: set CANCELLED + orchestrator.cancelWorkflow(id) (archives pending workflow tasks), inside a transaction.
  • updateClaim is rejected unless status === PENDING_APPROVAL ("Cannot edit a claim with status <s>"). Loan/Advance have no update mutation.
  • APPROVED / REJECTED are terminal (only reachable via the workflow callbacks); cancellation is impossible after approval.

4.7 Cancellation summary helpers

myClaimSummary / myLoanSummary / myAdvanceSummary are pure in-memory reductions over repo.find({ employeeId }) — counts and sums by status. (Loan/advance activeCount/approvedCount = status APPROVED; loan totalRemainingBalance = Σ remainingBalance of approved loans; advance totalOutstanding = Σ amount of approved advances.)


5. Permissions

  • Feature gate: @RequireFeature('HR_MODULE') + GqlFeatureGuard — the tenant's subscription must include the HR module (see subscription-config).
  • Auth: @ApGqlAuthorize() (JWT) on every resolver. See auth.
  • ESS scoping (self-service): the repositories' buildQuery injects employeeId = contextSvc.employeeId when the caller is acting as an employee — so a logged-in employee only ever sees their own claims/loans/advances regardless of the employeeId filter passed. Admin callers (no employeeId in context) see all. See ess.
  • Approval authority is not a static permission — it is dynamic, resolved per-document by the approval policy + the employee's reportingTo / hrId / department hodId (see §6 and permissions-access for RBAC vs. workflow authority distinction).

6. Flows

6.1 Create → approve (happy path, loan example)

Admin /hr/loan → "New Loan" → CreateLoan form (employee, amount, installments, startDate, purpose)
  → GQL createLoan(loan: CreateLoanInput)
  → LoanResolver.create → LoanService.createLoan
       1. employeeSvc.findById            (404 → BadRequest)
       2. orchestrator.validateAndResolveStages(employeeId, Loan)   ← validates policy+approvers, NO writes yet
       3. tx {
            loanRepo.create({ ..., installmentAmount=amount/N, remainingBalance=amount, status=PENDING_APPROVAL })
            orchestrator.resolveAndSubmit({ refId, kind=Loan, employeeId, submitterId, ref })
              → WorkflowEngine.submit → creates WorkflowTask(s) (one per policy level/stage) → routes to approver 1
          }
  ← Loan (PENDING_APPROVAL)

... approver acts in their inbox (workflow module) ...
  Approver approves final stage
  → WorkflowEngine (engine.ts:313) → taskFactory.getTaskKindService(Loan).workflowCompleted(loanId)
  → LoanService.workflowCompleted
       status = APPROVED
       if config.loanAccountId && config.loanPaymentAccountId:
         journalSvc.addEntry( DR loanAccountId / CR loanPaymentAccountId, amount )  ← disbursement GL
         loan.journalEntryId = entry._id
  ← Loan (APPROVED, journalEntryId set)

Claim and Advance follow the same skeleton; only step 3's extra fields and workflowCompleted's GL differ (§4.3–4.4).

6.2 Approval routing (how stages/approvers are resolved)

HrApprovalOrchestratorService.validateAndResolveStages (approval-orchestrator.service.ts):

  1. Map WorkflowTaskKind → HrApprovalKind (Claim→CLAIM, Loan→LOAN, Advance→ADVANCE).
  2. policyService.findByKind(approvalKind, companyId) — load the HR approval policy; throw if none / no levels.
  3. Resolve each policy level's role to a User._id:
    • MANAGER → employee's reportingTo (an Employee._id) → looked up to its userId.
    • HEAD_OF_DEPARTMENT → the employee's department hodId (already a User._id).
    • HR → employee's hrId (an Employee._id) → looked up to its userId.
  4. Sort levels by order, drop levels with no resolvable approver, build DynamicStage[] ({ name, order, approveBy: ANY|ALL, approverIds }).
  5. If zero stages resolve → throw (configure the policy / assign manager/HOD/HR).

resolveAndSubmit then hands dynamicStages to WorkflowEngine.submit. See the workflow approval engine for the multi-stage SEQUENTIAL/PARALLEL, ANY/ALL execution semantics. The callback contract is IWorkflowTaskKindService { workflowCompleted(refId), workflowRejected?(refId) }; each service registers itself in onModuleInit via taskFactory.register(WorkflowTaskKind.X, this).

6.3 Loan/Advance settlement (manual repayment)

Admin /hr/loan/[id] (LoanDetailPage) → "Record Repayment" modal (amount, date, description, receipts)
  → createLoanTransaction({ loanId, amount, date, ... })   // type omitted ⇒ REPAYMENT
  → LoanTransactionService.createTransaction
       require config.loanAccountId + loanPaymentAccountId  (else BadRequest)
       tx {
         journalSvc.addEntry( DR loanPaymentAccountId / CR loanAccountId, amount )  ← repayment GL
         loanSvc.update(loanId, { remainingBalance: max(0, remainingBalance − amount) })
       }
  ← LoanTransaction (synthetic shape)
  UI reloads loan + transactions → progress bar advances

Advance settlement is the same via createAdvanceTransaction, but the advance's outstanding is recomputed from the transactions (no balance field mutated).

6.4 Unhappy paths

  • No approval policy / no approverscreateX throws before any write (e.g. "No approval policy configured for Loan", "No approvers could be resolved for Loan..."). Nothing persisted.
  • Employee not foundBadRequestException("Employee not found").
  • Cancel after approval"Cannot cancel a <x> with status APPROVED".
  • Edit claim after approval"Cannot edit a claim with status <s>".
  • Record repayment with accounts unconfigured"Loan accounts not configured. Please set them in Config." (same for advance).
  • Workflow rejectionworkflowRejected sets REJECTED; no GL, no settlement possible.
  • Delete loan/advancedeleteLoanWithCascade / deleteAdvanceWithCascade: finds all journal entries { refId: id }, deletes them (which cascades their AccountTransaction lines), then soft-deletes the document — all in one transaction.

7. Admin UI

Routes (zerp-admin/src/pages/hr/...): /hr/claim, /hr/claim-group, /hr/loan, /hr/advance (+ detail views rendered in-page/modal).

Each module follows the zync-nextjs standard: context.tsx is the sole use<Feature>Query() consumer and exposes plain async methods; components use use<Feature>State() only.

7.1 Claim (src/modules/hr/claim)

  • Context methods: fetchClaimPage, fetchClaimById, createClaim, updateClaim, cancelClaim, fetchClaimSummary, uploadFile, deleteFile.
  • Components: components/create.tsx (Formik form), detail.tsx, page.tsx list. Receipts uploaded via useUploadFileQueryattachments[].
  • Claim group sub-module (claim/group) has its own context + page; components/async-select.tsx is an inline-create select for assigning a claim group (used on the employee form).

7.2 Loan (src/modules/hr/loan)

  • Context methods: fetchLoanPage, fetchLoanById, createLoan, cancelLoan, deleteLoan, fetchTransactions, fetchTransactionsById, createTransaction, fetchLoanSummary, uploadFile, deleteFile. Modal kinds: 'create' | 'repayments'.
  • CreateLoan form (components/create.tsx): Yup-validated employeeId, amount (min 0.01), purpose, numberOfInstallments (min 1), startDate. Employee select pre-fillable from a detail page.
  • LoanDetailPage (detail.tsx): header (employee, purpose, status tag, ref), a stats strip (Loan Amount / Installment Amount / Total Installments / Remaining Balance), a Repayment Progress card (progress bar = (amount − remainingBalance)/amount, Repaid vs Outstanding), a Transactions table (type tag, amount, date, description, attachments, linked journal), and the Approval Journal Entry card (the disbursement entry's legs). "Record Repayment" modal posts createLoanTransaction.

7.3 Advance (src/modules/hr/advance)

  • Context methods: fetchAdvancePage, fetchAdvanceById, createAdvance, cancelAdvance, deleteAdvance, fetchTransactions, fetchAdvanceSummary, createTransaction, file helpers.
  • detail.tsx mirrors the loan detail (disbursed / repaid / outstanding instead of a fixed installment plan; supports both DISBURSEMENT and REPAYMENT transaction types).

Notable UX across all three: file attachments on both the request and each settlement transaction; journal entries deep-link to /finance/journals/[id]; status shown as colored Ant Tag.


8. Dependencies & integrations

Calls into:

  • employee (EmployeeService) — resolve employee + companyId; resolve reportingTo/hrId → userIds for routing. Hard dependency.
  • WorkflowHrApprovalOrchestratorService (HR side) → WorkflowEngine / WorkflowTaskService (platform). Registers via WorkflowTaskFactory. See workflow approval engine.
  • approval-policy (HrApprovalPolicyService) + department (hodId) — approver resolution.
  • Finance (finance domain): JournalEntryService (post/find/delete entries), AccountTransaction discriminator collection (settlement lines), ApConfigService (account ids). Loan/Advance only.
  • audit-trail (@AuditMeta), subscription (GqlFeatureGuard), auth, fileUpload (admin attachments).

Called by: the workflow engine (via IWorkflowTaskKindService callbacks); the employee detail page embeds Claims/Loans/Advances tabs.

Events / cron: none. No payroll deduction job, no scheduled installment posting.


9. Gotchas & project-specific rules

  1. No interest, no real amortization schedule. Loan installmentAmount = amount / numberOfInstallments is a flat division stored as a raw float (can be non-terminating, e.g. 333.333…). There is no installment table, no due dates, no last-installment rounding. The only live balance is remainingBalance, decremented per ad-hoc repayment. "Schedule" is descriptive, not enforced.
  2. No payroll integration. Loans/advances are not auto-deducted from payroll. Repayment is a manual admin transaction. (Confirmed: zero references to loan/advance repayment under hr/payroll/.) If payroll-driven deduction is required, it is unimplemented.
  3. Claims have no GL and no payment record. ClaimService.workflowCompleted only flips status to APPROVED. There is no reimbursement journal entry, no claim accounts in config, and no claim settlement transaction. The "reimbursement" step is out of the modeled flow.
  4. Claim group limits are not enforced. ClaimGroup.types[].limit is stored and shown in admin but never checked in createClaim. It is policy metadata only.
  5. approverId/approvalId on the claim are dead fields. CreateClaimInput.approverId is accepted but ignored; neither column is written. Approvers come from the policy, not the request.
  6. PENDING status is unused. All documents start PENDING_APPROVAL; PENDING exists in the enum but no code path sets it.
  7. Validate-before-write. validateAndResolveStages runs before the create transaction, so a misconfigured approval policy fails the request cleanly with nothing persisted (good for replication — don't move it inside the transaction).
  8. Settlement transactions are finance discriminators, not HR collections. LoanTransaction/AdvanceTransaction live in finance_account_transactions (discriminator values LoanRepayment / AdvanceTransaction). The GraphQL type returned is a synthetic projection, not the stored doc. Listing relies on ref2Id being set on only the receivable leg so each event yields one row.
  9. Advance outstanding is derived; loan balance is stored. Advance has no balance column — disbursed/repaid/outstanding are aggregated on read. Loan keeps a materialized remainingBalance that only REPAYMENT transactions decrement (disbursement transactions do not).
  10. Disbursement GL fires at two different points for advance vs. its transactions. AdvanceService.workflowCompleted posts one disbursement entry on approval; createAdvanceTransaction with type=DISBURSEMENT posts another. Recording a manual disbursement after an approval-time disbursement double-counts unless operators are careful (no guard against it). Loan has the same dual-path shape.
  11. Delete cascades through finance. deleteLoan/deleteAdvance delete all journal entries keyed by refId (cascading their transaction lines) before soft-deleting the request. There is no deleteClaim.
  12. ESS auto-scopes via context. The repositories silently override employeeId with contextSvc.employeeId for self-service callers — a passed employeeId filter is ignored when an employee (not admin) is the caller.