"REW statutory deductions — BHXH 10.5% + BHYT 1.5% + BHTN 1% + PIT progressive per Decree 152/2020 with TASK-HR-005 policy lookup"
§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.
- MUST validate
deduction_kindagainst closed enum per DEC-2181.
- MUST compute at
computer.rs::compute(member, gross_taxable, period)per DEC-2180:
- Read rates from TASK-REW-002 / TASK-HR-005 policy at period start
- Default rates per Decree 152: BHXH 8%, BHYT 1.5%, BHTN 1%
- PIT via
pit_progressive.rs::compute(taxable_income, brackets)
- MUST check contractor exemption per DEC-2183 — if
member.contract_type='contractor', skip BHXH/BHYT/BHTN; still compute PIT.
- MUST be deterministic per DEC-2182 — pure function; same input + same policy version → same output.
- 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;``
- MUST emit 3 memory audit kinds per DEC-2184. PII per TASK-MEMORY-111: amounts SHA-256 hashed.
- MUST thread trace_id from TASK-REW-005 compute → deductions → audit.
- MUST NOT deduct SI from contractors per DEC-2183.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Policy lookup fail | catch | sev-1; reject compute | retry |
| Bracket misconfigured | validator | reject | fix policy |
| Decimal precision | bigint | inherent | inherent |
| Contractor type missing | default exempt? no — reject | sev-2 | data fix |
| Cross-tenant query | RLS | 0 rows | inherent |
| Rate change mid-period | use period start version | inherent | inherent |
| Negative gross | reject | 400 | data fix |
| Zero deductions edge | inherent | inherent | inherent |
| Mid-compute crash | rollback | sev-2 | retry |
| Compute non-deterministic | code review | inherent | bug fix |
§11 — Implementation notes
- §11.1 Brackets stored as JSONB in TASK-REW-002 param_versions; computer reads + applies.
- §11.2 PIT computer iterates brackets; per-bracket: taxable_in_bracket × rate.
- §11.3 Rate (7,6) = up to 99.9999%; comfortably handles all SI/PIT scenarios.
- §11.4 memory audit body: payroll_run_id, member_id, kind; amounts SHA256.
- §11.5 union_due + voluntary = optional kinds for future use.
End of TASK-REW-004 spec.