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_demoThe 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_demodependencies:
flutter:
sdk: flutter
appmint_flutter_client:
git:
url: https://github.com/JacLight/appmint-client.git
path: appmint_flutter_clientflutter pub getDelete 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:
| Rule | What the server says |
|---|---|
| Cash may exceed the balance. The payment is booked as the amount due; the rest is change | The 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 own | Cash 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/refundIdentified 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-receiptThe 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-secretIn order, you should see:
- Staff sign-in, then the register —
GET /storefront/pos/tabs. - New tab →
POST /storefront/pos/tab→ an empty tab, Walk-up #n. - Add, pick two of one thing and one of another →
POST /storefront/order/:id/items→ lines and totals; say $60.00 due. - 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.
- 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.
- Take is now Paid in full and disabled. (Post a settle by hand and the server says Order is already paid in full.)
- Refund on the card line, 10 → a red −$10.00 line,
refunded $10.00on the original, status back topaid-partial. - Receipt → the printed layout, then an email address and Send → Sent 1 by email to ….
- Take → Reader → the connection token and the sentence about the device.
When it does not work
| What you see | What it means |
|---|---|
| Product not found: XYZ | No 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 full | Nothing is due. Refund first if the sale needs changing |
| Payment is already refunded | That line has been refunded to zero. A partial can be repeated; a full one cannot |
| Cannot add items to paid order | The tab is settled. Open a new one |
| No connection token / Stripe Terminal is not configured | The organization has no Stripe integration with Terminal enabled. Cash and manual tenders still work |
| Receipt lines print at 0.00 under a correct subtotal | An 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 change | It did on the server — the page reloads the order after every action; if you are looking at a stale tab, go back and reopen |