Вендинг машин — бүрэн жишээ¶
Энэ хуудсаар
Юу хийх: вендинг машинд Gerege App-аар төлүүлж, мөнгийг бараа гарсны дараа л авах — гараагүй бол хүнд буцааж суллах, хэсэгчлэн гарсан бол гараагүй хэсгийг буцаах. Сүлжээ тасрах, машин гацах, хариу хоцрох бүх тохиолдол. Хугацаа: 1–2 өдөр (машины firmware-ийн талыг оруулаад). Хэрэгтэй: худалдагч апп ба test credential (бүртгэл), машин бүрт терминал ба sticker (терминал ба sticker), машин ↔ таны backend холболт (HTTPS эсвэл MQTT).
Ажилладаг бүтэн код: examples/go/cmd/vending/main.go, examples/node/src/vending.ts (энэ репод, §12).
1. Архитектур¶
┌──────────── Машин ─────────────┐ ┌──────── Таны backend ────────┐ ┌──────── Gerege ────────┐
│ контроллер · дэлгэц · мотор │ HTTPS/ │ сагс, үнэ, журнал │ HTTPS │ /api/v1/miniapp │
│ NFC/QR sticker (терминалын код) │ MQTT ─▶│ client_id + client_secret │───────▶│ payment-intents │
│ eID насны шалгалт (тамхи 21+) │◀────── │ webhook хүлээн авагч │◀────── │ webhook (HMAC) │
│ Gerege-ийн secret: ҮГҮЙ │ │ reconcile loop (30 с) │ │ │
└─────────────────────────────────┘ └───────────────────────────────┘ └────────────────────────┘
▲ sticker: https://geregeapp.mn/pay/t/<code> │
└──────────────── хүний утас (Gerege App): claim → Face ID → hold ─────────────┘
- Машин secret барихгүй. Машиныг задалж, флаш санах ойг уншиж болно — тэнд Gerege-ийн түлхүүр байвал бүх машины мөнгийг өөр тийш эргүүлэх зам болно. Машин зөвхөн таны backend-тэй өөрийн (машин бүрийн) credential-аар ярина: mTLS эсвэл төхөөрөмж бүрийн түлхүүртэй MQTT.
- Sticker зөвхөн терминалыг нэрлэнэ — дүн серверээс, тухайн мөчид терминалд уясан нээлттэй intent-ээс ирнэ.
- Мөнгө manual capture-ээр: хүн баталмагц түүний дансанд барьцаа (hold) тавигдана, бараа гарсны дараа л
таны backend
captureхийнэ.
2. Нэг удаагийн тохиргоо¶
- Худалдагч апп (
kind: "merchant"), test ба live түлхүүр (preset «Вендинг / kiosk backend»), webhook URL — Gerege App-аар төлөх §2. Webhook ньpayment_intent.*баrefund.*-ийг хүлээн авна (орчин бүрт ≤ 5 endpoint, тус бүр өөрийн event шүүлтүүр ба secret-тэй; анхныwebhook_urlнь test, live хоёуланд —livemode-оор ялга). - Олон эзэмшигч (машин бүр өөр эзэнтэй) бол эзэмшигч бүрийг дэд худалдагч болгож, эзэмшигч өөрөө Gerege
App-д payout дансаа батална — дэд худалдагч. Батлагдаагүй бол live intent
409 payouts_not_configured. - Машин бүр = терминал,
reference= машины serial (давхар бүртгэгдэхгүй, дахинPOST→ тэр терминал200):
curl https://geregeapp.mn/api/v1/miniapp/terminals -u "$GEREGE_LIVE_ID:$GEREGE_LIVE_SECRET" \
-H 'Content-Type: application/json' \
-d '{"label": "Машин №7", "location": "СБД, 1-р хороо, Их сургуулийн гудамж 3, 1 давхар",
"reference": "VM-0007", "sub_merchant_id": "9a7c…"}'
Терминал бичих нь зөвхөн live түлхүүр эсвэл консол (test → 403 live_only; test түлхүүр уншина). label,
location нь хүнд Face ID-аас өмнө харагдана — яг тэр машиныг таних байдлаар бич.
4. Sticker — терминалын url-ийг QR болгож хэвлэх, NFC tag-д бичиж түгжих: терминал ба sticker.
3. Сагс бүрт — дарааллын диаграм¶
Хүн Машин Таны backend Gerege Gerege App
│ бараа сонгоно │ │ │ │
│──────────────▶│ (насны бараа: машины eID шалгалт) │ │
│ │── POST /machine/baskets ▶│ │ │
│ │ │── POST /payment-intents ───────▶│ terminal, manual, │
│ │ │ Idempotency-Key: 77120 │ capture_within 180 │
│ │◀── «3 500₮ · утсаа хүргэнэ үү» ◀── 201 pi_… ────────────│ │
│ утсаа sticker-т хүргэнэ / QR уншуулна ─────────────────────────────────────────────────────────────▶│
│ │ │ │◀──── claim (код) ──────────│
│ │ │◀── payment_intent.claimed ──────│ │
│ │◀── «Утсаараа баталгаажуулна уу» │ │
│ Face ID ───────────────────────────────────────────────────────────────────────────────────────────▶│
│ │ │ │◀── confirm → HOLD ─────────│
│ │ │◀── payment_intent.requires_capture │
│ │ │── GET /payment-intents/{id} ───▶│ requires_capture мөн үү? │
│ │◀──── dispense ────────│ │ │
│◀── бараа ─────│── result {dropped} ──▶│ │ │
│ │ │── capture | cancel | capture + refund ▶│ │
│ │ │◀── payment_intent.succeeded / .canceled · refund.succeeded │
│ │◀── «Баярлалаа» ───────│ │ │
4. Intent үүсгэх¶
POST /api/v1/miniapp/payment-intents · Authorization: Basic … · Idempotency-Key: 77120
{
"amount": 5500,
"description": "Захиалга #77120",
"reference": "77120",
"expires_in": 90,
"presentment": "terminal",
"terminal_id": "4b8e2f0c-…",
"capture_method": "manual",
"capture_within": 180,
"metadata": {"machine": "VM-0007", "basket": "77120"}
}
| Талбар | Яагаад |
|---|---|
amount |
таны үнийн жагсаалтаас (машины илгээсэн тоо биш — машиныг хуурч болно) |
description |
«Захиалга #77120» — бүтээгдэхүүний нэр хэзээ ч биш (§11) |
reference = Idempotency-Key = сагсны дугаар |
нэг сагс = нэг intent: хүсэлт хэдэн удаа давтагдсан ч ижил intent (200) |
expires_in: 90 |
машины өмнө зогсож буй хүнд; 60–1800 с |
capture_method: "manual", capture_within: 180 |
баталснаас хойш 180 с-д capture/cancel; эс бөгөөс hold хүнд суллагдана (§7) |
sub_merchant_id |
орхино — терминалынх автоматаар |
Терминалд claim хийгдээгүй нээлттэй intent нэг л байна (орчин бүрт). Шинэ сагс эхлэхэд өмнөх төлөгдөөгүй сагсаа
эхлээд өөрөө цуцал. Цуцлахаа мартсан бол (машин дахин асав, хариу алдагдав) 409 terminal_busy ирж,
payment_intent талбарт нээлттэй intent-ийн id байна — түүнийг POST …/cancel хийгээд дахин үүсгэ, машинд
«Өмнөх захиалга цуцлагдлаа». Хугацаа нь өнгөрсөн ч job хараахан хаагаагүй intent-ийг сервер өөрөө хаагаад дахин оролдоно.
5. Claim, hold, гаргах команд¶
payment_intent.claimed→ дэлгэц «Утсаараа баталгаажуулна уу». Хүн 90 с-д баталгаажуулаагүй бол claim дуусч дараагийн уншуулсан хүн авна (шинэuser_ref, дахинpayment_intent.claimed); таPOST …/unclaim-аар түрүүлж суллаж болно (тэгвэлclaimed_at: null).payment_intent.requires_capture→ мөнгө барьцаалагдсан. Гаргах командаас өмнө intent-ийгGET /payment-intents/{id}-оор дахин уншижstatus == "requires_capture"баamount,referenceтаны сагстай тэнцүү эсэхийг шалга (event давтагдаж, дараалал алдагдаж болно).- Машинд dispense (intent id-тай). Машин мотор бүрийг эргүүлэхээс өмнө локал журналд бичнэ (
77120 A1 эхэлсэн), дараа нь үр дүнг (A1 унасан/B1 гацсан) — тэр журнал нь гэрэл тасарсны дараах үнэн. - Машин үр дүнгээ backend-д:
{dropped: [{slot, qty}]}.
6. Capture, cancel, хэсэгчлэн гаргалт¶
| Машины үр дүн | Таны backend | Хүнд |
|---|---|---|
| Бүгд гарсан | POST /payment-intents/{id}/capture → succeeded |
бүтэн дүн хасагдана |
| Юу ч гараагүй | POST /payment-intents/{id}/cancel → hold суллагдана (canceled) |
юу ч хасагдахгүй (буцаалт биш — шимтгэлгүй) |
| Хэсэгчлэн (3-аас 2) | capture бүтэн → succeeded болтол хүлээ → POST …/refunds {"amount": <гараагүйн үнэ>, "reason": "dispense_partial"} + Idempotency-Key: refund:77120 |
бүтэн хасагдаж, гараагүй хэсэг буцна |
- Дүнтэй хэсэгчилсэн capture (
POST /capture {amount}) — «удахгүй»: core banking-д дүнтэй capture гартал бүтэн + буцаалт. - Live capture нь
202+status: "processing"буцааж болно (дэвтэр хараахан хариулаагүй). Буцаалт зөвхөнsucceededintent-д (409 not_refundable) — тиймээсGET-ээрsucceededболтол хүлээнэ. - Capture, cancel нь төлвөөр idempotent: аль хэдийн capture хийсэн intent-ийг дахин capture →
200тэр intent. Буцаалт өөрийнIdempotency-Key-ээр —refund:<сагс>тул дахин settle хийхэд хоёр дахь буцаалт үүсэхгүй.
// examples/node/src/vending.ts — settle()
if (b.dropped === 0) return void (await retry(() => gerege.paymentIntents.cancel(b.intent)));
let pi = await retry(() => gerege.paymentIntents.capture(b.intent));
while (pi.status === "processing") { await sleep(1000); pi = await gerege.paymentIntents.retrieve(b.intent); }
const rest = b.amount - b.dropped;
if (rest > 0 && pi.refunded_amount < rest) {
await retry(() => gerege.refunds.create(b.intent, { amount: rest, reason: "dispense_partial" },
{ idempotencyKey: `refund:${b.id}` }));
}
7. Хугацаа ба timeout-ууд¶
| Хугацаа | Утга | Дууссан үед |
|---|---|---|
expires_in (энд 90 с) |
сагс үүссэнээс хүн баталгаажуулах хүртэл | expired, payment_intent.expired → дэлгэц «Хугацаа дууслаа» |
| Claim-ийн хугацаа 90 с | хүн уншуулаад баталгаажуулаагүй | дараагийн хүн claim хийж чадна (шинэ user_ref, дахин claimed) |
capture_within (энд 180 с; 60–604 800) |
баталснаас capture/cancel хүртэл; capture_before = баталсан агшин + capture_within |
expired, failure_code: "authorization_expired", hold хүнд суллагдана, payment_intent.expired; capture → 409 expired |
| Дэвтрийн нөөц хугацаа | capture_before + цонхны хагас (≥ 60 с, ≤ 15 мин) |
платформ өөрөө суллаж чадаагүй үед л core banking hold-ыг суллана |
capture_within-ийг яаж сонгох: гаргах хугацаа + машины үр дүнг мэдээлэх хамгийн муу саатал (сүлжээ
тасарч сэргэх) + нөөц. Тогтвортой сүлжээтэй машинд 120–300 с; 4G-ээр тасалддаг газар илүү (600–1800 с) — гэхдээ
урт байх тусам машин унтарвал хүний мөнгө удаан барьцаалагдана. capture_before-оос хойш ирсэн «гарсан» үр дүнг
capture хийх боломжгүй — тэр бол алдагдал (§8).
8. Алдаа ба offline¶
| Тохиолдол | Юу болох | Юу хийх |
|---|---|---|
| Webhook ирээгүй / хоцорсон | — | машин хүлээж байх үед backend 2 с тутам GET (60 с хүртэл); reconcile loop ?status=requires_capture-ийг 30 с тутам |
| Backend → Gerege timeout (create) | intent үүссэн эсэх тодорхойгүй | ижил Idempotency-Key-ээр давтах → тэр intent (200) |
| Capture/refund 5xx, timeout | — | ижил дуудлагыг давтах (capture төлвөөр, refund key-ээр idempotent); 202 → дараа нь уншина |
| Машин гаргасан ч backend-д мэдэгдэж чадаагүй | hold хүлээгдэнэ | машин журналаасаа онлайн болмогц мэдээлнэ; capture_before-оос өмнө бол capture, хойш бол алдагдал (журналд тэмдэглэ) |
| Машин гаргахаас өмнө гацсан / унтарсан | hold хүлээгдэнэ | backend (эсвэл оператор) cancel → суллагдана; эс бөгөөс capture_within дуусахад өөрөө |
| Хүн апп дотроо цуцалсан | payment_intent.canceled |
дэлгэц шинэчлэх |
Төлбөр амжилтгүй (failed) |
хүн хугацаа дуустал дахин оролдож болно | «Дахин оролдоно уу» — сагсыг бүү хаа |
| Нэг хүн дахин уншуулсан | claim нэг хүнд idempotent | — |
| Өөр хүн уншуулсан (claim идэвхтэй) | түүнд 409 already_claimed, retry_after: 90 |
— |
| Хүн уншуулаад явчихсан | 90 с-д claim дуусна | эсвэл POST …/unclaim; машин «утсаараа баталгаажуул» дэлгэцээс 90 с-ийн дараа буцна |
| Нэг хүн > 3 баталгаажаагүй claim | 429 too_many_open_claims |
— (алсаас claim хийх оролдлогын хамгаалалт) |
| Өмнөх сагс нээлттэй | 409 terminal_busy (payment_intent) |
тэрийг цуцлаад дахин (§4) |
| Терминал идэвхгүй | 409 terminal_disabled |
PATCH /terminals/{id} {"status": "active"} |
| Test сагс ба live сагс | орчин бүрт тусдаа | test сагс live машиныг хаахгүй |
reference-гүй / үл таних intent мөнгө барьж байна |
(crash-аар сагс алдагдсан) | reconcile: cancel — хүний мөнгийг дэмий барихгүй |
9. Idempotency¶
| Дуудлага | Давтахад юу хамгаална |
|---|---|
POST /payment-intents |
Idempotency-Key = сагсны дугаар (= reference): ижил key + ижил бие → ижил intent 200; өөр бие → 409 idempotency_conflict |
POST …/capture, …/cancel |
intent-ийн төлөв: аль хэдийн хийгдсэн бол тэр intent-ийг буцаана |
POST …/refunds |
өөрийн Idempotency-Key (refund:<сагс>) |
POST …/unclaim |
claim хийгдээгүй intent-ийг байгаагаар нь 200 |
| Webhook | id-аар dedupe (at-least-once) |
Машин ↔ backend хооронд ч ижил дүрэм: машин сагсны дугаараа өөрөө үүсгэж (VM-0007-000123), давтагдсан
POST /machine/baskets тэр сагсыг буцаана.
10. Тулгалт¶
- Reconcile loop (30 с):
GET /payment-intents?status=requires_capture— үр дүн нь мэдэгдсэн сагсыг settle, үл мэдэгдэх intent-ийг cancel, хугацаа нь дөхсөнийг сэрэмжлүүлэх; хүлээгдэж буй сагс бүрийн intent-ийгGET. - Өдөр бүр: машины журнал ↔
GET /payment-intents?reference=<сагс>↔ таны сагсны мөр. Тэнцээгүйг гараар. - Машин/backend унтарсан бол алдсан event-ээ
GET /events?after=<сүүлийн evt>-оор нөхнө (30 хоног). Тайлан CSV — «удахгүй». Дэлгэрэнгүй: тулгалт.
11. Насны хязгаартай бараа¶
- Насны шалгалт машин дээр. Тамхи (21+) зэрэг бараанд машин өөрийн eID QR шалгалтаа хийж, тэр шалгалтыг тухайн
сагстай холбоно (шалгасан хүн = авч буй хүн; eID хариуны гарын үсгийг шалгах) — дараа нь л
POST /machine/baskets. - Төлөх цонх бүтээгдэхүүнийг хэзээ ч нэрлэхгүй. Gerege App-д харагдах нь: апп-ын бүртгэлтэй нэр, лого,
терминалын
label/location, дэд худалдагчийн нэр, дүн — худалдагчийнdescription-ийг төлөх цонх харуулахгүй (M4), гэхдээ хүний хэтэвчний хуулгад<slug>: <description>орно. Тиймээсdescription= «Захиалга #77120»; апп-ын нэр, лого, терминалын нэрэнд тамхины брэнд/дүрс оруулахгүй. Шалтгаан: Gerege App бол ерөнхий төлбөрийн хэрэгсэл — App Store-ийн Review Guidelines 1.4.3 нь тамхи, vape хэрэглээг сурталчилсан апп-ыг хориглодог; апп дотор тамхины нэр гарвал Gerege App тамхи борлуулдаг апп мэт харагдана. min_age(13–21) — сонголтоор, хоёр дахь давхарга. Intent-дmin_age: 21өгвөл Gerege төлөгчийг шалгана: eID-гүй →403 verification_required, нас хүрэхгүй →403 age_restricted(claim ба confirm-д; хүнд зөвхөн «насны хязгаартай» гэж — бараа биш). Энэ нь төлж буй хүнийг шалгана, машины шалгалт авч буй хүнийг — хоёулаа байвал хоёулаа давах ёстой. Машины шалгалтыг орлохгүй.- App Clip (Gerege App суугаагүй утсанд) тамхины машинд хэзээ ч идэвхжихгүй.
12. Бүтэн код¶
| Файл | Юу |
|---|---|
examples/go/cmd/vending/main.go |
машины API, intent (terminal_busy-тэй), webhook, settle (capture / cancel / capture + refund), reconcile loop |
examples/node/src/vending.ts |
яг ижил, TypeScript |
examples/*/…/webhook* |
ерөнхий webhook хүлээн авагч (queue, dedupe) |
export GEREGE_CLIENT_ID=gk_test_… GEREGE_CLIENT_SECRET=gks_test_… GEREGE_WEBHOOK_SECRET=whsec_…
export GEREGE_TERMINAL_ID=4b8e2f0c-… MACHINE_TOKEN=$(openssl rand -hex 24)
cd examples/go && go run ./cmd/vending # эсвэл: cd examples/node && npm run build && npm run vending
# Машины оронд:
curl -X POST localhost:8080/machine/baskets -H "Authorization: Bearer $MACHINE_TOKEN" \
-d '{"basket_id": "77120", "lines": [{"slot": "A1", "qty": 1}, {"slot": "B1", "qty": 2}]}'
curl localhost:8080/machine/baskets/77120 -H "Authorization: Bearer $MACHINE_TOKEN" # "state": "dispense" хүртэл
curl -X POST localhost:8080/machine/baskets/77120/result -H "Authorization: Bearer $MACHINE_TOKEN" \
-d '{"dropped": [{"slot": "A1", "qty": 1}, {"slot": "B1", "qty": 1}]}' # 1 500₮ буцна
Go-гийн settle (examples/go/cmd/vending/main.go-оос; shared.Retry нь ижил Idempotency-Key-ээр давтдаг туслах):
pi, err := shared.Retry(ctx, func(ctx context.Context) (miniapp.PaymentIntent, error) {
return s.gerege.PaymentIntents.Capture(ctx, intent)
})
if shared.Code(err) == "expired" { // capture_before өнгөрсөн: hold хүнд суллагдсан
s.end(b, stDone, "АЛДАГДАЛ")
return nil
}
// … processing бол GET-ээр succeeded болтол, дараа нь:
_, err = s.gerege.PaymentIntents.Refund(ctx, intent, "refund:"+b.ID,
miniapp.RefundParams{Amount: amount - dropped, Reason: "dispense_partial"})
13. Туршилт¶
- Test түлхүүр (
gk_test_…, эсвэл хуучинmapp_test_…): мөнгө хөдлөхгүй,livemode: false. Терминалын test сагсыг зөвхөн апп-ын эзэн ба тест хэрэглэгчид (таны байгууллагын идэвхтэй гишүүд) claim хийнэ; бусдад байхгүй мэт. - Test дүн (сүүлийн хоёр орон):
…01→failed(test_declined) — «төлбөр амжилтгүй» дэлгэцийг турш;…02→canceled; бусад →requires_capture. Test capture шуудsucceeded, test буцаалт ажиллана. - Терминалын бүртгэлийг test түлхүүр уншина — терминалыг live түлхүүр эсвэл консолоор нэг удаа бүртгэнэ.
- Хүний тал: тест хэрэглэгчийн iPhone дээрх Gerege App-аар sticker-ийн QR-ийг уншуулах эсвэл апп доторх «NFC уншуулах» → claim → Face ID → test hold. Утасгүй дуурайх «Симуляц» — «удахгүй». Backend-ээ дээрх curl-уудаар, webhook-оо консолын тест илгээлт ба дахин илгээлтээр (консол) шалгана.
14. Go-live шалгалт¶
- Машин бүр терминалтай,
label/locationнь бодит байршил; sticker бичигдэж түгжигдсэн, QR уншигддаг. - Олон эзэмшигч бол эзэмшигч бүрийн payout батлагдсан (
payouts_configured: true). -
capture_withinмашины сүлжээний хамгийн муу саатлаас урт; алдагдлын журнал бий. - Reconcile loop ажиллаж, үл мэдэгдэх hold-ыг суллаж байна.
-
description-д бүтээгдэхүүний нэр алга; насны бараанд машины шалгалт сагстай холбоотой. - Webhook гарын үсэг, dedupe, 10 с-ийн хариу; client secret зөвхөн backend-д.