借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 15:22:12 +08:00
共同撰写人 Claude Opus 5.5
父节点 2063ff1e93
当前提交 4a5cd8f2b3
共修改 34 个文件,包含 1610 行新增和 41 行删除
@@ -0,0 +1,161 @@
-- N5-C: lender's statement is separate from company-representative authority.
-- Additive only; no historical acknowledgement or balance is manufactured.
CREATE TABLE loan_lender_acknowledgements (
id UUID PRIMARY KEY,
legal_entity_id UUID NOT NULL,
lender_id UUID NOT NULL,
employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
identity_version INTEGER NOT NULL CHECK(identity_version>0),
account_version INTEGER NOT NULL CHECK(account_version>0),
lender_name VARCHAR(120) NOT NULL,
company_name VARCHAR(200) NOT NULL,
loan_ids UUID[] NOT NULL CHECK(cardinality(loan_ids) BETWEEN 1 AND 50),
content_hash VARCHAR(64) NOT NULL CHECK(content_hash ~ '^[a-f0-9]{64}$'),
request_hash VARCHAR(64) NOT NULL CHECK(request_hash ~ '^[a-f0-9]{64}$'),
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
request_key UUID NOT NULL,
invalidated_at TIMESTAMP(3),
invalid_reason VARCHAR(40),
CHECK(proposed_by<>employee_id),
CHECK((invalidated_at IS NULL)=(invalid_reason IS NULL)),
FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT,
UNIQUE(proposed_by,request_key)
);
CREATE INDEX loan_lender_acknowledgements_employee_id_proposed_at_idx ON loan_lender_acknowledgements(employee_id,proposed_at);
CREATE INDEX loan_lender_acknowledgements_lender_id_proposed_at_idx ON loan_lender_acknowledgements(lender_id,proposed_at);
CREATE TABLE loan_lender_acknowledgement_loans (
acknowledgement_id UUID NOT NULL REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
snapshot_json JSONB NOT NULL,
PRIMARY KEY(acknowledgement_id,loan_id)
);
CREATE INDEX loan_lender_acknowledgement_loans_loan_id_idx ON loan_lender_acknowledgement_loans(loan_id);
CREATE TABLE loan_lender_acknowledgement_decisions (
acknowledgement_id UUID PRIMARY KEY REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT,
action VARCHAR(16) NOT NULL CHECK(action IN ('confirm','return')),
actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2),
original_checked BOOLEAN NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(action<>'confirm' OR original_checked)
);
-- One canonical projection, excluding changing payment balances and ordinary
-- row versions. Adding accounting evidence / confirming balance keeps it valid.
CREATE FUNCTION loan_lender_ack_snapshot(loan_id_arg UUID) RETURNS JSONB LANGUAGE sql STABLE AS $$
SELECT jsonb_build_object(
'loanId',l.id,'loanNo',l.loan_no,'lenderId',l.lender_id,'legalEntityId',l.legal_entity_id,
'originReference',l.origin_reference,'currency',l.currency_code,
'originalPrincipal',l.original_principal::text,
'openingOutstandingPrincipal',l.opening_outstanding_principal::text,
'receivedDate',to_char(l.received_date,'YYYY-MM-DD'),
'balanceAsOf',to_char(l.balance_as_of,'YYYY-MM-DD'),'historicalEntry',l.historical_entry,
'originals',COALESCE((SELECT jsonb_agg(jsonb_build_object(
'id',e.id,'sha256',e.file_sha256,'originalName',e.original_name,
'fileSize',e.file_size,'mimeType',e.mime_type) ORDER BY e.id)
FROM loan_evidences e WHERE e.loan_id=l.id AND e.kind='incoming'
AND e.archived_at IS NULL AND e.governance_status='approved'),'[]'::jsonb))
FROM company_loans l WHERE l.id=loan_id_arg
$$;
CREATE FUNCTION guard_loan_lender_ack_history() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE a loan_lender_acknowledgements; l loan_lenders; c company_loans;
BEGIN
IF TG_TABLE_NAME='loan_lender_acknowledgements' AND TG_OP='UPDATE' THEN
IF OLD.invalidated_at IS NOT NULL OR NEW.invalidated_at IS NULL OR NEW.invalid_reason IS NULL OR
(to_jsonb(NEW)-'invalidated_at'-'invalid_reason') IS DISTINCT FROM (to_jsonb(OLD)-'invalidated_at'-'invalid_reason')
THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
RETURN NEW;
END IF;
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF;
IF TG_TABLE_NAME='loan_lender_acknowledgements' THEN
SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE;
IF l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM NEW.employee_id OR
l.identity_version<>NEW.identity_version OR l.account_version<>NEW.account_version OR
l.name<>NEW.lender_name OR NEW.company_name IS DISTINCT FROM (SELECT name FROM legal_entities WHERE id=l.legal_entity_id) OR
NEW.invalidated_at IS NOT NULL OR NEW.loan_ids IS DISTINCT FROM ARRAY(SELECT DISTINCT unnest(NEW.loan_ids) ORDER BY 1)
THEN RAISE EXCEPTION 'invalid lender acknowledgement source' USING ERRCODE='23514'; END IF;
ELSE
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=NEW.acknowledgement_id;
IF TG_TABLE_NAME='loan_lender_acknowledgement_loans' THEN
SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id;
IF NOT (NEW.loan_id=ANY(a.loan_ids)) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR
(c.confirmation_status<>'unconfirmed' AND NOT EXISTS(
SELECT 1 FROM loan_lender_acknowledgement_loans old_scope
JOIN loan_lender_acknowledgements old_ack ON old_ack.id=old_scope.acknowledgement_id
WHERE old_scope.loan_id=c.id AND old_ack.invalidated_at IS NOT NULL)) OR
NEW.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(NEW.loan_id) OR
jsonb_array_length(NEW.snapshot_json->'originals')=0 OR
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_decisions WHERE acknowledgement_id=a.id)
THEN RAISE EXCEPTION 'invalid lender acknowledgement scope' USING ERRCODE='23514'; END IF;
ELSE
-- Same lock order as the service and source maintenance. Never take the
-- acknowledgement first and then wait for its source loan/lender.
SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id FOR UPDATE;
PERFORM id FROM company_loans WHERE id=ANY(a.loan_ids) ORDER BY id FOR UPDATE;
SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=a.id FOR UPDATE;
IF a.invalidated_at IS NOT NULL OR NEW.actor_id<>a.employee_id OR NEW.actor_id=a.proposed_by OR
l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM a.employee_id OR
l.identity_version<>a.identity_version OR l.account_version<>a.account_version OR
NOT EXISTS(SELECT 1 FROM employees WHERE id=NEW.actor_id AND status='active' AND archived_at IS NULL) OR
EXISTS(SELECT 1 FROM loan_lender_acknowledgement_loans s WHERE s.acknowledgement_id=a.id
AND s.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(s.loan_id)) OR
(SELECT count(*) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=a.id)<>cardinality(a.loan_ids)
THEN RAISE EXCEPTION 'invalid lender acknowledgement decision' USING ERRCODE='23514'; END IF;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER lender_ack_header_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgements FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE TRIGGER lender_ack_loan_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE TRIGGER lender_ack_decision_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_decisions FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history();
CREATE FUNCTION check_loan_lender_ack_complete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.loan_ids IS DISTINCT FROM (SELECT array_agg(loan_id ORDER BY loan_id)
FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=NEW.id)
THEN RAISE EXCEPTION 'incomplete lender acknowledgement snapshot' USING ERRCODE='23514'; END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER lender_ack_complete AFTER INSERT ON loan_lender_acknowledgements
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_lender_ack_complete();
CREATE FUNCTION invalidate_loan_lender_ack() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE affected UUID[];
BEGIN
IF TG_TABLE_NAME='company_loans' THEN
IF ROW(OLD.lender_id,OLD.legal_entity_id,OLD.loan_no,OLD.origin_reference,OLD.currency_code,
OLD.original_principal,OLD.opening_outstanding_principal,OLD.received_date,OLD.balance_as_of,OLD.historical_entry)
IS NOT DISTINCT FROM ROW(NEW.lender_id,NEW.legal_entity_id,NEW.loan_no,NEW.origin_reference,NEW.currency_code,
NEW.original_principal,NEW.opening_outstanding_principal,NEW.received_date,NEW.balance_as_of,NEW.historical_entry)
THEN RETURN NULL; END IF;
affected:=ARRAY[NEW.id];
ELSIF TG_TABLE_NAME='loan_lenders' THEN
IF ROW(OLD.employee_id,OLD.identity_status,OLD.identity_version,OLD.account_version,OLD.archived_at,
OLD.name,OLD.bank_account_encrypted,OLD.account_name_encrypted,OLD.bank_institution_id,OLD.bank_name)
IS NOT DISTINCT FROM ROW(NEW.employee_id,NEW.identity_status,NEW.identity_version,NEW.account_version,NEW.archived_at,
NEW.name,NEW.bank_account_encrypted,NEW.account_name_encrypted,NEW.bank_institution_id,NEW.bank_name)
THEN RETURN NULL; END IF;
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,invalid_reason='lender_changed'
WHERE lender_id=NEW.id AND invalidated_at IS NULL;
RETURN NULL;
ELSE
IF TG_OP='INSERT' THEN
IF NEW.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[NEW.loan_id];
ELSIF TG_OP='DELETE' THEN
IF OLD.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id];
ELSE
IF (OLD.kind<>'incoming' AND NEW.kind<>'incoming') OR OLD IS NOT DISTINCT FROM NEW THEN RETURN NULL; END IF;
affected:=ARRAY[OLD.loan_id,NEW.loan_id];
END IF;
END IF;
UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,
invalid_reason=CASE WHEN TG_TABLE_NAME='company_loans' THEN 'loan_changed' ELSE 'original_changed' END
WHERE invalidated_at IS NULL AND loan_ids && affected;
RETURN NULL;
END $$;
CREATE TRIGGER lender_ack_loan_source AFTER UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
CREATE TRIGGER lender_ack_lender_source AFTER UPDATE ON loan_lenders FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
CREATE TRIGGER lender_ack_original_source AFTER INSERT OR UPDATE OR DELETE ON loan_evidences FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack();
+55
查看文件
@@ -3935,6 +3935,60 @@ model LoanRelatedAuthorizationEvent {
@@map("loan_related_authorization_events")
}
model LoanLenderAcknowledgement {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
lender_id String @db.Uuid
employee_id String @db.Uuid
identity_version Int
account_version Int
lender_name String @db.VarChar(120)
company_name String @db.VarChar(200)
loan_ids String[] @db.Uuid
content_hash String @db.VarChar(64)
request_hash String @db.VarChar(64)
reason String @db.VarChar(500)
proposed_by String @db.Uuid
proposed_at DateTime @default(now())
request_key String @db.Uuid
invalidated_at DateTime?
invalid_reason String? @db.VarChar(40)
loans LoanLenderAcknowledgementLoan[]
decision LoanLenderAcknowledgementDecision?
@@unique([proposed_by, request_key])
@@index([employee_id, proposed_at])
@@index([lender_id, proposed_at])
@@map("loan_lender_acknowledgements")
}
model LoanLenderAcknowledgementLoan {
acknowledgement_id String @db.Uuid
loan_id String @db.Uuid
snapshot_json Json
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict)
@@id([acknowledgement_id, loan_id])
@@index([loan_id])
@@map("loan_lender_acknowledgement_loans")
}
model LoanLenderAcknowledgementDecision {
acknowledgement_id String @id @db.Uuid
action String @db.VarChar(16)
actor_id String @db.Uuid
reason String @db.VarChar(500)
original_checked Boolean
created_at DateTime @default(now())
acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict)
@@map("loan_lender_acknowledgement_decisions")
}
model LoanLender {
id String @id @default(uuid()) @db.Uuid
legal_entity_id String @db.Uuid
@@ -3998,6 +4052,7 @@ model CompanyLoan {
evidences LoanEvidence[]
supplemental_settlements LoanSupplementalSettlement[]
lender_acknowledgements LoanLenderAcknowledgementLoan[]
@@unique([created_by, request_key])
@@unique([legal_entity_id, lender_id, origin_reference])
+5 -1
查看文件
@@ -29,7 +29,11 @@ const ERROR_REASONS = [
{"code":"loan.account_unavailable","domain":"loan","audience":"user","defaultMessage":"借款收款资料暂不可用,请联系财务核对账户加密配置。","recovery":"follow_guidance"},
{"code":"loan.duplicate_registration","domain":"loan","audience":"user","defaultMessage":"该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。","recovery":"follow_guidance"},
{"code":"loan.confirmed_immutable","domain":"loan","audience":"user","defaultMessage":"借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。","recovery":"follow_guidance"},
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_scope_invalid","domain":"loan","audience":"user","defaultMessage":"请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_exists","domain":"loan","audience":"user","defaultMessage":"所选借款已有待确认或有效的出借人声明,请查看现有记录。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_changed","domain":"loan","audience":"user","defaultMessage":"借款内容、原件或出借人资料已变化,请由财务重新发起确认。","recovery":"follow_guidance"},
{"code":"loan.lender_ack_decided","domain":"loan","audience":"user","defaultMessage":"这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。","recovery":"follow_guidance"},
{"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"},
{"code":"loan.evidence_invalid","domain":"loan","audience":"user","defaultMessage":"借款材料不符合要求,请使用有效的 PDF 或图片文件。","recovery":"follow_guidance"},
{"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"},
{"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"},
@@ -20,6 +20,7 @@ const NOTIFICATION_TYPES = Object.freeze({
paymentBatchReviewRequested: 'payment_batch.review_requested',
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
paymentBatchReturned: 'payment_batch.returned',
loanLenderAcknowledgementRequested: 'loan.lender_acknowledgement_requested',
leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected',
leaveReturned: 'leave.returned',
+1
查看文件
@@ -41,6 +41,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
const EMPLOYEE_BASE_PERMISSIONS = [
"loan.acknowledge_own",
...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
+60 -4
查看文件
@@ -16,6 +16,7 @@ const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, b
const approvalPolicy = require("../services/loanApprovalPolicy");
const employeeLenders = require("../services/loanLenderEmployee");
const incomingReceipts = require("../services/loanIncomingReceipt");
const lenderAcknowledgements = require("../services/loanLenderAcknowledgement");
const UUID = z.string().uuid();
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
@@ -45,6 +46,11 @@ const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef:
const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]);
const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict();
const ACK_PROPOSE = z.object({ lenderId: UUID, expectedIdentityVersion: VERSION, requestKey: UUID, reason: TEXT,
loans: z.array(z.object({ id: UUID, version: VERSION }).strict()).min(1).max(50) }).strict();
const ACK_DECIDE = z.object({ action: z.enum(["confirm", "return"]), reason: TEXT,
contentHash: z.string().regex(/^[a-f0-9]{64}$/), originalChecked: z.boolean() }).strict()
.refine((value) => value.action !== "confirm" || value.originalChecked);
const MAX_BYTES = 10 * 1024 * 1024;
function parse(schema, value) {
@@ -110,6 +116,41 @@ async function companyLoansRoutes(fastify) {
const auth = { preHandler: fastify.authenticate };
const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
fastify.hasPermission(request.employee, "loan.manage");
function ensureAcknowledgementReader(request) {
if (!isFinance(request) && !fastify.hasPermission(request.employee, "loan.acknowledge_own")) ensureLoanManager(fastify, request);
}
fastify.get("/loan-lender-acknowledgements", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const query = parse(LIST.omit({ lenderId: true }), request.query ?? {});
const where = isFinance(request) ? {} : { employee_id: request.employee.id };
const [items, total] = await Promise.all([
fastify.prisma.loanLenderAcknowledgement.findMany({ where, include: lenderAcknowledgements.INCLUDE,
orderBy: [{ proposed_at: "desc" }, { id: "asc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
fastify.prisma.loanLenderAcknowledgement.count({ where }),
]);
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, { items: await Promise.all(items.map((record) =>
lenderAcknowledgements.present(fastify.prisma, record, request.employee.id))), total });
});
fastify.get("/loan-lender-acknowledgements/:id", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const record = await lenderAcknowledgements.read(fastify.prisma, fastify, request, parse(UUID, request.params.id));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
fastify.post("/loan-lender-acknowledgements", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
const record = await lenderAcknowledgements.propose(fastify.prisma, fastify, request, parse(ACK_PROPOSE, request.body));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
fastify.post("/loan-lender-acknowledgements/:id/decision", auth, async (request, reply) => {
ensureAcknowledgementReader(request);
const record = await lenderAcknowledgements.decide(fastify.prisma, fastify, request,
parse(UUID, request.params.id), parse(ACK_DECIDE, request.body));
reply.header("Cache-Control", "private, no-store");
return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id));
});
async function readAuthorization(request, id) {
const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE });
if (!record) throw loanNotFound();
@@ -366,7 +407,13 @@ async function companyLoansRoutes(fastify) {
ensureLoanManager(fastify, request);
const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body);
const item = await fastify.prisma.$transaction(async (tx) => {
const target = await tx.companyLoan.findUnique({ where: { id }, select: { lender_id: true } });
if (!target) throw loanNotFound();
// Serialize consuming an acknowledgement with identity/original changes,
// including changes to another loan in the same frozen statement.
await lockLender(tx, target.lender_id);
const loan = await lockLoan(tx, id);
if (loan.lender_id !== target.lender_id) throw loanNotFound();
assertCurrentVersion(loan.version, body.expectedVersion);
if (loan.confirmation_status === "confirmed") throw buildBusinessValidationError("loan.confirmed_immutable", {}, 409);
assertEvidenceComplete(loan);
@@ -431,11 +478,16 @@ async function companyLoansRoutes(fastify) {
fastify.post("/company-loans/:id/evidences/:evidenceId/download", auth, async (request, reply) => {
const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage");
if (!finance && (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review"))) ensureLoanManager(fastify, request);
const id = parse(UUID, request.params.id); const evidenceId = parse(UUID, request.params.evidenceId);
const { reason, paymentBatchId, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict()
.refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body);
if (!finance) {
const { reason, paymentBatchId, authorizationId, lenderAcknowledgementId } = parse(z.object({ reason: TEXT,
paymentBatchId: UUID.optional(), authorizationId: UUID.optional(), lenderAcknowledgementId: UUID.optional() }).strict()
.refine((v) => [v.paymentBatchId, v.authorizationId, v.lenderAcknowledgementId].filter(Boolean).length <= 1), request.body);
let acknowledgedOriginal;
if (lenderAcknowledgementId) {
ensureAcknowledgementReader(request);
acknowledgedOriginal = await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
} else if (!finance) {
if (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review")) ensureLoanManager(fastify, request);
if (authorizationId) {
const record = await readAuthorization(request, authorizationId);
if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound();
@@ -452,6 +504,7 @@ async function companyLoansRoutes(fastify) {
}
const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } });
if (!item) throw loanNotFound();
if (acknowledgedOriginal && acknowledgedOriginal.sha256 !== item.file_sha256) throw loanNotFound();
let validated;
try {
if (item.file_size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
@@ -459,12 +512,15 @@ async function companyLoansRoutes(fastify) {
validated = await validateEvidenceFile({ buffer: await boundedBuffer(opened.stream), fileName: item.original_name,
declaredMimeType: item.mime_type, normalizeImages: false });
if (validated.fileSha256 !== item.file_sha256) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409);
// A source/identity change during storage IO must not grant stale access.
if (lenderAcknowledgementId) await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId);
} catch (error) {
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOAD_BLOCKED", id, { evidenceId });
if (error instanceof StorageNotFoundError) throw loanNotFound();
throw error;
}
await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason,
...(lenderAcknowledgementId ? { lenderAcknowledgementId } : {}),
...(authorizationId ? { authorizationId } : {}), ...(paymentBatchId ? { paymentBatchId } : {}) });
return reply.header("Cache-Control", "private, no-store").header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "default-src 'none'; sandbox")
+9 -2
查看文件
@@ -3,9 +3,11 @@ const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { buildAuditLogData } = require("../utils/audit");
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
const { validAcknowledgement, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true,
lender_acknowledgements: { include: { acknowledgement: { include: { decision: true } } } } };
function ensureLoanManager(fastify, request) {
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
@@ -80,6 +82,7 @@ function mapEvidence(evidence) {
}
function mapLoan(config, loan) {
const acknowledgement = validAcknowledgement(loan);
return {
id: loan.id, loanNo: loan.loan_no, originReference: loan.origin_reference,
lender: mapLender(config, loan.lender), currency: loan.currency_code,
@@ -89,6 +92,9 @@ function mapLoan(config, loan) {
balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), historicalEntry: loan.historical_entry,
confirmationStatus: loan.confirmation_status, confirmedAt: loan.confirmed_at, version: loan.version,
accountingConfirmationRef: loan.accounting_confirmation_ref,
lenderAcknowledgement: acknowledgement ? { id: acknowledgement.id, confirmedAt: acknowledgement.decision.created_at,
lenderName: acknowledgement.lender_name } : null,
lenderAcknowledgementRenewable: acknowledgementRenewable(loan),
...loanBalances(loan), evidences: (loan.evidences ?? []).map(mapEvidence),
};
}
@@ -126,7 +132,8 @@ async function inFlightLenderIds(db, lenderIds) {
function assertEvidenceComplete(loan) {
const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind));
const required = ["agreement", "incoming", "accounting_confirmation"];
const required = ["incoming", "accounting_confirmation"];
if (!validAcknowledgement(loan)) required.push("agreement");
if (new Prisma.Decimal(loan.opening_outstanding_principal).lt(loan.original_principal)) required.push("adjustment");
if (required.some((kind) => !kinds.has(kind))) throw buildBusinessValidationError("loan.evidence_required", {}, 409);
}
@@ -0,0 +1,147 @@
const { randomUUID, createHash } = require("node:crypto");
const { Prisma } = require("@prisma/client");
const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { lockLender, auditLoan, loanNotFound, assertCurrentVersion } = require("./companyLoans");
const { canonical, same, snapshot, matchesLender, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot");
const { createNotifications } = require("./notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, decision: true };
const SOURCE = { lender: true, evidences: true };
const digest = (value) => createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
const errors = {
"loan.lender_ack_changed": () => buildBusinessValidationError("loan.lender_ack_changed", {}, 409),
"loan.lender_ack_scope_invalid": () => buildBusinessValidationError("loan.lender_ack_scope_invalid", {}, 409),
"loan.lender_ack_exists": () => buildBusinessValidationError("loan.lender_ack_exists", {}, 409),
"loan.lender_ack_decided": () => buildBusinessValidationError("loan.lender_ack_decided", {}, 409),
"loan.duplicate_registration": () => buildBusinessValidationError("loan.duplicate_registration", {}, 409),
};
const fail = (code = "loan.lender_ack_changed") => { throw errors[code](); };
const forbidden = () => { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); };
const isFinance = (fastify, request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) &&
fastify.hasPermission(request.employee, "loan.manage");
async function lockLoans(tx, ids) {
await tx.$queryRaw`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR UPDATE`;
return tx.companyLoan.findMany({ where: { id: { in: ids } }, include: { ...SOURCE,
lender_acknowledgements: { select: { acknowledgement: { select: { invalidated_at: true } } } } }, orderBy: { id: "asc" } });
}
async function activeEmployee(tx, id, lock = false) {
if (lock) await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR NO KEY UPDATE`;
return tx.employee.findFirst({ where: { id, status: "active", archived_at: null }, select: { id: true, name: true } });
}
function sourceMatches(record, loans) {
return !record.invalidated_at && record.loans.length === record.loan_ids.length && loans.length === record.loan_ids.length &&
record.loans.every((item) => {
const loan = loans.find((l) => l.id === item.loan_id);
return loan && matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
});
}
async function read(db, fastify, request, id) {
const record = await db.loanLenderAcknowledgement.findFirst({
where: { id, ...(isFinance(fastify, request) ? {} : { employee_id: request.employee.id }) }, include: INCLUDE,
});
if (!record) throw loanNotFound();
return record;
}
async function present(db, record, actorId) {
const [loans, maker, signer] = await Promise.all([
db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE }),
db.employee.findUnique({ where: { id: record.proposed_by }, select: { name: true } }),
activeEmployee(db, record.employee_id),
]);
const current = sourceMatches(record, loans);
return {
id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id,
lenderName: record.lender_name, companyName: record.company_name,
proposedByName: maker?.name ?? "原经办人", proposedAt: record.proposed_at, reason: record.reason,
contentHash: record.content_hash, current, invalidatedAt: record.invalidated_at,
status: !current ? "invalid" : record.decision?.action === "confirm" ? "confirmed" : record.decision ? "returned" : "pending",
decision: record.decision ? { action: record.decision.action, reason: record.decision.reason,
createdAt: record.decision.created_at, lenderName: record.lender_name } : null,
canDecide: current && !record.decision && Boolean(signer) && actorId === record.employee_id && actorId !== record.proposed_by,
canReadOriginals: current,
loans: record.loans.map((item) => item.snapshot_json),
};
}
async function propose(prisma, fastify, request, input) {
const ids = input.loans.map((l) => l.id).sort();
if (ids.length !== new Set(ids).size) fail("loan.lender_ack_scope_invalid");
const requestHash = digest({ ...input, loans: [...input.loans].sort((a, b) => a.id.localeCompare(b.id)) });
const lookup = (db) => db.loanLenderAcknowledgement.findUnique({ where: {
proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey },
}, include: INCLUDE });
const matchRetry = (record) => { if (record.request_hash !== requestHash) fail("loan.duplicate_registration"); return record; };
try {
return await prisma.$transaction(async (tx) => {
const lender = await lockLender(tx, input.lenderId);
const old = await lookup(tx); if (old) return matchRetry(old);
if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id || lender.employee_id === request.employee.id) fail("loan.lender_ack_scope_invalid");
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
const loans = await lockLoans(tx, ids);
if (loans.length !== ids.length || loans.some((loan) => loan.lender_id !== lender.id ||
loan.legal_entity_id !== lender.legal_entity_id ||
(loan.confirmation_status !== "unconfirmed" && !acknowledgementRenewable(loan)) || !snapshot(loan).originals.length)) fail("loan.lender_ack_scope_invalid");
for (const loan of loans) assertCurrentVersion(loan.version, input.loans.find((l) => l.id === loan.id).version);
const existing = await tx.loanLenderAcknowledgement.findFirst({ where: {
lender_id: lender.id, invalidated_at: null, loan_ids: { hasSome: ids },
OR: [{ decision: null }, { decision: { action: "confirm" } }],
} });
if (existing) fail("loan.lender_ack_exists");
if (!await activeEmployee(tx, lender.employee_id, true)) fail("loan.lender_ack_scope_invalid");
const snapshots = loans.map(snapshot);
const record = await tx.loanLenderAcknowledgement.create({ data: {
id: randomUUID(), lender_id: lender.id, legal_entity_id: lender.legal_entity_id, employee_id: lender.employee_id,
identity_version: lender.identity_version, account_version: lender.account_version,
lender_name: lender.name, company_name: lender.legal_entity.name, loan_ids: ids,
content_hash: digest(snapshots), request_hash: requestHash, reason: input.reason,
proposed_by: request.employee.id, request_key: input.requestKey,
loans: { create: snapshots.map((value) => ({ loan_id: value.loanId, snapshot_json: value })) },
}, include: INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_ACK_PROPOSED", record.id, { loanIds: ids, contentHash: record.content_hash });
await createNotifications(tx, {
recipientEmployeeIds: [record.employee_id],
type: NOTIFICATION_TYPES.loanLenderAcknowledgementRequested,
title: "借款声明待本人确认",
body: "财务已发起借款声明,请核对借款明细及原件后确认,或填写原因退回。",
targetType: "loan_lender_acknowledgement", targetId: record.id,
});
return record;
});
} catch (error) {
if (error?.code === "P2002") { const old = await lookup(prisma); if (old) return matchRetry(old); }
throw error;
}
}
async function decide(prisma, fastify, request, id, input) {
// Restrict before locking or disclosing the existence of someone else's request.
const initial = await read(prisma, fastify, request, id);
if (initial.employee_id !== request.employee.id || initial.proposed_by === request.employee.id) forbidden();
return prisma.$transaction(async (tx) => {
await lockLender(tx, initial.lender_id);
const loans = await lockLoans(tx, initial.loan_ids);
await tx.$queryRaw`SELECT id FROM loan_lender_acknowledgements WHERE id=${id}::uuid FOR UPDATE`;
const record = await read(tx, fastify, request, id);
if (input.contentHash !== record.content_hash || !sourceMatches(record, loans)) fail();
if (!await activeEmployee(tx, request.employee.id, true)) forbidden();
if (record.decision) {
if (record.decision.actor_id === request.employee.id && record.decision.action === input.action &&
record.decision.reason === input.reason && record.decision.original_checked === input.originalChecked) return record;
fail("loan.lender_ack_decided");
}
await tx.loanLenderAcknowledgementDecision.create({ data: { acknowledgement_id: id,
action: input.action, actor_id: request.employee.id, reason: input.reason, original_checked: input.originalChecked } });
await auditLoan(tx, request, input.action === "confirm" ? "LOAN_LENDER_ACK_CONFIRMED" : "LOAN_LENDER_ACK_RETURNED", id,
{ loanIds: record.loan_ids, contentHash: record.content_hash });
return tx.loanLenderAcknowledgement.findUniqueOrThrow({ where: { id }, include: INCLUDE });
});
}
async function assertOriginalAccess(db, fastify, request, acknowledgementId, loanId, evidenceId) {
const record = await read(db, fastify, request, acknowledgementId);
const loans = await db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE });
if (!sourceMatches(record, loans)) fail();
const original = record.loans.find((l) => l.loan_id === loanId)?.snapshot_json?.originals?.find((e) => e.id === evidenceId);
if (!original) throw loanNotFound();
return original;
}
module.exports = { INCLUDE, isFinance, read, present, propose, decide, assertOriginalAccess, sourceMatches };
@@ -0,0 +1,42 @@
const { Prisma } = require("@prisma/client");
// Compare structured data independently of PostgreSQL JSONB key order.
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
return value;
}
const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
function snapshot(loan) {
return {
loanId: loan.id, loanNo: loan.loan_no, lenderId: loan.lender_id, legalEntityId: loan.legal_entity_id,
originReference: loan.origin_reference, currency: loan.currency_code,
originalPrincipal: new Prisma.Decimal(loan.original_principal).toFixed(2),
openingOutstandingPrincipal: new Prisma.Decimal(loan.opening_outstanding_principal).toFixed(2),
receivedDate: loan.received_date.toISOString().slice(0, 10), balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10),
historicalEntry: loan.historical_entry,
originals: (loan.evidences ?? []).filter((e) => e.kind === "incoming" && !e.archived_at && e.governance_status === "approved")
.sort((a, b) => a.id.localeCompare(b.id)).map((e) => ({ id: e.id, sha256: e.file_sha256,
originalName: e.original_name, fileSize: e.file_size, mimeType: e.mime_type })),
};
}
function matchesLender(record, lender) {
return Boolean(lender && !lender.archived_at && lender.identity_status === "verified" &&
record.lender_id === lender.id && record.legal_entity_id === lender.legal_entity_id &&
record.employee_id === lender.employee_id && record.identity_version === lender.identity_version &&
record.account_version === lender.account_version && record.lender_name === lender.name);
}
function validAcknowledgement(loan) {
return (loan.lender_acknowledgements ?? []).find((item) => {
const record = item.acknowledgement;
return record && !record.invalidated_at && record.decision?.action === "confirm" &&
record.decision.original_checked && record.decision.actor_id === record.employee_id &&
record.employee_id !== record.proposed_by && record.loan_ids.includes(loan.id) &&
matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan));
})?.acknowledgement ?? null;
}
function acknowledgementRenewable(loan) {
return loan.confirmation_status === "confirmed" && Boolean(loan.lender_acknowledgements?.some((item) => item.acknowledgement?.invalidated_at));
}
module.exports = { canonical, same, snapshot, matchesLender, validAcknowledgement, acknowledgementRenewable };
+3 -2
查看文件
@@ -348,9 +348,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
const financeNavigation = hasFinanceRole(hasPermission, employee);
const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee);
const moduleFinanceNavigation = PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation;
if (
FINANCE_NAVIGATION_MODULES.has(moduleKey) &&
!(PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation)
!moduleFinanceNavigation
) {
continue;
}
@@ -373,7 +374,7 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission })
const section = resolveNavigationSection({
moduleKey,
placement,
financeNavigation: PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation,
financeNavigation: moduleFinanceNavigation,
});
if (!section) {
@@ -29,7 +29,11 @@
"loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。",
"loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。",
"loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。",
"loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。",
"loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。",
"loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。",
"loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。",
"loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。",
"loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。",
"loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。",
"loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。",
@@ -0,0 +1,81 @@
const { assertEvidenceComplete } = require('../../../src/services/companyLoans');
const { snapshot, validAcknowledgement, acknowledgementRenewable } = require('../../../src/services/loanLenderAcknowledgementSnapshot');
function fixture() {
const lender = { id: 'lender', legal_entity_id: 'company', employee_id: 'signer', name: '合成出借人',
identity_status: 'verified', identity_version: 1, account_version: 1, archived_at: null };
const loan = { id: 'loan', loan_no: 'JK-SYN', origin_reference: 'SYN-IN', lender_id: lender.id, legal_entity_id: 'company',
currency_code: 'CNY', original_principal: '100', opening_outstanding_principal: '100', historical_entry: true,
received_date: new Date('2025-03-01'), balance_as_of: new Date('2026-01-01'), lender,
evidences: [{ id: 'original', kind: 'incoming', governance_status: 'approved', file_sha256: 'a'.repeat(64),
original_name: 'synthetic.pdf', mime_type: 'application/pdf', file_size: 123 },
{ id: 'accounting', kind: 'accounting_confirmation', governance_status: 'approved' }] };
const record = { id: 'ack', lender_id: lender.id, legal_entity_id: 'company', employee_id: 'signer', lender_name: lender.name,
identity_version: 1, account_version: 1, proposed_by: 'finance', loan_ids: [loan.id], invalidated_at: null,
decision: { action: 'confirm', actor_id: 'signer', original_checked: true } };
loan.lender_acknowledgements = [{ acknowledgement: record, snapshot_json: snapshot(loan) }];
return { loan, record, lender };
}
function refused(loan) {
expect(() => assertEvidenceComplete(loan)).toThrow(expect.objectContaining({ details: { reasonCode: 'loan.evidence_required' } }));
}
describe('lender statement as one specific evidence alternative', () => {
it('accepts a personally confirmed statement with originals and accounting, without paper agreement', () => {
const { loan } = fixture(); expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each(['incoming', 'accounting_confirmation'])('never replaces %s evidence', (kind) => {
const { loan } = fixture(); loan.evidences = loan.evidences.filter(e => e.kind !== kind); refused(loan);
});
it.each(['archived_at', 'governance_status'])('rejects unusable accounting evidence: %s', (field) => {
const { loan } = fixture(); loan.evidences[1][field] = field === 'archived_at' ? new Date() : 'pending'; refused(loan);
});
it('still requires the separate basis for historical repayments', () => {
const { loan } = fixture(); loan.opening_outstanding_principal = '80';
loan.lender_acknowledgements[0].snapshot_json = snapshot(loan); refused(loan);
loan.evidences.push({ kind: 'adjustment', governance_status: 'approved' });
expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it('keeps the existing paper path when an online statement is absent or invalid', () => {
const { loan, record } = fixture(); record.invalidated_at = new Date();
loan.evidences.push({ kind: 'agreement', governance_status: 'approved' });
expect(() => assertEvidenceComplete(loan)).not.toThrow();
delete loan.lender_acknowledgements; expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each([
['pending', ({ record }) => { record.decision = null; }],
['returned', ({ record }) => { record.decision.action = 'return'; }],
['already invalidated', ({ record }) => { record.invalidated_at = new Date(); }],
['unchecked original', ({ record }) => { record.decision.original_checked = false; }],
['another signer', ({ record }) => { record.decision.actor_id = 'someone-else'; }],
['maker signs own request', ({ record }) => { record.proposed_by = 'signer'; }],
['another loan', ({ record }) => { record.loan_ids = ['other-loan']; }],
['unverified identity', ({ lender }) => { lender.identity_status = 'unverified'; }],
['different association', ({ lender }) => { lender.employee_id = 'other-employee'; }],
['identity version changed', ({ lender }) => { lender.identity_version++; }],
['account version changed', ({ lender }) => { lender.account_version++; }],
['lender archived', ({ lender }) => { lender.archived_at = new Date(); }],
['lender name changed', ({ lender }) => { lender.name = '另一合成人'; }],
['company changed', ({ lender }) => { lender.legal_entity_id = 'other-company'; }],
['amount changed', ({ loan }) => { loan.original_principal = '101'; }],
['balance date changed', ({ loan }) => { loan.balance_as_of = new Date('2026-01-02'); }],
['original changed', ({ loan }) => { loan.evidences[0].file_sha256 = 'b'.repeat(64); }],
])('does not consume a statement with %s', (_, change) => {
const f = fixture(); change(f); expect(validAcknowledgement(f.loan)).toBeNull(); refused(f.loan);
});
it('balance confirmation, later repayments and additional accounting evidence leave the frozen declaration valid', () => {
const { loan, record } = fixture(); loan.version = 9; loan.confirmation_status = 'confirmed';
loan.allocations = [{ amount: '30.00', reservation_state: 'settled' }];
loan.evidences.push({ kind: 'accounting_confirmation', governance_status: 'approved', id: 'extra' });
// PostgreSQL JSONB may return keys in another order.
loan.lender_acknowledgements[0].snapshot_json = Object.fromEntries(Object.entries(snapshot(loan)).reverse());
expect(validAcknowledgement(loan)).toBe(record); expect(() => assertEvidenceComplete(loan)).not.toThrow();
});
it.each([
['no online history', null, false], ['current declaration', null, true], ['invalidated declaration', new Date('2026-01-03'), true],
])('confirmed-balance renewal scope: %s', (_, invalidatedAt, withRecord) => {
const { loan, record } = fixture(); loan.confirmation_status = 'confirmed'; record.invalidated_at = invalidatedAt;
if (!withRecord) loan.lender_acknowledgements = [];
expect(acknowledgementRenewable(loan)).toBe(Boolean(withRecord && invalidatedAt));
});
});
@@ -128,6 +128,11 @@ describe("workspace small-team navigation defaults", () => {
const mainLabels = navigation.sections.main.map((item) => item.label);
expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]);
expect(Object.values(navigation.sections).flat().find(item => item.key === "companyLoans")).toBeUndefined();
expect(employee.permissionCodes).toContain('loan.acknowledge_own');
expect(employee.permissionCodes).not.toContain('loan.manage');
expect(mainLabels).not.toContain('付款');
expect(mainLabels).not.toContain('应付账款');
expect(mainLabels).not.toContain("待我处理");
expect(mainLabels).not.toContain("票据");
expect(mainLabels).not.toContain("个人设置");