Plan — Vendor Expansion + PO PDF readiness (Ship-11)
Context
The example Purchase Order PDF (IKN/PROC/IV/2026/001 — CoherentPlus Sdn Bhd, Malaysia, USD 19,500) revealed that the merchant.vendors table lacks several fields needed to produce an official PO document for a foreign vendor:
- SWIFT/BIC code — required for international wire transfers
- Bank address — some correspondent banks require the branch address
- Tax registration other than NPWP — MY vendors use SST, EU vendors use VAT, SG vendors use GST-reg, etc.
- Website — printed on the PO header for dual verification
- Vendor category — manufacturer / distributor / service / reseller classification for Purchasing Report segmentation
Also for the Ship-11.b PDF engine, before the HTML→PDF generator is built the source data must be complete first — so Ship-11.a focuses on schema + form expansion, Ship-11.b handles PDF rendering.
Decisions about data sources:
- SWIFT: no master ref table — reason: 8–11-char ISO-9362 codes, rarely change per vendor, vary per bank branch. Operator inputs manually with format validation regex
^[A-Z0-9]{8}([A-Z0-9]{3})?$. - Secondary tax reg: also not referenced — SST/VAT/GST is too varied across jurisdictions to enum. Two free-text columns
tax_reg_secondary_label+tax_reg_secondary_value. - Vendor category: a CHECK constraint at the DB level (
manufacturer | distributor | service | reseller | other), not a ref table — the set is small and stable. - Primary tax ID (
vendors.npwp) is still validated through the existingpublic.ref_country.tax_id_regex(db_reference migration 009).
Ship-11.a — Vendor expansion (schema + form)
Status (2026-06-23): shipped. Migration 055 applied;
dashboard_vendors.goand the Flutter vendor form/mapper carry the new fields. Ship-11.b/c/d remain planned.
Migration 055 — merchant_database/db_kesles_merchant/migrations/legacy/055_vendors_expansion_for_po_pdf.sql
alter table merchant.vendors
add column if not exists swift_code varchar(15),
add column if not exists bank_address text,
add column if not exists tax_reg_secondary_label varchar(40),
add column if not exists tax_reg_secondary_value varchar(80),
add column if not exists website varchar(200),
add column if not exists category varchar(24);
alter table merchant.vendors
add constraint chk_vendors_category
check (category is null or category in (
'manufacturer','distributor','service','reseller','other'
));
create index if not exists idx_vendors_category
on merchant.vendors (category)
where deleted_at is null;
comment on column merchant.vendors.swift_code is
'Bank SWIFT/BIC code (ISO-9362). 8 or 11 chars, uppercase. Required for international wires.';
comment on column merchant.vendors.tax_reg_secondary_label is
'Label for a second tax registration (e.g. SST, VAT, GST) — free-text because variants per jurisdiction are too many to enum.';
All columns nullable — domestic vendors don't need SWIFT / bank_address / tax_reg_secondary. No backfill required.
Backend — services/dashboard_api/internal/app/dashboard_vendors.go
Field additions in:
VendorRecordstruct (response JSON)vendorPayloadstruct (input parse)listVendorsquery (SELECT + scan)createVendor/updateVendorINSERT/UPDATE statements
Validation in normalizeVendorPayload:
- SWIFT: trim + uppercase + match
^[A-Z0-9]{8}([A-Z0-9]{3})?$if non-empty → 422 if invalid - Category: if non-empty must be one of the 5 enums; the DB CHECK constraint is the safety net
- Website: trim; not forced to start with
http://— operator may inputkesles.comdirectly
Frontend — VendorItem entity + mapper + form dialog
VendorItem(entity): +swiftCode,bankAddress,taxRegSecondaryLabel,taxRegSecondaryValue,website,categoryVendorInput(payload): same- Mapper
home_dashboard_mapper.dart: add parsing forswift_code,bank_address, etc. vendor_form_dialog.dart: add fields in the appropriate sections- Banking section: after "Bank Account Number" → SWIFT / BIC field (uppercase input) + Bank Address (2-line textarea)
- Legal & Tax section: after NPWP → two side-by-side fields: Tax Reg Label (e.g. "SST", "VAT") + Tax Reg Value
- Company section: add Website + Category (dropdown: Manufacturer / Distributor / Service / Reseller / Other)
Verification
- Run migration 055
go build ./...inservices/dashboard_apicleanflutter analyze lib/dashboard/clean- Manual: open Vendor panel → Create new vendor → fill all the new fields → Save → reopen → verify the data persists
- Smoke: create a vendor with an invalid SWIFT (
FOO) → expect 422 from the backend - Smoke: select category "manufacturer" → the table row shows the badge
Estimate: 0.5 day (small schema, additive UI).
Ship-11.b — Multi-currency + Payment milestones + Bill-to/Ship-to/signer snapshot on PO
Context
The example IKN/PROC/IV/2026/001 PDF is USD with a 50/50 payment milestone and a CEO signer snapshot. Not yet supported in the current PO schema.
Migration 056 — PO enrichment
purchase_orders.currency char(3) not null default 'IDR'+ CHECKin ('IDR','USD','SGD','MYR','EUR')(expand as needed)purchase_orders.exchange_rate_to_base numeric(18,6)— rate daricurrencyke base currency company (default IDR). Snapshot at approval; NULL kalaucurrency= base currency. Pakaipurchase_orders.base_currency(atau ambil daricompany_profile.base_currency) untuk eksplisit, hindari hardcode IDR di nama kolom.purchase_orders.vendor_quotation_ref varchar(100)— printed on the PDF header (e.g.CPLUS-ADHOC-IKN26-001)- Bill To / Ship To snapshots from
company_profile:bill_to_name,bill_to_address_line,bill_to_attnship_to_name,ship_to_address_line,ship_to_attn
- Signer snapshots (also planned in Ship-10 — combined here):
signer_user_id uuid,signer_name,signer_title,signer_signature_url,signed_at
- Child table
purchase_order_payment_milestones:id, purchase_order_id fk, line_no, label, amount, percent_basis, due_condition, due_date
Backend
approvePurchaseOrderhandler → copycompany_profilefields into the snapshot columnspostMilestones— create/update via array on PO PATCH- Validation: sum(milestone.percent) = 100 or sum(milestone.amount) = total_amount
Frontend
- PO form dialog: Currency dropdown, Exchange Rate input (auto-disabled if IDR), Vendor Quotation Ref
- Payment Milestones section: repeatable row, template-fill from
vendor.default_payment_schedule(optional Ship-11.a.2 field) - PO detail dialog: add Bill To / Ship To / Signer snapshot columns (read-only after approval)
Estimate: 2-3 days.
Ship-11.c — PDF Generator + Download button
Engine choice
Options:
- Gotenberg (Docker sidecar) — standard HTML/CSS, headless Chrome, sandbox security, scalable. Recommended.
unidoc/unipdf— native Go, but commercial license.jung-kurt/gofpdf— native Go, free, but layout HTML-less (manual cell positioning).chromedp— in-process Chrome, no sidecar, but memory-heavy.
Recommendation: Gotenberg. Deploy as services/gotenberg in the docker-compose, reachable at http://gotenberg:3000. The PO handler converts the HTML template → multipart POST → byte stream.
Template
File: merchant_core_api/internal/pdf/templates/purchase_order.html
Structure:
- Header: logo + company address (from
company_profile) + "PURCHASE ORDER" title + number - Metadata: PO date, quotation ref, payment terms (the first one in milestones)
- Vendor block (Ship From): legal name, attn, address, email
- Bill To block: from the PO snapshot
- Ship To block: from the PO snapshot
- Items table: description, qty, unit, unit price, total (currency-aware formatting)
- Terms & Bank: incoterm, lead time (from vendor default), payment milestones list, bank details (Name / Account Name / Account No + currency / SWIFT / Bank Address)
- Subtotal + TOTAL AMOUNT
- Footer: signature block (signer image + name + title + company name)
Endpoint
POST /api/dashboard/purchasing/purchase-orders/{id}/pdf/generate
→ regenerate from current data, upload to MinIO `company-documents/purchase-orders/PO-YYYY-NNNN-v{updated_epoch}.pdf`
→ return { pdf_url, object_key, generated_at }
GET /api/dashboard/purchasing/purchase-orders/{id}/pdf
→ if a cached URL exists + updated_at matches → return cached
→ otherwise regenerate
RBAC: purchaseRead for generate (every role that reads a PO may generate — PDF is read-only).
Frontend
- PO detail dialog: Download PDF button (download icon) → hit the endpoint → open the URL in a new tab
- Visible only when PO status >=
approved(no PDF for draft) - After send: button auto-attaches the PDF to the vendor email (continues in Ship-11.d)
Estimate: 2-3 days (including the Gotenberg setup).
Ship-11.d — Send integration (email + WhatsApp + audit)
New table — purchase_order_send_events
id, purchase_order_id fk, channel (email|whatsapp), recipient, status (pending|sent|failed),
error_message, provider_message_id, payload_json, created_at
/send flow
- Validate PO.status in ('approved').
- Regenerate the PDF (call 11.c).
- Fire the email via core-api's existing
emailSender(internal RPC dashboard-api→core-api). - Optionally fire WhatsApp (Meta template with 1 param: PDF download link).
- Insert an event row per channel.
- Flip PO status → 'sent', stamp
sent_at= now() (existing).
Retry
- If the provider returns a non-4xx error, insert an event with status='pending' + enqueue a retry (a chronos cron on dashboard-api or a goroutine worker — stretch goal).
Estimate: 2 days.
Rollout order
- Ship-11.a lands first (schema + form) — vendor data complete before the PDF can be rendered.
- Ship-11.b (PO enrichment) — the PDF needs currency + milestones + Bill/Ship/Signer snapshot.
- Ship-11.c (PDF engine + template + endpoint + Download button).
- Ship-11.d (Send integration) last — highest risk (email/WA provider, retry logic).
Total: 7-8 person-days for the full Ship-11.
Open questions
- Do we also need multi-currency on
purchase_invoices? (Likely yes — bundle into Ship-11.b.) - The CHECKed currency list — is IDR/USD/SGD/MYR/EUR enough, or wider?
- Exchange rate source: manual input vs auto-fetch from an API (BI/Google/Fixer)? Manual for v1, auto later.
- Gotenberg deployment: sidecar in the existing docker-compose or a separate k8s?
- PDF versioning: regenerate on every PO change (timestamp
-v{epoch}in the object key) or overwrite? Recommendation: versioning so the audit trail is preserved.