Payments — QPay + SocialPay

QR-based payment gateways for Mongolia. No quality SDKs exist — build thin REST clients (~150 lines each). Webhook security via path-token HMAC; lookup gateway for source-of-truth amounts.

Phase 4

Library picks

ClusterPickWhy
HTTP clientBuilt-in fetch (Node 22) + Zod for response validationNo SDK churn risk
QPayRaw REST, in-house clientExisting npm packages low-quality; none verify callbacks
SocialPayRaw REST + node:crypto for HMAC-SHA256No real TS client exists
Moneydinero.js v2 with bigint calculator (replace custom)ISO-currency type safety at compile time; native bigint; tree-shakeable

Install:

pnpm --filter @spacehub/integrations add zod
pnpm --filter @spacehub/shared add dinero.js @dinero.js/currencies

Money: switch to dinero.js

Current 50-LOC custom impl works but dinero.js gives ISO-currency type literals (catches "added USD to MNT" at compile time), allocation/distribution helpers (split rent across roommates), and a stable API for future-proofing.

import { dinero, add, multiply, subtract, lessThan, equal, toSnapshot } from "dinero.js/bigint";
import { MNT } from "@dinero.js/currencies";

const rent = dinero({ amount: 1_500_000n, currency: MNT });   // ₮1,500,000
const paid = dinero({ amount: 1_500_000n, currency: MNT });
const fullyPaid = !lessThan(paid, rent);                       // safe, exact

// store as bigint; restore via dinero({ amount: row.amount, currency: MNT })

For display:

function formatMNT(amountMinor: bigint): string {
  return new Intl.NumberFormat("mn-MN", {
    style: "currency", currency: "MNT", maximumFractionDigits: 0,
  }).format(Number(amountMinor) / 100);
  // → "1,500,000 ₮"
}

QPay client (developer.qpay.mn v2)

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

const QPAY_BASE = "https://merchant.qpay.mn/v2";

interface QpayConfig { merchantId: string; merchantSecret: string; redis: Redis; }

export class QPayClient {
  constructor(private cfg: QpayConfig) {}

  // 1. Auth — cache token in Redis just under expires_in
  private async token(): Promise<string> {
    const cached = await this.cfg.redis.get("qpay:token");
    if (cached) return cached;

    const res = await fetch(`${QPAY_BASE}/auth/token`, {
      method: "POST",
      headers: {
        Authorization: "Basic " + Buffer.from(`${this.cfg.merchantId}:${this.cfg.merchantSecret}`).toString("base64"),
      },
    });
    const j = z.object({ access_token: z.string(), expires_in: z.number() }).parse(await res.json());
    await this.cfg.redis.setex("qpay:token", j.expires_in - 60, j.access_token);
    return j.access_token;
  }

  // 2. Create invoice
  async createInvoice(p: {
    invoiceCode: string;            // your code (idempotency-friendly)
    senderInvoiceNo: string;        // human ref
    invoiceReceiverCode: string;    // tenant identifier
    amount: bigint;                 // MNT minor units
    description: string;
    callbackUrl: string;            // see HMAC pattern below
  }) {
    const token = await this.token();
    const res = await fetch(`${QPAY_BASE}/invoice`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        invoice_code:        p.invoiceCode,
        sender_invoice_no:   p.senderInvoiceNo,
        invoice_receiver_code: p.invoiceReceiverCode,
        amount:              Number(p.amount) / 100,  // QPay wants major units
        invoice_description: p.description,
        callback_url:        p.callbackUrl,
      }),
    });
    return InvoiceResponseSchema.parse(await res.json());
  }

  // 3. Check payment (source of truth — always call this, don't trust webhook body)
  async checkPayment(qpayInvoiceId: string) {
    const token = await this.token();
    const res = await fetch(`${QPAY_BASE}/payment/check`, {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ object_type: "INVOICE", object_id: qpayInvoiceId }),
    });
    return PaymentCheckSchema.parse(await res.json());
  }
}

const InvoiceResponseSchema = z.object({
  invoice_id: z.string(),
  qr_text: z.string(),
  qr_image: z.string(),    // base64 PNG
  urls: z.array(z.object({ name: z.string(), description: z.string(), logo: z.string(), link: z.string() })),
});

