Skip to content

Layaway and Self-Checkout

Hyvä POS is in closed beta

Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change - possibly in backwards-incompatible ways - before the general release. Want to take part? Sign up at hyva.io/pos.

Two unrelated features share this page: layaway (partial-payment) orders, and the email one-time-code sign-in used by self-checkout kiosks. All routes require the Hyva_Pos::locations ACL resource.

Method Route Purpose
POST /V1/pos/layaways/:orderId/payments Capture a deposit, top-up, or final payment
POST /V1/pos/layaways/:orderId/cancel Cancel a layaway, retaining a fee
POST /V1/pos/self-checkout/otp/request Email a one-time sign-in code
POST /V1/pos/self-checkout/otp/verify Verify the code, return a customer summary

How Layaways Are Modeled

There is no layaway table and no custom order state. A layaway is a regular Magento order held in the stock pending_payment state, with metadata stamped onto extension attributes (persisted as sales_order columns):

  • pos_layaway_deposit_amount - the initial deposit
  • pos_layaway_balance_owed - remaining balance
  • pos_layaway_expires_at - due date, from the location's expiry-days setting
  • pos_layaway_initial_payment_method - the original tender, so a refund can be routed back to it
  • pos_layaway_status - open / ready_to_settle / stale / cancelled

Creating a layaway is therefore the stock POST /V1/orders call with these extension attributes set - no custom endpoint. Placement also creates the order's full invoice in the open state (module-side, automatic): accounting integrations that sync invoices see the revenue and VAT at placement, and the open invoice is the receivable. The invoice is marked paid at settlement - by the final capture below, or by Capture Offline in the admin for a bank-transferred balance. Listing uses the stock GET /V1/orders with searchCriteria. Only the payment capture and cancellation below are custom, because they must update the balance bookkeeping atomically. An hourly cron flags overdue layaways stale.

Canonical bookkeeping lives in the order's total_paid / total_due columns (visible in the admin grid); the extension attributes mirror them but are not the source of truth.

Capture a Payment

POST /V1/pos/layaways/:orderId/payments

One endpoint for all three capture kinds: the initial deposit, subsequent top-ups, and the final payment. The first capture on an order records the deposit amount and the initial payment method; every capture updates total_paid / total_due, appends a status-history comment (Layaway payment 50.00 via cash (ref tr_abc123) — balance 100.00), and flips the status to ready_to_settle when the balance reaches zero so the app can offer the Settle action. Settlement itself (invoicing, completing the order) then runs through stock Magento APIs.

{
  "amount": 50.0,
  "paymentMethod": "cash",
  "reference": "L-T4-000012"
}

reference is optional and doubles as the idempotency key: the register sends the terminal transaction id for card payments and the POS receipt number for cash. When the order's history already carries the reference, the capture is skipped and the current snapshot returned unchanged - a client that retries after a lost response cannot book the payment twice. Omitting reference keeps the legacy behavior: the capture always applies, with no dedupe.

Response - the post-capture snapshot, so the app updates its detail panel without another round-trip:

{
  "order_id": 981,
  "order_increment_id": "000000981",
  "total_captured": 150.0,
  "balance_owed": 100.0,
  "status": "open",
  "expires_at": "2026-09-11T14:03:22+00:00"
}

Validation: amount must be positive - 400 Layaway capture amount must be positive. Captures are clamped so total_paid never exceeds the grand total. An unknown order id returns the stock 404.

Cancel a Layaway

POST /V1/pos/layaways/:orderId/cancel

Cancels an open or stale layaway. The server computes the cancellation fee from the supplied percentage (the app sources it from the location's layaway_cancellation_fee_percent), retains it from the captured total, and stamps pos_layaway_status = cancelled plus an audit comment. For invoiced layaways (every layaway placed since the placement invoice shipped) the server issues a credit memo for the order total minus the fee - all items returned to stock, the fee as the memo's adjustment - and the order closes; the response carries the memo's increment id in credit_memo_increment_id. Legacy layaways without an invoice fall back to cancelling the Magento order directly, releasing MSI reservations the historical way (credit_memo_increment_id is null).

{
  "feePercent": 10.0,
  "reason": "Customer changed their mind"
}

Response - the snapshot gains three cancellation-only fields:

{
  "order_id": 981,
  "order_increment_id": "000000981",
  "total_captured": 150.0,
  "balance_owed": 100.0,
  "status": "cancelled",
  "expires_at": "2026-09-11T14:03:22+00:00",
  "cancellation_fee": 15.0,
  "refund_amount": 135.0,
  "refund_method": "cash"
}

The server does not move money: refund_amount and refund_method (the original tender, echoed back) tell the cashier what to physically hand back through the cash drawer or terminal reversal. Pass feePercent: 0 to refund in full.

Validation and refusals (400):

  • Layaway cancellation fee percent must be between 0 and 100.
  • Layaway is already cancelled.
  • Layaway is already paid in full - issue a refund instead of cancelling. - a ready_to_settle layaway is a refund case, not a cancellation.

The order-cancel step is non-fatal: if Magento's cancel fails after the status and comment are saved, the call still succeeds and the failure is logged.

Self-Checkout OTP Sign-In

Customers at a self-checkout kiosk identify themselves with their email address and a one-time code - no password entry on a shared screen, and no customer token is ever minted. After verification, subsequent credit and loyalty lookups use the terminal's own integration auth, identical to the cashier path.

Request a Code

POST /V1/pos/self-checkout/otp/request

{
  "request": {
    "email": "customer@example.com",
    "terminalId": 3
  }
}

Generates a numeric code, stores a hash, and emails it to the customer. The response is identical whether or not the email belongs to an account - the kiosk cannot distinguish "code sent" from "no such customer", which prevents email enumeration. Expired rows are purged by an hourly cron.

Rate limits throw 400 LocalizedExceptions:

  • Resend cooldown: Please wait %1 seconds before requesting another code.
  • Per email: Too many sign-in codes requested for this email. Try again later.
  • Per terminal: This terminal has reached its hourly sign-in code limit. Try again later.

All knobs live under hyva_pos_advanced/self_checkout/ in store configuration:

Setting Default
otp_length 6 digits (clamped 4-10)
otp_ttl_seconds 180
otp_max_attempts 5
otp_resend_cooldown_seconds 30 (clamped 0-300)
otp_rate_limit_per_email_hour 5
otp_rate_limit_per_terminal_hour 20

Verify a Code

POST /V1/pos/self-checkout/otp/verify

{
  "request": {
    "email": "customer@example.com",
    "code": "482913",
    "terminalId": 3
  }
}

The code must have been issued for the same email + terminal pair. On success, returns an unprivileged customer summary the kiosk attaches to the cart:

{
  "customer_id": 42,
  "email": "customer@example.com",
  "firstname": "Jane",
  "lastname": "Doe",
  "group_id": 1,
  "website_id": 1,
  "company": null,
  "taxvat": null,
  "default_billing_address_id": 118
}

Failures: 401 AuthenticationException for an invalid, expired, or attempt-exhausted code (each wrong attempt counts against otp_max_attempts), and 404 NoSuchEntityException when no code exists for the email or the terminal does not match.