docs
/
Example apps

Tutorial: the payments app

Build the payments example from scratch — open a tab, put items on it, and settle it one tender at a time: cash with change, a card with a reference, a split, a refund, and a receipt printed or sent — with every rule the server enforces shown in its own words.

appmint_flutter_payments_demo

Get the code

Browse it on GitHub · Download the repository as a zip

git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/appmint_flutter_payments_demo

The example depends on the Flutter client by relative path (../../appmint-client/appmint_flutter_client), so clone appmint-client beside it — or change that one line in pubspec.yaml to the git dependency shown in Step 1.

This tutorial builds the payments example one file at a time. It answers the question people gate on — can I take money with this? — by doing it: a member of staff opens a tab, puts items on it, and takes the money one tender at a time. Cash, with the change worked out by the server. A card, with the approval code recorded so a refund can find it later. A split across two tenders. A refund of one of them. A receipt, drawn the way a printer would print it, and sent by email or text.

Everything on this page was run against a live AppEngine while it was written. The rules — what a tender may and may not do — are the server's, and the app shows its refusals word for word rather than guessing at them.

Start with authentication. This app reuses the request log and the staff sign-in from the events tutorial. Those parts are not repeated here.

Before you start

Flutter 3.27+, an organization with an app credential from Studio Manager, a staff account, and at least one product with a SKU — items go on a tab by SKU. Cash, card-with-reference, refunds and receipts need nothing else configured. Card-present through a reader needs a Stripe integration with Terminal enabled and a device build — Step 6 says exactly where the browser example stops.

Step 1 — The project

flutter create --platforms=android,ios,web --org io.appmint \
  --project-name appmint_flutter_payments_demo \
  appmint_flutter_payments_demo
cd appmint_flutter_payments_demo
dependencies:
  flutter:
    sdk: flutter

  appmint_flutter_client:
    git:
      url: https://github.com/JacLight/appmint-client.git
      path: appmint_flutter_client
flutter pub get

Delete test/widget_test.dart. Copy lib/call_log.dart and lib/screens/sign_in_page.dart from the events example; lib/main.dart is the same shape and routes to a RegisterPage after sign-in.

Step 2 — The POS API

lib/payments_api.dart. A sale is an sf_order — the same record a web checkout makes — opened as a "tab", filled with items, and settled one tender at a time. The endpoints live under /storefront.

A tab, and what goes on it

openTab({alias})     → POST /storefront/pos/tab          {alias?}
openTabs()           → GET  /storefront/pos/tabs
tab(id)              → GET  /repository/get/sf_order/:id
addItems(id, items)  → POST /storefront/order/:id/items  {items:[{sku, quantity}]}

A new tab is an order with status: new and amount: 0; with no alias the server names it Walk-up #n. Items are added by SKU — the server looks each product up, prices it, and recomputes the totals. An unknown SKU is "Product not found: …"; a paid tab refuses new items with "Cannot add items to paid order". And if two products share a SKU, the server takes the first it finds — the picker showed 83" and the tab got 55" while writing this. SKUs are the identity here; keep them unique.

The catalogue for the picker is a repository read, filtered to products that have a SKU:

await appmint.repository.find('sf_product',
    filter: {'data.sku': {r'$exists': true, r'$ne': ''}},
    sort: {'data.title': 1}, pageSize: 100);

Money

Future<SettleResult> settle(String tabId, {
  required num amount, required String method,
  String gateway = 'manual', String? ref, num? tip,
}) async {
  final res = _map(await _http.post('/storefront/pos/tab/$tabId/settle', body: {
    'amount': amount, 'method': method, 'gateway': gateway, 'ref': ?ref, 'tip': ?tip,
  }));
  return SettleResult(order: _map(res['order']), transaction: _map(res['transaction']));
}

One tender per call. The server holds the rules, and each of these was confirmed by hitting it:

RuleWhat the server says
Cash may exceed the balance. The payment is booked as the amount due; the rest is changeThe transaction's remarks: "tendered: 50, change: 30" — and the order's payment line carries tendered and change
Any other tender for more than is due is refused"Payment of 100.00 exceeds the 60.00 due on order IN18NI257. Charge the balance due; overpayment is only accepted on cash tenders (change is returned)."
A paid tab refuses another tender"Order is already paid in full"
Each tender stands on its ownCash 40 then cash 20 on a 60 tab: two payment lines, two sf_transaction records, status paid-partial then paid. A card that declines does not undo cash already taken

The three fields mean different things. method is what the receipt says (cash, card, other). gateway is where the money actually moved — manual when it did not move through this server. ref is the approval code, payment intent id, or note that lets a refund find the line later.

Refunds

refund(id, {transactionId, amount?, reason?}) → POST /storefront/pos/tab/:id/refund

Identified by the transaction id the settle handed back (or by ref). Writes a negative payment line whose ref is refund:<original>, accumulates refundedAmount on the original, and recomputes the order's status. A partial refund can be repeated until the line is fully refunded; after that, "Payment is already refunded". Refunding more than is left is refused with the amount that is.

Receipts

receiptPayload(id)         → GET  /storefront/pos/tab/:id/receipt-payload
sendReceipt(id, {email|phone}) → POST /storefront/pos/tab/:id/send-receipt

