# POS Itemised Payment Contract

Updated: 2026-10-26

This is the mobile/POS contract for submitting an itemised cart through the five supported
Transaction APIs. It complements [Device Integration](./device-integration.md) and
[Product Catalog](./product-catalog.md).

## Endpoints

| Flow                  | Endpoint                                     | Initial financial status                         | Inventory finality                                                |
| --------------------- | -------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------- |
| New card or cash sale | `POST /api/v1/transactions/sale`             | Card: processor result; cash/check: `settled`    | Approved card or settled cash queues settlement                   |
| Card authorization    | `POST /api/v1/transactions/auth`             | Authorization result                             | No deduction until successful capture                             |
| Saved-card payment    | `POST /api/v1/transactions/token-sale`       | Processor result                                 | SALE deducts after approval; AUTH waits for capture               |
| ACH debit             | `POST /api/v1/transactions/ach-sale`         | Usually `pending`                                | No deduction until ACH settlement                                 |
| Device SDK payment    | `POST /api/v1/transactions/external-payment` | `pending` when device notification arrives first | Confirmed external SALE queues settlement; AUTH waits for capture |

The inventory-finality column assumes POS inventory settlement is enabled in that environment.
The client cannot read or control that server-side rollout setting, and payment success by itself
never proves that stock was deducted.

Do not use `POST /api/v1/public/transaction` for a POS cart. Its compatibility whitelist does not
forward `lineItems`.

## Authentication and headers

Mobile must send:

```http
Authorization: Bearer <user-jwt>
X-Device-Token: <device-token>
X-Tenant-ID: <tenant-id>
Content-Type: application/json
```

The device token supplies the current server-authoritative merchant, device, and physical branch.
Do not send a cashier-selected stock location or calculate Available on the device.

The full catalog, location, and stock preflight in this document applies because these requests
carry verified device context. Non-device API integrations retain the quantity-limit-only contract
documented in [Transactions](../core/transactions.md), section "Itemised sale (`lineItems`)".

For `/sale`, `/auth`, `/token-sale`, and `/ach-sale`, also send:

```http
Idempotency-Key: <stable-uuid-for-this-payment-attempt>
```

The key must be 16–255 characters. A network retry must reuse the same key and identical body. A
new payment attempt must use a new key. Reusing a key with a different body returns
`422 IDEMPOTENCY_KEY_REUSED`; a concurrent duplicate may return `409` until the first attempt
finishes. `/external-payment` is instead idempotent by `(tenantId, gatewayTransactionId)`; always
reuse the provider's original transaction id.

For the first four endpoints, `merchantId` must be the merchant selected by the authenticated
mobile session and must match the device context. `/external-payment` derives the merchant from
`X-Device-Token` and does not need `merchantId` in its body.

The client must preserve that match; the current runtime does not yet reject every mismatched body
merchant/device combination on the first four routes. Refresh the device session after login or a
merchant switch and never reuse a cart/payment body from the previous merchant.

## Cart contract

All five endpoints accept the same optional `lineItems` array:

| Field       | Type                | Required     | Notes                                                                                   |
| ----------- | ------------------- | ------------ | --------------------------------------------------------------------------------------- |
| `type`      | `product` \| `misc` | Recommended  | Defaults to `product` when `productId` is present; otherwise `misc`                     |
| `productId` | UUID                | Product only | `id` returned by `GET /api/v1/device/products`; forbidden on `misc`                     |
| `name`      | string              | Yes          | Receipt snapshot; trimmed; max 150 characters                                           |
| `quantity`  | number              | Yes          | `> 0`, maximum 1,000,000, maximum 3 decimal places                                      |
| `unit`      | string              | No           | Catalog unit code or display text; max 50 characters                                    |
| `unitPrice` | number              | Yes          | `>= 0`, maximum 2 decimal places                                                        |
| `lineTotal` | number              | No           | Must equal rounded `quantity × unitPrice` within $0.01; server computes it when omitted |