const PaymentCheckSchema = z.object({
  count: z.number(),
  paid_amount: z.number(),
  rows: z.array(z.object({
    payment_id: z.string(),
    payment_date: z.string(),
    payment_amount: z.number(),
    payment_status: z.enum(["NEW","FAILED","PAID","REFUNDED"]),
  })),
});

Webhook security (no built-in signature)

QPay v2 has no callback signature. Strategy:

  1. Make callback_url contain a 32-byte HMAC of invoice_id: https://api.spacehub.mn/v2/qpay/webhook/{hmac}. Only your server can produce the hmac.
  2. On hit: verify hmac matches stored invoice; ignore body, call checkPayment() against QPay.
  3. Idempotency key = qpay_payment_id. Upsert into payment_events with unique constraint.
// apps/api/src/routes/qpay.ts
app.post("/qpay/webhook/:hmac", async (c) => {
  const hmac = c.req.param("hmac");
  const invoice = await db().query.qpayInvoices.findFirst({ where: eq(qpayInvoices.callbackHmac, hmac) });
  if (!invoice) return c.json({ error: { code: "INVALID_TOKEN" } }, 404);

  const check = await qpay.checkPayment(invoice.qpayInvoiceId);
  for (const row of check.rows.filter(r => r.payment_status === "PAID")) {
    await db().transaction(async (tx) => {
      const exists = await tx.query.paymentEvents.findFirst({ where: eq(paymentEvents.externalId, row.payment_id) });
      if (exists) return;
      await tx.insert(paymentEvents).values({
        externalId: row.payment_id, gateway: "qpay",
        amount: BigInt(Math.round(row.payment_amount * 100)), receivedAt: new Date(row.payment_date),
      });
      await applyPayment(tx, invoice.invoiceId, BigInt(Math.round(row.payment_amount * 100)), "qpay");
    });
  }
  return c.json({ ok: true });
});

SocialPay client

Pre-shared inStoreCode + secret. Request body signed with SHA-256 HMAC of canonical field order; verify same on callback. Endpoints: /SmartPos/invoice/request, /SmartPos/invoice/check. ~200 lines using node:crypto. Treat checksum as required; reject on mismatch.

Common gateway interface

// packages/integrations/src/payment-gateway.ts
export interface PaymentGateway {
  name: "qpay" | "socialpay" | "manual_bank";
  createInvoice(p: CreateInvoiceParams): Promise<{ externalId: string; qrText: string; qrImage?: string; payUrl?: string }>;
  checkPayment(externalId: string): Promise<{ status: "pending" | "paid" | "failed"; paidAmount?: bigint; paidAt?: Date; gatewayPaymentId?: string }>;
  verifyCallback(payload: unknown, headers: Record<string, string>): { externalId: string; valid: boolean };
}

Wrap both QPay + SocialPay behind this. Reconciliation code stays gateway-agnostic.

API surface

POST /v2/invoices/{id}/pay-link                  # body: { gateway: "qpay" | "socialpay" }
                                                  # returns: { qrText, qrImage, payUrl, expiresAt }
GET  /v2/payments?invoice={id}                   # list of applied payments
POST /v2/qpay/webhook/{hmac}                     # gateway callback
POST /v2/socialpay/webhook                       # gateway callback (signed body)
POST /v2/invoices/{id}/manual-payment            # owner records cash/transfer manually

Build steps

  1. Switch money to dinero.js — update @spacehub/shared/money, migrate call sites.
  2. Implement QPayClient + SocialPayClient + common interface in packages/integrations.
  3. Schemas: qpay_invoices, socialpay_invoices, payment_events, payments.
  4. Webhook routes (HMAC path-token for QPay, body signature for SocialPay).
  5. Pay-link endpoint that creates invoice on gateway + returns QR.
  6. Web: invoice detail "Pay" button showing QR; mobile shows native QR.
  7. Flutter: native QR display + deep-link to bank apps (use urls[] from QPay response).
  8. Reconciliation test against gateway sandbox.

Open questions

Sources