Task — engineering-spec@1

"INV VN hóa đơn cancellation flow — Decree 123 Art. 19 replacement-or-cancellation protocol with GDT 1-1 mapping + amendment audit"

draftTASK-INV-008
module inv · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-INV-007 · blocks none

§1 — Description (BCP-14 normative)

The INV service MUST ship VN hóa đơn cancellation at services/invoicing/src/hoadon/cancel.rs supporting replace + cancel flows, customer agreement upload, GDT form 04/SS-HDDT submission, replacement-pointer audit trail, 4 memory audit kinds.

  1. MUST expose POST /v1/inv/hoadon/{id}/cancel body { reason, customer_agreement_doc_id, replacement_invoice_id?, notes } — CFO-role only via TASK-AUTH-101.
  1. MUST validate reason against closed enum per DEC-1532; reject invalid values 400.
  1. MUST require customer_agreement_doc_id (TASK-DOC-001 reference) per DEC-1530 — without it, return 422.
  1. MUST build form 04/SS-HDDT at form_04_builder.rs::build(cancellation) per Decree 123 Annex 1 — root <TBao> with <DLTBao> (notification data) + <DSHDon> (cancelled hóa đơn list).
  1. MUST submit form to GDT within 24h of decision per DEC-1533 — via TASK-MCP-007 task (GDT may ack async).
  1. MUST update original vn_hoadon row to hoadon_status='cancelled', set replacement_hoadon_id if replace flow per DEC-1534. Never delete row.
  1. MUST define table extension at migration 0008: ```sql ALTER TABLE vn_hoadon ADD COLUMN replacement_hoadon_id UUID REFERENCES vn_hoadon(hoadon_id); ALTER TABLE vn_hoadon ADD COLUMN cancel_reason TEXT CHECK (cancel_reason IS NULL OR cancel_reason IN ('error_correction','customer_dispute','engagement_terminated','duplicate_emission')); ALTER TABLE vn_hoadon ADD COLUMN cancellation_form_id UUID; ALTER TABLE vn_hoadon ADD COLUMN customer_agreement_doc_id UUID; ALTER TABLE vn_hoadon ADD COLUMN cancelled_at TIMESTAMPTZ; GRANT UPDATE (replacement_hoadon_id, cancel_reason, cancellation_form_id, customer_agreement_doc_id, cancelled_at, hoadon_status, updated_at) ON vn_hoadon TO cyberos_app;

CREATE TABLE vn_hoadon_cancellation_forms ( form_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, cancelled_hoadon_ids UUID[] NOT NULL, reason TEXT NOT NULL, form_xml BYTEA NOT NULL, gdt_response JSONB, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','submitted','accepted','rejected')), submitted_at TIMESTAMPTZ, accepted_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE vn_hoadon_cancellation_forms ENABLE ROW LEVEL SECURITY; CREATE POLICY cancel_forms_rls ON vn_hoadon_cancellation_forms USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON vn_hoadon_cancellation_forms FROM cyberos_app; GRANT UPDATE (status, gdt_response, submitted_at, accepted_at) ON vn_hoadon_cancellation_forms TO cyberos_app; ```

  1. MUST support replace flow: when replacement_invoice_id supplied, trigger TASK-INV-007 emit for replacement BEFORE marking original cancelled; ensures continuity.
  1. MUST emit 4 memory audit kinds per DEC-1535. PII per TASK-MEMORY-111 (reason text scrubbed of customer names → SHA256 hash of full notes).
  1. MUST thread trace_id from CFO action → cancel → form submit → GDT response.
  1. MUST NOT allow cancel of accepted hóa đơn without customer agreement (DEC-1530); pending or rejected may cancel without it (admin discretion).
  1. MUST NOT delete the original row per DEC-1534.

§2 — Why this design

Why customer agreement required (DEC-1530)? Per Decree 123 Art. 19, cancellation without customer biên bản is legally void; GDT will reject form 04.

Why replace + cancel separate flows (DEC-1531)? Replace preserves AR continuity (new invoice issued); cancel terminates. Distinct legal treatments.

Why append-only with replacement_hoadon_id (DEC-1534)? Audit lineage — accountant must trace original → replacement chain for VAT reconciliation.


§3 — API contract

Endpoints:

POST   /v1/inv/hoadon/{id}/cancel       (CFO-only)
GET    /v1/inv/hoadon/{id}/cancellation  (status + form)
GET    /v1/inv/cancellation-forms        (list pending/submitted/accepted)

Sample request:

{
  "reason": "error_correction",
  "customer_agreement_doc_id": "uuid-of-uploaded-biên-bản",
  "replacement_invoice_id": "uuid-of-new-invoice",
  "notes": "Customer disputed line item 3 quantity; corrected from 100 to 80"
}

Form 04/SS-HDDT (Decree 123 Annex 1):

<TBao xmlns="http://kekhaithue.gdt.gov.vn/TBaoSaiSot">
  <DLTBao>
    <TTChung><MCQT>{tax_id}</MCQT><MLTBao>1</MLTBao></TTChung>
    <NDTBao>
      <DSHDon>
        <HDon><MSHDon>K24TAA-00000001</MSHDon><LDo>1</LDo></HDon>
      </DSHDon>
    </NDTBao>
  </DLTBao>
  <DSCKS>...</DSCKS>
