|
|
|
@@ -169,10 +169,63 @@ function repaymentMatchesInput(repayment, input) {
|
|
|
|
|
repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function settleLoanRepayment(tx, batch, request) {
|
|
|
|
|
const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"];
|
|
|
|
|
|
|
|
|
|
// A matched receipt proves amount/accounts/currency/status, not which loan batch
|
|
|
|
|
// the bank paid. Any other batch for the same lender and amount that ever reached
|
|
|
|
|
// the bank (exported or receipted), including ones cancelled after a confirmed
|
|
|
|
|
// failure, could own the same transaction; settlement then needs attribution.
|
|
|
|
|
async function competingLoanBatches(tx, batch, repayment) {
|
|
|
|
|
return tx.paymentBatch.findMany({ where: {
|
|
|
|
|
id: { not: batch.id }, entity_type: "loan_repayment",
|
|
|
|
|
loan_repayment: { lender_id: repayment.lender_id, amount: repayment.amount },
|
|
|
|
|
OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }],
|
|
|
|
|
}, select: { id: true, batch_no: true, status: true }, orderBy: { id: "asc" } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sameIdSet = (stored, current) => {
|
|
|
|
|
const left = Array.isArray(stored) ? [...stored].sort() : null;
|
|
|
|
|
const right = [...(current ?? [])].sort();
|
|
|
|
|
return Boolean(left) && left.length === right.length && left.every((value, index) => value === right[index]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Valid only for the exact transaction, the exact receipt evidence that was
|
|
|
|
|
// reviewed (same verifications and manual proposals; a re-scan or re-keyed
|
|
|
|
|
// receipt of the same transaction invalidates it) and every current competitor.
|
|
|
|
|
function attributionMatches(attribution, claim, competitors) {
|
|
|
|
|
return Boolean(claim) && attribution.fingerprint === claim.fingerprint && attribution.key_id === claim.keyId &&
|
|
|
|
|
sameIdSet(attribution.verification_ids, claim.verificationIds) && sameIdSet(attribution.manual_proposal_ids, claim.manualProposalIds) &&
|
|
|
|
|
attributionCovers(attribution, competitors);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function attributionCovers(attribution, competitors) {
|
|
|
|
|
const covered = Array.isArray(attribution?.competitor_batch_ids) ? attribution.competitor_batch_ids : [];
|
|
|
|
|
return competitors.every((item) => covered.includes(item.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Final settlement gate: runs after claimVerifiedReceipts created this item's
|
|
|
|
|
// claim in the same transaction, so OCR matches and manual receipt reviews both
|
|
|
|
|
// pass through it. Only an approved attribution for the exact transaction
|
|
|
|
|
// fingerprint, covering every current competitor, unlocks settlement.
|
|
|
|
|
async function assertReceiptAttributed(tx, config, batch, repayment) {
|
|
|
|
|
const competitors = await competingLoanBatches(tx, batch, repayment);
|
|
|
|
|
if (!competitors.length) return;
|
|
|
|
|
const claim = await tx.paymentReceiptClaim.findUnique({ where: { item_id: batch.items[0].id } });
|
|
|
|
|
// Re-derive the evidence behind this claim (read-only; the claim row itself
|
|
|
|
|
// stores only one verification) so the approval's evidence binding is checked.
|
|
|
|
|
const planned = claim && await plannedClaimOrNull(tx, config, batch);
|
|
|
|
|
const approved = planned && planned.fingerprint === claim.fingerprint && await tx.loanReceiptAttribution.findFirst({ where: {
|
|
|
|
|
payment_batch_id: batch.id, status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } });
|
|
|
|
|
if (!approved || !attributionMatches(approved, planned, competitors)) {
|
|
|
|
|
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function settleLoanRepayment(tx, batch, request, config) {
|
|
|
|
|
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
|
|
|
|
if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
|
|
|
|
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
|
|
|
|
await assertReceiptAttributed(tx, config, batch, repayment);
|
|
|
|
|
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
|
|
|
|
data: { reservation_state: "settled", settled_at: new Date() } });
|
|
|
|
|
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
|
|
|
@@ -210,6 +263,10 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
|
|
|
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
|
|
|
|
|
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`;
|
|
|
|
|
const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id });
|
|
|
|
|
// A failed receipt that may belong to a same-lender, same-amount batch cannot
|
|
|
|
|
// release this reservation; without an attribution path for failures the
|
|
|
|
|
// repayment stays reserved for bank reconciliation.
|
|
|
|
|
if ((await competingLoanBatches(tx, batch, repayment)).length) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
|
|
|
|
|
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
|
|
|
|
|
// More than one bank attempt/evidence is ambiguous: one failed attempt cannot
|
|
|
|
|
// establish that another attempt did not pay. Preserve all reservations.
|
|
|
|
@@ -237,6 +294,127 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
|
|
|
|
|
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function plannedLoanClaim(tx, config, batch) {
|
|
|
|
|
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
|
|
|
|
|
let planned;
|
|
|
|
|
try { planned = await claimVerifiedReceipts(tx, config, batch, { readOnly: true }); }
|
|
|
|
|
catch (error) {
|
|
|
|
|
if (error instanceof ReceiptVerificationError && error.code === "receipt_verification_required") { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const claim = planned?.find((entry) => entry.itemId === batch.items[0].id);
|
|
|
|
|
if (!claim) { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
|
|
|
|
|
return claim;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function assertAttributableBatch(tx, batch, { requireCompetitors = true } = {}) {
|
|
|
|
|
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
|
|
|
|
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
|
|
|
|
if (repayment.bank_result_state === "confirmed_failed" || repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
|
|
|
|
const competitors = await competingLoanBatches(tx, batch, repayment);
|
|
|
|
|
if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); };
|
|
|
|
|
return { repayment, competitors };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Finance documents why the verified bank transaction belongs to this batch and
|
|
|
|
|
// why each competitor is excluded. Time alignment alone is not an accepted basis.
|
|
|
|
|
async function proposeReceiptAttribution(tx, config, batch, request, input) {
|
|
|
|
|
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
|
|
|
|
|
const { competitors } = await assertAttributableBatch(tx, batch);
|
|
|
|
|
const claim = await plannedLoanClaim(tx, config, batch);
|
|
|
|
|
// A still-valid open/approved record blocks duplicates. One whose receipt
|
|
|
|
|
// transaction or competitor coverage no longer matches is closed as
|
|
|
|
|
// superseded in the same transaction so the batch never deadlocks.
|
|
|
|
|
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } });
|
|
|
|
|
if (active) {
|
|
|
|
|
if (attributionMatches(active, claim, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); };
|
|
|
|
|
await tx.loanReceiptAttribution.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
|
|
|
|
|
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: active.id,
|
|
|
|
|
reason: active.fingerprint !== claim.fingerprint || active.key_id !== claim.keyId ? "receipt_transaction_changed"
|
|
|
|
|
: attributionCovers(active, competitors) ? "receipt_evidence_changed" : "competitors_changed" });
|
|
|
|
|
}
|
|
|
|
|
const attribution = await tx.loanReceiptAttribution.create({ data: {
|
|
|
|
|
payment_batch_id: batch.id, fingerprint: claim.fingerprint, key_id: claim.keyId,
|
|
|
|
|
verification_ids: claim.verificationIds, manual_proposal_ids: claim.manualProposalIds,
|
|
|
|
|
competitor_batch_ids: competitors.map((item) => item.id), basis_kind: input.basisKind,
|
|
|
|
|
basis_note: input.basisNote, exclusion_note: input.exclusionNote, proposed_by: request.employee.id,
|
|
|
|
|
} });
|
|
|
|
|
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_PROPOSED", batch.loan_repayment_id, {
|
|
|
|
|
paymentBatchId: batch.id, attributionId: attribution.id, basisKind: input.basisKind, competitorCount: competitors.length });
|
|
|
|
|
return attribution;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Independent review: the current reviewer must be eligible now and differ from
|
|
|
|
|
// the proposer, batch creator, submitter and lender employee. Approval is
|
|
|
|
|
// refused if the receipt evidence or competitor set changed since proposal.
|
|
|
|
|
async function reviewReceiptAttribution(tx, config, batch, request, attributionId, input) {
|
|
|
|
|
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
|
|
|
|
|
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
|
|
|
|
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
|
|
|
|
|
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by];
|
|
|
|
|
if (input.decision === "approve") {
|
|
|
|
|
const { competitors } = await assertAttributableBatch(tx, batch);
|
|
|
|
|
const claim = await plannedLoanClaim(tx, config, batch);
|
|
|
|
|
if (!attributionMatches(attribution, claim, competitors)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
|
|
|
|
// Also independent of everyone who produced the claimed evidence: uploaders
|
|
|
|
|
// of every receipt carrying this transaction and any manual-receipt proposer.
|
|
|
|
|
const verifications = await tx.paymentReceiptVerification.findMany({ where: { id: { in: claim.verificationIds } }, include: { attachment: true } });
|
|
|
|
|
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { id: { in: claim.manualProposalIds } }, select: { proposed_by: true } });
|
|
|
|
|
excluded.push(...verifications.map((v) => v.attachment?.uploaded_by), ...manual.map((m) => m.proposed_by));
|
|
|
|
|
}
|
|
|
|
|
// Rejection needs no receipt, so an open proposal can always be closed.
|
|
|
|
|
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
|
|
|
|
|
const reviewed = await tx.loanReceiptAttribution.update({ where: { id: attribution.id }, data: {
|
|
|
|
|
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
|
|
|
|
|
reviewed_at: new Date(), review_note: input.reason,
|
|
|
|
|
} });
|
|
|
|
|
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_RECEIPT_ATTRIBUTION_APPROVED" : "LOAN_RECEIPT_ATTRIBUTION_REJECTED",
|
|
|
|
|
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id });
|
|
|
|
|
return reviewed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function plannedClaimOrNull(tx, config, batch) {
|
|
|
|
|
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
|
|
|
|
|
try { return (await claimVerifiedReceipts(tx, config, batch, { readOnly: true }))?.find((entry) => entry.itemId === batch.items[0].id) ?? null; }
|
|
|
|
|
catch (error) { if (error instanceof ReceiptVerificationError) return null; throw error; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Display only: batch detail is also the response of mutating routes, so any
|
|
|
|
|
// failure to rebuild the planned claim (e.g. key mismatch) degrades to unknown.
|
|
|
|
|
async function displayPlannedClaim(tx, config, batch) {
|
|
|
|
|
try { return { claim: await plannedClaimOrNull(tx, config, batch), known: true }; }
|
|
|
|
|
catch { return { claim: null, known: false }; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read-only readiness for receipt-readiness: false when competitors exist and no
|
|
|
|
|
// approved attribution matches the receipt that execute would claim.
|
|
|
|
|
async function loanAttributionSatisfied(tx, config, batch) {
|
|
|
|
|
const repayment = await readLoanRepayment(tx, batch);
|
|
|
|
|
const competitors = await competingLoanBatches(tx, batch, repayment);
|
|
|
|
|
if (!competitors.length) return true;
|
|
|
|
|
const claim = await plannedClaimOrNull(tx, config, batch);
|
|
|
|
|
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
|
|
|
|
return Boolean(approved && attributionMatches(approved, claim, competitors));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loanReceiptAttributionState(tx, batch, repayment, config) {
|
|
|
|
|
const competitors = await competingLoanBatches(tx, batch, repayment);
|
|
|
|
|
const planned = competitors.length && config ? await displayPlannedClaim(tx, config, batch) : { claim: null, known: false };
|
|
|
|
|
const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id },
|
|
|
|
|
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], include: { proposer: { select: { name: true } }, reviewer: { select: { name: true } } } });
|
|
|
|
|
return {
|
|
|
|
|
required: competitors.length > 0,
|
|
|
|
|
competitors: competitors.map((item) => ({ id: item.id, batchNo: item.batch_no, status: item.status })),
|
|
|
|
|
current: current ? { id: current.id, status: current.status, basisKind: current.basis_kind, basisNote: current.basis_note,
|
|
|
|
|
exclusionNote: current.exclusion_note, proposedBy: current.proposed_by, proposedByName: current.proposer?.name ?? null,
|
|
|
|
|
proposedAt: current.proposed_at, reviewedByName: current.reviewer?.name ?? null, reviewedAt: current.reviewed_at,
|
|
|
|
|
reviewNote: current.review_note, coversCurrentCompetitors: attributionCovers(current, competitors),
|
|
|
|
|
stale: ["proposed", "approved"].includes(current.status) && competitors.length > 0 && planned.known && planned.claim !== null &&
|
|
|
|
|
!attributionMatches(current, planned.claim, competitors) } : null,
|
|
|
|
|
receiptAvailable: planned.known ? planned.claim !== null : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapRepayment(repayment, batch) {
|
|
|
|
|
return {
|
|
|
|
|
id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id,
|
|
|
|
@@ -251,4 +429,5 @@ function mapRepayment(repayment, batch) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
|
|
|
|
|
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure };
|
|
|
|
|
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
|
|
|
|
|
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied };
|
|
|
|
|