Merconiq Documentation

Public API contract

Merconiq exposes its public resource HTTP API through versioned MVC controllers at /api/v1. Resource routes, authentication, validation, response envelopes, rate limits, and OpenAPI metadata are defined on the controllers. Auth, webhook, and AI operations remain minimal endpoints because they are not duplicate resource routes.

This document describes the HTTP contract implemented by Merconiq’s current /api/v1 endpoints. It is intentionally limited to behavior present in the application source; it does not describe a separate gateway, tenant header, or external identity service.

Versioning and transport

Resource endpoints are versioned MVC controllers under /api/v1. The API versioning configuration also accepts x-api-version; the URL form is the canonical form used by the routes and examples below. Responses use JSON.

The request host is part of the security boundary. The application resolves a tenant before authentication by looking up Request.Host.Host in Tenancy:HostTenants. Host matching is case-insensitive and ignores a trailing dot. The mapped tenant identifier must be 64 characters or fewer and contain only letters, digits, -, _, or .. A host that is not in the configured allow-list is rejected with 400 before an endpoint runs.

There is no supported Tenant-Id, X-Tenant-Id, or similar request header. Forwarded host information is not treated as a tenant selector by the resolver; the trusted edge must route the request to a configured host. The default configuration maps localhost, 127.0.0.1, and ::1 to a tenant; deployments must replace or extend those bindings for their own hosts.

Authentication

Obtain a bearer token

POST /api/v1/auth/token is the only anonymous API endpoint. Send the user name or email and password in the host-bound tenant:

POST /api/v1/auth/token
Host: tenant.example
Content-Type: application/json

{"username":"user@example.com","password":"Password1"}

A successful response is an unwrapped object containing a JWT and its expiry:

{"token":"<jwt>","expires":"<utc timestamp>"}

The token is valid for two hours. Its issuer, audience, and signing key come from JwtSettings; the secret must be at least 32 bytes. The token contains the user name, user identifier, tenant_id, and distinct role claims. A user with no stored roles receives the Staff role fallback.

Use the token on all other API calls:

Authorization: Bearer <jwt>

API authorization explicitly uses the JWT bearer scheme. The tenant_id claim must equal the tenant resolved from the request host. A missing, invalid, expired, or wrong-tenant token receives 401 Unauthorized. An authenticated token that lacks an endpoint’s required role receives 403 Forbidden.

The application also has cookie authentication for the MVC UI. API callers should use bearer tokens; cookie login paths and redirects are not part of the API contract.

Roles and routes

All routes below except token issuance require the Api policy (an authenticated JWT). Routes may add role or capability checks as shown in the Authorization column; reads without an additional restriction accept any valid API role. Admin, Manager, and Staff are the roles accepted by stock mutations. Webhook administration is restricted to Admin and Manager.