The payload is the receipt as a printer would be handed it: {lines, width, mode} where each line is {kind: 'text', text, bold, size} or {kind: 'image', url}, already padded to the paper width. The app draws it in a monospace box; a real register hands it to the ESC/POS printer. Sending answers {sent: 1, channel: 'email', recipients: [...]}.

Card present

terminalConnectionToken() → POST /storefront/stripe/terminal/connection-token → {secret: 'pst_…'}

The server's half of a card-present payment: a single-use Stripe Terminal connection token. Step 6 is about the other half.

Step 3 — The register

lib/screens/register_page.dart lists open tabs newest first — alias, item count, status, total, and due in amber when there is a balance — with New tab, which opens one and goes straight into it.

Step 4 — The tab

lib/screens/tab_page.dart is items, totals, the two actions, and the payment lines.

Add opens the catalogue with − / + per product and posts the chosen SKUs. The totals box shows subtotal, tax, total, paid and due; the button reads Take $60.00 until it reads Paid in full and disables. A tab with nothing on it cannot take money:

Nothing on this tab yet. Add something before taking money — the server refuses a payment on a zero tab.

Each payment is a row: method and amount, then whatever the line knows — the reference, what was tendered, the change, how much has been refunded. Refunds are red negative rows pointing at the payment they undo. A row that can still be refunded has a Refund button.

Step 5 — Taking payment

lib/screens/take_payment_sheet.dart is four tenders under one sheet — Cash, Card, Reader, Other — each with one sentence saying what it is for and what the server will do with it. The amount field starts at the balance due; for cash it is labelled Tendered.

final r = await api.settle(tab.id, amount: amount, method: 'cash', tip: tip);
final change = RegExp(r'change: ([\d.]+)').firstMatch('${r.transaction['data']['remarks']}');
// → "$20.00 taken by cash · change due $30.00 · tab is now paid."

The sentence the sheet hands back to the tab page is built from the server's answer, not from the amount typed: the transaction's amount is what was booked, the change is read out of its remarks, and the status is the order's. When the server refuses — the card overpayment, the paid tab — its sentence appears under the fields, unedited, because it already says what to do.

Step 6 — Where the browser stops

Choose Reader and the sheet offers Ask the server for a Terminal connection token. It comes back — pst_…, a few hundred characters — and the sheet says what happens next:

On a device, the Terminal SDK takes this, finds the reader over Bluetooth and collects the card; the payment intent id then goes in "Reference" below and the tender is recorded like any other. A browser cannot do the reader half.

That is honest, and it is the whole shape of card-present on AppEngine: the server mints connection tokens and records tenders; the device runs the Stripe Terminal SDK. The working device implementation is Appmint Mobile's stripe_terminal_service.dart (the mek_stripe_terminal plugin, Bluetooth permissions in Info.plist, a Terminal location per business location via POST /storefront/stripe/terminal/location). Two things from it worth knowing before you start: a first pair of an M2 takes two to five minutes while Stripe pushes firmware, and iOS aborts at initTerminal — natively, uncatchably — if the Bluetooth usage strings are missing.

Step 7 — Run the whole thing

flutter run -d chrome \
  --dart-define=APPMINT_URL=https://appengine.appmint.io \
  --dart-define=APPMINT_ORG=your-org \
  --dart-define=APPMINT_APP_ID=your-app-id \
  --dart-define=APPMINT_APP_KEY=your-app-key \
  --dart-define=APPMINT_APP_SECRET=your-app-secret

In order, you should see:

  1. Staff sign-in, then the register — GET /storefront/pos/tabs.
  2. New tab → POST /storefront/pos/tab → an empty tab, Walk-up #n.
  3. Add, pick two of one thing and one of another → POST /storefront/order/:id/items → lines and totals; say $60.00 due.
  4. Take $60.00 → Card, amount 100 → the server's refusal, verbatim. Change it to 40 with a reference → $40.00 taken by card · tab is now paid-partial.
  5. Take $20.00 → Cash, tendered 50, tip 2 → $20.00 taken by cash · change due $30.00 · tab is now paid. Two payment lines, two transactions.
  6. Take is now Paid in full and disabled. (Post a settle by hand and the server says Order is already paid in full.)
  7. Refund on the card line, 10 → a red −$10.00 line, refunded $10.00 on the original, status back to paid-partial.
  8. Receipt → the printed layout, then an email address and Send → Sent 1 by email to ….
  9. Take → Reader → the connection token and the sentence about the device.

When it does not work

What you seeWhat it means
Product not found: XYZNo product has that SKU. Items go on a tab by SKU, case-insensitively
Payment of 100.00 exceeds the 60.00 due …You charged more than the balance on a non-cash tender. Charge the balance; only cash gets change
Order is already paid in fullNothing is due. Refund first if the sale needs changing
Payment is already refundedThat line has been refunded to zero. A partial can be repeated; a full one cannot
Cannot add items to paid orderThe tab is settled. Open a new one
No connection token / Stripe Terminal is not configuredThe organization has no Stripe integration with Terminal enabled. Cash and manual tenders still work
Receipt lines print at 0.00 under a correct subtotalAn older AppEngine's receipt builder read price from each line; POS items carry unitPrice. Fixed on the server — update it
Refund shows but the status did not changeIt did on the server — the page reloads the order after every action; if you are looking at a stale tab, go back and reopen