Go SDK — sdk/go¶
Энэ хуудсаар: Go backend-аасаа худалдагчийн API-г (claimless төлбөр, буцаалт, терминал, дэд худалдагч, тооцоо) дуудаж,
webhook-ийн гарын үсгийг шалгана; mini app-ын token-ыг golang.org/x/oauth2-оор солилцоно. ~15 минут. Хэрэгтэй:
Go ≥ 1.22, консолоос авсан test API түлхүүр (gk_test_… / gks_test_…, эсвэл хуучин mapp_test_… / gma_cs_…),
webhook secret (whsec_…).
Модуль github.com/gerege-systems/app-gerege-nexus/sdk/go, package miniapp. Runtime хамааралгүй (net/http,
стандарт сан; testify зөвхөн өөрийн тестэд). Client бүх дуудлагыг апп-ын client credential-аар (Basic) хийнэ —
хүний token-оор дуудах зүйлсийг (launch code солилцоо, /me, app intent) энгийн OAuth client-аар хийнэ (доор).
Зөвхөн сервер дээр
Client secret, webhook secret терминал, машин, мобайл апп, браузерт хэзээ ч очихгүй — тэд PayURL эсвэл
терминалын URL-ийг л авна.
Суулгах¶
Модуль хувийн репод байна (тусдаа нийтийн репо руу салгах хүртэл), хувилбарын tag хараахан алга. Gerege-ээс унших эрх авсны дараа (Тусламж):
export GOPRIVATE=github.com/gerege-systems/*
# git-д тэр репог унших эрхтэй credential (жишээ нь GitHub token):
git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "https://github.com/"
go get github.com/gerege-systems/app-gerege-nexus/sdk/go@<commit>
Өөр нэг арга: client.go ба webhook.go хоёр файлыг (Apache-2.0) өөрийн төсөлдөө хуулна — гадаад хамааралгүй.
Client үүсгэх¶
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"
c := miniapp.NewClient(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"),
// miniapp.WithBaseURL("https://geregeapp.mn"),
// miniapp.WithHTTPClient(&http.Client{Timeout: 20 * time.Second}),
// miniapp.WithUserAgent("my-shop/1.0"),
)
| Сонголт | Анхдагч |
|---|---|
WithBaseURL(u) |
https://geregeapp.mn (DefaultBaseURL) |
WithHTTPClient(h) |
&http.Client{Timeout: 20 * time.Second} |
WithUserAgent(ua) |
gerege-miniapp-go/0.2.0 |
Хоосон credential-ийг NewClient биш, эхний дуудлага ErrNoCredentials-аар мэдэгдэнэ (буруу тохиргоотой процесс
эхэлсээр байна). Дуудлага бүр түлхүүрийн scope-ыг шаардана (Refund → refunds:write, Terminals.Batch →
terminals:write г.м. — API-ийн нийтлэг дүрэм); дутуу бол *APIError Code:
"insufficient_scope", Extra["required_scope"].
Худалдагч: claimless төлбөр¶
// Redirect checkout: return_url-ийн host апп-ын return_hosts-д.
pi, err := c.PaymentIntents.Create(ctx, "order:"+order.ID, miniapp.CreatePaymentIntentParams{
Amount: order.Total, Description: "Захиалга №" + order.ID, Reference: order.ID,
Presentment: miniapp.PresentmentRedirect, ReturnURL: "https://shop.mn/orders/" + order.ID + "/done",
})
if err != nil {
return err
}
http.Redirect(w, r, pi.PayURL, http.StatusSeeOther) // буцаж ирэхэд ?pi=… — Get-ээр шалга
// Терминал (вендинг): сагс бүрт manual, богино цонх. Idempotency-Key = сагсны дугаар.
pi, err = c.PaymentIntents.Create(ctx, basket.ID, miniapp.CreatePaymentIntentParams{
Amount: 3500, Description: "Ус 0.5л", Reference: basket.ID, ExpiresIn: 300,
Presentment: miniapp.PresentmentTerminal, TerminalID: vm.ID,
CaptureMethod: miniapp.CaptureManual, CaptureWithin: 120,
})
Create нь ClaimlessPaymentIntent (PaymentIntent + PayURL) буцаана. PayURL нь зөвхөн энэ хариунд (ба ижил key-тэй
давталтад) — Get, List, webhook-д хэзээ ч үгүй. Presentment нь qr / redirect / terminal биш бол хүсэлт
илгээхээс өмнө ErrPresentment; key буруу бол ErrIdempotencyKey. Дэлгэрэнгүй:
Gerege App-аар төлөх.
Intent ба буцаалт¶
pi, err := c.PaymentIntents.Get(ctx, "pi_…")
page, err := c.PaymentIntents.List(ctx, miniapp.ListPaymentIntentsParams{Reference: order.ID, Limit: 20})
// дараагийн хуудас: After = page.Data[len(page.Data)-1].ID, page.HasMore үнэн байхад
pi, err = c.PaymentIntents.Capture(ctx, pi.ID) // manual + requires_capture; бүтэн дүн
pi, err = c.PaymentIntents.Cancel(ctx, pi.ID) // төлөгдөөгүйг цуцлах эсвэл барьцааг суллах
pi, err = c.PaymentIntents.Unclaim(ctx, pi.ID) // claim хийгдсэн, баталгаажаагүйг суллах
rf, err := c.PaymentIntents.Refund(ctx, pi.ID, "refund:"+basket.ID,
miniapp.RefundParams{Amount: 1500, Reason: "not_dispensed"}) // Amount 0 = үлдсэн бүгд
| Арга | HTTP | Хариу |
|---|---|---|
PaymentIntents.Create(ctx, key, CreatePaymentIntentParams) |
POST /payment-intents |
ClaimlessPaymentIntent |
PaymentIntents.Get(ctx, id) |
GET /payment-intents/{id} |
PaymentIntent |
PaymentIntents.List(ctx, ListPaymentIntentsParams{Reference, Status, After, Limit}) |
GET /payment-intents |
PaymentIntentPage{Data, HasMore} |
PaymentIntents.Capture · Cancel · Unclaim (ctx, id) |
POST …/capture · /cancel · /unclaim |
PaymentIntent |
PaymentIntents.Refund(ctx, intentID, key, RefundParams{Amount, Reason}) |
POST …/refunds |
Refund |
Capture-ийн 202 (processing) нь алдаа биш — PaymentIntent.Status-ыг шалгаад дараа нь дахин Get. Хэсэгчилсэн
capture «удахгүй» — бүтэн capture + буцаалт. Дэлгэрэнгүй: Payment intent,
Буцаалт.
Тооцоо¶
page, err := c.Settlements.List(ctx, miniapp.ListSettlementsParams{Status: "paid", Limit: 20})
for page.HasMore && page.NextCursor != nil {
page, err = c.Settlements.List(ctx, miniapp.ListSettlementsParams{Status: "paid", Cursor: *page.NextCursor})
}
detail, err := c.Settlements.Get(ctx, "st_…") // Settlement + Intents, RefundItems, Truncated
Зөвхөн live — test түлхүүрээр хоосон. Settlement.SubMerchant нь дэд худалдагчийн тооцоог ялгана.
Дэлгэрэнгүй: Тооцоо.
Терминал ба дэд худалдагч¶
vm, err := c.Terminals.Create(ctx, miniapp.CreateTerminalParams{Label: "Машин №7", Location: "СБД, 1-р хороо", Reference: "vm-7"})
// vm.URL → sticker-ийн QR, NFC tag. Ижил Reference дахин → тэр терминал.
list, err := c.Terminals.List(ctx)
vm, err = c.Terminals.Update(ctx, vm.ID, miniapp.UpdateTerminalParams{Status: miniapp.String(miniapp.StatusDisabled)})
// Олноор (≤ MaxBatchTerminals = 500): нэг буруу мөр бүгдийг татгалзана; Reference-тэй мөр давталтад "existing".
batch, err := c.Terminals.Batch(ctx, []miniapp.TerminalBatchRow{
{Label: "Машин №8", Reference: "vm-8"},
{Label: "Машин №9", Reference: "vm-9", SubMerchantCode: "owner-17"},
})
// batch.Data[i].Status: created | existing | failed | skipped; batch.Created, .Existing, .Failed, .Skipped
// Sticker-ийн PDF (test түлхүүр ч болно); TerminalIDs-гүй бол бүх идэвхтэй терминал.
pdf, err := c.Terminals.PrintSheet(ctx, miniapp.PrintSheetParams{Layout: miniapp.LayoutLabel40, Locale: "mn",
CropMarks: miniapp.Bool(false)})
_ = os.WriteFile("stickers.pdf", pdf, 0o644)
owner, err := c.SubMerchants.Create(ctx, miniapp.CreateSubMerchantParams{Code: "owner-17", DisplayName: "Батаа ХХК"})
link, err := c.SubMerchants.RequestPayoutLink(ctx, owner.ID, "MN750098000981234560")
links, err := c.SubMerchants.ListPayoutLinks(ctx, owner.ID)
| Арга | HTTP |
|---|---|
Terminals.Create · List · Get · Update |
POST/GET /terminals, GET/PATCH /terminals/{tid} |
Terminals.Batch(ctx, []TerminalBatchRow) |
POST /terminals/batch → TerminalBatch |
Terminals.PrintSheet(ctx, PrintSheetParams{TerminalIDs, Layout, Locale, CropMarks}) |
POST /terminals/print-sheet → PDF ([]byte, ≤ 32 MiB); LayoutA4 (анхдагч) · LayoutLabel50 · LayoutLabel40 · LayoutTentA6 |
SubMerchants.Create · List · Get · Update |
POST/GET /sub-merchants, GET/PATCH /sub-merchants/{sid} |
SubMerchants.RequestPayoutLink(ctx, id, account) · ListPayoutLinks(ctx, id) |
POST …/payout-link · GET …/payout-links |
Update…Params-ийн талбарууд pointer — зөвхөн өгсөн нь өөрчлөгдөнө (miniapp.String(…); PrintSheetParams.CropMarks-д
miniapp.Bool(…)). Бичих (Batch-ийг оролцуулаад) нь live түлхүүр (test-д 403 live_only), PrintSheet ба
уншилт test-д ч болно; account хоосон бол ErrMissingAccount. Объектууд: Объектууд.
SDK-д хараахан ороогүй API¶
Эдгээр server API ажилладаг, гэхдээ client-д арга алга — net/http-ээр (ижил Basic) дуудна: GET /events,
GET /events/{id} (events:read — Events API), /webhook-endpoints… (webhooks:write,
live — Webhook), GET /statements (settlements:read — Тайлан).
data[i]-ийг miniapp.Event болгон задалж болно — webhook-ийн яг ижил body.
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://geregeapp.mn/api/v1/miniapp/events?limit=100&after="+url.QueryEscape(lastID), nil)
req.SetBasicAuth(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"))
res, err := http.DefaultClient.Do(req)
// …
var page struct {
Data []miniapp.Event `json:"data"` // хуучин нь эхэнд
NextCursor *string `json:"next_cursor"`
}
err = json.NewDecoder(res.Body).Decode(&page)
Webhook¶
secrets := strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",") // солих үед: "шинэ,хуучин"
http.HandleFunc("POST /webhooks/gerege", func(w http.ResponseWriter, r *http.Request) {
ev, _, err := miniapp.VerifyRequest(r, secrets, time.Now()) // түүхий body, ≤ 1 MiB
if err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
switch ev.Type {
case miniapp.EventPaymentIntentSucceeded, miniapp.EventPaymentIntentRequiresCapture:
pi, err := ev.PaymentIntent()
if err == nil {
enqueue(ev.ID, pi.ID) // ev.ID-аар давхардлыг арилга; олгохоос өмнө Get-ээр дахин унш
}
case miniapp.EventRefundFailed:
rf, _ := ev.Refund()
alert(rf)
case miniapp.EventSettlementPaid:
st, _ := ev.Settlement()
record(st)
}
w.WriteHeader(http.StatusNoContent) // 10 секундэд; мэдэхгүй төрөлд ч 2xx
})
Verify(body, header, secrets, now, tolerance)— body ба толгойг өөрөө өгөх хувилбар (tolerance0 =DefaultTolerance, 5 минут).VerifyRequestньGerege-Signature, байхгүй болOpenPay-Signature-ийг уншиж body-г (хадгалахад) буцаана.- Event-ийн decoder:
ev.PaymentIntent(),ev.Refund(),ev.Settlement(),ev.AppStatus(),ev.Consent(). Sign(secret, t, body),SignatureValue(secrets, t, body)— өөрийн тестэд гарын үсэгтэй хүсэлт бэлдэнэ.- Татгалзах шалтгаан (бүгдэд 400 хариул):
ErrMissingHeader,ErrMalformedHeader,ErrTimestamp,ErrSignatureMismatch,ErrInvalidPayload,ErrNoSecret(secret тохируулаагүй — таны тохиргооны алдаа).
Дэлгэрэнгүй: Гарын үсэг шалгах, Event-ийн лавлах.
Mini app: golang.org/x/oauth2¶
Go client-д хүний token-оор дуудах арга байхгүй — энэ нь энгийн OAuth тул стандарт сангаар:
conf := &oauth2.Config{
ClientID: os.Getenv("GEREGE_CLIENT_ID"), // gk_… (oauth:token scope) эсвэл хуучин mapp_…
ClientSecret: os.Getenv("GEREGE_CLIENT_SECRET"), // gks_… эсвэл хуучин gma_cs_…
Endpoint: oauth2.Endpoint{
TokenURL: "https://geregeapp.mn/api/v1/miniapp/oauth/token",
AuthStyle: oauth2.AuthStyleInHeader, // Basic — body-д давхар бүү илгээ
},
}
tok, err := conf.Exchange(ctx, launchCode) // ?launch=<code>, 60 секунд
user := tok.Extra("user").(map[string]any) // {"ref", "sub", "verified", "locale"}
client := conf.Client(ctx, tok) // refresh-ийг (rotation) өөрөө хийнэ
res, err := client.Get("https://geregeapp.mn/api/v1/miniapp/me")
// app intent: client.Post(".../payment-intents", "application/json", body) + Idempotency-Key толгой
conf.Client шинэ refresh token-ыг зөвхөн санах ойд хадгална — процесс дахин асахад хэрэгтэй бол TokenSource-оос
Token()-ыг аваад өөрөө хадгал. Хуучин refresh token-ыг дахин ашиглавал бүх гинж хүчингүй. Дэлгэрэнгүй:
Mini app-ын нээлт ба token.
Алдаа¶
_, err := c.PaymentIntents.Capture(ctx, id)
var apiErr *miniapp.APIError
switch {
case errors.As(err, &apiErr) && apiErr.Retryable(): // 429, 5xx — ижил Idempotency-Key-ээр дахин
retryLater()
case errors.As(err, &apiErr) && apiErr.Code == "terminal_busy":
var open string
_ = json.Unmarshal(apiErr.Extra["payment_intent"], &open)
_, _ = c.PaymentIntents.Cancel(ctx, open)
case errors.As(err, &apiErr) && apiErr.Code == "validation_error":
log.Println(apiErr.Details) // []Issue{Path, Message}
case err != nil:
return err // сүлжээний алдаа (APIError биш) — мөн дахин оролдож болно
}
| Төрөл | Утга |
|---|---|
*APIError |
2xx бус хариу: StatusCode, Code, Message, RequestID (X-Request-Id), Details ([]Issue), Extra (бусад талбар, түүхий JSON), Retryable() (429, 5xx). Модулийн {"error","code"} ба OAuth-ын {"error","error_description"} хоёуланг уншина; code-гүй бол http_<status> |
Wrap хийсэн error |
сүлжээ, timeout, JSON задлах — APIError биш |
ErrNoCredentials · ErrIdempotencyKey · ErrPresentment · ErrMissingID · ErrMissingAccount |
хүсэлт илгээхээс өмнө |
Бүх кодын жагсаалт: Алдааны код.
Тогтмол¶
DefaultBaseURL, APIBasePath (/api/v1/miniapp), SignatureHeader, OMASSignatureHeader, EventIDHeader,
OMASEventIDHeader, DefaultTolerance (5 мин), MaxBodyBytes (1 MiB), MaxBatchTerminals (500); PresentmentApp ·
PresentmentQR · PresentmentRedirect · PresentmentTerminal; CaptureAutomatic · CaptureManual; StatusActive ·
StatusDisabled; LayoutA4 · LayoutLabel50 · LayoutLabel40 · LayoutTentA6; туслах String(s), Bool(b);
event бүрийн Event… тогтмол (EventPaymentIntentSucceeded, …Canceled, …Failed, …RequiresCapture, …Claimed,
…Expired, EventRefundSucceeded, EventRefundFailed, EventAppStatusChanged, EventConsentRevoked,
EventWebhookTest, EventSettlementPaid). Шинэ event minor хувилбарт нэмэгдэж болно — мэдэхгүйд 2xx хариул.