Method Route Authorization Success
POST /api/v1/auth/token Anonymous; host still selects the tenant 200
GET /api/v1/items?page=1&pageSize=25 Any API JWT 200
GET /api/v1/items/{id} Any API JWT 200 or 404
GET /api/v1/items/search?q=... Any API JWT 200
POST /api/v1/items Any API JWT 201
PUT /api/v1/items/{id} Any API JWT 204
DELETE /api/v1/items/{id} Any API JWT 204
GET /api/v1/companies/{companyId}/customers?page=1&pageSize=50 Company View 200 or 400
GET /api/v1/companies/{companyId}/customers/{customerId} Company View 200 or 404
POST /api/v1/companies/{companyId}/customers Company Edit 201, 400, 404, or 409
GET /api/v1/companies/{companyId}/chart-of-accounts?page=1&pageSize=50 Company View 200 or 400
POST /api/v1/companies/{companyId}/chart-of-accounts Company Edit 201, 400, 404, or 409
PUT /api/v1/companies/{companyId}/customers/{customerId} Company Edit 204, 400, 404, or 409
POST /api/v1/companies/{companyId}/customers/{customerId}/deactivate Company Edit 204 or 404
POST /api/v1/companies/{companyId}/customers/{customerId}/reactivate Company Edit 204 or 404
GET /api/v1/tax-rules Any API JWT 200
GET /api/v1/tax-rules/{id} Any API JWT 200 or 404
POST /api/v1/tax-rules Edit capability 201 or 400
GET /api/v1/stock/in-hand Any API JWT 200
GET /api/v1/stock/in-hand/{itemId}/{locationId} Any API JWT 200 or 404
GET /api/v1/stock/transactions Any API JWT 200
GET /api/v1/stock/valuation Any API JWT 200
GET /api/v1/transfer-orders/aging?pageSize=50 Company View capability 200 or 400
POST /api/v1/transfer-orders/{id}/lines/{lineId}/transit/{transitEntryId}/write-off Company Approve capability at both transfer locations 200, 400, 403, 404, or 409
POST /api/v1/stock/receive Admin, Manager, or Staff 204 with an idempotency key; otherwise 400
POST /api/v1/stock/transfer Admin, Manager, or Staff 204
POST /api/v1/stock/sell Admin, Manager, or Staff 204
POST /api/v1/stock/opening/preview Tenant Admin with Approve 200 or 422
POST /api/v1/stock/opening/replay Tenant Admin with Approve 200 or 422
POST /api/v1/stock/opening/reverse Tenant Admin with Approve and Reverse 200
GET /api/v1/webhooks Admin or Manager 200
GET /api/v1/webhooks/deliveries?page=1&pageSize=50 Tenant Admin 200 or 400
POST /api/v1/webhooks Admin or Manager 200
PUT /api/v1/webhooks/{id} Admin or Manager 200 or 404
DELETE /api/v1/webhooks/{id} Admin or Manager 204 or 404
GET /api/v1/forecast/{itemId} Any API JWT; AI limit 200
GET /api/v1/forecast Any API JWT; AI limit 200
GET /api/v1/anomalies Any API JWT; AI limit 200
POST /api/v1/organization/companies/import Tenant Admin 200 or 422
POST /api/v1/organization/branches/import Company Edit 200 or 422
POST /api/v1/organization/locations/import Company Administer 200 or 422
POST /api/v1/organization/suppliers/import Company Edit 200 or 422
POST /api/v1/organization/units/import Tenant Admin plus CompanyId 200 or 422
GET /api/v1/organization/units/export Tenant Admin 200 or 400
GET /api/v1/organization/items/export Tenant Admin 200 or 400
GET /api/v1/organization/suppliers/export Tenant Admin 200 or 400
POST /api/v1/organization/items/import Company Edit plus CompanyId 200 or 422

Company-scoped customer master

Customer records are owned by exactly one tenant company; there is no shared customer master or legacy company/customer mapping. The company is selected in the route, never in the request body. Each action checks the caller’s current company capability, and record reads/updates also constrain both customer ID and company ID. The tenant query filter and a composite (CompanyId, TenantId) foreign key enforce the persisted ownership boundary.

The paged list defaults to active records, ordered by normalized customer code. page must be at least 1 and pageSize must be 1–100; includeInactive=true includes deactivated records for company members with View. Its envelope contains page, pageSize, hasMore, and items. A customer code is trimmed, stored uppercase, and unique within its company. A duplicate returns 409.

The master stores the organization’s name and optional operational email, phone, billing address, shipping address, and paymentTermDays. Customer code and name are required; contact values are bounded and email syntax is checked. Payment-term days are non-negative configuration only; the API does not calculate invoice due dates, snapshot terms onto documents, or implement receivables or credit enforcement. Customer records can be deactivated/reactivated but cannot be hard-deleted. No current sales or receivable document references customers, so there is not yet a reference-based deactivation guard.

Company-scoped chart-of-account configuration (partial foundation)

The chart-of-accounts API stores account definitions owned by one active company. GET returns a bounded page (1–100 rows, default 50) ordered by the company-supplied account code; it defaults to active rows and accepts includeInactive=true. POST requires the caller’s current company Edit capability, while list access requires View. Restricted auditors can read with a View grant but cannot create. The route determines company ownership; tenant identity comes from the authenticated request. Database keys constrain both the company and any parent account to the same tenant/company, and account codes are unique within that company.

The company supplies the code, name, and free-form accountType label. No code sequence, account taxonomy, sample chart, or default account is seeded. A parent must be active and marked as a group account. isGroupAccount and isActive are configuration flags only; this API does not create a journal, post transactions, or make any account classification operational. Writes flow through the shared append-only audit pipeline. This partial increment is API-only and does not yet provide a chart UI.

