UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

187 lines (160 loc) • 6.85 kB
/** * The canonical GPM Pay integration: you build the QR with your own code, and * match incoming webhooks against your own database. * * GPM Pay mints no codes and holds no order state — the `orders` map below is * *yours*, standing in for your database. * * npm install express @gpmpay/sdk * export GPMPAY_API_TOKEN=gpm_... * node server.mjs * * Then expose it (`ngrok http 3000`) and POST /setup-webhook to register. */ import express from 'express'; import { GpmPay, formatVnd } from '@gpmpay/sdk'; import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr'; import { gpmpayWebhook } from '@gpmpay/sdk/webhooks'; // Fail at boot, not at the first checkout. The SDK throws on a missing token // anyway; reading it into a local first is what makes this file type-check. const apiToken = process.env.GPMPAY_API_TOKEN; if (!apiToken) throw new Error('GPMPAY_API_TOKEN is not set'); const webhookSecret = process.env.GPMPAY_WEBHOOK_SECRET; if (!webhookSecret) throw new Error('GPMPAY_WEBHOOK_SECRET is not set'); const client = new GpmPay({ apiToken }); // Resolved once at boot. Needs the `bank-accounts:read` scope. const account = (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]; if (!account) throw new Error('No ACTIVE bank account on this GPM Pay account'); // Hoisted into a const so the narrowing survives into the callbacks below. const bank = account.bank; if (!bank) throw new Error('Bank account is missing its `bank` relation'); const app = express(); // --------------------------------------------------------------------------- // Stand-ins for your database. // --------------------------------------------------------------------------- /** @typedef {{ id: string, total: number, status: 'PENDING' | 'PAID' | 'UNDERPAID' }} LocalOrder */ /** @type {Map<string, LocalOrder>} */ const orders = new Map(); /** @type {Set<string>} */ const processedTransactions = new Set(); /** Your own code format. Keep it A-Z0-9 — banks normalize content aggressively. */ const CODE_PREFIX = 'DH'; const CODE_REGEX = /DH(\d+)/; /** * @param {string} transactionId * @returns {boolean} false when this transaction was already handled. */ function claimTransaction(transactionId) { if (processedTransactions.has(transactionId)) return false; processedTransactions.add(transactionId); return true; } // --------------------------------------------------------------------------- // Webhook — MUST come before any global express.json() // --------------------------------------------------------------------------- app.post( '/webhooks/gpmpay', express.raw({ type: 'application/json' }), // ← without this, verification always fails gpmpayWebhook({ secret: webhookSecret, onEvent: async (event) => { const { id: transactionId, content, transferAmount, transferType } = event.payload; // Deliveries retry up to 6 times — the same transaction WILL arrive twice. if (!claimTransaction(transactionId)) return; // You receive a webhook for EVERY transaction on the account, including // outgoing ones and money that has nothing to do with you. if (transferType !== 'in') return; const code = CODE_REGEX.exec(content)?.[0]; if (!code) { console.log(`💸 unmatched ${formatVnd(transferAmount)} — "${content}"`); return; } const order = orders.get(code); if (!order) { console.log(`❓ ${code} không có trong hệ thống — "${content}"`); return; } // Nothing compares the amount for you. This check is yours. if (order.total !== transferAmount) { console.error( `⚠️ ${code}: cần ${formatVnd(order.total)}, nhận ${formatVnd(transferAmount)}`, ); order.status = 'UNDERPAID'; return; } order.status = 'PAID'; console.log(`✅ ${code} paid ${formatVnd(transferAmount)}`); // Real work goes here — or better, enqueue it. This runs AFTER the 200. }, onError: (error) => { console.error(`✗ rejected webhook: ${error.reason} — ${error.message}`); }, }), ); app.use(express.json()); // --------------------------------------------------------------------------- // Checkout — build the QR ourselves, no GPM Pay order created // --------------------------------------------------------------------------- app.post('/checkout', (req, res) => { const total = Math.round(Number(req.body.amount)); // integer VND const code = `${CODE_PREFIX}${req.body.orderId}`; orders.set(code, { id: req.body.orderId, total, status: 'PENDING' }); // One call gives the checkout page everything it needs. Pure local // computation — no network round-trip, so this cannot fail on a GPM Pay // outage. res.json( buildPaymentInstructions({ bankAccount: account, amount: total, transferContent: code, // ← the payer MUST include this }), ); }); app.get('/orders/:code', (req, res) => { const order = orders.get(req.params.code); if (!order) { res.status(404).json({ error: 'not found' }); return; } res.json(order); }); // --------------------------------------------------------------------------- // One-off helper: register this server as a webhook endpoint // --------------------------------------------------------------------------- app.post('/setup-webhook', async (req, res, next) => { try { const { secret } = await client.webhookSettings.createHmacEndpoint({ url: `${req.body.publicUrl}/webhooks/gpmpay`, name: 'self-reconcile example', }); console.log('\n GPMPAY_WEBHOOK_SECRET=%s\n', secret); // shown once, ever res.json({ ok: true, message: 'secret printed to the server console' }); } catch (error) { next(error); } }); /** * Params are annotated individually rather than typing the whole function as * `ErrorRequestHandler` — that alias returns `unknown`, which would force a * pointless `return` here. * * @param {unknown} error * @param {import('express').Request} _req * @param {import('express').Response} res * @param {import('express').NextFunction} _next */ const errorHandler = (error, _req, res, _next) => { console.error(error); res.status(500).json({ error: error instanceof Error ? error.message : String(error), // Every GpmPayAPIError carries one — quote it in a support ticket. requestId: /** @type {{ requestId?: string }} */ (error).requestId, }); }; app.use(errorHandler); app.listen(3000, () => { console.log(`listening on http://localhost:3000`); console.log(` receiving into ${bank.shortName} ${account.accountNumber}`); console.log(' POST /checkout { "orderId": "123", "amount": 50000 }'); console.log(' POST /setup-webhook { "publicUrl": "https://xxx.ngrok.app" }'); });