`lineItems[].type` is a transaction-line classification, not the catalog item's
`GET /device/products` type. Map mobile cart rows as follows:

| Mobile cart source                                      | Catalog `type` | Send `lineItems[].type` | `productId` | Inventory behavior                       |
| ------------------------------------------------------- | -------------- | ----------------------- | ----------- | ---------------------------------------- |
| Catalog physical product                                | `product`      | `product`               | Required    | May be tracked and deducted              |
| Catalog service                                         | `service`      | `product`               | Required    | Validated against catalog; never tracked |
| Manually entered/custom “Miscellaneous” row at the till | Not applicable | `misc`                  | Omit        | Never affects catalog or inventory       |

Do not send `lineItems[].type: "service"`; the Transaction API accepts only `product` or `misc`
and returns `400 INVALID_LINE_ITEMS` for any other value. In this contract, `product` means
“catalog-backed line”, so it intentionally includes both catalog products and catalog services.

Additional rules:

- Maximum 200 lines. `[]`, `null`, or omitted means no cart.
- Sum of normalized `lineTotal` must equal `baseAmount` within $0.01.
- If `baseAmount` is omitted, the server derives it as
  `amount - tipAmount - taxAmount - convenienceFee`.
- Duplicate product lines are allowed. The server aggregates their quantities to three decimals
  for `maxQuantity` and inventory validation, while preserving the individual receipt lines.
- Product lines are revalidated against the live product, current device branch, `maxQuantity`,
  tracking policy, `allowZeroStockSale`, and server-calculated Available.
- `misc` lines never create catalog products and never affect inventory or most-sold counts.
- The saved cart is immutable receipt history; later catalog edits do not rewrite it.

Example cart and amount breakdown:

```json
{
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "tipAmount": 0,
  "convenienceFee": 0,
  "lineItems": [
    {
      "type": "product",
      "productId": "3f2b8c1e-4d5a-4e6f-8a7b-9c0d1e2f3a4b",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8.0
    }
  ]
}
```

The normalized product line total is `10.48`; the misc line total is `8.00`; therefore
`baseAmount` is `18.48`.

## 1. Sale

### Cash/check — easiest safe Postman test

```http
POST /api/v1/transactions/sale
```

```json
{
  "merchantId": "{{merchantId}}",
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "tipAmount": 0,
  "currency": "USD",
  "paymentMethod": "Cash",
  "lineItems": [
    {
      "type": "product",
      "productId": "{{productId}}",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8
    }
  ]
}
```

Cash and check skip the card gateway and are immediately stored as `settled`. AUTH does not support
cash/check.

### Card sale

Use the same amount/cart fields plus a PCI token:

```json
{
  "merchantId": "{{merchantId}}",
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "currency": "USD",
  "paymentMethod": "Card",
  "pciToken": "{{pciToken}}",
  "lineItems": [
    {
      "type": "product",
      "productId": "{{productId}}",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8
    }
  ]
}
```

Never send PAN or CVV to this endpoint. Obtain `pciToken` from the hosted PCI fields/SDK. Sandbox
testers may use the documented secret-key tokenization endpoint, but raw card data must not enter
mobile, Postman collections shared with the team, logs, or Production.

Success: `201 { "success": true, "data": <transaction> }`.

## 2. Authorization

```http
POST /api/v1/transactions/auth
```

Body: the card-sale body above with a fresh `Idempotency-Key`. A successful AUTH stores the cart but
does not deduct inventory. Capture it later with:

```http
POST /api/v1/transactions/<data.id>/capture
```

Only the successful capture queues inventory settlement. Capture requires the ordinary capture
permission and its own idempotency key.

## 3. Saved-card sale or authorization

```http
POST /api/v1/transactions/token-sale
```

```json
{
  "merchantId": "{{merchantId}}",
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "currency": "USD",
  "gatewayToken": "{{gatewayToken}}",
  "paymentMethodId": "{{paymentMethodId}}",
  "transactionType": "sale",
  "lineItems": [
    {
      "type": "product",
      "productId": "{{productId}}",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8
    }
  ]
}
```