Finance-owner and qualified-accounting-reviewer decisions are still required before defining approved account-type values, control-account roles or inventory/GRNI/COGS/AR/AP/cash/tax/rounding/adjustment mappings, fiscal periods and reopen authority, base-currency posting rules, opening balances, journal validation, reversals, or financial statements. No tax rule, fiscal date, account-number convention, posting policy, or approved chart is implied by these fields.

Finance posting kernel (partial foundation)

The finance APIs expose the general-ledger posting kernel: fiscal-calendar controls, immutable balanced journals, and a ledger-backed trial balance. Company ownership comes from the route; capabilities gate each action. GET /companies/{companyId}/fiscal-years and journal/trial-balance reads require View; creating a fiscal year and reopening a closed period require Administer; closing a period requires Approve; posting a journal requires Post; reversing a journal requires the separate Reverse capability.

POST .../journals commits one balanced journal for a named source action (sourceDocumentType, sourceDocumentId, sourceAction, sourceVersion). That tuple is unique per company: an identical retry replays the committed result with replayed: true, and a retry with changed amounts is rejected with 409. Lines must be 2-500 rows, each exactly one side positive, amounts already rounded to the company currency scale, in the company base currency, against active non-group chart accounts of that company, dated inside an open fiscal period. POST .../journals/{id}/reversals posts one source-linked reversal with swapped sides and a mandatory reason; reversals cannot be reversed. GET .../journals/trial-balance aggregates posted ledger lines and always balances. PUT .../account-posting-mappings maps named account roles (Inventory, GoodsReceivedNotInvoiced, AccountsPayable, AccountsReceivable, Bank, CashOnHand, CostOfGoodsSold, SalesRevenue, TaxPayable, Rounding, OpeningEquity, StockInTransit) to active, posting-capable company chart accounts.

No business document (purchase order, goods receipt, invoice, stock movement) posts through this kernel yet, and no financial statement beyond the trial balance is served. Journal rows are append-only; corrections are reversals. See docs/FINANCE_POSTING.md for the full invariants and evidence.

Procurement-to-pay and order-to-cash posting APIs

Posted business documents now flow through the finance kernel. Goods receipts (POST /companies/{companyId}/goods-receipts), supplier invoices (POST .../purchase-invoices), payments (POST .../payments), sales orders, deliveries and sales invoices (POST .../sales/orders|deliveries|invoices) all require the company Post capability and an Idempotency-Key header: committed requests replay with replayed: true, and conflicting payloads return 409. Reads require View. Receipts, deliveries, invoices and payments are immutable; purchase-order approval and sales-order creation never post. Supplier invoices match posted receipt lines exactly once and clear GRNI; payments fully allocate against open invoices; deliveries issue valued stock (exact moving-average COGS); sales invoices match delivered lines exactly once and recognize revenue and AR. The purchase-order obligations view now derives received, rejected and outstanding quantities from posted receipt lines, and sales-order progress derives from posted delivery lines.

Point-of-sale APIs

Registers (POST .../pos/registers, Administer), cashier shifts (POST .../pos/shifts/open, Post), checkouts (POST .../pos/sales, Post), refunds (POST .../pos/refunds, Post) and shift close (POST .../pos/shifts/{id}/close, Post) run on real stock and the finance kernel. Checkouts require an Idempotency-Key, issue valued stock, and post tender/revenue/COGS journals atomically; only open shifts transact. Refunds are bounded by the sold-minus-refunded balance per line. Shift close reconciles counted cash against the drawer derived from tenders and posts any variance to the cash over/short account exactly once.

Tenant unit source-ID export

GET /api/v1/organization/units/export requires an API JWT and the tenant Admin role. Units are tenant-scoped; this endpoint does not accept a company scope.

Query parameter Default Bounds / meaning
pageSize 50 1–100 records per response; values outside this range return 400.
afterExternalId omitted Optional cursor, at most 128 characters. Pass the previous response’s nextCursor unchanged to continue after that source ID; longer values return 400.

The response is wrapped in the standard API envelope. units are ordered by externalId and expose only externalId, code, name, decimalPlaces, and isWholeUnitOnly. hasMore indicates another page is available, and nextCursor is the last returned externalId when more rows remain (otherwise null). Units without a source ID—including legacy units assigned a synthetic ID because their original source ID is unknown—are omitted.

