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), modulegithub.com/StackCatalyst/Infra_Notes. - All paths below are mounted under
/apion 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 id —
middleware.GetUserID(r); missing →401. - tenant id — optional (
middleware.GetTenantID), used only as a Kafka event key. - team id — optional (
middleware.TeamIDKeyin 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.limitmax 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_idpresent → caller must be a member
(else404); absent → personal scope (user_idfilter). -
Query params:
param type default bounds notes team_idint — — optional; member-checked horizon_monthsint 6 1–36 months of not-yet-due AR to cluster; <=0→ default;>36→400top_nint 8 1–36 number of month clusters (this is the audit's clusterLimit);<=0→ default;>36→400 -
Request body: none (GET).
-
Response envelope: A.
data=ARInflowsSnapshot:field type notes team_idint? present in team scope user_idint? present in personal scope as_oftimestamp horizon_monthsint echoes effective value totalsobject total_outstanding,overdue_amount,not_yet_due_amount,open_invoice_countbucketsarray {label, amount, invoice_count}; labels:overdue,due_0_7,due_8_30,due_31_60,due_61_90,due_90_plusclustersarray {period (YYYY-MM), period_start, period_end, amount, invoice_count, share_of_total}peak_clusterobject? {period, amount, share_of_total}; omitted when no clusterscomputed_attimestamp -
Empty state:
buckets/clustersserialize as[](repo returns non-nil
slices);peak_clusteris omitted; numeric totals are0.success:true, HTTP 200. -
Degraded state: none. Live compute over the
invoicestable; either succeeds (200)
or returns500({"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_idmember-checked, else personal). -
Query params:
param type default bounds team_idint — optional; member-checked top_nint 10 1–100 ( >100→400) -
Request body: none.
-
Response envelope: A.
data=ARControlSnapshot:field type notes team_id/user_idint? one set per scope as_oftimestamp totalsobject total_invoiced_open,total_outstanding,open_invoice_count,overdue_invoice_countagingobject current,days_30,days_60,days_90,days_90_plusdisputesobject active_count,historical_count,disputed_amounttop_overdue_invoicesarray {invoice_id, invoice_number, client_id, client_name, balance_due, due_date, days_overdue}top_overdue_clientsarray {client_id, client_name, outstanding, overdue_count, oldest_overdue_days}computed_attimestamp -
Empty state:
top_overdue_invoices/top_overdue_clients=[]; all numeric
fields0. 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. Noteam_idquery 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_idint client_namestring team_idint? present for team clients scoreint 0–100, higher is safer bandstring low(≥80),medium(60–79),high(<60),unknown(no signal)componentsobject see below computed_attimestamp 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
yieldsband:"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.sqlto 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_idint invoice_numberstring client_idint client_namestring denormalised; ""if client load fails (non-fatal)team_idint? statusstring invoice status ( draft/sent/paid/partial/overdue/canceled)total_amountnumber amount_paidnumber balance_duenumber paid_pctnumber 0.0–1.0+ (overpaid > 1.0) statestring settlement state: pending/partial/settled/overpaid(independent of dispute)timelineobject see below allocationsarray {allocation_id, payment_id, amount_applied, applied_at, applied_by?, note?, payment_method, payment_date, transaction_id?}active_disputeobject? omitted when no open/under_review dispute; shape = §6 InvoiceDisputecomputed_attimestamp (UTC) timeline:issued_at,due_at,first_payment_at?,last_payment_at?,
settled_at?(only whenstate==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_disputeomitted, and a timeline
with onlyissued_at/due_at(andis_overdueif past due). HTTP 200. -
Degraded state: none.
stateis derived fromamount_paidvstotal_amountat
cent precision (invoices.amount_paidis trigger-maintained fromSUM(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_status → 400;
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 isPOST /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 optionalteam_idfilter, but do not re-derive
tenant from the payment/invoice the way §4/§6.2 do. Treat results as tenant-filtered
by the suppliedteam_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 beclientif 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_numberis 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-inserting —created: 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
-
Existence-hiding (team/owner-scoped 404-on-deny). §3 (payment-risk), §4
(settlement), and §5 (promises) return404— not403— 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)
returns403for a wrong-tenant invoice. Handle both403and404as "no access"
on list views. -
Import content-hash replay semantics. §8.2 keys replay on
sha256(file bytes) + owner + entity_type, not onIdempotency-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 noids on a replay. Don't expect to map replayed
rows back to created customers. - A different
Idempotency-Keyon 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).
- A byte-identical re-upload returns
-
Risk/settlement are computed live, not from snapshots. §3 ignores the
client_payment_risk_snapshotstable (migration 000120); §4 derives state from the
trigger-maintainedinvoices.amount_paid. Expect on-demand latency on large tenants;
there is nodegraded/staleflag in the payload. -
active_disputeonly reflects open/under_review. §4'sactive_disputeis omitted
once a dispute isresolved/withdrawn; use §6.2 to see dispute history. -
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 |