Task — engineering-spec@1

"REW statutory deductions — BHXH 10.5% + BHYT 1.5% + BHTN 1% + PIT progressive per Decree 152/2020 with TASK-HR-005 policy lookup"

draftTASK-REW-004
module rew · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-005 · blocks none

§1 — Description (BCP-14 normative)

The REW service MUST ship statutory deductions at services/rew/src/deductions/ computing BHXH + BHYT + BHTN + PIT per Decree 152/2020 with versioned rates, 3 memory audit kinds.

  1. MUST validate deduction_kind against closed enum per DEC-2181.
  1. MUST compute at computer.rs::compute(member, gross_taxable, period) per DEC-2180:
  1. MUST check contractor exemption per DEC-2183 — if member.contract_type='contractor', skip BHXH/BHYT/BHTN; still compute PIT.
  1. MUST be deterministic per DEC-2182 — pure function; same input + same policy version → same output.
  1. MUST define table at migration 0004: ``sql CREATE TABLE rew_deductions ( deduction_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, payroll_run_id UUID NOT NULL, member_id UUID NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('bhxh','bhyt','bhtn','pit','union_due','voluntary')), rate NUMERIC(7,6), base_amount_vnd BIGINT NOT NULL, deducted_amount_vnd BIGINT NOT NULL, policy_version_id UUID NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX deductions_payroll_member_idx ON rew_deductions(tenant_id, payroll_run_id, member_id); ALTER TABLE rew_deductions ENABLE ROW LEVEL SECURITY; CREATE POLICY deductions_rls ON rew_deductions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_deductions FROM cyberos_app; ``
  1. MUST emit 3 memory audit kinds per DEC-2184. PII per TASK-MEMORY-111: amounts SHA-256 hashed.
  1. MUST thread trace_id from TASK-REW-005 compute → deductions → audit.
  1. MUST NOT deduct SI from contractors per DEC-2183.
  1. MUST NOT use unversioned rates per DEC-2182.

§2 — Why this design

Why employee-side rates (DEC-2180)? Decree 152 distinguishes employer (17.5+4.5+2=24%) from employee (8+1.5+1=10.5%). Common bug: using wrong side.

Why versioned (DEC-2182)? Rates change annually; TASK-REW-002 replay requires deterministic lookup.

Why contractor exempt (DEC-2183)? Decree 152 explicitly exempts contractors from SI participation.


§3 — API contract

Sample deduction list (in payroll context):

{
  "member_id": "uuid",
  "deductions": [
    {"kind": "bhxh", "rate": 0.08, "base": 30000000, "deducted": 2400000},
    {"kind": "bhyt", "rate": 0.015, "base": 30000000, "deducted": 450000},
    {"kind": "bhtn", "rate": 0.01, "base": 30000000, "deducted": 300000},
    {"kind": "pit", "rate": null, "base": 26850000, "deducted": 1842500}
  ],
  "total_deducted": 4992500
}

§4 — Acceptance criteria

  1. deduction_kind enum cardinality 6. 2. BHXH 8% (employee-side, not 17.5%). 3. BHYT 1.5%. 4. BHTN 1%. 5. PIT progressive brackets. 6. Contractor SI-exempt. 7. Contractor still PIT'd. 8. Versioned policy lookup. 9. Deterministic. 10. 3 memory audit kinds emitted. 11. PII scrubbed (amounts SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Append-only via REVOKE. 15. Bigint VND (no float). 16. Rate precision (7,6). 17. Bracket edge handling per Decree 152 Art. 7. 18. Policy version_id stored per deduction. 19. Multiple deductions per member per run. 20. Total = sum(deducted_amount_vnd) matches.

§5 — Verification

#[tokio::test]
async fn bhxh_8pct_not_17() {
    let ctx = TestContext::with_gross_30m_vnd().await;
    let deductions = compute(ctx.member, dec!(30_000_000), period).await;
    let bhxh = deductions.iter().find(|d| d.kind == "bhxh").unwrap();
    assert_eq!(bhxh.deducted_amount_vnd, 2_400_000);
}

#[tokio::test]
async fn contractor_si_exempt() {
    let ctx = TestContext::with_contractor_gross_30m().await;
    let deductions = compute(ctx.member, dec!(30_000_000), period).await;
    assert!(!deductions.iter().any(|d| d.kind == "bhxh"));
    assert!(deductions.iter().any(|d| d.kind == "pit"));
}

#[tokio::test]
async fn pit_progressive_brackets() {
    let taxable = dec!(20_000_000);
    let pit = pit_progressive::compute(taxable, default_brackets()).await;
    // Per Decree 152 Art. 7:
    // 5M @ 5% = 250k
    // 5M @ 10% = 500k
    // 10M @ 15% = 1500k
    // Total: 2.25M
    assert_eq!(pit, dec!(2_250_000));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-005. Downstream: TASK-REW-005 (payroll compute uses this). Cross-module: TASK-REW-002 (versioning), TASK-HR-002 (contract type), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Policy lookup failcatchsev-1; reject computeretry
Bracket misconfiguredvalidatorrejectfix policy
Decimal precisionbigintinherentinherent
Contractor type missingdefault exempt? no — rejectsev-2data fix
Cross-tenant queryRLS0 rowsinherent
Rate change mid-perioduse period start versioninherentinherent
Negative grossreject400data fix
Zero deductions edgeinherentinherentinherent
Mid-compute crashrollbacksev-2retry
Compute non-deterministiccode reviewinherentbug fix

§11 — Implementation notes


End of TASK-REW-004 spec.