⚠️ Modul LAN nu funcționează din pagina găzduită (HTTPS blochează apelurile către http:// din rețea).
Descarcă demo-ul
și deschide-l local pe un PC din aceeași rețea cu terminalul.
Provider status
Items
TVA % este opțional — dacă e gol, terminalul folosește cota din tabela locală pentru codul TVA ales. Modificator pe produs: alege tipul (reducere/adaos × % sau MDL) și introdu valoarea. Doar un singur tip se aplică pe linie.
| Name | Price | Qty | VAT | VAT % (opt.) | Mod. | Val. |
|---|
Payments
| Type | Amount | RRN |
|---|
Delivery (SMS/email receipt)
Generează QR livrare (curier)
Transformă coșul curent (produse + reduceri) într-un lot de livrare prin relay și generează QR-ul cu starea curentă a vânzării. Conexiunea relay se configurează în tab-ul „Lot livrare”.
Nume + telefon se preiau din secțiunea „Livrare (SMS/email)” de mai sus.
La finalizarea vânzării, terminalul trimite aici (POST JSON) soarta fiecărei linii (vândută/ștearsă/modificată).
Current shift
Open a new shift
Close the open shift
Closing does not generate a Z-Report — run that separately from the Reports tab.
Bank card terminal corrections — these reverse the money on the bank side only; no fiscal (ACPS) document is produced.
Void cancels a card transaction from the same batch (before the card terminal Day Close). Use the chequeNumber from the original sale's cardPaymentResult — RRN does NOT work for voids.
Refund returns money for a settled transaction (after the card terminal Day Close). Requires the original RRN; some Moldovan banks (MAIB, Victoriabank) also require the auth code.
Creează un lot de livrare prin terminalul în Provider Mode — folosește conexiunea principală de sus (Base URL + API Key). Terminalul îl trimite la platformă și întoarce QR-ul lotului. Depozitul și comerciantul provin din terminalul procesator.
Date client
Produse în lot
| Produs | Cant. | Preț | TVA |
|---|
Contract API — răspunsuri, coduri de eroare & webhook
Toate apelurile trec prin serverul Provider (LAN http://<device>:4567 sau relay https://dev.posfix.md/api/relay/<publicId>/...). Autentificare: header X-API-Key (excepție: /api/health, /api/catalog/health).
{
"success": true,
"data": { ... },
"error": null,
"timestamp": "2026-06-07T08:47:36.186Z"
}
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "items[0].vatCode",
"code": "UNKNOWN_VAT_CODE",
"message": "Unknown VAT code 'XYZ'." }
]
},
"timestamp": "2026-06-07T08:47:36.186Z"
}
Câmpul details[] apare doar la VALIDATION_ERROR (erori per câmp). Endpoint-urile /api/catalog/* întorc JSON brut (fără envelope), pentru compatibilitate.
Headere
| Header | Direcție | Rol |
|---|---|---|
| X-API-Key | cerere | Obligatoriu (mai puțin health). Greșit/lipsă → 401. |
| Idempotency-Key | cerere (POST) | Opțional. Reluare sigură: aceeași cheie + același payload → răspuns din cache. Cheie + payload diferit → 409. |
| X-Request-Id | răspuns | Id-ul intrării din jurnal (audit). |
| X-Idempotency-Replayed | răspuns | true doar când răspunsul e o reluare din cache. |
Coduri top-level — error.code → status HTTP
Mesajele de mai jos sunt exact cele din cod ($... = interpolare în runtime). „dynamic” = mesajul vine de la sursa subiacentă (MEV/imprimantă/platformă).
| HTTP | Cod | message (din cod) |
|---|---|---|
| 400 | INVALID_JSON | Malformed JSON body · Request body must be a JSON object · Request body is not valid JSON |
| 400 | VALIDATION_ERROR | Request validation failed (+ details[]) |
| 401 | MISSING_API_KEY | Header X-API-Key is required |
| 401 | INVALID_API_KEY | The provided API key is not valid |
| 402 | CARD_PAYMENT_DECLINED | Card payment declined: ${cardResult.errorMessage ?? "Unknown reason"} |
| 404 | NOT_FOUND | Endpoint not found: ${request.method} /${request.url.path} |
| 404 | SALE_NOT_FOUND | Sale not found |
| 405 | METHOD_NOT_ALLOWED | mapped in ApiErrors; unused in handlers |
| 409 | TERMINAL_NOT_FISCAL | Terminal is not fiscalized |
| 409 | FISCAL_DAY_EXPIRED | Fiscal day has expired. Generate a Z Report first. |
| 409 | MEV_CREDENTIALS_MISSING | MEV credentials are missing |
| 409 | INSUFFICIENT_CASH_BALANCE | Insufficient cash balance. Current: $balanceBefore, requested: ${apiRequest.amount} |
| 409 | DUPLICATE_LIMIT_REACHED | A duplicate copy has already been printed for this receipt (SCE Imaginea 39). |
| 409 | IDEMPOTENCY_KEY_REUSE_MISMATCH | Idempotency-Key was previously used with a different payload. |
| 429 | BUSY | A fiscal operation is in progress. Please retry in a few seconds. |
| 500 | INTERNAL_ERROR | An unexpected error occurred (audit: „Internal error”) |
| 500 | PRINT_ERROR | = printer.errorMessage (dynamic) |
| 500 | CARD_PAYMENT_ERROR | Card payment failed with an unexpected error. |
| 502 | MEV_ERROR | = MevFailure.message (dynamic) |
| 502 | MEV_TIMEOUT | MEV message (dynamic) |
| 503 | MEV_UNAVAILABLE | Could not reach the platform: $e |
| 503 | PRINTER_UNAVAILABLE | Printer is not available |
| 503 | CARD_TERMINAL_UNAVAILABLE | Card payment requested but card terminal is not available. / ...this hardware does not support card payments. |
| 4xx/5xx | PLATFORM_ERROR | propagated from platform (code + message from response); fallback when code can't be extracted |
Coduri de validare — details[].code
Toate apar sub VALIDATION_ERROR (HTTP 400) în details[]. field + message sunt exact cele din validatoare ($prefix = items[i]/payments[i]).
| code | field | message (din cod) |
|---|---|---|
| ITEMS_REQUIRED | items | At least one item is required |
| ITEM_NAME_INVALID | items[i].name | Item name must be 1-100 characters |
| ITEM_PRICE_INVALID | items[i].unitPrice | Unit price must be > 0 with max 2 decimal places |
| ITEM_QUANTITY_INVALID | items[i].quantity | Quantity must be > 0 with max 3 decimal places |
| ITEM_VAT_CODE_INVALID | items[i].vatCode | VAT code must be one of: A, B, C, D, E, _ |
| UNKNOWN_VAT_CODE | items[$i].vatCode | Unknown VAT code '${item.vatCode}'. |
| ITEM_NET_AMOUNT_NEGATIVE | items[$i].netAmount | Item '${lineCalc.name}' net amount is ${lineCalc.netAmount} after discount. Discount exceeds item subtotal. |
| ITEM_VAT_AMOUNT_NEGATIVE | items[$i].vatAmount | Item '${lineCalc.name}' VAT amount is negative |
| ITEM_DISCOUNT_PERCENT_INVALID | items[i].discountPercent | Discount percent must be 0-100 |
| ITEM_DISCOUNT_ABSOLUTE_INVALID | items[i].discountAbsolute | Discount absolute must be >= 0 |
| ITEM_MARKUP_PERCENT_INVALID | items[i].markupPercent | Markup percent must be >= 0 |
| ITEM_MARKUP_ABSOLUTE_INVALID | items[i].markupAbsolute | Markup absolute must be >= 0 |
| CART_DISCOUNT_PERCENT_INVALID | cartDiscount.percent | Cart discount percent must be between 0 and 100 |
| CART_DISCOUNT_ABSOLUTE_INVALID | cartDiscount.absolute | Cart discount absolute must be >= 0 |
| CART_MARKUP_PERCENT_INVALID | cartMarkup.percent | Cart markup percent must be >= 0 |
| CART_MARKUP_ABSOLUTE_INVALID | cartMarkup.absolute | Cart markup absolute must be >= 0 |
| PAYMENTS_REQUIRED | payments | At least one payment is required |
| PAYMENT_TYPE_INVALID | payments[i].type | Payment type '$type' is not a valid ACPS code |
| PAYMENT_AMOUNT_INVALID | payments[i].amount | Payment amount must be > 0 with max 2 decimal places |
| PAYMENT_RRN_INVALID | payments[i].rrn | RRN must be max 12 characters |
| PAYMENT_SUM_MISMATCH | payments | Sum of payments ($paymentSum) does not match amountReceived ($amountReceived) |
| AMOUNT_RECEIVED_INVALID | amountReceived | Amount received must be >= 0 |
| INSUFFICIENT_PAYMENT | amountReceived | Amount received (${request.amountReceived}) is less than total (${calculation.grandTotal}) |
| CHANGE_INVALID | change | Change must be >= 0 |
| CHANGE_MISMATCH | change | change ($requestedChange) does not match expected ${calculation.change} |
| NON_CASH_CHANGE_NOT_ALLOWED | payments | Change (${calculation.change}) is only allowed when cash payment is present. |
| CALCULATION_TOTAL_ZERO | total | Calculated grand total must be positive |
| AMOUNT_INVALID | amount | Amount must be > 0 with max 2 decimal places |
| CUSTOMER_NAME_INVALID | delivery.customerName | Customer name must be max 60 characters |
| PHONE_FORMAT_INVALID | delivery.customerPhone | Phone must be in international format (e.g. +37369123456) |
| EMAIL_FORMAT_INVALID | delivery.customerEmail | Invalid email format |
| LANGUAGE_INVALID | delivery.language | Language must be one of: ro, ru, en |
| REASON_TOO_LONG | reason | Reason must be max 100 characters |
| LINES_REQUIRED | lines | At least one line is required |
| LINE_FORMAT_INVALID | lines[$i] | Each line must have 1-3 columns |
| LINE_TEXT_TOO_LONG | lines[$i][$j] | Line text must be max 100 characters |
| FEED_LINES_INVALID | feedLines | feedLines must be 0-10 |
| FILTER_REQUIRED | filter | Either dateFrom+dateTo or reportFrom/reportTo must be provided |
| DATE_RANGE_INVALID | dateFrom | dateFrom must be <= dateTo |
| DATE_TO_FUTURE | dateTo | dateTo cannot be in the future |
| REPORT_NUMBER_INVALID | reportFrom | reportFrom must be >= 1 |
| REPORT_RANGE_INVALID | reportFrom | reportFrom must be <= reportTo |
Sursă: validatoarele models/api_sale_request.dart, api_cash_request.dart, api_report_request.dart, api_print_request.dart + calculul din sale_handler.dart. Paritate de vocabular cu PC-ECC ApiErrorCodes.cs.
Webhook lot de livrare
Dacă lotul a fost generat cu un webhookUrl, la finalizarea fiscală a vânzării (coșul încărcat din lot) terminalul trimite un POST JSON la acel URL cu soarta fiecărei linii. Fire-and-forget, fără semnătură (plain), fără retry; cheia stabilă de reconciliere e lotLineId (nu poziția în coș — liniile pot fi șterse).
sold— vândută integral (soldQty = orderedQty)modified— vândută cu altă cantitateremoved— scoasă din coș (soldQty = 0)added— adăugată de casier (nu era în lot)
Orice 2xx. Terminalul nu reîncearcă; rezultatul e logat local. Validează pe lotId și nu te baza pe autenticitate (POST simplu, fără HMAC).
{
"lotId": "f1c776f1-6a03-4190-837c-9bf9b01f41f7",
"saleId": "MEV-or-server-sale-id",
"soldAt": "2026-06-07T08:47:36.186Z",
"lines": [
{ "lotLineId": "a1b2c3", "name": "Apă plată 0.5L",
"orderedQty": 2, "soldQty": 2, "status": "sold" },
{ "lotLineId": "d4e5f6", "name": "Pâine",
"orderedQty": 1, "soldQty": 0, "status": "removed" },
{ "lotLineId": "g7h8i9", "name": "Lapte 1L",
"orderedQty": 3, "soldQty": 1, "status": "modified" }
],
"extraLines": [
{ "productId": "prod-42", "name": "Pungă",
"soldQty": 1, "status": "added" }
]
}
Sursă: lib/core/api_server/models/api_response.dart (envelope + coduri), middleware auth/error/idempotency, delivery_lot_webhook_dispatcher.dart (webhook). Paritate de vocabular cu PC-ECC ApiErrorCodes.cs.
PosFix Public Integration API (Cloud) Cloud REST · v1
REST API for external systems (e-shop, ERP) — catalog, stock, orders and webhooks. Separate from the fiscal PC-ECC API above.
This tab issues real browser requests to the Integration API. It needs (1) a real API key from your merchant (Control Panel → Settings → API keys) and (2) CORS enabled on the gateway for this page's origin. The public API is currently server-to-server only (CORS "no-cors") and does not list docs.posfix.md — until that is added, calls from this hosted page are blocked by the browser (CORS), even though the request still appears in the execution log below. From an allowed origin, or via curl/Postman, the same requests work.
Production: https://api.posfix.md. For dev, put your dev host (e.g. https://dev.posfix.md).
Format rk_… — shown once in the Control Panel. Different from the fiscal X-API-Key above.
How to connect
- In the Control Panel, open Settings → API keys (Setări → Chei API).
- Create a connection: give it a name, pick the system type, and select the scopes it needs.
- Copy the key (rk_…) immediately — it is shown once and never again (PosFix stores only a hash). If lost, rotate it.
- Send it on every request in the
X-Posfix-Api-Keyheader. The merchant (tenant) is derived from the key — you never pass a merchant id.
products:read— list products, get by codeinventory:read— read on-hand stockorders:write— create orderswebhooks:manage— create / list / delete webhooks
- Missing/invalid key → 401. Missing scope → 403. Fail-closed.
- Versioned in the path: /api/public/v1/. v1 is additive-only — ignore unknown JSON fields.
- Errors: JSON { status, title, detail }. Branch on status, don't parse detail.
Products
GET /api/public/v1/products
Cursor-paginated, each with on-hand stock. Scope: products:read.
Returns multilingual descriptions and content blocks (detail-only).
Content blocks
GET /api/public/v1/content-blocks
The merchant's editorial block definitions (key + labels) — build your template slots. Per-product HTML is on the product detail. Scope: products:read.
Inventory
GET /api/public/v1/inventory?productCode=
On-hand quantity for one product. Scope: inventory:read.
Create order
POST /api/public/v1/orders
Creates a CustomerOrder (Draft). No fiscal document, no journal entry. Idempotent on externalOrderId. Scope: orders:write.
Webhooks
POST /api/public/v1/webhooks
Register an https endpoint. Returns the signing secret once. Scope: webhooks:manage.
Every delivery carries these headers: X-Posfix-Signature, X-Posfix-Webhook-Id, X-Posfix-Timestamp (unix seconds), X-Posfix-Event. Read the RAW body (do not re-serialise), rebuild the signed string, HMAC it with your signing secret, and compare constant-time. Reject deliveries whose timestamp is more than ~5 min from your clock (replay guard).
signedString = "{X-Posfix-Webhook-Id}.{X-Posfix-Timestamp}.{rawBody}"
X-Posfix-Signature = base64( HMAC_SHA256(signingSecret, signedString) )
const crypto = require("crypto");
// rawBody is a Buffer captured with express.raw()
const signed = Buffer.concat([
Buffer.from(`${webhookId}.${timestamp}.`, "utf8"),
rawBody,
]);
const expected = crypto.createHmac("sha256", signingSecret)
.update(signed).digest("base64");
const ok = expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));