{
  "success": true,
  "data": {
    "pageSize": 1,
    "hasMore": true,
    "nextCursor": "unit-each",
    "units": [
      {
        "externalId": "unit-each",
        "code": "EA",
        "name": "Each",
        "decimalPlaces": 0,
        "isWholeUnitOnly": true
      }
    ]
  },
  "errorMessage": null,
  "errors": null
}

Tenant item-master source-ID export

GET /api/v1/organization/items/export requires an API JWT and the tenant Admin role. Items are currently tenant-scoped and have no company ownership field. The endpoint therefore returns the authenticated tenant’s catalog only to its tenant administrator; it does not infer company ownership or accept a company filter. Company capability membership alone does not authorize this tenant-wide export.

Query parameter Default Bounds / meaning
pageSize 50 1–100 records per response; values outside this range return 400.
afterExternalId omitted Optional keyset cursor, at most 128 characters. Pass the previous response’s nextCursor unchanged to continue after that source ID; longer values return 400.

The response uses the standard API envelope. items are ordered by externalId and contain only externalId, itemCode, description, the base/purchase/sales unit source IDs when known, purchaseToBaseFactor, salesToBaseFactor, quantityPrecision, wholeUnitOnly, and isActive. Items with missing or blank source IDs are omitted. Synthetic legacy unit source IDs are returned as null, not as if they were identifiers from an upstream system. The export does not include internal entity, tenant or company IDs, prices/costs, supplier details, barcodes, stock, or reorder levels. It is not a complete import round-trip format.

hasMore indicates whether another page exists. nextCursor is the last returned item’s externalId when more rows remain, otherwise null.

{
  "success": true,
  "data": {
    "pageSize": 1,
    "hasMore": true,
    "nextCursor": "item-001",
    "items": [
      {
        "externalId": "item-001",
        "itemCode": "SKU-001",
        "description": "Widget",
        "baseUnitExternalId": "unit-each",
        "purchaseUnitExternalId": null,
        "salesUnitExternalId": "unit-each",
        "purchaseToBaseFactor": 1.0,
        "salesToBaseFactor": 1.0,
        "quantityPrecision": 0,
        "wholeUnitOnly": true,
        "isActive": true
      }
    ]
  },
  "errorMessage": null,
  "errors": null
}

Tenant supplier source-ID export

GET /api/v1/organization/suppliers/export requires an API JWT and the tenant Admin role. Suppliers are tenant-scoped; no company ownership is inferred or returned. Company capability membership alone does not authorize this tenant-wide export.

Query parameter Default Bounds / meaning
pageSize 50 1–100 records per response; values outside this range return 400.
afterExternalId omitted Optional keyset cursor, from 1 through 128 nonblank characters. Pass the previous response’s nextCursor unchanged to continue after that source ID; blank or longer values return 400.

The response uses the standard API envelope. suppliers are ordered by externalId and contain only externalId and name. Suppliers with a missing or blank source ID are omitted. Contact person, phone, email, address, internal IDs, tenant/company IDs, and related purchasing data are not exposed. This bounded source-ID export is not a complete supplier import round-trip format.

hasMore indicates whether another page exists. nextCursor is the last returned supplier’s externalId when more rows remain, otherwise null.

{
  "success": true,
  "data": {
    "pageSize": 1,
    "hasMore": true,
    "nextCursor": "supplier-001",
    "suppliers": [
      {
        "externalId": "supplier-001",
        "name": "Acme Supplies"
      }
    ]
  },
  "errorMessage": null,
  "errors": null
}

Forecast endpoints return 400 Bad Request if the requested horizon, matching historical sell-row count, or all-item catalog size exceeds its configured limit. Over-limit forecasts are rejected, never silently truncated; see FORECASTING.md for defaults and supported configuration ceilings.

Opening baseline endpoints accept an explicit-cost CSV with the header external_reference,item_external_id,location_id,quantity,unit_cost. Preview is read-only and reports current-versus-approved unbatched quantities. Replay uses a stable import reference and approval reference, defaults the cutover to the server’s current UTC instant when omitted, and creates one immutable opening movement and valuation entry per valid source row. A tenant can have one approved baseline. Reversal requires a distinct correction and approval reference plus a reason; it posts forward stock effects and never edits the original baseline. Missing cost is rejected, while explicit zero cost is accepted. The M06 opening journal and financial activation gate are outside this API slice.

