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, astatusstate 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, orinstallmentAmountanywhere underhr/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/approvalIdcolumn persisted on the claim despiteCreateClaimInputaccepting an optionalapproverId— it is ignored by the service (approvers are resolved by the policy, not passed in). The DTO exposesapproverId/approvalIdfields 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
AdvanceTransactionrows (disbursed vs repaid), surfaced as the deriveddisbursed/repaid/outstandingfields on theAdvanceDTO (computed in the repository'spage()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" }
PENDINGis declared but unused in the create paths — every document is created asPENDING_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):
- Load the employee (
employeeSvc.findById); throwBadRequestException("Employee not found")if missing. - Validate the approval chain BEFORE writing anything —
orchestrator.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). - 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.
- Return the created document.
Loan-only at step 3:
installmentAmount = input.amount / input.numberOfInstallmentsandremainingBalance = input.amountare 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;installmentAmountmay 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):
- Load loan + company config.
- In a transaction, set
status = APPROVED. - If
config.loanAccountIdANDconfig.loanPaymentAccountIdare set, post aJournalEntryTypes.GENERALentry (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. workflowRejected → REJECTED.
Claim has no
workflowCompletedGL.ClaimService.workflowCompletedonly doesstatus = APPROVED;workflowRejectedonly doesstatus = REJECTED. Claims post nothing to the GL on approval (confirmed: no journal/account references inclaim.service.ts). Reimbursement payment is not modeled in code — see §9.
4.5 Settlement transactions — double-entry math (LoanTransactionService / AdvanceTransactionService)
createLoanTransaction / createAdvanceTransaction:
- Load the loan/advance; require the two config accounts (else
BadRequestException("... accounts not configured")). - Decide direction (
typedefaults toREPAYMENT):
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 |
- Post a balanced
JournalEntry(refId = loan/advance._id) with both legs taggedkind = LoanRepayment/AdvanceTransaction. Only the receivable side carriesref2Id = loan/advance._idand theloanType/advanceTypelabel sofindByLoan/findByAdvancereturn exactly one row per event. - Loan only: if
REPAYMENT, decrementloan.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)
cancelXis rejected unlessstatus === PENDING_APPROVAL("Cannot cancel a <x> with status <s>"). On cancel: setCANCELLED+orchestrator.cancelWorkflow(id)(archives pending workflow tasks), inside a transaction.updateClaimis rejected unlessstatus === PENDING_APPROVAL("Cannot edit a claim with status <s>"). Loan/Advance have no update mutation.APPROVED/REJECTEDare 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'
buildQueryinjectsemployeeId = contextSvc.employeeIdwhen the caller is acting as an employee — so a logged-in employee only ever sees their own claims/loans/advances regardless of theemployeeIdfilter passed. Admin callers (noemployeeIdin 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/ departmenthodId(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):
- Map
WorkflowTaskKind → HrApprovalKind(Claim→CLAIM,Loan→LOAN,Advance→ADVANCE). policyService.findByKind(approvalKind, companyId)— load the HR approval policy; throw if none / nolevels.- Resolve each policy level's role to a User._id:
MANAGER→ employee'sreportingTo(anEmployee._id) → looked up to itsuserId.HEAD_OF_DEPARTMENT→ the employee's departmenthodId(already aUser._id).HR→ employee'shrId(anEmployee._id) → looked up to itsuserId.
- Sort levels by
order, drop levels with no resolvable approver, buildDynamicStage[]({ name, order, approveBy: ANY|ALL, approverIds }). - 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 approvers →
createXthrows before any write (e.g."No approval policy configured for Loan","No approvers could be resolved for Loan..."). Nothing persisted. - Employee not found →
BadRequestException("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 rejection →
workflowRejectedsetsREJECTED; no GL, no settlement possible. - Delete loan/advance →
deleteLoanWithCascade/deleteAdvanceWithCascade: finds all journal entries{ refId: id }, deletes them (which cascades theirAccountTransactionlines), 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.tsxlist. Receipts uploaded viauseUploadFileQuery→attachments[]. - Claim group sub-module (
claim/group) has its own context + page;components/async-select.tsxis 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'. CreateLoanform (components/create.tsx): Yup-validatedemployeeId,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 postscreateLoanTransaction.
7.3 Advance (src/modules/hr/advance)
- Context methods:
fetchAdvancePage,fetchAdvanceById,createAdvance,cancelAdvance,deleteAdvance,fetchTransactions,fetchAdvanceSummary,createTransaction, file helpers. detail.tsxmirrors the loan detail (disbursed / repaid / outstanding instead of a fixed installment plan; supports bothDISBURSEMENTandREPAYMENTtransaction 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; resolvereportingTo/hrId→ userIds for routing. Hard dependency. - Workflow —
HrApprovalOrchestratorService(HR side) →WorkflowEngine/WorkflowTaskService(platform). Registers viaWorkflowTaskFactory. See workflow approval engine. - approval-policy (
HrApprovalPolicyService) + department (hodId) — approver resolution. - Finance (finance domain):
JournalEntryService(post/find/delete entries),AccountTransactiondiscriminator 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
- No interest, no real amortization schedule. Loan
installmentAmount = amount / numberOfInstallmentsis 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 isremainingBalance, decremented per ad-hoc repayment. "Schedule" is descriptive, not enforced. - 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. - Claims have no GL and no payment record.
ClaimService.workflowCompletedonly flips status toAPPROVED. 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. - Claim group limits are not enforced.
ClaimGroup.types[].limitis stored and shown in admin but never checked increateClaim. It is policy metadata only. approverId/approvalIdon the claim are dead fields.CreateClaimInput.approverIdis accepted but ignored; neither column is written. Approvers come from the policy, not the request.PENDINGstatus is unused. All documents startPENDING_APPROVAL;PENDINGexists in the enum but no code path sets it.- Validate-before-write.
validateAndResolveStagesruns 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). - Settlement transactions are finance discriminators, not HR collections.
LoanTransaction/AdvanceTransactionlive infinance_account_transactions(discriminator valuesLoanRepayment/AdvanceTransaction). The GraphQL type returned is a synthetic projection, not the stored doc. Listing relies onref2Idbeing set on only the receivable leg so each event yields one row. - Advance outstanding is derived; loan balance is stored. Advance has no balance column —
disbursed/repaid/outstandingare aggregated on read. Loan keeps a materializedremainingBalancethat onlyREPAYMENTtransactions decrement (disbursement transactions do not). - Disbursement GL fires at two different points for advance vs. its transactions.
AdvanceService.workflowCompletedposts one disbursement entry on approval;createAdvanceTransactionwithtype=DISBURSEMENTposts 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. - Delete cascades through finance.
deleteLoan/deleteAdvancedelete all journal entries keyed byrefId(cascading their transaction lines) before soft-deleting the request. There is nodeleteClaim. - ESS auto-scopes via context. The repositories silently override
employeeIdwithcontextSvc.employeeIdfor self-service callers — a passedemployeeIdfilter is ignored when an employee (not admin) is the caller.