借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -0,0 +1,146 @@
|
||||
-- S4: separately reviewed manual no-submission exception, never bank finality.
|
||||
ALTER TABLE loan_bank_terminal_evidences ADD COLUMN evidence_kind VARCHAR(32) NOT NULL DEFAULT 'bank_terminal'
|
||||
CHECK(evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement'));
|
||||
CREATE FUNCTION guard_loan_evidence_kind() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.evidence_kind IS DISTINCT FROM OLD.evidence_kind THEN RAISE EXCEPTION 'evidence category immutable' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_evidence_kind_guard BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION guard_loan_evidence_kind();
|
||||
CREATE TABLE loan_not_submitted_proposals (
|
||||
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
sequence INTEGER NOT NULL CHECK(sequence>0), status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','rejected','superseded')),
|
||||
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
payload_json JSONB NOT NULL, snapshot_json JSONB NOT NULL, UNIQUE(payment_batch_id,sequence)
|
||||
);
|
||||
CREATE UNIQUE INDEX loan_not_submitted_open ON loan_not_submitted_proposals(payment_batch_id) WHERE status IN ('proposed','approved');
|
||||
CREATE TABLE loan_not_submitted_reviews (
|
||||
id UUID PRIMARY KEY, proposal_id UUID NOT NULL REFERENCES loan_not_submitted_proposals(id) ON DELETE RESTRICT,
|
||||
reviewed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
decision VARCHAR(16) NOT NULL CHECK(decision IN ('approve','reject')), reason VARCHAR(500) NOT NULL CHECK(length(trim(reason))>=2),
|
||||
reviewed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(proposal_id,reviewed_by)
|
||||
);
|
||||
CREATE FUNCTION guard_loan_not_submitted() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p loan_not_submitted_proposals; b payment_batches;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_TABLE_NAME='loan_not_submitted_reviews' THEN
|
||||
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'manual review immutable' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE id=NEW.proposal_id;
|
||||
PERFORM id FROM payment_batches WHERE id=p.payment_batch_id FOR UPDATE;
|
||||
IF p.status<>'proposed' OR NEW.reviewed_by=p.proposed_by OR
|
||||
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id)>=2 THEN
|
||||
RAISE EXCEPTION 'invalid manual independent review' USING ERRCODE='23514';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
|
||||
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') THEN RAISE EXCEPTION 'manual fact batch unavailable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.sequence<>COALESCE((SELECT max(sequence)+1 FROM loan_not_submitted_proposals WHERE payment_batch_id=b.id),1) THEN
|
||||
RAISE EXCEPTION 'manual fact initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-'status') IS DISTINCT FROM (to_jsonb(OLD)-'status') OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','rejected','superseded')) OR (OLD.status='approved' AND NEW.status='superseded')) THEN
|
||||
RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.status='approved' AND (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'two independent approvals required' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.status='rejected' AND NOT EXISTS(SELECT 1 FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='reject') THEN
|
||||
RAISE EXCEPTION 'rejection required' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_not_submitted_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_proposals FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
||||
CREATE TRIGGER loan_not_submitted_review_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_reviews FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
||||
ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check;
|
||||
ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK (
|
||||
amount > 0 AND currency_code = 'CNY' AND version > 0
|
||||
AND lender_identity_version > 0 AND lender_account_version > 0
|
||||
AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success')
|
||||
AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL)
|
||||
AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL)
|
||||
OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL))
|
||||
);
|
||||
|
||||
-- Integrity and history functions re-created with confirmed_not_paid as a
|
||||
-- cancellable terminal state (otherwise identical to 20260917010000).
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid','confirmed_not_submitted'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_repayment_allocations' THEN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM
|
||||
(OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version)
|
||||
OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD)
|
||||
THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount,
|
||||
NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key)
|
||||
IS DISTINCT FROM
|
||||
(OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount,
|
||||
OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key)
|
||||
OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') AND
|
||||
(NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref))
|
||||
OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent')
|
||||
OR (OLD.accounting_handoff_at IS NOT NULL AND
|
||||
(NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM
|
||||
(OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref))
|
||||
THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Even a direct SQL state change requires two distinct, recorded approvals.
|
||||
CREATE FUNCTION check_loan_manual_conclusion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=NEW.id;
|
||||
IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
IF p.id IS NULL OR p.status<>'approved' OR (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'manual conclusion requires two approvals' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_manual_conclusion AFTER INSERT OR UPDATE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_conclusion();
|
||||
|
||||
-- The Q6 exception attests an empty submission list, with actual exports.
|
||||
ALTER TABLE loan_bank_submission_attestations DROP CONSTRAINT loan_bank_submission_attestations_ids_check;
|
||||
ALTER TABLE loan_bank_submission_attestations ADD CONSTRAINT loan_bank_submission_attestations_ids_check CHECK (
|
||||
jsonb_typeof(submission_ids)='array' AND jsonb_typeof(all_submission_ids)='array' AND jsonb_typeof(export_ids)='array'
|
||||
AND (jsonb_array_length(submission_ids)>0 OR (jsonb_array_length(all_submission_ids)=0 AND jsonb_array_length(export_ids)>0))
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- S5: each cause closes separately. No administrator unfreeze switch.
|
||||
CREATE TABLE loan_freeze_reasons (
|
||||
id UUID PRIMARY KEY, lender_id UUID NOT NULL REFERENCES loan_lenders(id) ON DELETE RESTRICT,
|
||||
payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(32) NOT NULL CHECK(kind IN ('late_success','reservation_conflict','receivable')),
|
||||
source_key VARCHAR(160) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK(status IN ('active','closed')),
|
||||
created_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, closed_at TIMESTAMP(3), closure_basis JSONB,
|
||||
UNIQUE(kind,source_key), CHECK((status='closed')=(closed_by IS NOT NULL AND closed_at IS NOT NULL AND closure_basis IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX loan_freeze_reasons_lender_id_status_idx ON loan_freeze_reasons(lender_id,status);
|
||||
CREATE FUNCTION guard_loan_freeze_reason() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE lender UUID;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'freeze history retained' USING ERRCODE='23514'; END IF;
|
||||
SELECT r.lender_id INTO lender FROM payment_batches b JOIN loan_repayments r ON r.id=b.loan_repayment_id WHERE b.id=NEW.payment_batch_id;
|
||||
IF lender IS DISTINCT FROM NEW.lender_id THEN RAISE EXCEPTION 'freeze source mismatch' USING ERRCODE='23514'; END IF;
|
||||
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'active' OR NEW.closed_by IS NOT NULL OR NEW.closed_at IS NOT NULL OR NEW.closure_basis IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'new freeze must be active' USING ERRCODE='23514'; END IF;
|
||||
ELSIF OLD.status<>'active' OR NEW.status<>'closed' OR
|
||||
(to_jsonb(NEW)-ARRAY['status','closed_by','closed_at','closure_basis']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','closed_by','closed_at','closure_basis']) THEN
|
||||
RAISE EXCEPTION 'freeze history immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_freeze_history BEFORE INSERT OR UPDATE OR DELETE ON loan_freeze_reasons FOR EACH ROW EXECUTE FUNCTION guard_loan_freeze_reason();
|
||||
@@ -0,0 +1,224 @@
|
||||
-- S6/S7: append actual bank payments and returns, never issue a payment batch.
|
||||
CREATE TABLE loan_bank_result_events (
|
||||
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(16) NOT NULL CHECK(kind IN ('late_success','return')), parent_id UUID REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
evidence_id UUID NOT NULL REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
request_key UUID NOT NULL, proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, payload_encrypted TEXT NOT NULL,
|
||||
fingerprint VARCHAR(64) NOT NULL, key_id VARCHAR(64) NOT NULL, amount DECIMAL(14,2) NOT NULL CHECK(amount>0), snapshot_json JSONB NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','booked','dismissed','rejected','superseded')),
|
||||
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(1000), correction_evidence_id UUID REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
UNIQUE(payment_batch_id,request_key), CHECK((kind='return')=(parent_id IS NOT NULL)),
|
||||
CHECK(reviewed_by IS NULL OR reviewed_by<>proposed_by),
|
||||
CHECK(status IN ('proposed','superseded') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>=5))
|
||||
);
|
||||
CREATE INDEX loan_bank_result_events_payment_batch_id_kind_idx ON loan_bank_result_events(payment_batch_id,kind);
|
||||
CREATE TABLE loan_supplemental_settlements (
|
||||
id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
|
||||
bank_amount DECIMAL(14,2) NOT NULL CHECK(bank_amount>0), settled_amount DECIMAL(14,2) NOT NULL CHECK(settled_amount>=0),
|
||||
receivable_amount DECIMAL(14,2) NOT NULL CHECK(receivable_amount>=0), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id,loan_id), CHECK(bank_amount=settled_amount+receivable_amount)
|
||||
);
|
||||
CREATE TABLE loan_bank_event_claims (
|
||||
fingerprint VARCHAR(64) PRIMARY KEY, key_id VARCHAR(64) NOT NULL,
|
||||
event_id UUID UNIQUE NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TRIGGER loan_supplemental_history BEFORE UPDATE OR DELETE ON loan_supplemental_settlements FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE TRIGGER loan_bank_event_claim_history BEFORE UPDATE OR DELETE ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE FUNCTION guard_loan_bank_event_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'bank event history retained' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.reviewed_by IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.correction_evidence_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'bank event initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM
|
||||
(to_jsonb(OLD)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','dismissed','rejected','superseded')) OR (OLD.status='approved' AND NEW.status IN ('booked','superseded'))) THEN
|
||||
RAISE EXCEPTION 'bank event immutable' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.status='approved' AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note,NEW.correction_evidence_id) IS DISTINCT FROM
|
||||
(OLD.reviewed_by,OLD.reviewed_at,OLD.review_note,OLD.correction_evidence_id) THEN RAISE EXCEPTION 'bank event review immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_result_events FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_event_history();
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid','confirmed_not_submitted'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) +
|
||||
(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=loan_uuid) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
|
||||
CREATE FUNCTION check_loan_supplemental_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; lid UUID; total NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.event_id;
|
||||
IF e.kind<>'late_success' OR e.status<>'booked' OR
|
||||
(SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'supplemental event totals invalid' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_supplemental_settlements s WHERE s.event_id=e.id AND NOT EXISTS (
|
||||
SELECT 1 FROM loan_repayment_allocations a JOIN payment_batches b ON b.loan_repayment_id=a.repayment_id
|
||||
WHERE b.id=e.payment_batch_id AND a.loan_id=s.loan_id AND a.amount=s.bank_amount AND a.reservation_state='released')) THEN
|
||||
RAISE EXCEPTION 'supplemental source allocation invalid' USING ERRCODE='23514'; END IF;
|
||||
FOR lid IN SELECT loan_id FROM loan_supplemental_settlements WHERE event_id=e.id ORDER BY loan_id LOOP
|
||||
PERFORM id FROM company_loans WHERE id=lid FOR UPDATE;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_repayment_allocations WHERE loan_id=lid AND reservation_state IN ('reserved','settled');
|
||||
IF total+(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=lid)>
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=lid) THEN RAISE EXCEPTION 'supplemental principal exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_supplemental_integrity AFTER INSERT ON loan_supplemental_settlements DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_supplemental_integrity();
|
||||
CREATE OR REPLACE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id;
|
||||
ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF;
|
||||
SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
IF latest.id IS NULL OR EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE id=bid;
|
||||
rid:=b.loan_repayment_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND
|
||||
(latest.status<>'consumed' OR b.status<>'cancelled') THEN
|
||||
RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN
|
||||
RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION guard_loan_bank_transaction_identity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(1002,1);
|
||||
IF TG_TABLE_NAME='loan_bank_event_claims' THEN
|
||||
IF EXISTS(SELECT 1 FROM payment_receipt_claims WHERE fingerprint=NEW.fingerprint) OR
|
||||
EXISTS(SELECT 1 FROM payment_receipt_transaction_aliases WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already used' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already supplemented' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_transaction_aliases FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
|
||||
ALTER TABLE loan_bank_terminal_evidences DROP CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check;
|
||||
ALTER TABLE loan_bank_terminal_evidences ADD CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check CHECK(
|
||||
evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement','late_success','incoming_return'));
|
||||
CREATE OR REPLACE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID; b payment_batches; lender UUID; data JSONB;
|
||||
BEGIN
|
||||
data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
|
||||
bid := (data->>'payment_batch_id')::uuid;
|
||||
IF bid IS NULL THEN
|
||||
SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid;
|
||||
END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE;
|
||||
IF b.entity_type='loan_repayment' THEN
|
||||
IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') AND NOT (TG_OP='INSERT' AND TG_TABLE_NAME='loan_bank_terminal_evidences') THEN
|
||||
RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN
|
||||
SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id;
|
||||
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
|
||||
END IF;
|
||||
INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1)
|
||||
ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1;
|
||||
END IF;
|
||||
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- Business application cannot bypass atomic fact/claim/ledger consistency.
|
||||
CREATE FUNCTION check_loan_bank_event_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; parent loan_bank_result_events; total NUMERIC; expected NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.id;
|
||||
IF e.status='booked' AND NOT EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE event_id=e.id AND fingerprint=e.fingerprint AND key_id=e.key_id) THEN
|
||||
RAISE EXCEPTION 'bank event requires unique claim' USING ERRCODE='23514'; END IF;
|
||||
IF e.kind='late_success' THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM loan_freeze_reasons WHERE payment_batch_id=e.payment_batch_id AND kind='late_success' AND source_key=e.id::text) THEN
|
||||
RAISE EXCEPTION 'late event requires recorded freeze' USING ERRCODE='23514'; END IF;
|
||||
IF e.status='booked' AND (SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'late event requires complete supplemental allocation' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO parent FROM loan_bank_result_events WHERE id=e.parent_id;
|
||||
IF parent.kind<>'late_success' OR parent.payment_batch_id<>e.payment_batch_id OR parent.status<>'booked' THEN
|
||||
RAISE EXCEPTION 'return parent invalid' USING ERRCODE='23514'; END IF;
|
||||
SELECT coalesce(sum(receivable_amount),0) INTO expected FROM loan_supplemental_settlements WHERE event_id=e.parent_id;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_bank_result_events WHERE parent_id=e.parent_id AND kind='return' AND status='booked';
|
||||
IF total>expected THEN RAISE EXCEPTION 'return exceeds receivable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_bank_event_integrity AFTER INSERT OR UPDATE ON loan_bank_result_events DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_bank_event_integrity();
|
||||
|
||||
CREATE FUNCTION check_loan_manual_release() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals; revision BIGINT;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=NEW.repayment_id;
|
||||
IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
SELECT m.revision INTO revision FROM loan_release_material_revisions m WHERE payment_batch_id=bid;
|
||||
IF p.id IS NULL OR p.status<>'approved' OR (p.snapshot_json->>'revision')::bigint IS DISTINCT FROM COALESCE(revision,0) OR
|
||||
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'manual release requires current double review' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_manual_release AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_release();
|
||||
|
||||
CREATE FUNCTION protect_reviewed_bank_event_evidence() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE (evidence_id=OLD.id AND status IN ('approved','booked')) OR (correction_evidence_id=OLD.id AND status='dismissed')) THEN
|
||||
RAISE EXCEPTION 'reviewed bank event evidence retained' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER reviewed_bank_event_evidence BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION protect_reviewed_bank_event_evidence();
|
||||
|
||||
CREATE FUNCTION check_loan_release_against_late_fact() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status IN ('proposed','approved'))
|
||||
AND NOT EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN
|
||||
RAISE EXCEPTION 'late success blocks original release conclusion' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_release_late_fact AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_release_against_late_fact();
|
||||
@@ -750,6 +750,12 @@ model Employee {
|
||||
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
|
||||
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
|
||||
loan_recovery_events LoanReleaseRecoveryEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_not_submitted_reviews LoanNotSubmittedReview[]
|
||||
loan_freezes_created LoanFreezeReason[] @relation("LoanFreezeCreator")
|
||||
loan_freezes_closed LoanFreezeReason[] @relation("LoanFreezeCloser")
|
||||
loan_bank_events_proposed LoanBankResultEvent[] @relation("LoanBankEventProposer")
|
||||
loan_bank_events_reviewed LoanBankResultEvent[] @relation("LoanBankEventReviewer")
|
||||
|
||||
}
|
||||
|
||||
@@ -1999,6 +2005,9 @@ model PaymentBatch {
|
||||
loan_failure_evidence_uses LoanFailureEvidenceUse[]
|
||||
@@map("payment_batches")
|
||||
loan_release_recoveries LoanReleaseRecovery[]
|
||||
loan_freeze_reasons LoanFreezeReason[]
|
||||
loan_bank_result_events LoanBankResultEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_material_revision LoanReleaseMaterialRevision?
|
||||
|
||||
}
|
||||
@@ -3897,6 +3906,7 @@ model LoanLender {
|
||||
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
|
||||
loans CompanyLoan[]
|
||||
repayments LoanRepayment[]
|
||||
freeze_reasons LoanFreezeReason[]
|
||||
|
||||
@@unique([legal_entity_id, id])
|
||||
@@unique([created_by, request_key])
|
||||
@@ -3931,6 +3941,8 @@ model CompanyLoan {
|
||||
allocations LoanRepaymentAllocation[]
|
||||
evidences LoanEvidence[]
|
||||
|
||||
supplemental_settlements LoanSupplementalSettlement[]
|
||||
|
||||
@@unique([created_by, request_key])
|
||||
@@unique([legal_entity_id, lender_id, origin_reference])
|
||||
@@index([lender_id, confirmation_status, received_date])
|
||||
@@ -4125,6 +4137,7 @@ model LoanBankSubmissionAttestation {
|
||||
// Loan bank-result reconciliation S3 (migration 20261008010000_loan_bank_terminal_facts).
|
||||
// Evidence is stored apart from payment receipts; history triggers live in the migration.
|
||||
model LoanBankTerminalEvidence {
|
||||
evidence_kind String @default("bank_terminal") @db.VarChar(32)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
storage_path String
|
||||
@@ -4141,6 +4154,8 @@ model LoanBankTerminalEvidence {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
facts LoanBankTerminalFact[]
|
||||
bank_result_events LoanBankResultEvent[] @relation("LoanBankEventEvidence")
|
||||
bank_result_corrections LoanBankResultEvent[] @relation("LoanBankEventCorrection")
|
||||
|
||||
@@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx")
|
||||
@@map("loan_bank_terminal_evidences")
|
||||
@@ -4244,3 +4259,107 @@ model LoanReleaseMaterialRevision {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_release_material_revisions")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedProposal {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
sequence Int
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_json Json
|
||||
snapshot_json Json
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation(fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviews LoanNotSubmittedReview[]
|
||||
@@unique([payment_batch_id, sequence])
|
||||
@@map("loan_not_submitted_proposals")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedReview {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
proposal_id String @db.Uuid
|
||||
reviewed_by String @db.Uuid
|
||||
decision String @db.VarChar(16)
|
||||
reason String @db.VarChar(500)
|
||||
reviewed_at DateTime @default(now())
|
||||
proposal LoanNotSubmittedProposal @relation(fields: [proposal_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee @relation(fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([proposal_id, reviewed_by])
|
||||
@@map("loan_not_submitted_reviews")
|
||||
}
|
||||
|
||||
model LoanFreezeReason {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(32)
|
||||
source_key String @db.VarChar(160)
|
||||
status String @default("active") @db.VarChar(16)
|
||||
created_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
closed_by String? @db.Uuid
|
||||
closed_at DateTime?
|
||||
closure_basis Json?
|
||||
lender LoanLender @relation(fields: [lender_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
creator Employee @relation("LoanFreezeCreator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
closer Employee? @relation("LoanFreezeCloser", fields: [closed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([kind, source_key])
|
||||
@@index([lender_id, status])
|
||||
@@map("loan_freeze_reasons")
|
||||
}
|
||||
|
||||
model LoanBankResultEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(16)
|
||||
parent_id String? @db.Uuid
|
||||
evidence_id String @db.Uuid
|
||||
request_key String @db.Uuid
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_encrypted String
|
||||
fingerprint String @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
amount Decimal @db.Decimal(14,2)
|
||||
snapshot_json Json
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String? @db.VarChar(1000)
|
||||
correction_evidence_id String? @db.Uuid
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
evidence LoanBankTerminalEvidence @relation("LoanBankEventEvidence", fields: [evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
correction_evidence LoanBankTerminalEvidence? @relation("LoanBankEventCorrection", fields: [correction_evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation("LoanBankEventProposer", fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee? @relation("LoanBankEventReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
parent LoanBankResultEvent? @relation("LoanBankEventParent", fields: [parent_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children LoanBankResultEvent[] @relation("LoanBankEventParent")
|
||||
settlements LoanSupplementalSettlement[]
|
||||
claim LoanBankEventClaim?
|
||||
@@unique([payment_batch_id, request_key])
|
||||
@@index([payment_batch_id, kind])
|
||||
@@map("loan_bank_result_events")
|
||||
}
|
||||
model LoanSupplementalSettlement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
event_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
bank_amount Decimal @db.Decimal(14,2)
|
||||
settled_amount Decimal @db.Decimal(14,2)
|
||||
receivable_amount Decimal @db.Decimal(14,2)
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([event_id, loan_id])
|
||||
@@map("loan_supplemental_settlements")
|
||||
}
|
||||
model LoanBankEventClaim {
|
||||
fingerprint String @id @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
event_id String @unique @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_bank_event_claims")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.lender_frozen", "domain": "loan", "audience": "user", "defaultMessage": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.not_submitted_invalid", "domain": "loan", "audience": "user", "defaultMessage": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "recovery": "follow_guidance"},
|
||||
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -149,6 +149,7 @@ function mapManualReceiptError(error) {
|
||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||
mode: z.enum(["download", "preview"]).default("download"),
|
||||
reason: z.string().max(500).optional(),
|
||||
purpose: z.literal("loan_reconciliation").optional(),
|
||||
}).strict();
|
||||
|
||||
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||
@@ -1054,7 +1055,15 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch) };
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch),
|
||||
notSubmitted: await require("../services/loanNotSubmitted").state(tx, batch),
|
||||
failureAttributions: await Promise.all((await tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id, kind: "failure" }, orderBy: { proposed_at: "desc" } })).map(async (a) => ({
|
||||
id: a.id, status: a.status, proposedBy: a.proposed_by, basisNote: a.basis_note, exclusionNote: a.exclusion_note,
|
||||
coveredSubmissionIds: a.covered_submission_ids, failureScope: a.failure_scope,
|
||||
receiptAttachmentIds: [...new Set((await tx.paymentReceiptVerification.findMany({ where: { id: { in: a.verification_ids } }, select: { attachment_id: true } })).map((v) => v.attachment_id))],
|
||||
}))),
|
||||
bankResults: await require("../services/loanBankResults").state(tx, batch),
|
||||
freeze: await require("../services/loanFreezes").state(tx, repayment.lender_id) };
|
||||
})() } : {}),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
@@ -1586,7 +1595,61 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.withdrawRecovery(tx, batch, request, id, reason));
|
||||
return sendData(reply, request, recoveryResponse(result));
|
||||
});
|
||||
const BANK_RESULT_SCHEMA = z.object({
|
||||
kind: z.enum(["late_success", "return"]), parentId: UUID_SCHEMA.optional(), evidenceId: UUID_SCHEMA, requestKey: UUID_SCHEMA,
|
||||
fields: z.array(z.object({ Name: z.string().max(50), Value: z.string().max(200) }).strict()).min(6).max(20),
|
||||
attributionNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
const BANK_RESULT_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(5).max(1000),
|
||||
correctionEvidenceId: UUID_SCHEMA.optional(), correctionKind: z.enum(["misassociated", "duplicate"]).optional() }).strict();
|
||||
fastify.get("/payment-batches/:id/loan-bank-results/:eventId/details", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
let reason = ""; try { reason = decodeURIComponent(String(request.headers["x-access-reason"] ?? "")); } catch { /* invalid reason */ }
|
||||
parseBody(z.object({ reason: z.string().trim().min(5).max(500) }).strict(), { reason });
|
||||
const event = await fastify.prisma.loanBankResultEvent.findUnique({ where: { id: parseId(request.params.eventId) } });
|
||||
if (!event || event.payment_batch_id !== parseId(request.params.id)) throw buildNotFoundError();
|
||||
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LOAN_BANK_RESULT_DETAILS_VIEWED", targetType: "payment_batch", targetId: event.payment_batch_id, extra: { eventId: event.id, reason } }) });
|
||||
reply.header("cache-control", "no-store");
|
||||
return sendData(reply, request, JSON.parse(decryptStringOrThrow(fastify.config, event.payload_encrypted)).input);
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_REVIEW_SCHEMA, request.body); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/apply", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
parseBody(z.object({}).strict(), request.body ?? {}); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").apply(tx, fastify.config, batch, request, id));
|
||||
return sendData(reply, request, { id: result.id, status: result.status, conflict: Boolean(result.conflict) });
|
||||
});
|
||||
const NOT_SUBMITTED_SCHEMA = z.object({
|
||||
exportIds: z.array(UUID_SCHEMA).min(1).max(100), queryEvidenceId: UUID_SCHEMA, statementEvidenceId: UUID_SCHEMA,
|
||||
exporterStatements: z.array(z.object({ exporterId: UUID_SCHEMA, evidenceId: UUID_SCHEMA }).strict()).min(1).max(100),
|
||||
coverageFrom: z.string().datetime({ offset: true }), coverageThrough: z.string().datetime({ offset: true }),
|
||||
note: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(NOT_SUBMITTED_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.get("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const batch = await loadBatchWithItems(fastify.prisma, parseId(request.params.id));
|
||||
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
return sendData(reply, request, await require("../services/loanNotSubmitted").state(fastify.prisma, batch));
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted/:proposalId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
|
||||
const id = parseId(request.params.proposalId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const { kind: evidenceKind } = parseBody(z.object({ kind: z.enum(["bank_terminal", "query_no_result", "account_statement", "exporter_statement", "late_success", "incoming_return"]).default("bank_terminal") }).strict(), request.query);
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: 10 * 1024 * 1024 } })) {
|
||||
@@ -1604,7 +1667,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const storagePath = await writeBuffer({ config: fastify.config, key: `loan-terminal-evidence/${parseId(request.params.id)}/${crypto.randomUUID()}${file.extension}`,
|
||||
buffer: file.buffer, contentType: file.mimeType });
|
||||
try {
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath }));
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath, evidenceKind }));
|
||||
return sendData(reply, request, { id: result.id });
|
||||
} catch (error) {
|
||||
try { await deleteObject({ config: fastify.config, storagePath }); } catch { request.log.error("loan terminal evidence cleanup failed"); }
|
||||
@@ -1993,7 +2056,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
|
||||
// No new payment order once failure / non-payment was concluded.
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
downloadContext.purpose = "loan_principal_repayment";
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
@@ -2220,7 +2283,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
// After a failure / not-paid conclusion a late receipt is still recorded
|
||||
// (state unchanged) so the final release re-check sees and blocks on it.
|
||||
if (!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) {
|
||||
if (!["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
}
|
||||
@@ -2326,6 +2389,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2336,7 +2400,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!attachment) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at) {
|
||||
const reconciliationRead = queryParsed.data.purpose === "loan_reconciliation";
|
||||
if (reconciliationRead) {
|
||||
if (previewMode || batch.entity_type !== "loan_repayment" || !sanitizeDownloadReason(queryParsed.data.reason)) {
|
||||
throw buildBusinessValidationError("payment_batch.invalid_input", {}, 400);
|
||||
}
|
||||
const facts = await fastify.prisma.loanBankTerminalFact.findMany({ where: { payment_batch_id: batchId },
|
||||
select: { receipt_attachment_id: true, resolves_attachment_ids: true } });
|
||||
const attributions = await fastify.prisma.loanReceiptAttribution.findMany({ where: { payment_batch_id: batchId, kind: "failure" }, select: { verification_ids: true } });
|
||||
const linkedVerification = await fastify.prisma.paymentReceiptVerification.count({ where: {
|
||||
attachment_id: attachmentId, id: { in: attributions.flatMap((a) => a.verification_ids) } } });
|
||||
if (!linkedVerification && !facts.some((f) => f.receipt_attachment_id === attachmentId || f.resolves_attachment_ids.includes(attachmentId))) throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at && !reconciliationRead) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_voided", { receiptAttachment: "voided" }, 409);
|
||||
}
|
||||
const downloadReason = previewMode
|
||||
@@ -2410,6 +2486,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (reconciliationRead && (storedBuffer.length !== attachment.file_size || crypto.createHash("sha256").update(storedBuffer).digest("hex") !== attachment.file_sha256)) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = await validateAndNormalizePurchaseAttachment({
|
||||
@@ -2459,6 +2539,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
...(reconciliationRead ? { purpose: "loan_reconciliation", archived: Boolean(attachment.archived_at) } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
repayment = await readLoanRepayment(tx, batch);
|
||||
}
|
||||
const lender = repayment.lender;
|
||||
if (!postPayment) await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
if (postPayment) assertLenderIdentityVerified(lender);
|
||||
else assertLenderReady(lender);
|
||||
if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
@@ -112,6 +113,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
// waits on a previously existing batch. Lender/loan maintenance never locks a batch.
|
||||
async function prepareLoanRepayment(tx, config, request, input) {
|
||||
const lender = await lockLender(tx, input.lenderId);
|
||||
await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
assertLenderReady(lender);
|
||||
if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
|
||||
const ids = input.allocations.map((a) => a.loanId).sort();
|
||||
@@ -225,7 +227,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
|
||||
|
||||
async function settleLoanRepayment(tx, batch, request, config) {
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) { 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" },
|
||||
@@ -239,10 +241,13 @@ async function settleLoanRepayment(tx, batch, request, config) {
|
||||
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
|
||||
// Stale identity/account must not prevent cancelling a never-sent request.
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 ||
|
||||
await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
if (await tx.loanBankResultEvent.count({ where: { payment_batch_id: batch.id, kind: "late_success", status: { in: ["proposed", "approved"] } } })) {
|
||||
throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409);
|
||||
}
|
||||
// "Never sent" must really mean nothing reached the bank side of the record.
|
||||
if (repayment.bank_result_state === "not_sent" && (
|
||||
await tx.loanBankSubmission.count({ where: { payment_batch_id: batch.id } }) ||
|
||||
@@ -251,7 +256,9 @@ async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryI
|
||||
// Release re-validates the conclusion inside this transaction (batch lock held
|
||||
// by the caller serializes it with new submissions and receipt uploads).
|
||||
const recovered = await require("./loanReleaseRecoveries").consumeRecovery(tx, config, batch, request, recoveryInput);
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_submitted") {
|
||||
await require("./loanNotSubmitted").assertReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_failed") {
|
||||
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
|
||||
|
||||
@@ -5,7 +5,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
|
||||
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
|
||||
|
||||
function ensureLoanManager(fastify, request) {
|
||||
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
|
||||
@@ -26,7 +26,7 @@ async function auditLoan(tx, request, action, targetId, extra = {}) {
|
||||
function loanBalances(loan) {
|
||||
const sum = (state) => (loan.allocations ?? []).filter((a) => a.reservation_state === state)
|
||||
.reduce((total, a) => total.plus(a.amount), new Prisma.Decimal(0));
|
||||
const settled = sum("settled");
|
||||
const settled = (loan.supplemental_settlements ?? []).reduce((total, row) => total.plus(row.settled_amount), sum("settled"));
|
||||
const reserved = sum("reserved");
|
||||
const outstanding = new Prisma.Decimal(loan.opening_outstanding_principal).minus(settled);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
|
||||
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { auditLoan, loanBalances, LOAN_INCLUDE } = require("./companyLoans");
|
||||
const { digest } = require("./loanReleaseRecoveries");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const freezes = () => require("./loanFreezes");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409); };
|
||||
const TERMINAL = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"];
|
||||
|
||||
async function source(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (!TERMINAL.includes(repayment.bank_result_state) || !["reviewed", "failed", "cancelled"].includes(batch.status)) fail();
|
||||
// Bank facts use the frozen approval and account snapshots, not current
|
||||
// employment or subsequently maintained lender/bank master data.
|
||||
if (!batch.reviewed_by || !batch.reviewed_at || [batch.created_by, repayment.submitted_by].includes(batch.reviewed_by) ||
|
||||
repayment.legal_entity_id !== batch.legal_entity_id || !repayment.amount.eq(batch.items[0].amount)) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function evidence(tx, config, batch, id, kind) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path }); const hash = createHash("sha256"); let size = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); size += chunk.length; }
|
||||
if (size !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
return file;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const base = await require("./loanBankTerminalFacts").terminalParticipants(tx, batch, repayment);
|
||||
const [events, manual, q6, sr] = await Promise.all([
|
||||
tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } }),
|
||||
tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
]);
|
||||
return [...new Set([...base, ...events.map((e) => e.proposed_by), ...manual.map((p) => p.proposed_by), ...q6.map((p) => p.proposed_by), ...sr.map((p) => p.started_by)].filter(Boolean))];
|
||||
}
|
||||
async function bankSnapshot(tx, batch, file) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
return { evidenceId: file.id, sha: file.file_sha256, kind: file.evidence_kind,
|
||||
payment: digest({ payer: batch.payment_account_snapshot_json, items: batch.items.map((i) => ({ id: i.id, snapshot: i.snapshot_json, amount: String(i.amount) })) }),
|
||||
allocations: repayment.allocations.map((a) => ({ id: a.id, loanId: a.loan_id, amount: String(a.amount) })),
|
||||
competitors: await payments().competingLoanBatches(tx, batch, repayment) };
|
||||
}
|
||||
async function assertTransactionFree(tx, config, event) {
|
||||
const page = JSON.parse(decryptStringOrThrow(config, event.payload_encrypted)).page;
|
||||
const identity = transactionFingerprint(config, page);
|
||||
if (identity.fingerprint !== event.fingerprint || identity.key_id !== event.key_id) fail();
|
||||
const [claims, aliases, events] = await Promise.all([
|
||||
tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.paymentReceiptTransactionAlias.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: event.fingerprint, event_id: { not: event.id } }, { key_id: { not: event.key_id } }] } }),
|
||||
]);
|
||||
if (claims || aliases || events) fail();
|
||||
}
|
||||
async function outstandingReturn(tx, id) {
|
||||
const [settlements, returned] = await Promise.all([
|
||||
tx.loanSupplementalSettlement.findMany({ where: { event_id: id } }),
|
||||
tx.loanBankResultEvent.findMany({ where: { parent_id: id, kind: "return", status: "booked" } }),
|
||||
]);
|
||||
return settlements.reduce((sum, s) => sum.plus(s.receivable_amount), new Prisma.Decimal(0))
|
||||
.minus(returned.reduce((sum, r) => sum.plus(r.amount), new Prisma.Decimal(0)));
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
const existing = await tx.loanBankResultEvent.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
|
||||
if (existing) {
|
||||
const saved = JSON.parse(decryptStringOrThrow(config, existing.payload_encrypted));
|
||||
if (existing.proposed_by !== request.employee.id || digest(saved.input) !== digest(input)) fail();
|
||||
return existing;
|
||||
}
|
||||
await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, input.evidenceId, input.kind === "return" ? "incoming_return" : "late_success");
|
||||
const page = normalizeBankSlip(input.fields);
|
||||
if (page.issues.length || page.transactionStatus !== "success") fail();
|
||||
const [candidate] = batchCandidates(config, batch);
|
||||
const amount = new Prisma.Decimal(page.amount).div(100);
|
||||
if (!amount.gt(0)) fail();
|
||||
if (input.kind === "late_success") {
|
||||
if (input.parentId || matchBankSlip(page, [candidate]).status !== "matched") fail();
|
||||
} else {
|
||||
const parent = input.parentId && await tx.loanBankResultEvent.findUnique({ where: { id: input.parentId } });
|
||||
if (!parent || parent.payment_batch_id !== batch.id || parent.kind !== "late_success" || parent.status !== "booked" ||
|
||||
matchBankSlip(page, [{ ...candidate, amount: amount.toFixed(2), payerAccount: candidate.payeeAccount, payeeAccount: candidate.payerAccount }]).status !== "matched" ||
|
||||
amount.gt(await outstandingReturn(tx, parent.id))) fail();
|
||||
}
|
||||
const identity = transactionFingerprint(config, page);
|
||||
const previous = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id, fingerprint: identity.fingerprint,
|
||||
key_id: identity.key_id, kind: input.kind, parent_id: input.parentId ?? null, status: { in: ["proposed", "approved"] } }, orderBy: [{ proposed_at: "desc" }, { id: "desc" }] });
|
||||
const event = await tx.loanBankResultEvent.create({ data: { payment_batch_id: batch.id, kind: input.kind,
|
||||
parent_id: input.parentId ?? null, evidence_id: file.id, request_key: input.requestKey, proposed_by: request.employee.id,
|
||||
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({ page, input })), amount,
|
||||
fingerprint: identity.fingerprint, key_id: identity.key_id, snapshot_json: await bankSnapshot(tx, batch, file) } });
|
||||
if (input.kind === "late_success") await freezes().openReason(tx, batch, request, "late_success", event.id);
|
||||
// Claims are consumed only at final booking, never just by approval. A new
|
||||
// freeze exists before a stale proposed/approved entry is superseded.
|
||||
for (const old of previous) {
|
||||
await tx.loanBankResultEvent.update({ where: { id: old.id }, data: { status: "superseded" } });
|
||||
if (old.kind === "late_success") {
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: old.id } } });
|
||||
if (conflict?.status === "active") {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", event.id);
|
||||
await freezes().closeReason(tx, batch, request, "reservation_conflict", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
await freezes().closeReason(tx, batch, request, "late_success", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: event.id, kind: event.kind });
|
||||
return event;
|
||||
}
|
||||
async function eventForBatch(tx, batch, id) {
|
||||
const event = await tx.loanBankResultEvent.findUnique({ where: { id } });
|
||||
if (!event || event.payment_batch_id !== batch.id) fail();
|
||||
return event;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status !== "proposed") fail();
|
||||
await source(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (input.decision === "approve") {
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if (event.kind === "return" && event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
} else if (event.kind === "late_success") {
|
||||
// Uncertainty is not a dismissal. A correction document and a reviewed
|
||||
// specific reason are mandatory before removing a false-alarm freeze.
|
||||
if (!input.correctionEvidenceId || !["misassociated", "duplicate"].includes(input.correctionKind)) fail();
|
||||
await evidence(tx, config, batch, input.correctionEvidenceId, "bank_terminal");
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : event.kind === "late_success" ? "dismissed" : "rejected";
|
||||
const updated = await tx.loanBankResultEvent.update({ where: { id }, data: { status,
|
||||
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason, correction_evidence_id: input.correctionEvidenceId ?? null } });
|
||||
if (status === "dismissed") {
|
||||
const basis = { action: input.correctionKind, recordId: id, evidenceId: input.correctionEvidenceId };
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, basis);
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, basis);
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Finance applies an already independent bank fact. No exportable object is
|
||||
// created. Original bank outcome stays terminal and the event supersedes it.
|
||||
async function apply(tx, config, batch, request, id) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status === "booked") return event;
|
||||
if (event.status !== "approved") fail();
|
||||
const repayment = await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if ((await participants(tx, batch)).includes(event.reviewed_by)) fail();
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
|
||||
if (event.kind === "return") {
|
||||
if (event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if ((await outstandingReturn(tx, event.parent_id)).eq(0)) await freezes().closeReason(tx, batch, request, "receivable", event.parent_id, { action: "returned_in_full", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_RETURN_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id, parentId: event.parent_id, amount: event.amount.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
const conflicts = await tx.loanRepaymentAllocation.findMany({ where: { loan_id: { in: ids }, repayment_id: { not: repayment.id }, reservation_state: "reserved" } });
|
||||
if (conflicts.length) {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", id);
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_CONFLICT", repayment.id, { eventId: id, allocationIds: conflicts.map((a) => a.id) });
|
||||
return { ...event, conflict: true };
|
||||
}
|
||||
if (repayment.allocations.some((a) => !["reserved", "released"].includes(a.reservation_state))) fail();
|
||||
if (repayment.allocations.some((a) => a.reservation_state === "reserved")) {
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
||||
data: { reservation_state: "released", released_at: new Date(), release_reason: "原结论被晚到成功推翻,改由补记结清" } });
|
||||
await tx.paymentBatchItem.updateMany({ where: { payment_batch_id: batch.id, status: { in: ["pending", "failed"] } }, data: { status: "cancelled" } });
|
||||
await tx.paymentBatch.update({ where: { id: batch.id }, data: { status: "cancelled", cancelled_at: new Date(),
|
||||
cancelled_by: request.employee.id, cancelled_reason: "原结论被晚到成功推翻,改由补记结清", updated_by: request.employee.id } });
|
||||
}
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
let receivable = new Prisma.Decimal(0);
|
||||
for (const allocation of repayment.allocations) {
|
||||
const loan = await tx.companyLoan.findUniqueOrThrow({ where: { id: allocation.loan_id }, include: LOAN_INCLUDE });
|
||||
const outstanding = new Prisma.Decimal(loanBalances(loan).outstanding);
|
||||
const settled = Prisma.Decimal.min(allocation.amount, outstanding);
|
||||
const excess = allocation.amount.minus(settled); receivable = receivable.plus(excess);
|
||||
await tx.loanSupplementalSettlement.create({ data: { event_id: id, loan_id: loan.id,
|
||||
bank_amount: allocation.amount, settled_amount: settled, receivable_amount: excess } });
|
||||
}
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if (receivable.gt(0)) await freezes().openReason(tx, batch, request, "receivable", id);
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, { action: "allocations_resolved", recordId: id });
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, { action: "payment_supplemented", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id,
|
||||
originalState: repayment.bank_result_state, originalRef: repayment.bank_result_ref, bankAmount: event.amount.toFixed(2), receivable: receivable.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const events = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }], include: { settlements: true } });
|
||||
return { events: await Promise.all(events.map(async (e) => ({ id: e.id, kind: e.kind, parentId: e.parent_id, evidenceId: e.evidence_id,
|
||||
status: e.status, amount: e.amount.toFixed(2), proposedBy: e.proposed_by, reviewedBy: e.reviewed_by, reviewNote: e.review_note,
|
||||
outstandingReturn: e.kind === "late_success" && e.status === "booked" ? (await outstandingReturn(tx, e.id)).toFixed(2) : null,
|
||||
settlements: e.settlements.map((s) => ({ loanId: s.loan_id, bankAmount: s.bank_amount.toFixed(2), settledAmount: s.settled_amount.toFixed(2), receivableAmount: s.receivable_amount.toFixed(2) })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, apply, state, source };
|
||||
@@ -114,7 +114,7 @@ async function activeAttestation(tx, batch) {
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
if (!facts.active.length && (!facts.exports.length || facts.submissions.length)) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
const active = await activeAttestation(tx, batch);
|
||||
if (active) {
|
||||
if (attestationMatches(active, facts)) throw buildBusinessValidationError("loan.submission_attestation_exists", {}, 409);
|
||||
|
||||
@@ -59,7 +59,7 @@ async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (fact.kind === "termination_evidence") {
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at || evidence.evidence_kind !== "bank_terminal") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
} else {
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, fact.receipt_attachment_id); }
|
||||
@@ -178,12 +178,12 @@ function releaseWaitDays(config) {
|
||||
|
||||
// The full "not paid" release condition, evaluated under the batch lock both
|
||||
// when confirming and again in the final cancel transaction.
|
||||
async function assertNotPaidReleasable(tx, config, batch) {
|
||||
async function assertNotPaidReleasable(tx, config, batch, { manualExportIds = [] } = {}) {
|
||||
const { submissionFacts, attestationMatches, voidedAttachmentResolved } = submissions();
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!facts.active.length || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
if ((!facts.active.length && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const covered = new Set();
|
||||
const usedFacts = [];
|
||||
@@ -196,7 +196,7 @@ async function assertNotPaidReleasable(tx, config, batch) {
|
||||
usedFacts.push(fact.id);
|
||||
}
|
||||
// Every export must be accounted for by a submission that reached a terminal state.
|
||||
const linkedExports = new Set(facts.active.map((item) => item.export_id).filter(Boolean));
|
||||
const linkedExports = new Set([...facts.active.map((item) => item.export_id).filter(Boolean), ...manualExportIds]);
|
||||
if (facts.exports.some((item) => !linkedExports.has(item.id))) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
// No payment receipt may stay unexplained: covered by a terminal fact, or a
|
||||
// voided slip that is provably a failure or a clearly different payment.
|
||||
@@ -240,22 +240,23 @@ async function terminalState(tx, batch) {
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }] }),
|
||||
]);
|
||||
return {
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })),
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, evidenceKind: item.evidence_kind, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })),
|
||||
facts: terminal.map((item) => ({ id: item.id, submissionId: item.submission_id, kind: item.kind, terminalStatus: item.terminal_status,
|
||||
evidenceId: item.evidence_id, receiptAttachmentId: item.receipt_attachment_id, resolvesAttachmentIds: item.resolves_attachment_ids,
|
||||
status: item.status, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
status: item.status, note: item.note, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch);
|
||||
else assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
// The same bank letter cannot back terminal facts of two batches.
|
||||
if (await tx.loanBankTerminalEvidence.count({ where: { file_sha256: file.fileSha256, payment_batch_id: { not: batch.id } } })) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
const evidence = await tx.loanBankTerminalEvidence.create({ data: {
|
||||
payment_batch_id: batch.id, storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
payment_batch_id: batch.id, evidence_kind: file.evidenceKind ?? "bank_terminal", storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_UPLOADED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
return evidence;
|
||||
@@ -268,6 +269,7 @@ async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reas
|
||||
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
if (await tx.loanBankResultEvent.count({ where: { OR: [{ evidence_id: evidence.id, status: { in: ["approved", "booked"] } }, { correction_evidence_id: evidence.id, status: "dismissed" }] } })) throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409);
|
||||
const voided = await tx.loanBankTerminalEvidence.update({ where: { id: evidence.id }, data: {
|
||||
voided_at: new Date(), voided_by: request.employee.id, void_reason: reason } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_VOIDED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { lockLender, auditLoan } = require("./companyLoans");
|
||||
|
||||
// Call under the lender lock for all pre-payment actions. Bank facts never use
|
||||
// this gate. A closed cause cannot mask another active cause.
|
||||
async function assertNotFrozen(tx, lenderId) {
|
||||
if (await tx.loanFreezeReason.count({ where: { lender_id: lenderId, status: "active" } })) {
|
||||
throw buildBusinessValidationError("loan.lender_frozen", {}, 409);
|
||||
}
|
||||
}
|
||||
async function openReason(tx, batch, request, kind, sourceKey) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const existing = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (existing) {
|
||||
if (existing.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
return existing;
|
||||
}
|
||||
const reason = await tx.loanFreezeReason.create({ data: { lender_id: repayment.lender_id, payment_batch_id: batch.id,
|
||||
kind, source_key: sourceKey, created_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_OPENED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind });
|
||||
return reason;
|
||||
}
|
||||
|
||||
// Internal only: S6/S7 call after resolving their source fact in this same
|
||||
// transaction. Never expose a generic close/unfreeze route to users.
|
||||
async function closeReason(tx, batch, request, kind, sourceKey, basis) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const reason = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (!reason || reason.payment_batch_id !== batch.id || !basis?.action || !basis?.recordId) {
|
||||
throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
}
|
||||
if (reason.status === "closed") return reason;
|
||||
const closed = await tx.loanFreezeReason.update({ where: { id: reason.id }, data: { status: "closed",
|
||||
closed_by: request.employee.id, closed_at: new Date(), closure_basis: basis } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_CLOSED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind, basis });
|
||||
return closed;
|
||||
}
|
||||
async function state(tx, lenderId) {
|
||||
const reasons = await tx.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: [{ created_at: "asc" }, { id: "asc" }] });
|
||||
return { frozen: reasons.some((r) => r.status === "active"), reasons: reasons.map((r) => ({ id: r.id, kind: r.kind,
|
||||
sourceKey: r.source_key, paymentBatchId: r.payment_batch_id, status: r.status, closureBasis: r.closure_basis })) };
|
||||
}
|
||||
module.exports = { assertNotFrozen, openReason, closeReason, state };
|
||||
@@ -0,0 +1,130 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const terminal = () => require("./loanBankTerminalFacts");
|
||||
const { snapshot, digest } = require("./loanReleaseRecoveries");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.not_submitted_invalid", {}, 409); };
|
||||
const latest = (tx, batch) => tx.loanNotSubmittedProposal.findFirst({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
|
||||
async function materialSnapshot(tx, batch) {
|
||||
const value = await snapshot(tx, batch);
|
||||
// The conclusion changes only after the second review. It is not evidence.
|
||||
delete value.originalState; delete value.originalRef;
|
||||
return value;
|
||||
}
|
||||
async function unchanged(tx, batch, proposal) {
|
||||
return digest(proposal.snapshot_json) === digest(await materialSnapshot(tx, batch));
|
||||
}
|
||||
async function available(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) ||
|
||||
!["unknown", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
!repayment.allocations.length || repayment.allocations.some((a) => a.reservation_state !== "reserved")) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const proposals = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } });
|
||||
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } });
|
||||
return [...new Set([...(await terminal().terminalParticipants(tx, batch, repayment)),
|
||||
...proposals.map((p) => p.proposed_by), ...manual.map((p) => p.proposed_by)].filter(Boolean))];
|
||||
}
|
||||
|
||||
// All three documentary categories, all unlinked exports, and all exporters.
|
||||
// Dates are asserted by the registrar and independently reviewed, not OCR proof.
|
||||
async function assertEvidence(tx, config, batch, input) {
|
||||
await available(tx, batch);
|
||||
await payments().validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
const { submissionFacts } = require("./loanBankSubmissions");
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy || !facts.exports.length) fail();
|
||||
const linked = new Set(facts.active.map((s) => s.export_id).filter(Boolean));
|
||||
const unlinked = facts.exports.filter((e) => !linked.has(e.id));
|
||||
if (!unlinked.length || digest([...input.exportIds].sort()) !== digest(unlinked.map((e) => e.id).sort())) fail();
|
||||
// A withdrawn submission is not proof that the bank did not receive it.
|
||||
if (facts.submissions.some((s) => s.status !== "active" && (!s.export_id || input.exportIds.includes(s.export_id)))) fail();
|
||||
const first = Math.min(...facts.exports.map((e) => new Date(e.exported_at).getTime()));
|
||||
const from = new Date(input.coverageFrom).getTime(); const through = new Date(input.coverageThrough).getTime();
|
||||
if (!Number.isFinite(from) || !Number.isFinite(through) || from > first || through < new Date(input.registeredAt).getTime() || through > Date.now()) fail();
|
||||
const required = [
|
||||
[input.queryEvidenceId, "query_no_result"], [input.statementEvidenceId, "account_statement"],
|
||||
...input.exporterStatements.map((s) => [s.evidenceId, "exporter_statement"]),
|
||||
];
|
||||
const exporters = [...new Set(unlinked.map((e) => e.exported_by))].sort();
|
||||
if (digest(exporters) !== digest(input.exporterStatements.map((s) => s.exporterId).sort()) ||
|
||||
new Set(required.map(([id]) => id)).size !== required.length) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
for (const [id, kind] of required) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind ||
|
||||
(kind === "query_no_result" && file.mime_type !== "application/pdf")) fail();
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path });
|
||||
const hash = createHash("sha256"); let bytes = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); bytes += chunk.length; }
|
||||
if (bytes !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
}
|
||||
// S3 still validates every real submission, every receipt, the approved list
|
||||
// and the wait period. Only these named exports use the manual exception.
|
||||
await terminal().assertNotPaidReleasable(tx, config, batch, { manualExportIds: input.exportIds });
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
await available(tx, batch);
|
||||
const previous = await latest(tx, batch);
|
||||
if (previous && ["proposed", "approved"].includes(previous.status)) {
|
||||
if (await unchanged(tx, batch, previous)) fail();
|
||||
await tx.loanNotSubmittedProposal.update({ where: { id: previous.id }, data: { status: "superseded" } });
|
||||
}
|
||||
// The coverage end must reach registration (Asia/Shanghai calendar day), without asking
|
||||
// the client to predict the server's millisecond clock.
|
||||
const registeredAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + "T00:00:00.000+08:00";
|
||||
const payload = { ...input, registeredAt };
|
||||
await assertEvidence(tx, config, batch, payload);
|
||||
const proposal = await tx.loanNotSubmittedProposal.create({ data: { payment_batch_id: batch.id,
|
||||
sequence: (previous?.sequence ?? 0) + 1, proposed_by: request.employee.id,
|
||||
payload_json: payload, snapshot_json: await materialSnapshot(tx, batch) } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, proposalId: proposal.id });
|
||||
return proposal;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.id !== id || proposal.status !== "proposed") fail();
|
||||
const repayment = await available(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (proposal.reviews.some((r) => r.reviewed_by === request.employee.id)) fail();
|
||||
if (input.decision === "approve") {
|
||||
if (!await unchanged(tx, batch, proposal)) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
await tx.loanNotSubmittedReview.create({ data: { proposal_id: id, reviewed_by: request.employee.id,
|
||||
decision: input.decision, reason: input.reason } });
|
||||
let status = "proposed";
|
||||
if (input.decision === "reject") status = "rejected";
|
||||
else if (proposal.reviews.length === 1 && proposal.reviews[0].decision === "approve") status = "approved";
|
||||
if (status !== "proposed") await tx.loanNotSubmittedProposal.update({ where: { id }, data: { status } });
|
||||
if (status === "approved" && repayment.bank_result_state === "unknown") {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_not_submitted", bank_result_ref: `not-submitted:${id}` } });
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_REVIEWED", batch.loan_repayment_id,
|
||||
{ paymentBatchId: batch.id, proposalId: id, decision: input.decision, status });
|
||||
return { id, status };
|
||||
}
|
||||
async function assertReleasable(tx, config, batch) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.status !== "approved" || proposal.reviews.length !== 2 ||
|
||||
proposal.reviews.some((r) => r.decision !== "approve") ||
|
||||
new Set(proposal.reviews.map((r) => r.reviewed_by)).size !== 2 || !await unchanged(tx, batch, proposal)) fail();
|
||||
const excluded = await participants(tx, batch);
|
||||
if (proposal.reviews.some((r) => excluded.includes(r.reviewed_by))) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const rows = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
return { history: await Promise.all(rows.map(async (p) => ({ id: p.id, sequence: p.sequence, status: p.status,
|
||||
proposedBy: p.proposed_by, payload: p.payload_json, stale: ["proposed", "approved"].includes(p.status) && !await unchanged(tx, batch, p),
|
||||
reviews: p.reviews.map((r) => ({ reviewedBy: r.reviewed_by, decision: r.decision, reason: r.reason })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, state, assertReleasable };
|
||||
@@ -205,5 +205,5 @@ async function recoveryState(tx, batch) {
|
||||
submittedBy: r.submitted_by, reviewedBy: r.reviewed_by, reviewNote: r.review_note, originalState: r.original_state })) };
|
||||
}
|
||||
|
||||
module.exports = { startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
module.exports = { snapshot, digest, startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
isRecoveryCancelRetry, recoveryState, assertRecoveryCollecting, latestRecovery };
|
||||
|
||||
@@ -139,6 +139,7 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
|
||||
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
|
||||
const match = options[0];
|
||||
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
|
||||
requireFact(await tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: { in: match.aliases } }, { key_id: { not: keyId } }] } }) === 0, "receipt_transaction_reused");
|
||||
const existing = await tx.paymentReceiptClaim.findMany({
|
||||
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。",
|
||||
"loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。",
|
||||
"loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。",
|
||||
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
|
||||
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
|
||||
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
|
||||
|
||||
@@ -284,6 +284,7 @@ function buildPrisma(options = {}) {
|
||||
|
||||
const tx = {
|
||||
businessNumberPolicy: { findUnique: vi.fn(async () => null) },
|
||||
loanBankEventClaim: { count: vi.fn().mockResolvedValue(0) },
|
||||
paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptManualProposal: { findMany: vi.fn(async () => []) },
|
||||
|
||||
@@ -22,6 +22,7 @@ function fixture() {
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: attachment.id }]),
|
||||
paymentReceiptVerification: { findFirst: vi.fn().mockResolvedValue(verification) },
|
||||
loanBankEventClaim: { count: vi.fn().mockResolvedValue(0) },
|
||||
paymentReceiptClaim: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||
paymentReceiptTransactionAlias: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||
paymentReceiptManualProposal: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -30,6 +31,12 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("receipt evidence and transaction claims", () => {
|
||||
test("bank transactions already used for supplements or returns cannot be claimed again", async () => {
|
||||
const { tx, batch } = fixture();
|
||||
tx.loanBankEventClaim.count.mockResolvedValue(1);
|
||||
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_transaction_reused" });
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
});
|
||||
test("mixed historical cash items do not require bank snapshots or bank receipt claims", async () => {
|
||||
const { batch, attachment, recognition, tx } = fixture();
|
||||
batch.items.push({ id: "cash-item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||
|
||||
在新工单中引用
屏蔽一个用户