Infra Vendor Service · v0
Welcome
Select a document from the sidebar to read it.
Release 3 — Dev-0 (Frontend) Handoff
Acceptance-gate contract reference for the source-to-pay read-model endpoints
and the vendor CSV import endpoints shipped in Release 3 of infra-vendor.
This document is the source of truth for the frontend data layer: every field
name and type below is taken from the handler, service, and repository code (see
the Evidence lines), not inferred. If a field is not listed here, it is not in
the response.
Conventions (apply to every endpoint)
- Base path:
/api/v1(gateway-prefixed in staging). - Auth:
Authorization: Bearer <jwt>. The JWT must carrytenant_id; all
reads are scoped to that tenant at the repository layer. - Envelope (success):
{ "success": true, "data": <object> }. - Envelope (error):
{ "success": false, "error": { "code", "message", ... } }.
Evidence:pkg/errors/errors.go(NewErrorResponse), used by every handler below. - Monetary fields are integer minor units (cents), suffixed
_minor, and
are summed without FX conversion (single-currency assumption). Counts are
the primary, currency-agnostic control signal. - Timestamps (
computed_at) are RFC 3339 UTC. - Read models are deterministic: same data ⇒ same response. No persistence,
no side effects.
Standard error responses (read models)
| Status | When | Body |
|---|---|---|
| 401 | missing / invalid / expired token | error envelope |
| 403 | authenticated but missing reports:read |
error envelope |
| 500 | aggregation failure (safe message, no stack trace) | error envelope |
vendor-risks/:vendor_id additionally returns 400 (malformed UUID) and
404 (vendor not in tenant) — see its section.
1. GET /api/v1/vendor-risks/{vendor_id}
Per-vendor risk read model. Deterministic 0–100 score (higher is safer) with a
coarse band and an explainable four-component breakdown.
- Entitlement:
reports:read(auth.PermissionReportsRead). - Path param:
vendor_id— vendor UUID. Validated at the edge; a malformed
UUID is rejected before any DB call. - Query params: none.
Evidence: route internal/api/routes/routes.go:319-322; handler
internal/api/handlers/vendor_risk_handler.go:48-67; service
internal/application/services/vendor_risk_service.go:64-99; types
internal/domain/vendorrisk/types.go:90-108; repo
internal/infrastructure/database/vendor_risk_repository.go.
Response envelope — data
| Field | Type | Notes |
|---|---|---|
vendor_id |
string (UUID) | echoes the path param |
vendor_name |
string | resolved name within tenant scope |
tenant_id |
string | omitted when empty (omitempty) |
score |
integer | 0–100, higher is safer; 100 when band is unknown |
band |
string | low (≥80) | medium (≥60) | high (<60) | unknown |
components |
object | see below; always present |
computed_at |
string (RFC3339 UTC) |
components.payment (PaymentRiskComponent):
| Field | Type |
|---|---|
total_payment_count |
integer |
late_payment_count |
integer |
failed_payment_count |
integer |
late_payment_rate |
number (0.0–1.0) |
score |
integer (0–100 standalone sub-score) |
components.matching (MatchingRiskComponent):
| Field | Type |
|---|---|
total_match_count |
integer |
exception_match_count |
integer |
high_severity_count |
integer |
critical_severity_count |
integer |
exception_rate |
number (0.0–1.0) |
score |
integer |
components.esg (ESGRiskComponent):
| Field | Type |
|---|---|
open_violation_count |
integer |
critical_violation_count |
integer |
open_remediation_count |
integer |
score |
integer |
components.spend_anomaly (SpendAnomalyComponent) — one-way signal (only ever
raises risk):
| Field | Type | Notes |
|---|---|---|
months_of_history |
integer | |
recent_spend_minor |
integer (int64) | most recent month, minor units |
trailing_avg_minor |
integer (int64) | mean of prior months, minor units |
currency |
string | omitted when empty (omitempty) |
spike_ratio |
number | recent / trailing avg (0 when n/a) |
status |
string | ok | elevated | insufficient_data |
Empty / degraded state
This endpoint is the one exception to the "empty ⇒ zeros, not 404" rule:
- Vendor not found in tenant ⇒ 404 (
vendor not found). Cross-tenant
access is hidden as 404 (existence hiding), not 403.
Evidence:vendor_risk_service.go:74-76(name == ""⇒ErrVendorRiskNotFound),
handler mappingvendor_risk_handler.go:74-75. - Vendor exists but has no AP history (no payments / matches / ESG / spend)
⇒ 200 withband: "unknown",score: 100, and all component counts0.
This is the "no history" empty state — there is no null path. Evidence:
vendor_risk_service.go:83-88andscoreVendor:117-119. - Malformed
vendor_id⇒ 400 (vendor_id must be a valid UUID). Evidence:
vendor_risk_handler.go:50-55. - No degraded/partial mode: a DB failure surfaces as 500.
Sample response (200 — vendor with history, high band)
{
"success": true,
"data": {
"vendor_id": "22222222-2222-2222-2222-222222222222",
"vendor_name": "Risky Vendor Ltd",
"score": 0,
"band": "high",
"components": {
"payment": {
"total_payment_count": 2,
"late_payment_count": 1,
"failed_payment_count": 1,
"late_payment_rate": 0.5,
"score": 60
},
"matching": {
"total_match_count": 1,
"exception_match_count": 1,
"high_severity_count": 0,
"critical_severity_count": 1,
"exception_rate": 1,
"score": 65
},
"esg": {
"open_violation_count": 1,
"critical_violation_count": 1,
"open_remediation_count": 0,
"score": 88
},
"spend_anomaly": {
"months_of_history": 7,
"recent_spend_minor": 50000,
"trailing_avg_minor": 10000,
"currency": "USD",
"spike_ratio": 5,
"status": "elevated"
}
},
"computed_at": "2026-06-06T12:00:00Z"
}
}
2. GET /api/v1/procurement-readiness
Tenant-wide PO / requisition readiness snapshot.
- Entitlement:
reports:read. - Path / query params: none.
Evidence: route internal/api/routes/routes.go:333; handler
internal/api/handlers/po_readiness_handler.go:43-55; service
internal/application/services/po_readiness_service.go; types
internal/domain/poreadiness/facts.go:18-77; repo
internal/infrastructure/database/po_readiness_repository.go.
Response envelope — data
| Field | Type | Notes |
|---|---|---|
requisitions |
object (RequisitionFacts) |
see below |
purchase_orders |
object (PurchaseOrderFacts) |
see below |
computed_at |
string (RFC3339 UTC) |
requisitions:
| Field | Type | Notes |
|---|---|---|
total |
integer | |
status_breakdown |
object | counts per status (always present, 0 when none): draft, submitted, pending_approval, approved, rejected, converted, cancelled |
pending_approval_count |
integer | |
approved_unconverted_count |
integer | approved but converted_to_po = false (backlog ready to become POs) |
converted_count |
integer | converted_to_po = true |
conversion_rate |
number (0.0–1.0) | converted_count / total (0 when no requisitions) |
purchase_orders:
| Field | Type | Notes |
|---|---|---|
total |
integer | |
status_breakdown |
object | counts per status (always present, 0 when none): draft, pending_approval, approved, rejected, sent, acknowledged, partially_received, received, closed, cancelled |
pending_approval_count |
integer | |
open_count |
integer | in-flight: approved/sent/acknowledged/partially_received |
overdue_count |
integer | sent/acknowledged/partially_received only, past promised_delivery_date, no actual_delivery_date (approved is intentionally excluded) |
Empty / degraded state
- A tenant with no procurement activity ⇒ 200 with all counts
0and every
status_breakdownkey present as0(the structs are value types, never
null). Never 404. Evidence:po_readiness_repository.go:27("yields all
zeros, never an error"). - No degraded mode; aggregation failure ⇒ 500.
Sample response (200)
{
"success": true,
"data": {
"requisitions": {
"total": 7,
"status_breakdown": {
"draft": 1, "submitted": 1, "pending_approval": 1, "approved": 1,
"rejected": 1, "converted": 1, "cancelled": 1
},
"pending_approval_count": 1,
"approved_unconverted_count": 1,
"converted_count": 1,
"conversion_rate": 0.143
},
"purchase_orders": {
"total": 10,
"status_breakdown": {
"draft": 1, "pending_approval": 1, "approved": 1, "rejected": 1,
"sent": 1, "acknowledged": 1, "partially_received": 1, "received": 1,
"closed": 1, "cancelled": 1
},
"pending_approval_count": 1,
"open_count": 4,
"overdue_count": 1
},
"computed_at": "2026-06-06T12:00:00Z"
}
}
3. GET /api/v1/matching-facts
Tenant-wide three-way-match facts snapshot.
- Entitlement:
reports:read. - Path / query params: none.
Evidence: route internal/api/routes/routes.go:328; handler
internal/api/handlers/matching_facts_handler.go:43-55; service
internal/application/services/matching_facts_service.go; types
internal/domain/matchingfacts/facts.go:17-56; repo
internal/infrastructure/database/matching_facts_repository.go.
Response envelope — data
| Field | Type | Notes |
|---|---|---|
totals |
object (Totals) |
see below |
status_breakdown |
object (StatusCounts) |
counts per status (always present, 0 when none): pending, matched, mismatched, approved, rejected, payment_ready |
discrepancies |
object (DiscrepancyFacts) |
see below |
computed_at |
string (RFC3339 UTC) |
totals:
| Field | Type | Notes |
|---|---|---|
total_matches |
integer | |
exception_count |
integer | matches with status mismatched or rejected |
exception_rate |
number (0.0–1.0) | exception_count / total_matches (0 when none) |
pending_count |
integer | |
payment_ready_count |
integer | |
approval_required_count |
integer | approval_required AND not yet approved |
discrepancies — the open (unresolved) backlog only:
| Field | Type | Notes |
|---|---|---|
open_count |
integer | resolution_required AND resolved_at IS NULL |
critical_count |
integer | |
high_count |
integer | |
by_type |
object (map string→integer) | open count per discrepancy_type; only types with ≥1 open discrepancy appear |
Empty / degraded state
- A tenant with no matching activity ⇒ 200, all counts
0.status_breakdown
keys are all present as0.by_typeis an empty object{}(non-nil
map), not null. Never 404. Evidence:matching_facts_repository.go:30
(OpenByType: map[string]int{}) and:25-27("all zeros, never an error"). by_typeis a dynamic map — render it by iterating keys; do not assume a fixed
key set (thediscrepancy_typedomain includesprice,quantity,total,
tax, and others).- No degraded mode; failure ⇒ 500.
Sample response (200)
{
"success": true,
"data": {
"totals": {
"total_matches": 6,
"exception_count": 2,
"exception_rate": 0.333,
"pending_count": 2,
"payment_ready_count": 1,
"approval_required_count": 1
},
"status_breakdown": {
"pending": 2, "matched": 1, "mismatched": 1,
"approved": 0, "rejected": 1, "payment_ready": 1
},
"discrepancies": {
"open_count": 3,
"critical_count": 1,
"high_count": 1,
"by_type": { "price": 2, "quantity": 1 }
},
"computed_at": "2026-06-06T12:00:00Z"
}
}
4. GET /api/v1/payment-controls
Tenant-wide AP / payment controls snapshot (AP-side parallel of AR control).
- Entitlement:
reports:read. - Path / query params: none.
Evidence: route internal/api/routes/routes.go:338; handler
internal/api/handlers/payment_controls_handler.go:43-54; service
internal/application/services/payment_controls_service.go:40-61; types
internal/domain/paymentcontrols/facts.go:25-73; repo
internal/infrastructure/database/payment_controls_repository.go.
Response envelope — data
| Field | Type | Notes |
|---|---|---|
payments |
object (PaymentFacts) |
see below |
payables |
object (PayableFacts) |
see below |
top_vendors_by_outstanding |
array of VendorExposure |
ranked desc by outstanding amount, top 10; empty array (non-nil) when none |
computed_at |
string (RFC3339 UTC) |
payments:
| Field | Type | Notes |
|---|---|---|
status_breakdown |
object | payment-request counts per status (always present, 0 when none): pending, scheduled, approved, processing, completed, failed, cancelled |
in_flight_count |
integer | not-yet-terminal: pending/scheduled/approved/processing |
in_flight_amount_minor |
integer (int64) | sum of amount_minor over in-flight requests |
failed_count |
integer | |
failed_amount_minor |
integer (int64) |
payables:
| Field | Type | Notes |
|---|---|---|
outstanding_count |
integer | invoices not paid/rejected |
outstanding_amount_minor |
integer (int64) | sum of total_amount over outstanding invoices |
overdue_count |
integer | outstanding AND past due_date |
overdue_amount_minor |
integer (int64) |
top_vendors_by_outstanding[] (each VendorExposure):
| Field | Type | Notes |
|---|---|---|
vendor_id |
string (UUID) | |
vendor_name |
string | |
outstanding_count |
integer | |
outstanding_amount_minor |
integer (int64) | |
overdue_amount_minor |
integer (int64) | overdue subset of this vendor's outstanding |
Empty / degraded state
- A tenant with no AP activity ⇒ 200, all counts
0, all_minorsums0,
andtop_vendors_by_outstandingis an empty array[](non-nil), not null.
Never 404. Evidence:payment_controls_repository.go:31
(TopVendors: []paymentcontrols.VendorExposure{}) and service
payment_controls_service.go:50-53(re-defaults nil to[]). - The three aggregates run in one
REPEATABLE READread-only transaction for an
internally consistent snapshot. Evidence:payment_controls_repository.go:36. top_vendors_by_outstandingis capped at 10 (paymentcontrols.TopVendorLimit,
facts.go:20). Aging buckets are not here — they remain at
GET /api/v1/payables/aging.- No degraded mode; failure ⇒ 500.
Sample response (200)
{
"success": true,
"data": {
"payments": {
"status_breakdown": {
"pending": 1, "scheduled": 0, "approved": 1, "processing": 1,
"completed": 1, "failed": 1, "cancelled": 0
},
"in_flight_count": 3,
"in_flight_amount_minor": 50000,
"failed_count": 1,
"failed_amount_minor": 8000
},
"payables": {
"outstanding_count": 4,
"outstanding_amount_minor": 65000,
"overdue_count": 2,
"overdue_amount_minor": 50000
},
"top_vendors_by_outstanding": [
{
"vendor_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"vendor_name": "Payment Controls QA Vendor",
"outstanding_count": 3,
"outstanding_amount_minor": 60000,
"overdue_amount_minor": 50000
},
{
"vendor_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"vendor_name": "Payment Controls QA Vendor 2",
"outstanding_count": 1,
"outstanding_amount_minor": 5000,
"overdue_amount_minor": 0
}
],
"computed_at": "2026-06-06T12:00:00Z"
}
}
5. POST /api/v1/vendors/import/preview
Upload a vendor CSV to validate it without persisting anything.
- Entitlement:
vendor:create(auth.PermissionVendorCreate). - Request:
multipart/form-data, fieldfile(the CSV). Max 5 MiB;
max 1000 data rows. - Required CSV columns (case- and space-insensitive; order derived from the
header row):vendor_code,name,legal_name,vendor_type,
primary_email,contact_first_name,contact_last_name,contact_email,
contact_type,address_street,address_city,address_country,
address_type. Optional recognised columns includeprimary_phone,
contact_title,contact_phone,address_state,address_postal_code,
tax_id,website,payment_terms,business_classification,
preferred_currency. Validation is identical toPOST /api/v1/vendors.
Evidence: route internal/api/routes/routes.go:204; handler
internal/api/handlers/vendor_import_handler.go:95-109; service
internal/application/services/vendor_import_service.go:118-128 and DTOs
:44-58; required headers :28-32.
Response envelope — data (ImportPreview)
| Field | Type | Notes |
|---|---|---|
total_rows |
integer | data rows (excludes header) |
valid_count |
integer | rows that map to a creatable vendor |
invalid_count |
integer | rows that failed validation |
duplicate_count |
integer | vendor_code already exists or repeats in the file |
rows |
array of ImportRowResult |
per-row outcome (see below) |
rows[] (ImportRowResult):
| Field | Type | Notes |
|---|---|---|
line |
integer | 1-based data row (excludes header) |
vendor_code |
string | |
status |
string | valid | invalid | duplicate |
errors |
array of string | omitted when empty (omitempty) |
Error / empty state
| Status | When |
|---|---|
| 200 | parsed OK — even when some rows are invalid/duplicate (inspect rows) |
| 400 | missing file field; empty CSV; missing required column; unreadable CSV; >1000 rows |
| 401 | missing user context / token |
| 403 | missing vendor:create, or missing tenant context |
| 413 | file exceeds 5 MiB |
| 500 | unexpected error (e.g. duplicate-check DB failure) |
Note: a 403 is returned for a missing tenant context (tenant context required),
distinct from the 401 for a missing user context. Evidence:
vendor_import_handler.go:52-78.
Sample response (200)
{
"success": true,
"data": {
"total_rows": 3,
"valid_count": 2,
"invalid_count": 1,
"duplicate_count": 0,
"rows": [
{ "line": 1, "vendor_code": "ACME01", "status": "valid" },
{ "line": 2, "vendor_code": "GLOBEX02", "status": "valid" },
{
"line": 3,
"vendor_code": "BADROW03",
"status": "invalid",
"errors": ["primary_email is not a valid email address"]
}
]
}
}
6. POST /api/v1/vendors/import/commit
Upload a vendor CSV to create vendors. Atomic (all-or-nothing) and idempotent
on vendor_code.
- Entitlement:
vendor:create. - Request:
multipart/form-data, fieldfile. Same limits and column
rules aspreview. Idempotency-Key(optional header): recorded with the import in the
import-batch ledger.- Content-hash replay: the handler computes a SHA-256 of the exact uploaded
bytes. If a completed batch already exists for(tenant, "vendor", content_hash), the commit short-circuits and replays the recorded outcome
(created = 0,skipped = total_rows) without re-inserting — a byte-identical
re-upload is a no-op.
Evidence: route internal/api/routes/routes.go:205; handler
internal/api/handlers/vendor_import_handler.go:125-159 (hashing :136-145,
header const :27); service vendor_import_service.go:145-185, replay
:194-214, ledger record :221-245.
Response envelope — data (ImportResult)
| Field | Type | Notes |
|---|---|---|
total_rows |
integer | data rows processed |
created |
integer | vendors inserted this call |
skipped |
integer | duplicates: existing vendor_code or repeated in the file |
invalid |
integer | invalid rows (non-zero ⇒ the whole commit is rejected, 400) |
rows |
array of ImportRowResult |
same shape as preview (on a replay this is []) |
Status codes
| Status | When | Body |
|---|---|---|
| 200 | committed (or replayed) successfully | { "success": true, "data": <ImportResult> } |
| 400 | any invalid row — nothing is persisted | { "success": false, "data": <ImportResult>, "error": "import has invalid rows" } |
| 400 | malformed file (missing file, empty/unreadable CSV, missing column, >1000 rows) |
error envelope |
| 401 | missing user context / token | error envelope |
| 403 | missing vendor:create or missing tenant context |
error envelope |
| 413 | file exceeds 5 MiB | error envelope |
| 500 | persistence failure | error envelope |
Note the two distinct 400 shapes: an invalid-rows rejection carries the
fullImportResultunderdata(so the UI can show which rows to fix), whereas
a malformed-file error is the plain error envelope. Evidence:
vendor_import_handler.go:150-156.
Idempotency / replay behaviour (UI-relevant)
- Re-submitting the same file (byte-identical) returns
created: 0,
skipped: total_rows,rows: []— present this as "already imported", not as
a fresh create. Evidence:vendor_import_service.go:207-213. - Re-submitting a file whose codes already exist (but not a byte-identical file)
returns those rows asskippedvia the per-rowvendor_codecheck. - The ledger is best-effort: if recording fails, the vendors are still
durably created and the response is unaffected. Evidence:
vendor_import_service.go:241-244.
Sample response (200 — fresh commit)
{
"success": true,
"data": {
"total_rows": 3,
"created": 2,
"skipped": 1,
"invalid": 0,
"rows": [
{ "line": 1, "vendor_code": "ACME01", "status": "valid" },
{ "line": 2, "vendor_code": "GLOBEX02", "status": "valid" },
{
"line": 3,
"vendor_code": "ACME01",
"status": "duplicate",
"errors": ["vendor_code repeats line 1 earlier in the file"]
}
]
}
}
Sample response (400 — invalid rows present, nothing persisted)
{
"success": false,
"data": {
"total_rows": 2,
"created": 0,
"skipped": 0,
"invalid": 1,
"rows": [
{ "line": 1, "vendor_code": "ACME01", "status": "valid" },
{
"line": 2,
"vendor_code": "BADROW02",
"status": "invalid",
"errors": ["vendor_type must be one of: supplier, contractor, consultant, service_provider"]
}
]
},
"error": "import has invalid rows"
}
Local QA fixtures (frontend integration)
Deterministic seeds live in postman/ and target QA tenant
11111111-1111-1111-1111-111111111111 (mint a token for that tenant with
postman/mint-token.sh; reports:read is granted by the tenant_admin role):
| Endpoint | Seed | Testing guide |
|---|---|---|
vendor-risks/:vendor_id |
postman/seed_local.sql |
postman/VENDOR_RISK_TESTING_GUIDE.md |
matching-facts |
postman/matching_facts_seed.sql |
postman/MATCHING_FACTS_TESTING_GUIDE.md |
procurement-readiness |
postman/po_readiness_seed.sql |
postman/PO_READINESS_TESTING_GUIDE.md |
payment-controls |
postman/payment_controls_seed.sql |
(this handoff) |
The read-model seeds are mutually destructive within tenant 1111… (each
resets the tables it asserts against). Apply one at a time and re-seed when
switching the endpoint under test.