Outbound webhooks

Webhook deliveries are at-least-once: a receiver can see the same delivery again after a timeout or worker restart. X-Inventory-Event-Id is the stable event UUID and matches EventId in the JSON envelope; deduplicate business effects by this value. Retries and deliveries to multiple subscriptions retain that event identity. The sender does not expose its internal per-subscription delivery-row ID in request headers.

When a non-empty subscription secret is configured, the sender adds X-Inventory-Signature: lower-case hexadecimal HMAC-SHA256 of the exact UTF-8 JSON request-body bytes, using the subscription secret’s UTF-8 bytes as the key. HTTP headers are not part of the signed value. Verify the raw request-body bytes before parsing or reserializing JSON, and fail closed on a missing, malformed, or mismatched signature. A receiver that requires authenticity must use a non-empty secret. The webhook administration API never returns the secret.

Example receiver-side verification in .NET:

using System;
using System.Security.Cryptography;
using System.Text;

static bool HasValidWebhookSignature(
    ReadOnlySpan<byte> rawBody,
    string secret,
    string? signatureHeader)
{
    if (string.IsNullOrEmpty(secret) || signatureHeader is null || signatureHeader.Length != 64)
        return false;

    Span<byte> supplied = stackalloc byte[32];
    if (!Convert.TryFromHexString(signatureHeader, supplied, out var written) || written != supplied.Length)
        return false;

    Span<byte> expected = stackalloc byte[32];
    HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody, expected);
    return CryptographicOperations.FixedTimeEquals(expected, supplied);
}

After signature verification, parse the envelope and use its EventId for idempotent event handling. Do not log the secret, signature, or unredacted payload.

Webhook targets must be HTTPS on port 443 and resolve only to public addresses. The delivery worker retries transient failures with bounded exponential delay, up to five total attempts; permanent client errors are dead-lettered immediately. Operational response diagnostics are limited to 4 KiB and redact the configured secret and full target URL; transport errors are recorded without exception text or endpoint details. PostgreSQL workers claim due deliveries atomically with SKIP LOCKED and a two-minute lease. An expired in-progress lease can be reclaimed after a worker crash; a per-claim token prevents a superseded worker from persisting a stale completion. This protects outbox ownership, but does not make delivery exactly once: receivers must still deduplicate repeated HTTP requests by event ID. No remote endpoint is contacted unless an administrator explicitly configures the subscription.

Tenant administrators can inspect recent delivery metadata at GET /api/v1/webhooks/deliveries?page=1&pageSize=50. Pages are returned newest first; pageSize is limited to 100 and the response includes hasMore. The endpoint uses the active tenant filter and returns only delivery IDs, event type, status, attempt timing and HTTP status. Its failureReason is normally null; the only exposed reason is the fixed message Serialized webhook event envelope exceeds the 256 KiB UTF-8 limit. for an oversized persisted event. Arbitrary diagnostic text is never returned. The endpoint never returns payloads, target URLs, subscription secrets, lease tokens or raw response bodies.

Serialized outbound webhook event envelopes are limited to 256 KiB (262,144 UTF-8 bytes). Enqueueing rejects a larger envelope before creating any outbox rows; the payload is never truncated. A persisted oversized record—including one created before this limit was enforced—is not sent: the worker marks it dead-lettered and records the fixed safe reason shown above. Direct dispatch also rejects an oversized envelope before contacting any endpoint.

The item list validates page >= 1 and 1 <= pageSize <= 100. Item update also requires the route ID and body ID to match. Request validation failures are 400 responses.

Tax rules and deterministic amounts

Tax rules are tenant-scoped, effective-dated policies. EffectiveFromUtc is inclusive and EffectiveToUtc is exclusive; periods for one code cannot overlap. Create a new dated rule instead of editing a rule used by a document:

{
  "code": "STANDARD-15",
  "category": "Standard",
  "ratePercent": 15,
  "calculationMode": "Exclusive",
  "effectiveFromUtc": "2026-01-01T00:00:00Z",
  "effectiveToUtc": null,
  "isActive": true
}

Purchase-order creation calculates on the server. The shared policy rounds quantity × unit price, then the discount, taxable base, tax, and gross amount at the document currency scale using midpoint-away-from-zero. Inclusive tax is extracted from the rounded post-discount gross amount. Standard, zero-rated and exempt categories are supported; zero-rated and exempt rules must use a zero rate. Unsupported tax combinations are rejected rather than inferred.

