eBarimt 3.0

Mongolia's national electronic receipt system. POS API 3.0, Keycloak OAuth, JSON receipts. No Node SDK exists; build thin REST client. Outbox pattern with retry — replaces v1's fire-and-forget post-save hook.

Phase 4Compliance

What it is

Every sale to a Mongolian customer must push a receipt to eBarimt within a short window. Receipt issuance returns a lottery code + QR; QR printed on tenant receipt. Compliance is non-optional — accountants will check.

Reference bindings (no Node SDK)

LangRepoUse as
Javahurelhuyag/ebarimtWire-format reference
Gotechpartners-asia/ebarimt-pos3-goEndpoint + error code reference
Go (alt)lambda-platform/ebarimt-rest-apiAuth flow reference

Build TS client (~250 lines). Spec: developer.itc.gov.mn/docs/ebarimt-api/.

API shape (POS API 3.0)

Auth (Keycloak)

POST https://st.auth.itc.gov.mn/auth/realms/{Realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials
→ { access_token, expires_in, token_type: "Bearer" }

Cache token in Redis until expires_in - 30s. Prod realm differs from staging.

Push receipt

POST {merchantBase}/rest/receipt
Authorization: Bearer {token}
Content-Type: application/json

{
  "totalAmount": 1500000,             // major units (₮); confirm in production
  "totalVat": 136363,
  "totalCityTax": 0,
  "branchNo": "001",
  "districtCode": "34",
  "merchantTin": "1234567",
  "posNo": "1",
  "customerTin": "9876543",           // optional for B2C
  "type": "B2B",                      // "B2C" or "B2B"
  "receiptItems": [
    {
      "name": "Rent — 2026-06, Unit 101",
      "barCode": "00000",
      "measureUnit": "month",
      "qty": 1,
      "unitPrice": 1363637,
      "totalAmount": 1500000,
      "totalVat": 136363,
      "totalCityTax": 0
    }
  ]
}
→ { id, lottery, qrData, ... }

Common errors

CodeMeaningTreatment
MERCHANT_TIN_NOT_FOUNDBad configDead-letter, alert owner
INVALID_VATVAT math doesn't sumDead-letter, fix invoice
DUPLICATE_INVOICE_IDAlready pushedTreat as success (idempotent)
BRANCH_NOT_FOUNDBad branchNoDead-letter, fix config
5xx / networkTransientExp backoff, max 8 attempts

Outbox flow

  1. Invoice paid (status → paid) inside a transaction.
  2. Same tx: insert outbox row { eventType: "ebarimt.push", aggregateId: invoice.id, payload: { invoiceId } }.
  3. Tx commits. No HTTP call from the request handler.
  4. Worker drains outbox (pg-boss or LISTEN/NOTIFY) → loads invoice → builds receipt payload → POSTs to eBarimt.
  5. 2xx: store id, lottery, qrData on invoice; mark outbox sent; queue PDF render.
  6. 4xx non-retryable: mark dead; alert owner via in-app notification.
  7. 5xx / network: increment attempts, schedule next attempt: 1m, 5m, 30m, 2h, 12h, 24h; give up after 8.

Client sketch

// packages/integrations/src/ebarimt/client.ts
import { z } from "zod";

interface EbarimtConfig {
  realmUrl: string;
  realm: string;
  clientId: string;
  clientSecret: string;
  merchantBase: string;
  merchantTin: string;
  redis: Redis;
}

export class EbarimtClient {
  constructor(private cfg: EbarimtConfig) {}

  private async token(): Promise<string> {
    const cached = await this.cfg.redis.get(`ebarimt:token:${this.cfg.realm}`);
    if (cached) return cached;
    const form = new URLSearchParams({ grant_type: "client_credentials" });
    const res = await fetch(`${this.cfg.realmUrl}/realms/${this.cfg.realm}/protocol/openid-connect/token`, {
      method: "POST",
      headers: {
        Authorization: "Basic " + Buffer.from(`${this.cfg.clientId}:${this.cfg.clientSecret}`).toString("base64"),
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: form,
    });
    if (!res.ok) throw new Error(`eBarimt auth failed: ${res.status}`);
    const j = z.object({ access_token: z.string(), expires_in: z.number() }).parse(await res.json());
    await this.cfg.redis.setex(`ebarimt:token:${this.cfg.realm}`, j.expires_in - 30, j.access_token);
    return j.access_token;
  }

  async pushReceipt(receipt: ReceiptInput): Promise<ReceiptResult> {
    const t = await this.token();
    const res = await fetch(`${this.cfg.merchantBase}/rest/receipt`, {
      method: "POST",
      headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/json" },
      body: JSON.stringify(receipt),
    });
    const body = await res.json();
    if (!res.ok) {
      const err = z.object({ message: z.string().optional(), code: z.string().optional() }).safeParse(body);
      throw new EbarimtError(err.data?.code ?? "UNKNOWN", err.data?.message ?? "eBarimt error", res.status);
    }
    return ReceiptResultSchema.parse(body);
  }

  async getBranchInfo() { /* GET /rest/branch */ }
  async getTinInfo(tin: string) { /* GET /rest/tin/{tin} */ }
}

export class EbarimtError extends Error {
  constructor(public code: string, msg: string, public status: number) {
    super(msg);
  }
  isRetryable(): boolean {
    if (this.code === "DUPLICATE_INVOICE_ID") return false; // treat as success
    if (this.status >= 500) return true;
    return false;
  }
}

Worker handler

// apps/workers/src/ebarimt.ts
async function handleEbarimtPush(payload: { invoiceId: string }) {
  const inv = await db.query.invoices.findFirst({
    where: eq(invoices.id, payload.invoiceId),
    with: { items: true, owner: true },
  });
  if (!inv) throw new Error("invoice not found");
  if (inv.ebarimtReceiptId) return;                          // already pushed; idempotent

  const receipt = buildReceipt(inv);
  try {
    const result = await ebarimt.pushReceipt(receipt);
    await db.transaction(async (tx) => {
      await tx.update(invoices).set({
        ebarimtReceiptId: result.id,
        ebarimtLottery: result.lottery,
        ebarimtQrData: result.qrData,
      }).where(eq(invoices.id, inv.id));
      await tx.update(outbox).set({ status: "sent", sentAt: new Date() })
        .where(and(eq(outbox.aggregateId, inv.id), eq(outbox.eventType, "ebarimt.push")));
    });
  } catch (err) {
    if (err instanceof EbarimtError && err.code === "DUPLICATE_INVOICE_ID") {
      // pretend success — already on eBarimt; we'll re-fetch via getById on next reconcile
      await db.update(outbox).set({ status: "sent", sentAt: new Date(), lastError: "duplicate-treated-as-sent" })
        .where(and(eq(outbox.aggregateId, inv.id), eq(outbox.eventType, "ebarimt.push")));
      return;
    }
    throw err;   // pg-boss/BullMQ retry with exp backoff
  }
}

Build steps

  1. Client + types in packages/integrations/src/ebarimt.
  2. Schema additions on invoices for ebarimt_* columns (already in billing).
  3. Outbox event handler in apps/workers/src/ebarimt.ts.
  4. Config UI: property_owners stores eBarimt credentials per owner (encrypted).
  5. Stage against eBarimt staging realm before flipping to prod.
  6. Daily reconciliation job — re-fetch eBarimt status for any invoice with ebarimtReceiptId is null and status = paid > 1h ago; surface in admin.
  7. PDF template includes lottery + QR (uses qrcode npm package).

Open questions

Sources