Агуулгыг алгасах

Вендинг машин — бүрэн жишээ

Энэ хуудсаар

Юу хийх: вендинг машинд 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. Нэг удаагийн тохиргоо

  1. Худалдагч апп (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-оор ялга).
  2. Олон эзэмшигч (машин бүр өөр эзэнтэй) бол эзэмшигч бүрийг дэд худалдагч болгож, эзэмшигч өөрөө Gerege App-д payout дансаа батална — дэд худалдагч. Батлагдаагүй бол live intent 409 payouts_not_configured.
  3. Машин бүр = терминал, 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, гаргах команд

  1. payment_intent.claimed → дэлгэц «Утсаараа баталгаажуулна уу». Хүн 90 с-д баталгаажуулаагүй бол claim дуусч дараагийн уншуулсан хүн авна (шинэ user_ref, дахин payment_intent.claimed); та POST …/unclaim-аар түрүүлж суллаж болно (тэгвэл claimed_at: null).
  2. payment_intent.requires_capture → мөнгө барьцаалагдсан. Гаргах командаас өмнө intent-ийг GET /payment-intents/{id}-оор дахин уншиж status == "requires_capture" ба amount, reference таны сагстай тэнцүү эсэхийг шалга (event давтагдаж, дараалал алдагдаж болно).
  3. Машинд dispense (intent id-тай). Машин мотор бүрийг эргүүлэхээс өмнө локал журналд бичнэ (77120 A1 эхэлсэн), дараа нь үр дүнг (A1 унасан / B1 гацсан) — тэр журнал нь гэрэл тасарсны дараах үнэн.
  4. Машин үр дүнгээ 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" буцааж болно (дэвтэр хараахан хариулаагүй). Буцаалт зөвхөн succeeded intent-д (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-д.