InfraForge Docs

InfraNotes Core · v2

Welcome

Select a document from the sidebar to read it.

Release 3 — Dev-0 (Frontend) Handoff

Backend API contracts for the Release-3 receivables / treasury read-models and
state-machines, plus the customer import endpoints. This document is the
acceptance-gate reference the gap audit found missing (finding H-3) and is verified
against the live handlers, services, and DTOs — not the PRD.

  • Service: Infra_Notes (Go), module github.com/StackCatalyst/Infra_Notes.
  • All paths below are mounted under /api on the protected router
    (internal/app/app.go:264-562). The gateway forwards /api/... to this service;
    the vendor service owns /api/v1/..., which these routes intentionally avoid.

0. Cross-cutting conventions

0.1 Authentication / authorization

Every endpoint here sits behind composedAuthMiddleware
AuthenticateWithGatewayVerifier (internal/app/app.go:225-243). The caller must
present a valid session/JWT (or gateway-verified identity). The middleware populates:

  • user idmiddleware.GetUserID(r); missing → 401.
  • tenant id — optional (middleware.GetTenantID), used only as a Kafka event key.
  • team id — optional (middleware.TeamIDKey in context).

No per-route entitlement/plan gate is applied to these routes — authentication +
tenant-scoped ownership is the only gate. Authorization is resource-derived: the
handler loads the invoice/client, reads its team_id, and checks membership/ownership.

0.2 Two response envelopes (IMPORTANT — they differ)

The receivables/treasury endpoints and the import endpoints use different
envelopes. Type your client accordingly.

Envelope A — receivables/treasury (utils.RespondSuccess / RespondError,
internal/utils/handler_helpers.go:115-137):

// success (HTTP 200)
{ "success": true, "data": { /* payload */ } }
// created (HTTP 201, allocation/dispute/promise create)
{ "success": true, "data": { /* payload */ } }
// error
{ "success": false, "status": 404, "error": "invoice not found" }

Envelope B — customer import (utils.WriteSuccessResponse / RespondWith*,
internal/utils/http.go:16-214):

// success (HTTP 200)
{ "status": "success", "message": "...", "data": { /* payload */ },
  "timestamp": "2026-06-06T12:00:00Z" }
// error
{ "status": "error", "message": "Validation failed",
  "error": { "code": "VALIDATION_ERROR", "message": "...", "field": "file" },
  "timestamp": "2026-06-06T12:00:00Z" }

0.3 Existence-hiding (team/owner-scoped 404-on-deny)

For invoice-/client-scoped endpoints, a caller outside the resource's tenant
receives the same 404 body as a non-existent resource — the response code does not
leak existence. (Exception: the two list-by-invoice endpoints — disputes and the
allocation list — return 403 on a wrong-tenant invoice; see each section.)

0.4 Common query params

  • team_id (optional, integer) — accepted by the AR control / expected-inflows
    endpoints and by the allocation list endpoints. Malformed → 400. On the
    list-by-invoice paths it is ignored (tenant is derived from the invoice).
  • limit (default 100) / offset (default 0) — on the allocation/dispute/promise
    list endpoints. limit max 500; out of range → 400; negative offset → 400.

1. GET /api/ar/expected-inflows

