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-д хараахан ороогүй.

Ерөнхий дүрэм

Эрх (scope)

ScopeЮунд
adminбүх зүйл (бусад бүх scope-ийг агуулна): түлхүүр, харилцагч үүсгэх/засах, meter, plan, subscription, webhook, integration, хэтэвч кредит
events:writePOST /v1/events — зөвхөн usage event илгээх (үйлчилгээний сервер дээр байх түлхүүр)
readtenant, харилцагч, plan, subscription, usage, хэтэвчийн үлдэгдэл/гүйлгээ унших
wallet:chargePOST /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

Зам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

Алдаа

Бүх алдаа нэг хэлбэртэй:

{"error": {"code": "insufficient_funds", "message": "wallet balance is too low"}}

code тогтвортой — логикоо түүн дээр бич; message хүнд зориулсан. Дотоод алдааны текст хэзээ ч буцахгүй (500 internal). Нийтлэг кодууд:

HTTPcodeУтга
400invalid_json, idempotency_key_requiredбие буруу / үл мэдэгдэх талбар; хэтэвчид түлхүүр дутуу
401unauthorizedтүлхүүр байхгүй, буруу, цуцлагдсан
402insufficient_fundsхэтэвчийн үлдэгдэл хүрэлцэхгүй
403forbidden, tenant_suspendedscope хүрэлцэхгүй; tenant түр зогссон
404not_found, customer_not_foundбайхгүй (эсвэл өөр tenant-ынх — ялгахгүй)
409customer_exists, meter_exists, plan_code_exists, plan_archived, already_subscribed, subscription_exists, effective_at_overlaps, duplicate_reference, delivery_pending, endpoint_disabled, idempotency_key_reusedтөлөвийн зөрчил
413body_too_largeбие хэт том
422invalid_<талбар> (invalid_limit, invalid_amount_minor, invalid_charges …)талбарын шалгалт; plan-д message яг талбарыг заана (charges[0].tiers[1].up_to: …)
429rate_limitedхурдны хязгаар
503posapi_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>".

Хурдны хязгаар

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: <төрөл>
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 шиднэ

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 сүлжээ шаардана).