Gerege Billing API — гарын авлага
Машинд уншигдах бүрэн тодорхойлолт: api/openapi.yaml (OpenAPI 3.1, JSON-тэй нийцтэй YAML). SDK: Go — gitlab.gerege.mn/products/core-services/billing/sdk/go (stdlib-ээс өөр хамааралгүй), TypeScript — @gerege/billing (sdk/ts, runtime хамааралгүй, ESM + төрлүүд, Node 18+ ба browser fetch).
Энэ баримтад одоо ажиллаж буй API л орсон. Төлбөрийн үйлчилгээгээр хэтэвч цэнэглэх (wallet top-up) зэрэгцээ хөгжүүлэгдэж байгаа тул энд ороогүй. Нэхэмжлэх (
/v1/billing-*,/v1/invoices*) spec-д орсон; SDK-д хараахан ороогүй.
Ерөнхий дүрэм
- Бүх
/v1зам (QPay callback-аас бусад)Authorization: Bearer bk_<prefix>_<secret>шаардана. - Мөнгө үргэлж бүхэл тоо, минор нэгжээр (
*_minor, валютын 1/100). Нэгжийн үнэ, тоо хэмжээ, usage-ийн утга аравтын бутархай мөр ("10.9") — float болгож задлахгүй (Go:math/big, TS: decimal сан). - Цаг RFC 3339 (
2026-10-23T08:00:00Z). - Хүсэлтийн бие хатуу JSON: үл мэдэгдэх талбар →
400 invalid_json. Биеийн дээд хэмжээ 1 MiB (/v1/events3 MiB) →413 body_too_large.
Эрх (scope)
| Scope | Юунд |
|---|---|
admin | бүх зүйл (бусад бүх scope-ийг агуулна): түлхүүр, харилцагч үүсгэх/засах, meter, plan, subscription, webhook, integration, хэтэвч кредит |
events:write | POST /v1/events — зөвхөн usage event илгээх (үйлчилгээний сервер дээр байх түлхүүр) |
read | tenant, харилцагч, plan, subscription, usage, хэтэвчийн үлдэгдэл/гүйлгээ унших |
wallet:charge | POST /v1/customers/{id}/wallet/charge |
Анхны admin түлхүүрийг оператор billing tenant-create <slug> <name> [currency] командаар авна. Үйлчилгээ бүрд хамгийн бага scope-той тусдаа түлхүүр гарга (POST /v1/api-keys); secret зөвхөн үүсгэх хариунд нэг удаа харагдана. Түлхүүрийг browser-т бүү байршуул — TS SDK browser-т ажиллах нь өөрийн backend-ээс бусад орчинд (дотоод хэрэгсэл гм.) зориулагдсан.
Хурдан эхлэл
Жишээнүүд Go SDK-аар; TS-д ижил нэртэй (camelCase) методууд бий.
c := &billing.Client{BaseURL: "https://<billing-host>", APIKey: os.Getenv("BILLING_ADMIN_KEY")}
// 1. Харилцагч (external_id = таны системийн id, tenant дотор давтагдашгүй)
cust, err := c.CreateCustomer(ctx, billing.CustomerInput{ExternalID: "org-42", Name: "ХХК Жишээ"})
// 2. Meter: "api.call" event-ийг тоолно
m, err := c.CreateMeter(ctx, billing.MeterInput{
Code: "api_calls", Name: "API дуудлага", EventType: "api.call", Aggregation: billing.AggCount,
})
// 3. Plan: сарын 50,000₮ суурь + дуудлага бүр 10.5₮ (минор нэгжээр "1050")
fee := int64(5_000_000)
unit := "1050"
plan, err := c.CreatePlan(ctx, billing.PlanInput{
Code: "pro", Name: "Pro",
Charges: []billing.Charge{
{Name: "Суурь төлбөр", Model: billing.ModelFlat, AmountMinor: &fee},
{Name: "API дуудлага", Model: billing.ModelPerUnit, MeterID: &m.ID, UnitAmount: &unit},
},
}, "plan-pro-v1")
// 4. Захиалга
sub, err := c.Subscribe(ctx, cust.ID, billing.SubscribeInput{PlanID: plan.ID}, "sub-org-42-pro")
// 5. Event илгээх (events:write түлхүүрээр). 500-аас олон бол SDK өөрөө хуваана.
res, err := c.SendEvents(ctx, []billing.Event{
{IdempotencyKey: "req-8f2c", ExternalCustomerID: "org-42", Type: "api.call",
Properties: map[string]any{"endpoint": "/v1/sign"}},
})
for _, r := range res.Rejected() {
log.Printf("event %d татгалзсан: %s", r.Index, r.ErrorCode)
}
// 6. Usage унших (сарын эхнээс, өдрөөр)
u, err := c.GetUsage(ctx, billing.UsageParams{
CustomerID: cust.ID, MeterID: m.ID, From: monthStart, To: time.Now(), GroupBy: billing.GroupDay,
})
// 7. Хэтэвч: admin кредит (гараар цэнэглэлт), дараа нь wallet:charge түлхүүрээр хасалт
_, err = c.CreditWallet(ctx, cust.ID, billing.CreditInput{AmountMinor: 10_000_000, Reason: billing.ReasonManualTopup}, "topup-2026-10-23-1")
mv, err := c.Charge(ctx, cust.ID, billing.ChargeInput{AmountMinor: 250_000, Description: "Гарын үсэг 1ш"}, "charge-req-8f2c")
if billing.IsCode(err, "insufficient_funds") { /* үлдэгдэл хүрэлцэхгүй */ }
fmt.Println(mv.BalanceMinor)
TypeScript:
import { BillingClient, isCode } from "@gerege/billing";
const c = new BillingClient({ baseURL: "https://<billing-host>", apiKey: process.env.BILLING_KEY! });
const { results } = await c.sendEvents([{ idempotency_key: "req-8f2c", external_customer_id: "org-42", type: "api.call" }]);
await c.charge(customerId, { amount_minor: 250000 }, "charge-req-8f2c");
Event
idempotency_key(≤128) tenant дотор давтагдашгүй: дахин илгээсэн eventduplicateгэж буцаж, хоёр дахин тоологдохгүй. Иймээс batch бүтнээрээ дахин илгээхэд аюулгүй (SDK 429/5xx дээр өөрөө давтана).external_customer_idбайгаа харилцагчийг заах ёстой — үл мэдэгдэх ньunknown_customer-аар татгалзагдана (автоматаар үүсэхгүй). Эхлээд харилцагчаа үүсгэ.occurred_atсүүлийн 35 хоног дотор, серверийн цагаас 5 минутаас ихгүй урагш. SDK хоосон бол одоогийн цагийг тавина.propertiesJSON объект, ≤4 KiB.sum/maxmeter зөвхөн тоон утгыг (JSON тоо эсвэл"12.5"мөр) уншина.- Нэг хүсэлтэд 1..500 event; хариу
results[i]={index, status: accepted|duplicate|rejected, error_code?}. Татгалзсан event batch-ийг унагахгүй. Алдааны кодууд:invalid_idempotency_key,invalid_external_customer_id,invalid_type,invalid_occurred_at,invalid_properties,properties_too_large,occurred_at_in_future,occurred_at_too_old,unknown_customer.
Хуудаслалт
- Харилцагч, meter, plan, subscription:
?after=<сүүлийн id>&limit=1..200(анхдагч 50), id-аар өсөх.limit-ээс богино хуудас = сүүлчийнх. - Хэтэвчийн гүйлгээ: шинэ нь эхэнд,
?cursor=<next_cursor>&limit=;next_cursor: null= сүүлчийнх. - Webhook delivery:
?status=&event_id=&limit=(хуудаслалтгүй, шинэ нь эхэнд). API түлхүүр, webhook endpoint: ≤200, хуудаслалтгүй.
Idempotency
| Зам | Idempotency-Key |
|---|---|
POST /v1/customers/{id}/wallet/charge, POST /v1/customers/{id}/wallet/credits | заавал (1..128 printable ASCII) — байхгүй бол 400 idempotency_key_required |
POST /v1/billing-runs, POST /v1/billing-periods/{period}/close, POST /v1/invoices/{id}/issue\|void\|payments | заавал; ижил түлхүүрээр өөр хүсэлт → 409 idempotency_key_reused, эхний хүсэлт дуусаагүй бол 409 request_in_progress |
POST /v1/plans, POST /v1/plans/{id}/archive, POST/DELETE /v1/customers/{id}/subscription | сонголттой (≤128) |
POST /v1/events | толгой биш — event бүрийн idempotency_key |
- Ижил түлхүүр + ижил хүсэлт → анхны хариу (статус, бие) байт-байтаар давтагдана; гүйлгээ дахин хийгдэхгүй.
- Ижил түлхүүр + өөр хүсэлт →
idempotency_key_reused(хэтэвч дээр 422, pricing дээр 409). - Алдаатай дууссан хүсэлт түлхүүрийг эзэлдэггүй — дахин оролдлого шинээр шалгагдана.
- Хэтэвчийн
reference(анхдагч = Idempotency-Key) tenant дотор давтагдашгүй: өөр түлхүүрээр ижил reference →409 duplicate_reference. Бизнесийн id-аа (захиалгын дугаар) reference болговол давхар хасалт үгүй. - SDK зөвхөн давтахад аюулгүй хүсэлтийг давтана: GET, Idempotency-Key-тэй хүсэлт, event batch, meter archive. Түлхүүргүй POST/PUT/DELETE хэзээ ч автоматаар давтагдахгүй.
Алдаа
Бүх алдаа нэг хэлбэртэй:
{"error": {"code": "insufficient_funds", "message": "wallet balance is too low"}}
code тогтвортой — логикоо түүн дээр бич; message хүнд зориулсан. Дотоод алдааны текст хэзээ ч буцахгүй (500 internal). Нийтлэг кодууд:
| HTTP | code | Утга |
|---|---|---|
| 400 | invalid_json, idempotency_key_required | бие буруу / үл мэдэгдэх талбар; хэтэвчид түлхүүр дутуу |
| 401 | unauthorized | түлхүүр байхгүй, буруу, цуцлагдсан |
| 402 | insufficient_funds | хэтэвчийн үлдэгдэл хүрэлцэхгүй |
| 403 | forbidden, tenant_suspended | scope хүрэлцэхгүй; tenant түр зогссон |
| 404 | not_found, customer_not_found | байхгүй (эсвэл өөр tenant-ынх — ялгахгүй) |
| 409 | customer_exists, meter_exists, plan_code_exists, plan_archived, already_subscribed, subscription_exists, effective_at_overlaps, duplicate_reference, delivery_pending, endpoint_disabled, idempotency_key_reused | төлөвийн зөрчил |
| 413 | body_too_large | бие хэт том |
| 422 | invalid_<талбар> (invalid_limit, invalid_amount_minor, invalid_charges …) | талбарын шалгалт; plan-д message яг талбарыг заана (charges[0].tiers[1].up_to: …) |
| 429 | rate_limited | хурдны хязгаар |
| 503 | posapi_unavailable, not_configured | гадны үйлчилгээ бэлэн биш |
Зам бүрийн яг кодууд api/openapi.yaml-ийн x-error-codes-д. Go SDK: *billing.APIError{StatusCode, Code, Message}, billing.IsCode(err, "…"); TS: APIError {status, code}, isCode(err, "…"). Envelope-гүй хариу (proxy-ийн алдааны хуудас) → code = "http_<status>".
Хурдны хязгаар
- API түлхүүр бүрд секундэд 50 хүсэлт, burst 200 (token bucket). Хэтэрвэл
429 rate_limited. - Амжилтгүй нэвтрэлт IP бүрд секундэд 1, burst 20.
- QPay callback: IP бүрд 20/с, төлбөр бүрд 5/с.
- SDK 429 болон 5xx дээр exponential backoff (250 мс-ээс, дээд 8 с, full jitter, анхдагч 3 давталт) хийж,
Retry-Afterирвэл түүнийг дагана — зөвхөн давтахад аюулгүй хүсэлтэд.
Webhook
Endpoint бүртгэх (admin): POST /v1/webhook-endpoints {"url":"https://…","event_types":[…]} — хариуны secret (whsec_…) зөвхөн нэг удаа харагдана. event_types хоосон = бүх төрөл. URL нь нийтийн https байх ёстой (localhost, private/loopback хаяг хориотой).
Хүргэлт: POST <url>, бие {"id","type","created_at","data"}, толгой:
Billing-Signature: t=<unix секунд>,v1=<hex HMAC-SHA256(secret, "<t>.<түүхий бие>")>
Billing-Event-Id: <uuid>
Billing-Event-Type: <төрөл>
- 10 секундэд 2xx буцаа; үгүй бол 1м, 5м, 30м, 2ц, 6ц, 12ц, 24ц дараа дахин оролдоод
failedболно.POST /v1/webhook-deliveries/{id}/retry-аар дууссан хүргэлтийг дахин илгээнэ. - Нэг event хэд хэдэн удаа ирж болно —
Billing-Event-Id-аар давхардлыг шүү. - Баталгаажуулалт: түүхий биеийг (parse хийж дахин serialize хийсэн JSON биш) ашигла, тогтмол хугацааны харьцуулалт хий,
t-г ±5 минутаас хэтэрвэл (replay) татгалз.
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
ev, err := billing.ParseWebhook(secret, r.Header.Get(billing.HeaderSignature), body, 0) // 0 = 5 мин
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// ev.ID-аар давхардлыг шүүгээд ev.Type, ev.Data-г боловсруул
w.WriteHeader(http.StatusNoContent)
}
import { parseWebhook, HEADER_SIGNATURE } from "@gerege/billing";
const raw = await request.text(); // түүхий бие
const ev = await parseWebhook(secret, request.headers.get(HEADER_SIGNATURE), raw); // SignatureError шиднэ
- Secret солих:
POST /v1/webhook-endpoints/{id}/rotate-secret— хуучин secret шууд хүчингүй болно (давхцах хугацаагүй); хүргэлт илгээх мөчдөө гарын үсэг зурдаг тул дараагийн бүх оролдлого шинэ secret-тэй. Солих мөчид явж байсан ганц хүсэлтэд л хуучныг богино хугацаанд хүлээн ав. - Event төрлүүд:
invoice.issued,invoice.paid,invoice.voided(нэхэмжлэхийн товч: id, number, customer_id, subscription_id, period, status, currency, total/amount_paid/amount_due_minor),payment.received(payment_id, invoice_id, amount_minor, applied_minor, credited_to_wallet_minor).
Spec ба SDK-г синк байлгах
sdk/go/spec_test.go нь internal/*/handlers.go-ийн бүртгэсэн зам бүрийг (method, path, scope) api/openapi.yaml-тай тулгадаг — шинэ route нэмэхэд spec шинэчлэгдэх хүртэл make check унана. TS SDK-ийн тест: make sdk-ts (нэг удаа npm ci сүлжээ шаардана).