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.
Library picks
| Cluster | Pick | Why |
|---|---|---|
| HTTP client | Built-in fetch (Node 22) + Zod for response validation | No SDK churn risk |
| QPay | Raw REST, in-house client | Existing npm packages low-quality; none verify callbacks |
| SocialPay | Raw REST + node:crypto for HMAC-SHA256 | No real TS client exists |
| Money | dinero.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:
- Make
callback_urlcontain a 32-byte HMAC ofinvoice_id:https://api.spacehub.mn/v2/qpay/webhook/{hmac}. Only your server can produce the hmac. - On hit: verify hmac matches stored invoice; ignore body, call
checkPayment()against QPay. - Idempotency key =
qpay_payment_id. Upsert intopayment_eventswith 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
- Switch money to dinero.js — update
@spacehub/shared/money, migrate call sites. - Implement QPayClient + SocialPayClient + common interface in
packages/integrations. - Schemas:
qpay_invoices,socialpay_invoices,payment_events,payments. - Webhook routes (HMAC path-token for QPay, body signature for SocialPay).
- Pay-link endpoint that creates invoice on gateway + returns QR.
- Web: invoice detail "Pay" button showing QR; mobile shows native QR.
- Flutter: native QR display + deep-link to bank apps (use
urls[]from QPay response). - Reconciliation test against gateway sandbox.
Open questions
- One QR per invoice or per contract? Per invoice — clearer reconciliation. Tenants can have multiple open invoices.
- QR expiry: QPay invoice expires (default 24h). Refresh on demand or batch-refresh nightly for open invoices?
- Refunds via gateway? QPay/SocialPay refund APIs exist; v2 launch supports manual refund (owner marks invoice
refunded), gateway refund Phase 6+.