@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
187 lines (160 loc) • 6.85 kB
JavaScript
/**
* 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" }');
});