Required payment identity: `gatewayToken` or `gatewayCustomerId`. `paymentMethodId` selects a
specific saved method when applicable. `transactionType` defaults to `sale`; use `auth` to hold
funds and wait for capture. Success: `201`.

## 4. ACH sale

```http
POST /api/v1/transactions/ach-sale
```

Prefer an ACH vault token:

```json
{
  "merchantId": "{{merchantId}}",
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "currency": "USD",
  "pciAchToken": "{{pciAchToken}}",
  "accountType": "checking",
  "accountHolderName": "Test Customer",
  "secCode": "PPD",
  "lineItems": [
    {
      "type": "product",
      "productId": "{{productId}}",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8
    }
  ]
}
```

The API also accepts `accountNumber` plus a nine-digit `routingNumber`, but mobile should use
`pciAchToken` so bank details are not placed in app logs or Postman exports. `accountType` defaults
to `checking`. ACH submission commonly returns `pending`; no inventory is deducted until the ACH
transaction reaches settlement. Success: `201`.

## 5. External IPOS/NearPay payment

Call this only after the device SDK has returned its provider transaction id:

```http
POST /api/v1/transactions/external-payment
```

```json
{
  "gatewayTransactionId": "{{providerTransactionId}}",
  "paymentMode": "NEARPAY",
  "type": "sale",
  "amount": 19.98,
  "baseAmount": 18.48,
  "taxAmount": 1.5,
  "tipAmount": 0,
  "convenienceFee": 0,
  "currency": "USD",
  "cardLast4": "4242",
  "cardBrand": "Visa",
  "orderId": "MOBILE-ORDER-1001",
  "lineItems": [
    {
      "type": "product",
      "productId": "{{productId}}",
      "name": "Diesel",
      "quantity": 2.5,
      "unit": "gallon",
      "unitPrice": 4.19
    },
    {
      "type": "misc",
      "name": "Car wash",
      "quantity": 1,
      "unitPrice": 8
    }
  ]
}
```

`paymentMode` is case-insensitive `IPOS` or `NEARPAY` and must match the authenticated device
gateway. `type` defaults to `sale`; it may be `auth`. The server derives merchant, device, branch,
MID, and terminal id from the device token.

The client must send the authenticated device's actual mode. Runtime currently performs the
explicit mode-mismatch rejection only when an `enhancedData` candidate is also present, so clients
must not treat server acceptance by itself as proof that the provider mode was correct.

- `201`: the device notification arrived first; a `pending` record was created.
- `200`: the provider webhook arrived first; the existing record was enriched with device fee/cart
  facts without replacing webhook-owned status/card/auth facts.
- Retry with the same `gatewayTransactionId`; never generate a replacement id.
- Because the provider may already have collected money, product validation failure does not reject
  or roll back this financial record. When a final external SALE is confirmed, the server queues
  inventory settlement if the environment rollout is enabled. After Invoice accepts the settlement,
  unresolved stock or location outcomes become durable Pending Review. A terminal delivery failure
  remains operational outbox work rather than becoming a mobile payment failure.

Postman alone cannot prove this flow's final settlement. A sandbox provider callback/webhook is
needed to move the transaction from `pending` to the provider's final status.

## Responses and cart errors

The first four endpoints return `201` on success. `/external-payment` returns `201` or `200` as
described above. Success bodies use:

```json
{
  "success": true,
  "data": {
    "id": "<transaction-row-id>",
    "transactionId": "<display/reference-id>",
    "status": "approved|settled|pending|...",
    "lineItems": [
      {
        "type": "product",
        "productId": "<uuid>",
        "name": "Diesel",
        "quantity": 2.5,
        "unit": "gallon",
        "unitPrice": 4.19,
        "lineTotal": 10.48
      }
    ]
  }
}
```

Important errors:

| HTTP | Code                                    | Meaning                                                              |
| ---: | --------------------------------------- | -------------------------------------------------------------------- |
|  400 | `INVALID_LINE_ITEMS`                    | Invalid line shape/precision/count or subtotal mismatch              |
|  400 | `QUANTITY_LIMIT_EXCEEDED`               | Aggregated quantity exceeds product `maxQuantity`                    |
|  404 | `PRODUCT_NOT_FOUND`                     | Product is not in this merchant's live catalog                       |
|  422 | `PRODUCT_NOT_ACTIVE`                    | Product is inactive                                                  |
|  422 | `DEVICE_LOCATION_REQUIRED`              | Tracked product but device has no current branch                     |
|  422 | `DEVICE_LOCATION_INACTIVE`              | Current branch is not a usable active physical location              |
|  409 | `INSUFFICIENT_STOCK`                    | Short-selling is off and requested quantity exceeds Available        |
|  503 | `PRODUCT_CART_VALIDATION_UNAVAILABLE`   | Product validation could not complete; pre-payment paths fail closed |
|  401 | `INVALID_DEVICE_TOKEN` / `UNAUTHORIZED` | User/device token is malformed, expired, revoked, or unknown         |
|  403 | `DEVICE_SUSPENDED` / `TX_TYPE_DISABLED` | Device or transaction type is disabled                               |
|  422 | `LIMIT_EXCEEDED`                        | Device or merchant transaction limit rejected the payment            |

`error.details.violations` identifies every affected product. `INVALID_LINE_ITEMS` supplies the
line index when the failure belongs to one line. A subtotal mismatch supplies `lineItemsTotal` and
`baseAmount`.

## Inventory behavior mobile must assume

- `allowZeroStockSale=false`: the first four endpoints reject insufficient stock before provider
  work. No payment and no deduction occur.
- `allowZeroStockSale=true`: payment may complete. Under the balance lock, Available 2 / sell 5
  records deducted 2, not deducted 3, and leaves Available at 0. Later Quantity In does not consume
  the historical shortfall.
- AUTH and pending ACH do not deduct. Capture and ACH settlement deduct once.
- Duplicate requests, callbacks, reconciliation, and worker retries do not deduct twice.
- Settlement delivery retries are server-managed and bounded. A terminal delivery failure is
  retained for operational review; it does not create a second payment or change payment success.
- Refund, void, reversal, dispute, ACH return, and customer return do not automatically restock.
  Physical returns require an explicit Quantity In / Customer Return movement.
- Payment success is never rolled back because post-payment inventory work failed.
- Refresh `GET /api/v1/device/products` after final sale/capture/settlement; the previous Available
  value is advisory and may already be stale.

## Postman verification workflow

For local testing, import the ready-made collection and environment described in
"POS Inventory Local Postman". The steps below remain useful when recreating
the requests manually or adapting them for UAT.

Create environment variables:

```text
baseUrl = https://uat.auxcore.net
tenantId
userJwt
deviceToken
merchantId
productId
pciToken              # card sale/auth only
gatewayToken          # saved-card flow only
paymentMethodId       # when required by the saved method
pciAchToken            # ACH only
providerTransactionId # external-payment only
idempotencyKey         # stable for one attempt; replace for a new attempt
```

Use `{{baseUrl}}/api/v1/...` in each request. Before payment:

1. Confirm the paired device session/test-connection is usable.
2. Call `GET {{baseUrl}}/api/v1/device/products?limit=200` with both tokens.
3. Select a real `data[].id`, copy its name/unit/price, and note `trackInventory`,
   `allowZeroStockSale`, `available`, and `eligible`.
4. Build `lineItems` and make its sum equal `baseAmount`.
5. Generate a UUID for `idempotencyKey`; keep it unchanged only when retrying the exact request.

For the first four requests, this optional Postman pre-request script creates a key only when the
environment does not already have one:

```js
if (!pm.environment.get('idempotencyKey')) {
  pm.environment.set('idempotencyKey', pm.variables.replaceIn('{{$guid}}'));
}
```

Set the header to `Idempotency-Key: {{idempotencyKey}}`. Delete or replace the environment value
before starting a genuinely new payment; leave it unchanged for a transport retry. Add this test
script to save the returned transaction identifier and catch malformed success responses:

