InfraForge Docs

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 carry tenant_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 tenant404 (vendor not found). Cross-tenant
    access is hidden as 404 (existence hiding), not 403.
    Evidence: vendor_risk_service.go:74-76 (name == ""ErrVendorRiskNotFound),
    handler mapping vendor_risk_handler.go:74-75.
  • Vendor exists but has no AP history (no payments / matches / ESG / spend)
    200 with band: "unknown", score: 100, and all component counts 0.
    This is the "no history" empty state — there is no null path. Evidence:
    vendor_risk_service.go:83-88 and scoreVendor :117-119.
  • Malformed vendor_id400 (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 0 and every
    status_breakdown key present as 0 (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 as 0. by_type is 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_type is a dynamic map — render it by iterating keys; do not assume a fixed
    key set (the discrepancy_type domain includes price, 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 _minor sums 0,
    and top_vendors_by_outstanding is 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 READ read-only transaction for an
    internally consistent snapshot. Evidence: payment_controls_repository.go:36.
  • top_vendors_by_outstanding is 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, field file (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 include primary_phone,
    contact_title, contact_phone, address_state, address_postal_code,
    tax_id, website, payment_terms, business_classification,
    preferred_currency. Validation is identical to POST /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, field file. Same limits and column
    rules as preview.
  • 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
full ImportResult under data (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 as skipped via the per-row vendor_code check.
  • 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.