Purchase Invoice — Types & Selection Scenarios
Ship-11.a.3 introduces 5 Purchase Invoice types. This document explains when to choose which type, what validations run on the backend, and several end-to-end scenarios.
Migration (origin, legacy): merchant_database/db_kesles_merchant/migrations/legacy/057_purchase_invoices_advance_type.sql. The purchase_invoices table now lives in db_kesles_merchant_inventory (owned by inventory_service; see merchant_database/db_kesles_merchant_inventory/migrations/v1/009_purchasing_domain.sql and 010_migrate_purchasing_from_main_db.sql).
Backend: services/dashboard_api/internal/app/dashboard_purchase_invoices.go + db_purchasing.go (handler/validation); 3-way match and PO cap aggregation run in inventory_service (services/inventory_service/internal/inventory/store_purchase_invoices.go) and are reached over HTTP.
Frontend: apps/merchant_dashboard/lib/dashboard/features/home/presentation/widgets/forms/purchase_invoice_form_dialog.dart
1. Summary of the 5 Types
| Type | Wire value | When to use | PO | Vendor | GR link required | Qty-based |
|---|---|---|---|---|---|---|
| Final | final | Goods invoice after GR is posted | Required | Same as PO | Yes (per line) | Yes |
| Advance / DP | advance | Down payment / DP / pre-ship before GR | Required | Same as PO | No | No (milestone) |
| Shipping | shipping | Separate freight forwarder invoice | Optional | Free (forwarder) | No | No |
| Customs | customs | Customs duty + import VAT from DJBC/broker | Optional | Free (DJBC/broker) | No | No |
| Credit Note | credit_note | Credit note / return, value can be negative | Optional | Free | No | No |
2. Decision Tree — Pick the Type
Tagihan apa yang kamu terima?
│
├── Barang (yang dipesan di PO) ─────────────────┐
│ │ │
│ ├── Barang SUDAH datang + GR sudah di-post │
│ │ dan ini tagihan pelunasan/settlement ───► final
│ │
│ └── Barang BELUM datang (DP, pre-ship,
│ termin pembayaran, milestone) ──────────► advance
│
├── Ongkos kirim / freight / forwarder
│ dari vendor ekspedisi sendiri ──────────────► shipping
│
├── Bea Cukai / PPN Impor
│ dari DJBC atau broker kepabeanan ───────────► customs
│
└── Retur / koreksi / kelebihan bayar ──────────► credit_note
Practical question: "Who is the issuing vendor of this invoice?"
- Same as the goods vendor on the PO →
finaloradvance - Forwarder (DHL, FedEx, local courier) →
shipping - DJBC / import broker →
customs - Goods vendor, but with negative / correction value →
credit_note
3. Scenarios
3.1 Simple Domestic Purchase — Goods Only
Kesles buys 100 EDC machines from a domestic vendor, paid once after the goods are received. No DP, no separate freight (shipping already included in goods price), no customs.
PO-2026-00XX (Approved → Sent)
└── GR-2026-00XX (Posted)
└── Invoice #1 — type = final ✅ ONLY ONE invoice
total = qty × price + VAT
Form fields in Create Invoice:
- Invoice Type:
Final (goods, 3-way match) - Vendor Invoice #: invoice number from the vendor
- Purchase Order: pick the PO that already has a posted GR
- Lines: auto-pulled from GR (no manual typing required)
- VAT: PPN 11% if applicable
Backend validation on Approve:
- 3-way match (invoice qty ≤ remaining GR qty, price ±5% from PO)
3.2 Purchase With DP (Payment Terms)
Kesles buys 500 terminals from CoherentPlus Sdn Bhd (Malaysia). Terms: 50% DP at PO issuance, 50% settlement when goods are ready to ship. Shipping + customs paid separately.
PO IKN-2026-001 (Approved)
├── Invoice #1 — type = advance "50% DP upon PO issuance" ← bayar duluan
├── Invoice #2 — type = advance "50% Pre-Ship pelunasan barang" ← bayar sebelum GR
│
│ (Barang tiba di Priok, masuk gudang, GR di-post)
│
├── Invoice #3 — type = shipping "BL #ABC-123 — freight DHL" ← dari forwarder
└── Invoice #4 — type = customs "SPPB-20260422-000123" ← dari DJBC/broker
Important notes:
- Invoices #1 and #2 do not require GR — they are designed to be paid before the goods arrive.
- Cumulative total of advance + final for one PO is capped at
PO.total_amount × (1 + tolerance)(default tolerance 5%, can be overridden bysuper_adminwith?force=true). - Shipping + customs invoices are not capped against the PO total because the vendor differs and the charge baseline is different.
3.3 Imported Purchase With Post-GR Settlement
Variant of 3.2: DP paid, the remaining 50% is paid only after the goods are
received (not pre-ship). Then the settlement invoice = final (3-way
match active).
PO IKN-2026-002
├── Invoice #1 — type = advance "30% DP"
│
│ (GR di-post dengan serial number)
│
├── Invoice #2 — type = final "Pelunasan 70% pasca-GR" ← 3-way match
├── Invoice #3 — type = shipping "Freight"
└── Invoice #4 — type = customs "Bea cukai + PPN impor"
Difference from 3.2: Invoice #2 = final, will be validated for qty +
price vs GR + PO. Whereas in 3.2 Invoice #2 = advance because it is paid
before the goods arrive (pre-ship).
3.4 Partial Return
Of the 500 units in 3.2, 10 units are damaged on inspection and returned. The vendor issues a credit note for 10 units + their VAT.
Invoice #5 — type = credit_note "Retur 10 unit serial SN123-SN132"
total_amount = -(10 × unit_price + PPN) ← NEGATIF
A credit note does not automatically reduce stock in
payment_terminal_inventory — that is done via a separate inventory return
flow (not covered by the invoice).
4. Backend Validation per Type
4.1 On Create / Update (POST, PATCH /purchase-invoices)
| Validation | final | advance | shipping | customs | credit_note |
|---|---|---|---|---|---|
| PO required | ✓ | ✓ | |||
| Vendor required | ✓ | ✓ | ✓ | ✓ | ✓ |
Each line requires goods_receipt_item_id | ✓ | ||||
Line forbidden to have goods_receipt_item_id | ✓ | ✓ | ✓ | ✓ | |
| Qty > 0 required | ✓ | ||||
| Unit price may be negative | ✓ | ||||
| Description required per line | ✓ | ✓ | ✓ | ✓ | ✓ |
4.2 On Approve (POST /purchase-invoices/{id}/approve)
| Check | final | advance | shipping | customs | credit_note |
|---|---|---|---|---|---|
| 3-way match (qty ≤ GR remaining, price ±5% PO) | ✓ | ||||
| Cumulative cap: Σ(advance+final approved) ≤ PO×1.05 | ✓ | ✓ |
super_admin can override both with ?force=true (audit-logged in
the server log, printed as purchase_invoice approve forced by super_admin …).
The default tolerance (5%) can be overridden via the env var
PURCHASE_INVOICE_MATCH_TOLERANCE=0.03 for 3%.
5. Practical FAQ
Q: Domestic goods + freight already included, no DP, which type?
A: final only. One invoice, 3-way match.
Q: Goods arrived but the vendor wants payment first before issuing the invoice. Pick which?
A: If there is no formal invoice from the vendor yet, skip — do not create
an invoice without a vendor document. Once it exists (even at 100% value),
choose advance if the GR is not yet posted, or final if it has been.
Q: Freight is included in the goods price (DDP / CIF). Do I still need a shipping invoice?
A: No. shipping is only used when the forwarder issues its own
invoice. If freight rides on the goods invoice, let it stay in the
subtotal / other_charges of the final invoice.
Q: Can one PO have multiple advance invoices?
A: Yes. Example terms 30% + 40% + 30% = three advance invoices. The
cumulative cap still applies (total cannot exceed PO × 1.05).
Q: Can advance be used for a PO that is already received?
A: Schema-wise yes (the backend does not block), but processually it is
rarely sensible — typically once goods are received, only final remains
unpaid. Use advance only before GR.
Q: What is the difference between other_charges_amount and a shipping invoice type?
A: other_charges_amount = minor charges riding on the same invoice
(e.g., packing cost from the goods vendor). shipping = a SEPARATE invoice
from a forwarder vendor that is a different legal entity. Use the right
field so AP reconciliation is clean per vendor.
Q: Credit note for a different-vendor PO?
A: Allowed — purchase_order_id is optional for credit_note.
Fill milestone_label with the reference PO/invoice being returned for
traceability.
6. Possible Error Messages
| Error code | Meaning | Handling |
|---|---|---|
three_way_match_failed | Final line qty or price does not match GR/PO | Fix qty/price or request super_admin override |
po_cap_exceeded | Total advance+final > PO × (1+tolerance) | Check other invoices on the same PO; or super_admin override |
validation_failed (per field) | Wrong payload (e.g., advance has gr_item_id, or final qty=0) | Check the form UI — conditional fields should already prevent this |
duplicate_invoice (409) | Combination vendor_id + invoice_number already exists | Use a unique invoice number per vendor |
invoice_not_editable (409) | Tried to edit an already approved/paid invoice | Void first, or create a credit_note correction |
7. Document Relationship Diagram
┌──────────────┐
│ Vendor │
│ (master) │
└──────┬───────┘
│
▼
┌─────────────┐ ┌──────────────┐
│ │ │ │
│ Purchase ├────────►│ Goods │
│ Order (PO) │ │ Receipt │
│ │ │ (GR) │
└──────┬──────┘ └──────┬───────┘
│ │
┌─────────┼──────────┐ │
│ │ │ │
▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌──────────┐ ┌────────────┐
│advance│ │advance│ │ shipping │ │ final │
│ #1 │ │ #2 │ │ invoice │ │ invoice │
│ DP │ │Preship│ │ (opsion) │ │ (3-way │
└───────┘ └───────┘ └──────────┘ │ match) │
└────────────┘
│
▼
┌────────────┐
│ customs │
│ invoice │
│ (opsional) │
└────────────┘
advance + final di-cap vs PO total
shipping + customs tidak di-cap
8. Code References
- Type validation enum:
normalizePurchaseInvoiceTypeindb_purchasing.go(dashboard_api) - GR-requirement branching:
invoiceTypeRequiresGRLinkindb_purchasing.go - Cumulative cap:
poCapSummarytype indb_purchasing.go; on approve the handler callsinventoryClient.getApprovedInvoiceSumHTTP(dashboard_purchase_invoices.go), backed byinventory_service - 3-way match:
inventoryClient.getInvoiceThreeWayMatchHTTP(dashboard_purchase_invoices.go) →Store.VerifyThreeWayMatchinstore_purchase_invoices.go(inventory_service); mismatch shape =threeWayMatchErrortype indb_purchasing.go - Tolerance:
purchaseInvoiceThreeWayToleranceindashboard_purchase_invoices.go - UI conditional sections:
_InvoiceTypeX.poRequired,.allowsFreeVendor,.showMilestoneLabelinpurchase_invoice_form_dialog.dart - Migration columns + constraints:
legacy/057_purchase_invoices_advance_type.sql(origin); current schema indb_kesles_merchant_inventorymigrations009/010