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

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 ба толгойг өөрөө өгөх хувилбар (tolerance 0 = 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 хариул.