```js
pm.test('payment request succeeded', () => {
  pm.expect([200, 201]).to.include(pm.response.code);
});

const response = pm.response.json();
pm.expect(response.success).to.eql(true);
pm.expect(response.data.id).to.be.a('string');
pm.environment.set('transactionRowId', response.data.id);
pm.environment.set('transactionId', response.data.transactionId);
```

Recommended cases:

| Case                                       | Expected result                                                               |
| ------------------------------------------ | ----------------------------------------------------------------------------- |
| Cash sale with valid product cart          | `201`, `settled`, normalized `lineItems`; stock later reflects one settlement |
| Replay same body + same key                | Same response; `Idempotent-Replayed: true`; no second deduction               |
| Same key + changed body                    | `422 IDEMPOTENCY_KEY_REUSED`                                                  |
| Wrong `baseAmount`                         | `400 INVALID_LINE_ITEMS`; no payment                                          |
| Four-decimal quantity                      | `400 INVALID_LINE_ITEMS`; no payment                                          |
| Tracked product, no branch                 | `422 DEVICE_LOCATION_REQUIRED`; no payment                                    |
| Shortage with short-selling off            | `409 INSUFFICIENT_STOCK`; no payment                                          |
| Available 2 / sell 5 with short-selling on | Payment succeeds; deducted 2 / not deducted 3                                 |
| AUTH                                       | Authorization succeeds; stock unchanged until capture                         |
| ACH accepted/pending                       | Stock unchanged until settlement callback/reconciliation                      |
| External notification before webhook       | `201 pending`; repeat gives existing record; webhook needed for finality      |

After a successful request:

1. Save `data.id`.
2. Call `GET {{baseUrl}}/api/v1/transactions/{{transactionRowId}}`; the path accepts either the
   UUID `data.id` or the human-readable `data.transactionId`. The detail response includes
   normalized `lineItems`.
3. Do not use `GET /transactions` to validate the cart; list rows intentionally omit `lineItems`.
4. Re-fetch `GET /device/products` after financial finality to inspect server-confirmed Available.
   Inventory settlement is asynchronous, so an unchanged value on the first refresh is not by
   itself a failure. Retry briefly; if it still does not converge, do not resubmit the payment.
   Operational staff must inspect the Transaction settlement outbox and any Invoice Pending Review
   application before concluding that no deduction occurred.

## What can and cannot be tested in the current web UI

The web portal is useful for setup and observation:

- `/invoices/products`: create/edit a product, enable inventory tracking and short-sale policy;
  use the Inventory tab for Quantity In and Inventory Activity.
- `/branches`: create an active physical location and assign/reassign the device.
- `/transactions`: find the transaction; open `/transactions/<id>` to verify the normalized cart,
  amounts, payment status, branch, and receipt data.

The current Tenant Dashboard Virtual Terminal does not send this POS `lineItems` contract, so it
does not prove the five itemised endpoints end to end. Use Postman or the mobile build to submit the
cart. Also, the existing Inventory Pending Review component is not currently rendered in the
Inventory tab; verify accepted business-review work through the recovery API, and terminal
Transaction delivery failures through operational logs, metrics, or outbox tooling until that UI
is enabled.

## Mobile implementation checklist

- Gate POS cart UI on `GET /api/v1/device/parameters` → `posLite.enabled`.
- Read products only from `GET /api/v1/device/products`; never use portal `GET /products`.
- Send product id/name/unit/price as a sale snapshot; do not send Available or branch selection.
- Recalculate display totals locally for UX, but treat server subtotal/product validation as final.
- Persist one idempotency key per payment attempt and reuse it across transport retries.
- Treat AUTH and pending ACH/external payments as non-final; do not optimistically reduce stock.
- On `409/422` product violations, refresh the device catalog before the cashier retries.
- After final payment, show payment success even if inventory is delayed or under review; never
  create a replacement payment to recover inventory.