The selected rule ID, effective date, category, rate, discount, calculation version, rounded line amounts and document totals are stored as snapshots. Changing a tax master therefore cannot recalculate an existing document. These rules are configuration primitives and synthetic examples, not jurisdictional tax or compliance certification.

Response envelopes

Successful resource responses normally use ApiResponse<T>:

{"success":true,"data":{},"errorMessage":null,"errors":null}

Application-level failures that use the same type have this shape:

{"success":false,"data":null,"errorMessage":"...","errors":["..."]}

The following endpoints intentionally do not use that envelope:

Status codes

Status Meaning in this API
200 Successful read, token issuance, webhook mutation, forecast, or anomaly response
201 Item created; the Location header identifies the item resource
204 Successful stock mutation, item update/delete, or webhook delete; no body
400 Unmapped host, invalid query/body, validation failure, route/body ID mismatch, a missing required Idempotency-Key, or an Idempotency-Key longer than 200 characters
401 Missing/invalid/expired bearer token, failed credentials, or token tenant mismatch
403 Authenticated caller lacks the required role
404 Requested item, stock balance, or webhook subscription does not exist
409 Business conflict or reuse of an idempotency key with a different request
429 Fixed-window rate limit rejected the request after the configured queue is full
500 Unexpected API exception; the server logs the exception and returns a stable problem response

Rate limits

The normal Api policy is a fixed window of 100 permits per minute with a queue of 10. Its partition is tenant-id:sha256(client-discriminator), so the same client is isolated between tenants. Authentication runs before this limiter, and the client discriminator is selected from the authenticated name identifier, the authenticated client_id claim, the remote IP address, or anonymous, in that order. Caller-supplied X-Client-Id headers are not used for rate-limit partitioning because they can be rotated to evade a limit; they never select a tenant.

AI routes use the explicit Ai policy: 10 permits per minute with a queue of

  1. A rejected request receives 429 Too Many Requests; the application does not configure a Retry-After header.

Idempotent stock mutations

POST /api/v1/stock/receive requires the Idempotency-Key request header; missing or over-200-character keys are rejected with 400 before posting. POST /api/v1/stock/transfer, /api/v1/stock/sell, /api/v1/stock/quarantine, and /api/v1/stock/quarantine/release accept the header optionally; when supplied, it must be 200 characters or fewer. Those commands retain their normal one-shot path when the header is omitted.

For these stock routes, retry the same supplied key with the same request body to replay a completed operation. Receive always requires a key; the other listed routes retain their one-shot path when the header is omitted. The durable coordinator scopes the claim to the current tenant, HTTP method/path, key, and SHA-256 hash of the serialized command; it retains claims for one hour with a two-minute lease.

POST /api/v1/stock/receive
Host: tenant.example
Authorization: Bearer <jwt>
Idempotency-Key: receive-2026-09-16-001
Content-Type: application/json

{"itemId":42,"locationId":7,"quantity":10,"unitCost":12.50,"notes":"delivery"}

unitCost is optional and is the acquisition cost per base unit. When present, the receipt contributes to the tenant/item/location moving-average valuation and must be non-negative with at most six decimal places. Costed receipts are unbatched in this release; omitted unitCost preserves quantity-only behavior.

GET /api/v1/stock/valuation returns each current tenant/item/location moving-average bucket and its ordered immutable valuation entries from one repeatable-read snapshot, so each returned balance reconciles with its ledger at the time of the read. Optional itemId and locationId filters are applied within the caller’s authorized company scope; the endpoint is read-only and never recalculates or mutates posted costs.

The state transitions are:

Existing executable coverage for this contract is kept in tests/Merconiq.Tests/Web/Services/IdempotencyKeyStoreTests.cs, tests/Merconiq.Tests/Integration/TenantAuthenticationTests.cs, and tests/Merconiq.Tests/Web/Services/RateLimitPartitionKeyTests.cs. The integration test factory uses an in-memory database and synthetic signed JWTs; it does not represent an external identity provider or a production rate limit measurement.

Transfer-order dispatch

Dispatch an approved transfer-order line with company-scoped Post permission and a required Idempotency-Key:

POST /api/v1/transfer-orders/123/lines/456/dispatch
Host: tenant.example
Authorization: Bearer <jwt>
Idempotency-Key: dispatch-2026-09-17-001
Content-Type: application/json

{"quantity":30}

The response identifies the transfer line, source/destination, stock movement, captured unit cost/value, dispatcher, and dispatch time. Replaying the same key and command returns the original dispatch record; using that key with a different quantity is rejected. Dispatch consumes only the approved outstanding reservation, moves the source carrying value into the company transit ledger, and does not increase destination stock. Once any quantity is dispatched, the transfer order cannot be amended or cancelled.

This increment accepts only valued, unbatched source stock. Lot/expiry and quantity-only stock are rejected because current M03 valuation does not provide lot-level or historical acquisition-cost evidence.

Resolve a dispatched transit entry with company-scoped Post permission and a required Idempotency-Key:

POST /api/v1/transfer-orders/123/lines/456/transit/789/receive
Idempotency-Key: receive-2026-09-17-001
Content-Type: application/json

{"quantity":20,"notes":"Received in good condition"}

Use the same route with /quarantine (a non-blank reason is required) or /return. Receipt and quarantine increase destination on-hand quantity; only quarantined quantity is unavailable for further stock operations. Returns move the captured transit value back to the source. A separately authorized approver can use /write-off with {"quantity":4,"reason":"Damaged beyond recovery"} and a required Idempotency-Key; the settlement removes the selected quantity and its dispatch-captured carrying value from the transit subledger only. It does not create a stock transaction, change source/destination on-hand balances, or post a GL journal. Financial accounting remains in the later M06 scope. Partial settlement allocates the transit entry’s captured total value, with the final settlement receiving any rounding remainder so the ledger conserves the dispatched value. Each settlement is append-only, lineage-linked, limited to the unsettled dispatched quantity, and idempotent. The order reports PartiallyReceived only after destination receipt; it becomes Completed when all ordered quantity has been dispatched and every dispatched unit has been received, quarantined, returned, or written off. Lot/expiry inputs for receipt or quarantine must match the dispatched entry; write-offs do not accept destination lot details.

Transfer aging and conservation

GET /api/v1/transfer-orders/aging returns at most 100 transfer-order lines per page, ordered by line ID. pageSize defaults to 50; afterLineId is an exclusive keyset cursor returned as nextAfterLineId. An optional companyId narrows the report and is rejected unless the caller currently has that company’s View capability. The query uses the authenticated tenant’s data filter and the caller’s fresh company grants; completed and cancelled orders are included when their lines fall in the requested page.

Each row reports ordered quantity, currently active/non-expired reservation quantity, immutable dispatch quantity/value, received, quarantined, returned, written-off, and outstanding transit quantity/value. Written-off quantity/value is reported separately and remains part of transit quantity/value conservation; write-offs have no stock transaction or inventory valuation posting. Outstanding value is each dispatch’s captured transit total less its persisted settlements. Quantity and value conservation variance are dispatch totals less received, quarantined, returned, written-off, and outstanding amounts. Separate dispatch/settlement ledger variances compare physical transit events with their linked stock transaction and valuation posting; a write-off is not treated as a missing physical posting. dispatchLedgerQuantityVariance and settlementLedgerQuantityVariance sum the absolute per-event difference between transit quantity and the linked stock transaction. dispatchLedgerValueVariance and settlementLedgerValueVariance sum the absolute per-event difference between transit value and matching valuation value. The corresponding dispatchValuationPostingCountVariance / settlementValuationPostingCountVariance sum missing or extra matching valuation postings per event, while dispatchValuationQuantityVariance / settlementValuationQuantityVariance sum per-event valuation-quantity differences, including zero-value events. A missing event cannot be hidden by an opposite discrepancy elsewhere. Zero across these fields means the persisted event/posting pairs agree; a nonzero value is evidence to investigate, not an automatic repair. Transit and settlement history is aggregated in PostgreSQL; responses contain only the bounded line page and per-line summaries, not event history rows.

originalAverageUnitCost and outstandingAverageUnitCost are six-decimal, quantity-weighted summaries of the captured source costs. The corresponding total-value fields remain authoritative if individual dispatches had different moving-average costs.