</TBao>

§4 — Acceptance criteria

  1. CFO-only access (TASK-AUTH-101 enforced). 2. Reason enum 4 values + cardinality test. 3. Customer agreement required for accepted hóa đơn. 4. Replace flow issues new hóa đơn first. 5. Original row updated, never deleted. 6. Form 04 schema valid per Decree 123 Annex 1. 7. Form submitted to GDT within 24h. 8. GDT async via TASK-MCP-007. 9. 4 memory audit kinds emitted. 10. PII scrubbed (notes → SHA256). 11. RLS denies cross-tenant. 12. Replacement chain pointer queryable. 13. Append-only on form table. 14. GDT rejection → form status=rejected + CFO notification. 15. Cancellation of pending/rejected hóa đơn allowed without agreement. 16. Trace_id propagated. 17. Duplicate cancel attempt rejected (already cancelled). 18. Form xml signed via tenant KMS cert. 19. 24h window enforced (audit if past). 20. Cancellation event broadcast to TASK-CHAT-010 if customer-facing channel exists.

§5 — Verification

#[tokio::test]
async fn replace_flow_issues_new_then_cancels_original() {
    let ctx = TestContext::vn_tenant_with_accepted_hoadon().await;
    let new_inv = ctx.create_replacement_invoice().await;
    let resp = ctx.cancel_hoadon(ctx.original_id, "error_correction",
        ctx.agreement_doc, Some(new_inv)).await;
    assert_eq!(resp.status, 202);
    let original = ctx.fetch_hoadon(ctx.original_id).await;
    assert_eq!(original.hoadon_status, "cancelled");
    let new_h = ctx.fetch_hoadon_by_invoice(new_inv).await;
    assert_eq!(new_h.hoadon_status, "transmitted");
    assert_eq!(original.replacement_hoadon_id, Some(new_h.hoadon_id));
}

#[tokio::test]
async fn cancel_accepted_requires_agreement() {
    let ctx = TestContext::vn_tenant_with_accepted_hoadon().await;
    let resp = ctx.cancel_hoadon_without_agreement(ctx.original_id, "customer_dispute").await;
    assert_eq!(resp.status, 422);
}

#[tokio::test]
async fn form_submitted_within_24h() {
    let ctx = TestContext::vn_tenant_with_cancellation().await;
    let form: VnHoadonCancelForm = ctx.fetch_form(ctx.form_id).await;
    let elapsed = form.submitted_at.unwrap() - form.created_at;
    assert!(elapsed < Duration::hours(24));
}

// 5.4..5.10

§6 — Skeleton

pub async fn cancel(req: CancelRequest, actor: &CfoActor, db: &Db) -> Result<CancelResponse> {
    let original = db.fetch_hoadon(req.hoadon_id).await?;
    if original.hoadon_status == "accepted" && req.customer_agreement_doc_id.is_none() {
        return Err(CancelError::AgreementRequired.into());
    }
    if let Some(replacement_invoice_id) = req.replacement_invoice_id {
        let new_hoadon = super::emit(replacement_invoice_id, actor.tenant(), db).await?;
        db.set_replacement_pointer(req.hoadon_id, new_hoadon).await?;
    }
    let form_xml = form_04_builder::build(&original, &req)?;
    let form_id = db.insert_cancellation_form(&original, &req, &form_xml).await?;
    let trace = current_span_trace_id();
    audit::emit("inv.hoadon_cancel_initiated", json!({"hoadon_id": req.hoadon_id, "reason": req.reason}), trace).await?;
    queue_gdt_submission(form_id, form_xml, actor.tenant().clone()).await?;
    db.mark_hoadon_cancelled(req.hoadon_id, form_id, &req).await?;
    Ok(CancelResponse{form_id, ..})
}

§7 — Dependencies

Upstream: TASK-INV-007. Cross-module: TASK-AUTH-101 (CFO role), TASK-DOC-001 (agreement upload), TASK-MCP-007 (async), TASK-MEMORY-111 (PII), TASK-CHAT-010 (customer notification).

§8 — Sample payloads (see §3)

§9 — Open questions

None blocking.

§10 — Failure modes

FailureDetectionOutcomeRecovery
Agreement doc missingearly validate422CFO uploads doc
Invalid reason enumCHECK constraint400use valid enum
Original not cancellable (already cancelled)state check409inherent
GDT 4xx on formresponse codestatus=rejected; sev-2CFO fixes + resubmit
GDT 5xxretry 3x w/ backoffretryinherent
Replacement invoice doesn't existFK violation404provide valid id
Form > 24h windowauditsev-2 + submit anywaypost-hoc explanation
KMS sign fail on formsign errorstatus=pending; sev-1KMS recovery
Concurrent cancel attemptsUNIQUE on form for hoadonsecond 409inherent
Customer changes mind mid-flowmanual CFO actionrevert via new emitCFO escalation
GDT acceptance race vs replacementsequencereplacement first, then cancelinherent (order in skeleton)

§11 — Implementation notes


End of TASK-INV-008 spec.