Forward-looking expected-inflows / due-date clustering snapshot.
Handler ARInflowsHandler.GetInflows (internal/handlers/ar_inflows_handler.go:42),
service internal/services/ar_inflows_service.go:44, DTO
internal/models/ar_inflows.go.

  • Auth/scope: authenticated. team_id present → caller must be a member
    (else 404); absent → personal scope (user_id filter).

  • Query params:

    param type default bounds notes
    team_id int optional; member-checked
    horizon_months int 6 1–36 months of not-yet-due AR to cluster; <=0 → default; >36400
    top_n int 8 1–36 number of month clusters (this is the audit's clusterLimit); <=0 → default; >36400
  • Request body: none (GET).

  • Response envelope: A. data = ARInflowsSnapshot:

    field type notes
    team_id int? present in team scope
    user_id int? present in personal scope
    as_of timestamp
    horizon_months int echoes effective value
    totals object total_outstanding, overdue_amount, not_yet_due_amount, open_invoice_count
    buckets array {label, amount, invoice_count}; labels: overdue, due_0_7, due_8_30, due_31_60, due_61_90, due_90_plus
    clusters array {period (YYYY-MM), period_start, period_end, amount, invoice_count, share_of_total}
    peak_cluster object? {period, amount, share_of_total}; omitted when no clusters
    computed_at timestamp
  • Empty state: buckets / clusters serialize as [] (repo returns non-nil
    slices); peak_cluster is omitted; numeric totals are 0. success:true, HTTP 200.

  • Degraded state: none. Live compute over the invoices table; either succeeds (200)
    or returns 500 ({"success":false,...,"error":"internal error"}).

  • Sample (200, team scope):

{
  "success": true,
  "data": {
    "team_id": 7,
    "as_of": "2026-06-06T10:00:00Z",
    "horizon_months": 6,
    "totals": { "total_outstanding": 18250.00, "overdue_amount": 4000.00,
                "not_yet_due_amount": 14250.00, "open_invoice_count": 9 },
    "buckets": [
      { "label": "overdue",     "amount": 4000.00,  "invoice_count": 2 },
      { "label": "due_0_7",     "amount": 2250.00,  "invoice_count": 1 },
      { "label": "due_8_30",    "amount": 6000.00,  "invoice_count": 3 },
      { "label": "due_31_60",   "amount": 4000.00,  "invoice_count": 2 },
      { "label": "due_61_90",   "amount": 2000.00,  "invoice_count": 1 },
      { "label": "due_90_plus", "amount": 0.00,     "invoice_count": 0 }
    ],
    "clusters": [
      { "period": "2026-06", "period_start": "2026-06-01T00:00:00Z",
        "period_end": "2026-07-01T00:00:00Z", "amount": 8250.00,
        "invoice_count": 4, "share_of_total": 0.4521 },
      { "period": "2026-07", "period_start": "2026-07-01T00:00:00Z",
        "period_end": "2026-08-01T00:00:00Z", "amount": 6000.00,
        "invoice_count": 3, "share_of_total": 0.3288 }
    ],
    "peak_cluster": { "period": "2026-06", "amount": 8250.00, "share_of_total": 0.4521 },
    "computed_at": "2026-06-06T10:00:00Z"
  }
}

2. GET /api/ar/control

Team-level AR control / collections snapshot (overdue aging companion to §1).
Handler ARControlHandler.GetControl (internal/handlers/ar_control_handler.go:39),
service internal/services/ar_control_service.go, DTO internal/models/ar_control.go.

  • Auth/scope: identical to §1 (team_id member-checked, else personal).

  • Query params:

    param type default bounds
    team_id int optional; member-checked
    top_n int 10 1–100 (>100400)
  • Request body: none.

  • Response envelope: A. data = ARControlSnapshot:

    field type notes
    team_id / user_id int? one set per scope
    as_of timestamp
    totals object total_invoiced_open, total_outstanding, open_invoice_count, overdue_invoice_count
    aging object current, days_30, days_60, days_90, days_90_plus
    disputes object active_count, historical_count, disputed_amount
    top_overdue_invoices array {invoice_id, invoice_number, client_id, client_name, balance_due, due_date, days_overdue}
    top_overdue_clients array {client_id, client_name, outstanding, overdue_count, oldest_overdue_days}
    computed_at timestamp
  • Empty state: top_overdue_invoices / top_overdue_clients = []; all numeric
    fields 0. HTTP 200.

  • Degraded state: none (live compute → 200 or 500).

  • Sample (200):

{
  "success": true,
  "data": {
    "team_id": 7,
    "as_of": "2026-06-06T10:00:00Z",
    "totals": { "total_invoiced_open": 22000.00, "total_outstanding": 18250.00,
                "open_invoice_count": 9, "overdue_invoice_count": 2 },
    "aging": { "current": 14250.00, "days_30": 4000.00, "days_60": 0.00,
               "days_90": 0.00, "days_90_plus": 0.00 },
    "disputes": { "active_count": 1, "historical_count": 3, "disputed_amount": 4000.00 },
    "top_overdue_invoices": [
      { "invoice_id": 5012, "invoice_number": "INV-2026-0312", "client_id": 88,
        "client_name": "Atlas Bridgeworks Lda", "balance_due": 2500.00,
        "due_date": "2026-05-27T00:00:00Z", "days_overdue": 10 }
    ],
    "top_overdue_clients": [
      { "client_id": 88, "client_name": "Atlas Bridgeworks Lda",
        "outstanding": 2500.00, "overdue_count": 1, "oldest_overdue_days": 10 }
    ],
    "computed_at": "2026-06-06T10:00:00Z"
  }
}

3. GET /api/clients/{id}/payment-risk

Per-client payment-risk read-model.
Handler ClientPaymentRiskHandler.GetPaymentRisk
(internal/handlers/client_payment_risk_handler.go:41), service
internal/services/client_payment_risk_service.go, DTO
internal/models/client_payment_risk.go.

  • Auth/scope: tenant derived from the client. Team client → caller must be a
    member; personal client → caller must be owner. No team_id query param.
    Wrong tenant or missing client → 404 "client not found" (existence hiding).

  • Path params: id (int) — client id. Non-numeric → 400.

  • Request body: none.

  • Response envelope: A. data = ClientPaymentRiskSnapshot:

    field type notes
    client_id int
    client_name string
    team_id int? present for team clients
    score int 0–100, higher is safer
    band string low (≥80), medium (60–79), high (<60), unknown (no signal)
    components object see below
    computed_at timestamp

    components: total_invoiced, total_paid, total_outstanding,
    open_invoice_count, paid_invoice_count, overdue_invoice_count,
    aging {current, days_30, days_60, days_90, days_90_plus},
    average_days_to_payment, late_payment_rate (0.0–1.0), active_dispute_count,
    historical_dispute_count, disputed_amount.

  • Empty / no-signal state: a client with no paid invoices and no open balance
    yields band:"unknown" with zeroed components — still HTTP 200 (not 404).

  • Degraded state: none (live compute). The endpoint computes on demand;
    a snapshot table exists (client_payment_risk_snapshots, migration 000120) but is
    not read by this route yet.

  • Sample (200):

{
  "success": true,
  "data": {
    "client_id": 88,
    "client_name": "Atlas Bridgeworks Lda",
    "team_id": 7,
    "score": 64,
    "band": "medium",
    "components": {
      "total_invoiced": 30000.00, "total_paid": 24000.00, "total_outstanding": 6000.00,
      "open_invoice_count": 2, "paid_invoice_count": 6, "overdue_invoice_count": 1,
      "aging": { "current": 3500.00, "days_30": 2500.00, "days_60": 0.00,
                 "days_90": 0.00, "days_90_plus": 0.00 },
      "average_days_to_payment": 27.5, "late_payment_rate": 0.33,
      "active_dispute_count": 1, "historical_dispute_count": 3, "disputed_amount": 4000.00
    },
    "computed_at": "2026-06-06T10:00:00Z"
  }
}

4. GET /api/invoices/{id}/settlement

Per-invoice settlement read-model (payment timeline + active dispute).
Handler InvoiceSettlementHandler.GetSettlement
(internal/handlers/invoice_settlement_handler.go:40), service
internal/services/invoice_settlement_service.go:74, DTO
internal/models/invoice_settlement.go.

Use scripts/seed_invoice_settlement.sql to populate a non-empty example.

  • Auth/scope: tenant derived from the invoice. Team invoice → member;
    personal invoice → owner. Wrong tenant or missing invoice → 404 "invoice not found".

  • Path params: id (int) — invoice id. Non-numeric → 400.

  • Request body: none.

  • Response envelope: A. data = InvoiceSettlementSnapshot:

    field type notes
    invoice_id int
    invoice_number string
    client_id int
    client_name string denormalised; "" if client load fails (non-fatal)
    team_id int?
    status string invoice status (draft/sent/paid/partial/overdue/canceled)
    total_amount number
    amount_paid number
    balance_due number
    paid_pct number 0.0–1.0+ (overpaid > 1.0)
    state string settlement state: pending / partial / settled / overpaid (independent of dispute)
    timeline object see below
    allocations array {allocation_id, payment_id, amount_applied, applied_at, applied_by?, note?, payment_method, payment_date, transaction_id?}
    active_dispute object? omitted when no open/under_review dispute; shape = §6 InvoiceDispute
    computed_at timestamp (UTC)

    timeline: issued_at, due_at, first_payment_at?, last_payment_at?,
    settled_at? (only when state==settled), days_to_settle?, days_overdue_at?,
    is_overdue (bool — unsettled and past due now).

  • Empty state: an invoice with no payments/allocations and no dispute returns
    state:"pending", allocations: [], active_dispute omitted, and a timeline
    with only issued_at/due_at (and is_overdue if past due). HTTP 200.

  • Degraded state: none. state is derived from amount_paid vs total_amount at
    cent precision (invoices.amount_paid is trigger-maintained from SUM(payments.amount)).

  • Sample (200, partial + open dispute — matches the seed):

{
  "success": true,
  "data": {
    "invoice_id": 5012,
    "invoice_number": "SEED-SETTLE-1749200000",
    "client_id": 88,
    "client_name": "SEED Atlas Bridgeworks Lda",
    "team_id": 7,
    "status": "partial",
    "total_amount": 4000.00,
    "amount_paid": 1500.00,
    "balance_due": 2500.00,
    "paid_pct": 0.375,
    "state": "partial",
    "timeline": {
      "issued_at": "2026-04-27T10:00:00Z",
      "due_at": "2026-05-27T10:00:00Z",
      "first_payment_at": "2026-06-01T10:00:00Z",
      "last_payment_at": "2026-06-01T10:00:00Z",
      "is_overdue": true,
      "days_overdue_at": 10
    },
    "allocations": [
      { "allocation_id": 9001, "payment_id": 7001, "amount_applied": 1500.00,
        "applied_at": "2026-06-01T10:00:00Z", "applied_by": 42,
        "note": "Seeded allocation of the partial payment",
        "payment_method": "bank_transfer", "payment_date": "2026-06-01T10:00:00Z",
        "transaction_id": "SEED-TXN-0001" }
    ],
    "active_dispute": {
      "id": 4001, "team_id": 7, "invoice_id": 5012, "status": "open",
      "reason": "pricing_discrepancy",
      "note": "Seeded open dispute: client questions the line-item rate",
      "opened_by": 42, "opened_at": "2026-06-03T10:00:00Z",
      "created_at": "2026-06-03T10:00:00Z", "updated_at": "2026-06-03T10:00:00Z"
    },
    "computed_at": "2026-06-06T10:00:00Z"
  }
}

5. Promises to pay

Handler internal/handlers/promise_to_pay_handler.go, service
internal/services/promise_to_pay_service.go, DTO internal/models/promise_to_pay.go,
table migration 000121. Routes internal/app/app.go:3811-3814.

Tenant is derived from the parent invoice on every path; a caller outside the
invoice's tenant gets 404 ("invoice not found" / "promise to pay not found") —
existence hiding throughout (no 403).

PromiseToPay object (returned by create/get/transition):

field type notes
id int
team_id int? snapshotted from invoice
invoice_id int
status string open / kept / broken / cancelled
promised_amount number
currency string snapshotted from the invoice (not client-supplied)
due_date timestamp
note string?
created_by int?
last_transition_by int?
last_transition_at timestamp?
created_at / updated_at timestamp
is_overdue bool derived (open + due date in the past); computed on read

5.1 POST /api/invoices/{id}/promises-to-pay → 201

Record a promise on an invoice. Body:

field type required notes
promised_amount number yes > 0 and ≤ invoice total (else 400)
due_date string yes YYYY-MM-DD or RFC3339; not in the past (else 400)
note string no
  • Envelope A, HTTP 201, data = PromiseToPay.
  • Errors: invalid body / past due / amount > total → 400; invoice not in tenant or
    missing → 404; an open promise already exists for the invoice → 409.
  • Sample request:
curl -X POST 'http://localhost:8080/api/invoices/5012/promises-to-pay' \
  -H 'Content-Type: application/json' \
  -d '{"promised_amount": 2500.00, "due_date": "2026-06-30", "note": "client committed"}'
{ "success": true, "data": {
    "id": 3101, "team_id": 7, "invoice_id": 5012, "status": "open",
    "promised_amount": 2500.00, "currency": "MZN", "due_date": "2026-06-30T00:00:00Z",
    "note": "client committed", "created_by": 42,
    "created_at": "2026-06-06T10:00:00Z", "updated_at": "2026-06-06T10:00:00Z",
    "is_overdue": false } }

5.2 GET /api/invoices/{id}/promises-to-pay → 200

List promises for an invoice. Query: limit (≤500), offset, repeatable status
(open/kept/broken/cancelled; unknown value → 400). team_id is ignored.
Envelope A, data = { "promises": [ PromiseToPay, ... ] }. Empty: {"promises": []}.

5.3 GET /api/promises-to-pay/{id} → 200

Fetch one promise (authorized via its invoice's tenant). Envelope A, data = PromiseToPay
(with is_overdue recomputed). Not found / not in tenant → 404.

5.4 POST /api/promises-to-pay/{id}/transition → 200

Body: { "next_status": "kept" | "broken" | "cancelled" } (required). State machine:
open → kept | broken | cancelled; terminal states reject further transitions.
Envelope A, data = PromiseToPay. Errors: missing/unknown next_status400;
illegal transition (incl. concurrent change) → 409; not found → 404.


6. Invoice disputes

Handler internal/handlers/invoice_dispute_handler.go, service
internal/services/invoice_dispute_service.go, DTO internal/models/invoice_dispute.go,
table migration 000119. Routes internal/app/app.go:3788-3791.

InvoiceDispute object (returned by open/get/transition; also embedded as
active_dispute in §4):

field type notes
id int
team_id int? snapshotted from invoice
invoice_id int
status string open / under_review / resolved / withdrawn
reason string? free text (≤64 chars)
note string?
opened_by int?
opened_at timestamp
last_transition_by int?
last_transition_at timestamp?
resolution string? accepted_client_position / upheld_original_invoice / settled_with_adjustment (terminal only)
resolution_note string?
created_at / updated_at timestamp

6.1 POST /api/invoices/{id}/disputes → 201

Open a dispute. Body (both optional; empty body allowed):
{ "reason": "pricing_discrepancy", "note": "..." }. Envelope A, HTTP 201,
data = InvoiceDispute. Errors: invalid body → 400; invoice missing/not in tenant
404; an active (open/under_review) dispute already exists → 409.

6.2 GET /api/invoices/{id}/disputes → 200

List disputes for an invoice. Query: limit (≤500), offset, repeatable status
(unknown → 400). NB: wrong-tenant invoice → 403 here (not 404); missing invoice
404; caller-supplied team_id is discarded (tenant forced from the invoice).
Envelope A, data = { "disputes": [ InvoiceDispute, ... ] }. Empty: {"disputes": []}.

6.3 GET /api/disputes/{id} → 200

Fetch one dispute. Envelope A, data = InvoiceDispute. Not found → 404.
(Note: this route does not re-derive tenant from the invoice — it returns the row by
id for any authenticated caller; tenant scoping on this specific path is weaker than the
list path. Flag for Dev-0 awareness.)

6.4 POST /api/disputes/{id}/transition → 200

Body:

field type required notes
next_status string yes under_review / resolved / withdrawn
resolution string conditional required when next_status=resolved; allowed for withdrawn; rejected otherwise
resolution_note string no only meaningful on terminal transitions

State machine: open → under_review | resolved | withdrawn;
under_review → resolved | withdrawn; resolved/withdrawn terminal.
Envelope A, data = InvoiceDispute. Errors: missing/unknown next_status, missing
required resolution, or resolution on a non-terminal target → 400; illegal
transition → 409; not found → 404.

Doc-comment drift: the handler's Go doc-comment says PATCH /api/disputes/{id},
but the registered route is POST /api/disputes/{id}/transition
(app.go:3791). Use POST + /transition.


7. Payment allocations

Handler internal/handlers/payment_allocation_handler.go, service
internal/services/payment_allocation_service.go, DTO
internal/models/payment_allocation.go, table migration 000118.
Routes internal/app/app.go:3763-3765.

A single payment can be split across N invoices. POST replaces the full allocation
set
for the payment.

PaymentAllocation object: { id, team_id?, payment_id, invoice_id, amount_applied, note?, applied_by?, applied_at, created_at }.

7.1 POST /api/payments/{id}/allocations → 201

Body:

{ "allocations": [
    { "invoice_id": 5012, "amount_applied": 1500.00, "note": "partial" },
    { "invoice_id": 5013, "amount_applied": 500.00 }
] }
field type required notes
allocations array yes non-empty (else 400)
allocations[].invoice_id int yes must share the payment's source-invoice team
allocations[].amount_applied number yes > 0
allocations[].note string no

Rules (service): the sum of amount_applied must equal the payment's amount
(cent precision); duplicate invoice_id rejected; cross-team target rejected. On
success each affected invoice's amount_paid/balance_due/status is recomputed.
Response (note the bespoke shape — Envelope A but a custom data):

{ "success": true, "data": {
    "payment_id": 7001,
    "allocations": [ { "id": 9001, "team_id": 7, "payment_id": 7001,
      "invoice_id": 5012, "amount_applied": 1500.00, "applied_by": 42,
      "applied_at": "2026-06-06T10:00:00Z", "created_at": "2026-06-06T10:00:00Z" } ] } }
  • Errors: empty list / sum mismatch / non-positive / duplicate invoice → 400;
    cross-team target → 403 "allocation crosses team boundary"; payment/invoice
    missing → 404.

7.2 GET /api/payments/{id}/allocations → 200

List allocations on a payment. Query: team_id, limit (≤500), offset.
Envelope A, data = { "allocations": [ PaymentAllocation, ... ] }.
Empty: {"allocations": []}.

7.3 GET /api/invoices/{id}/allocations → 200

List allocations applied to an invoice. Same query params and envelope as §7.2.

Authorization note: the allocation list endpoints (§7.2/§7.3) check
authentication and apply the optional team_id filter, but do not re-derive
tenant from the payment/invoice the way §4/§6.2 do. Treat results as tenant-filtered
by the supplied team_id. Flag for Dev-0 awareness.


8. Customer import

Handler internal/handlers/customer_import_handler.go, service
internal/services/customer_import_service.go. Routes
internal/app/app.go:2339-2340 (registered only when the handler is wired).
Envelope B (see §0.2). Multipart upload, form field name file.

Shared upload rules (extractUpload): authenticated (401 otherwise); tenant/team
optional (taken from context if present); file required (400 field "file" is required); max 5 MiB (413 FILE_TOO_LARGE); max 1000 rows.

CSV columns (case-insensitive, spaces→underscores, BOM-tolerant):

  • Required: name, email.
  • Optional: default_currency (ISO-4217; default from server constant),
    category (individual/organization; inferred from tax/registration if blank),
    type (must be client if present), tax_number, company_registration_number,
    contact_name, phone, address, city, state, postal_code, country,
    notes, website, payment_terms_id (positive int), categories (;-delimited),
    tags (;-delimited).
  • Mozambique (country ∈ mz/moz/mozambique/…) tax_number is NUIT-validated/normalised.

Per-row status (valid / invalid / duplicate): a row is duplicate if its
email repeats earlier in the file or a client with the same email/tax-number already
exists for the caller's user/team.

8.1 POST /api/clients/import/preview → 200

Validates only; persists nothing. data = CustomerImportPreview:

field type
total_rows int
valid_count int
invalid_count int
duplicate_count int
rows array of {line, email, tax_number?, status, id?, errors?}
  • Sample request:
curl -X POST 'http://localhost:8080/api/clients/import/preview' \
  -F 'file=@customers.csv'
{ "status": "success", "message": "Customer import preview generated",
  "data": {
    "total_rows": 3, "valid_count": 2, "invalid_count": 1, "duplicate_count": 0,
    "rows": [
      { "line": 1, "email": "a@example.com", "status": "valid" },
      { "line": 2, "email": "b@example.com", "tax_number": "400000000", "status": "valid" },
      { "line": 3, "email": "", "status": "invalid", "errors": ["email is required"] }
    ] },
  "timestamp": "2026-06-06T10:00:00Z" }
  • Empty file → 400 (field:"file", "csv is empty"); missing required column → 400.

8.2 POST /api/clients/import/commit → 200

Validates and atomically creates every valid, non-duplicate row. If any row is
invalid, nothing is persisted
and the API returns 400 with the row report under
data. Headers:

  • Idempotency-Key (optional) — recorded on the import-ledger row for correlation;
    it does not itself gate the replay.
  • Content-hash replay: a re-uploaded byte-identical file by the same owner returns
    the recorded outcome without re-insertingcreated: 0, skipped: total_rows, invalid: 0, rows: [] (see Known limitations).

data = CustomerImportResult: { total_rows, created, skipped, invalid, rows[] }
(invariant: created + skipped + invalid == total_rows).

  • Sample request:
curl -X POST 'http://localhost:8080/api/clients/import/commit' \
  -H 'Idempotency-Key: 8f1c0b6e-2026-06-06-ar-import' \
  -F 'file=@customers.csv'
{ "status": "success", "message": "Customer import committed",
  "data": {
    "total_rows": 3, "created": 2, "skipped": 0, "invalid": 0,
    "rows": [
      { "line": 1, "email": "a@example.com", "status": "valid", "id": 9101 },
      { "line": 2, "email": "b@example.com", "tax_number": "400000000", "status": "valid", "id": 9102 }
    ] },
  "timestamp": "2026-06-06T10:00:00Z" }
  • Replay (same bytes, same owner) response data:
    { "total_rows": 3, "created": 0, "skipped": 3, "invalid": 0, "rows": [] }.
  • Invalid-rows response: HTTP 400, { "status": "error", "message": "Customer import has invalid rows", "data": { ...CustomerImportResult } }.

9. Known limitations

  1. Existence-hiding (team/owner-scoped 404-on-deny). §3 (payment-risk), §4
    (settlement), and §5 (promises) return 404 — not 403 — when the caller is
    outside the resource's tenant, so a frontend cannot distinguish "does not exist"
    from "not yours". The two list-by-invoice endpoints diverge: §6.2 (disputes)
    returns 403 for a wrong-tenant invoice. Handle both 403 and 404 as "no access"
    on list views.

  2. Import content-hash replay semantics. §8.2 keys replay on
    sha256(file bytes) + owner + entity_type, not on Idempotency-Key. Two
    consequences for the UI:

    • A byte-identical re-upload returns created:0, skipped:total_rows, rows:[] — there
      is no per-row detail and no ids on a replay. Don't expect to map replayed
      rows back to created customers.
    • A different Idempotency-Key on identical bytes still replays (the key is audit
      metadata only). Conversely, the same key on different bytes does not dedupe.
    • The ledger write is best-effort: if it fails, the import still succeeds but a later
      identical upload may create duplicates at the row level (natural-key dedup on
      email/tax still applies, so true duplicates are skipped, not double-inserted).
  3. Risk/settlement are computed live, not from snapshots. §3 ignores the
    client_payment_risk_snapshots table (migration 000120); §4 derives state from the
    trigger-maintained invoices.amount_paid. Expect on-demand latency on large tenants;
    there is no degraded/stale flag in the payload.

  4. active_dispute only reflects open/under_review. §4's active_dispute is omitted
    once a dispute is resolved/withdrawn; use §6.2 to see dispute history.

  5. Weaker tenant scoping on two by-id/list paths. §6.3 (GET /disputes/{id}) and
    the allocation list endpoints (§7.2/§7.3) do not re-derive tenant from the parent
    resource the way the other reads do. Do not rely on them for tenant isolation in the
    UI; prefer the invoice-scoped list endpoints.


Appendix — endpoint ↔ source map

Endpoint Handler (file:line) Route reg. (app.go)
GET /api/ar/expected-inflows ar_inflows_handler.go:42 :3858
GET /api/ar/control ar_control_handler.go:39 :3844
GET /api/clients/{id}/payment-risk client_payment_risk_handler.go:41 :3829
GET /api/invoices/{id}/settlement invoice_settlement_handler.go:40 :3873
POST /api/invoices/{id}/promises-to-pay promise_to_pay_handler.go:62 :3811
GET /api/invoices/{id}/promises-to-pay promise_to_pay_handler.go:211 :3812
GET /api/promises-to-pay/{id} promise_to_pay_handler.go:174 :3813
POST /api/promises-to-pay/{id}/transition promise_to_pay_handler.go:117 :3814
POST /api/invoices/{id}/disputes invoice_dispute_handler.go:56 :3788
GET /api/invoices/{id}/disputes invoice_dispute_handler.go:182 :3789
GET /api/disputes/{id} invoice_dispute_handler.go:150 :3790
POST /api/disputes/{id}/transition invoice_dispute_handler.go:101 :3791
POST /api/payments/{id}/allocations payment_allocation_handler.go:51 :3763
GET /api/payments/{id}/allocations payment_allocation_handler.go:104 :3764
GET /api/invoices/{id}/allocations payment_allocation_handler.go:133 :3765
POST /api/clients/import/preview customer_import_handler.go:74 :2339
POST /api/clients/import/commit customer_import_handler.go:95 :2340