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.
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.
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.
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 |
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.
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.
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.
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.
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.
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
}
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
}
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.
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 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.
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:
{token, expires};204 No Content operations have an empty body;{title, detail, status} body;ProblemDetails or
ValidationProblemDetails (application/problem+json), with title,
detail, status, and instance as applicable;| 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 |
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
429 Too Many Requests; the application does
not configure a Retry-After header.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:
204.204 with no body.409
as an API ProblemDetails response. Use a new key for a different command.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.
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.
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.
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.