const policy = require("../../../src/services/loanApprovalPolicy"); function scenario() { const batch = { id: "batch", entity_type: "loan_repayment", loan_repayment_id: "repayment", created_by: "finance", reviewed_by: "ma" }; const repayment = { id: "repayment", approval_mode: "related_party", lender_id: "lender", legal_entity_id: "company", submitted_by: "finance", approval_context: { approverId: "ma" }, lender: { employee_id: "ma" }, allocations: [{ loan_id: "loan" }] }; const grant = { id: "grant", version: 1, lender_id: "lender", legal_entity_id: "company", approver_id: "ma", loans: [{ loan_id: "loan" }], events: [{ id: "confirmed", action: "confirm", payment_enabled: true, reconcile_enabled: true }] }; const tx = { loanRepayment: { findUniqueOrThrow: vi.fn(async () => repayment) }, loanRelatedAuthorization: { findFirst: vi.fn(async () => grant), findUnique: vi.fn(async () => grant) }, employee: { findUnique: vi.fn(async () => ({ status: "active", archived_at: null, roles: [{ role_code: "manager" }] })) } }; for (const name of ["paymentOrderExport", "loanBankSubmission", "loanBankSubmissionAttestation", "paymentBatchReceiptAttachment", "paymentReceiptManualProposal", "loanReceiptAttribution", "loanBankTerminalEvidence", "loanBankTerminalFact", "loanReleaseRecovery", "loanBankResultEvent"]) { tx[name] = { findMany: vi.fn(async () => []) }; } const saved = { id: "decision", reviewed_by: "ma", loan_approval_mode: "related_party", loan_authorization_id: "grant", loan_approval_context: { version: 1, targetId: "decision", repaymentId: "repayment", batchId: "batch", purpose: "reconcile", approverId: "ma", lenderId: "lender", legalEntityId: "company", loanIds: ["loan"], payeeConflictWaived: true, authorizationVersion: 1, authorizationEventId: "confirmed", conflictIds: ["finance"], } }; return { tx, batch, repayment, grant, saved }; } describe("related loan authority and historical decisions", () => { it("permits a scoped payee to decide without deleting other conflict reasons", async () => { const { tx, batch, repayment } = scenario(); expect(policy.payeeConflicts(repayment)).toEqual([]); await expect(policy.assertReviewActor(tx, batch, "ma")).resolves.toBeUndefined(); batch.created_by = "ma"; await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } }); }); it.each([ ["paymentOrderExport", "exported_by"], ["loanBankSubmission", "registered_by"], ["loanBankSubmissionAttestation", "proposed_by"], ["paymentBatchReceiptAttachment", "uploaded_by"], ["paymentReceiptManualProposal", "proposed_by"], ["loanReceiptAttribution", "proposed_by"], ["loanBankTerminalEvidence", "uploaded_by"], ["loanBankTerminalFact", "proposed_by"], ["loanReleaseRecovery", "started_by"], ["loanReleaseRecovery", "submitted_by"], ["loanBankResultEvent", "proposed_by"], ])("refuses the payee who also produced %s.%s, including voided/superseded history", async (model, field) => { const { tx, batch, saved } = scenario(); tx[model].findMany.mockResolvedValue([{ [field]: "ma", status: "superseded", archived_at: new Date() }]); await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } }); await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } }); const where = tx[model].findMany.mock.calls[0][0].where; expect(where).not.toHaveProperty("status"); expect(where).not.toHaveProperty("archived_at"); }); it("keeps the payee excluded on an old independent repayment", async () => { const { tx, batch, repayment, saved } = scenario(); repayment.approval_mode = "independent"; expect(policy.payeeConflicts(repayment)).toEqual(["ma"]); await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } }); }); it("does not let finance or another manager use the designated representative's grant", async () => { const { tx, batch } = scenario(); for (const actor of ["finance", "other-manager", "technical-employee"]) { await expect(policy.assertReviewActor(tx, batch, actor)).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } }); } await expect(policy.assertMaker(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_actor_conflict" } }); }); it.each(["loan", "company", "lender", "approver"])("rejects a current grant outside the %s scope without falling back", async (kind) => { const { tx, repayment, grant } = scenario(); if (kind === "loan") grant.loans = [{ loan_id: "other" }]; else grant[{ company: "legal_entity_id", lender: "lender_id", approver: "approver_id" }[kind]] = "other"; await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } }); expect(tx.loanRelatedAuthorization.findFirst).toHaveBeenCalledTimes(1); }); it("separates stopping payment from reconciliation and preserves already-made decisions", async () => { const { tx, batch, repayment, grant, saved } = scenario(); grant.events.push({ id: "stop-payment", action: "stop_payment", payment_enabled: false, reconcile_enabled: true }); await expect(policy.currentForRepayment(tx, repayment, "payment")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } }); await expect(policy.currentForRepayment(tx, repayment)).resolves.toBe(grant); grant.events.push({ id: "stop-reconcile", action: "stop_reconcile", payment_enabled: false, reconcile_enabled: false }); await expect(policy.currentForRepayment(tx, repayment)).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } }); tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [] }); await expect(policy.assertDecision(tx, batch, saved)).resolves.toBeUndefined(); }); it("requires current employee eligibility for a new decision", async () => { const { tx, batch } = scenario(); tx.employee.findUnique.mockResolvedValue({ status: "archived", roles: [{ role_code: "manager" }] }); await expect(policy.assertReviewActor(tx, batch, "ma")).rejects.toMatchObject({ details: { reasonCode: "loan.related_authorization_required" } }); }); it.each(["missing", "purpose", "batchId", "targetId", "repaymentId", "lenderId", "legalEntityId", "loanIds", "conflictIds", "authorizationVersion", "authorizationEventId", "approverId", "payeeConflictWaived"])("rejects an approved record with invalid %s provenance at final use", async (field) => { const { tx, batch, saved } = scenario(); if (field === "missing") saved.loan_approval_context = null; else saved.loan_approval_context[field] = field === "loanIds" ? ["other"] : "invalid"; await expect(policy.assertDecision(tx, batch, saved)).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } }); }); it("never accepts a related-party Q6 grant", async () => { const { tx, batch } = scenario(); await expect(policy.assertNotRelatedQ6(tx, batch)).rejects.toMatchObject({ details: { reasonCode: "loan.related_q6_unavailable" } }); }); it("never treats a returned application as an approved payment", async () => { const { tx, batch, saved } = scenario(); saved.loan_approval_context.purpose = "payment"; saved.loan_approval_context.decision = "reject"; await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).rejects.toMatchObject({ details: { reasonCode: "loan.related_scope_invalid" } }); saved.loan_approval_context.decision = "approve"; await expect(policy.assertDecision(tx, batch, saved, { purpose: "payment" })).resolves.toBeUndefined(); }); });