Transit age is whole elapsed UTC days since the oldest dispatch that still has positive outstanding quantity; the page’s asOf timestamp anchors this value. The latest transit action means the newest persisted dispatch or settlement, with its recorded actor and timestamp; it does not claim to be a complete order approval/cancellation audit history. Source and destination location IDs are shown as persisted on the transfer. Branch responsibility is intentionally omitted because transfer/transit/settlement rows retain location IDs, not a historical branch snapshot; owner-approved legacy location mappings remain pending in #268. Write-off quantity and value are reported as transit dispositions and are not expected to have a physical stock transaction or valuation posting. Lot-specific valuation is also outside this report’s claims.

Finance, reconciliation and operations endpoints

All routes are under /api/v1/companies/{companyId} and need the capability shown. Writes that post journals use the finance kernel; reconciliation, CRM and card-settlement writes never post. Posting rules and journal sources are in docs/FINANCE_POSTING.md.

Route Capability Behavior
GET /statements/profit-and-loss, GET /statements/balance-sheet View Each response gains rollups: classified group accounts with same-class subtotals at every depth.
POST /fiscal-years/{fiscalYearId}/adjustment-period Administer Creates period 13 for the year, dated on the year end.
GET/POST /fiscal-periods/{periodId}/account-locks, POST .../account-locks/{chartOfAccountId}/release View / Approve / Administer Locks a posting account for one period; release needs a reason.
GET/POST /bank-statements, GET /bank-statements/{id}, GET /bank-statements/{id}/reconciliation View / Edit Import a statement that must foot exactly; read it with its matches and the reconciliation figures.
POST /bank-statements/{id}/lines/{lineId}/match, .../unmatch Approve One-to-one match to a bank-account ledger line with equal amount and date on or before the statement; unmatch needs a reason.
GET/POST /fixed-assets, GET /fixed-assets/{id} View / Edit Register an asset; the read includes posted depreciation, accumulated depreciation and net book value.
POST /fixed-assets/{id}/capitalize Post Posts the acquisition against the given payment or payable account.
POST /fixed-assets/depreciation-runs Post Posts every whole month that ends on or before asOfDate; committed months replay.
POST /fixed-assets/{id}/dispose Post Requires depreciation through the disposal date; posts proceeds, cost removal and the gain or loss.
GET/POST /expense-claims, GET /expense-claims/{id} View / Edit Create a draft with expense lines; the read includes the lines.
POST /expense-claims/{id}/submit Edit Draft to submitted.
POST /expense-claims/{id}/approval Approve Submitted to approved; posts the expenses against the employee payable.
POST /expense-claims/{id}/rejection Approve Submitted to rejected, with a mandatory reason; final.
POST /expense-claims/{id}/payment Post Approved to paid from a bank account on paymentDate.
GET/PUT /customers/{customerId}/credit View / Approve Read the limit, open receivable and headroom; set or clear the limit with a reason.
POST /sales/invoices Post Accepts overrideCreditLimit and creditOverrideReason. An override needs Approve and a reason and is recorded on the invoice.
GET /pos/sales/{saleId}/receipt View text/plain receipt for a committed sale, 40 columns; 404 outside the company.
GET/POST /card-settlements, GET /card-settlements/{batchId}/reconciliation View / Edit Import an acquirer batch (gross and fee; net is derived); the reconciliation lists card tenders still awaiting settlement.
POST /card-settlements/{batchId}/tenders/{tenderId}/match, .../unmatch Approve Settles a card tender against a batch on or after the sale date, never beyond the batch gross.
POST /pos/shifts/{shiftId}/close-approval, .../close-rejection Approve Approve posts the held over/short journal once; rejection reopens the shift with a reason. A close returns approvalRequired: true while held.
PUT /pos/registers/{registerId}/variance-threshold Administer Sets or clears the register threshold; null means every close posts immediately.
GET/POST /crm/leads, POST /crm/leads/{id}/qualification, .../disqualification, .../conversion View / Edit Lead lifecycle; conversion requires a qualified lead.
GET/POST /crm/opportunities, POST /crm/opportunities/{id}/stage, .../won, .../lost View / Edit Opportunities move between open stages; winning links an existing customer of the company; losing needs a reason.
GET/POST /crm/activities View / Edit Activities belong to exactly one lead or one opportunity.
GET /crm/pipeline View Count and expected value by stage, with the open value.

Account, fiscal and posting responses keep their existing fields; new fields are additive.