9pay-integrate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # 9pay-integrate
2
+
3
+ Complete 9Pay payment gateway integration for Node.js and Next.js. Generate payment URLs, verify callbacks, process IPN webhooks — all with a clean, framework-agnostic core.
4
+
5
+ ```bash
6
+ pnpm install 9pay-integrate
7
+ ```
8
+
9
+ ## Quick Start
10
+
11
+ ### 1. Configure
12
+
13
+ ```typescript
14
+ // lib/9pay-config.ts
15
+ import { configFromEnv } from "9pay-integrate";
16
+
17
+ export const ninepay = configFromEnv(process.env);
18
+ ```
19
+
20
+ Required environment variables:
21
+ - `NINEPAY_MERCHANT_KEY`
22
+ - `NINEPAY_SECRET_KEY`
23
+ - `NINEPAY_CHECKSUM_KEY`
24
+ - `NINEPAY_RETURN_URL` (e.g. `https://yoursite.com/api/checkout/9pay/callback`)
25
+
26
+ Optional:
27
+ - `NINEPAY_BASE_URL` (default: `https://payment.9pay.vn`)
28
+ - `NINEPAY_CANCEL_URL` (default: same as RETURN_URL)
29
+
30
+ ### 2. Implement Order Repository
31
+
32
+ The package needs a way to manage orders. Implement the `OrderRepository` interface for your data layer (Strapi, Prisma, Drizzle, MongoDB, REST API, etc.).
33
+
34
+ ```typescript
35
+ // lib/order-repository.ts
36
+ import type { OrderRepository, OrderStatus } from "9pay-integrate";
37
+
38
+ export const repo: OrderRepository = {
39
+ async createOrder(params) {
40
+ // Create order in your database
41
+ // Return whatever your DB returns
42
+ },
43
+ async getOrderStatus(orderId: string): Promise<OrderStatus | null> {
44
+ // Query order status from your database
45
+ // Return "neworder" | "pending" | "completed" | "failed" | null
46
+ },
47
+ async updateOrderStatus(orderId: string, status: "completed" | "failed") {
48
+ // Update order status in your database
49
+ // Return the updated order or null
50
+ },
51
+ };
52
+ ```
53
+
54
+ ### 3. Implement Notification Hooks (optional)
55
+
56
+ ```typescript
57
+ // lib/notifications.ts
58
+ import type { NotificationContext } from "9pay-integrate";
59
+
60
+ export const notifications = {
61
+ async onSuccess(ctx: NotificationContext) {
62
+ // Send confirmation email
63
+ // Send Telegram/Slack notification
64
+ console.log(`Order ${ctx.orderId} completed: ${ctx.amount} ${ctx.currency}`);
65
+ },
66
+ async onFailure(ctx: NotificationContext) {
67
+ // Alert on payment failure
68
+ console.log(`Order ${ctx.orderId} failed`);
69
+ },
70
+ };
71
+ ```
72
+
73
+ ### 4. Create Payment URL (Checkout POST)
74
+
75
+ In your checkout API route, create the order in your database, then generate the 9Pay redirect URL.
76
+
77
+ ```typescript
78
+ // app/api/checkout/route.ts
79
+ import { NextResponse } from "next/server";
80
+ import { createPaymentUrl } from "9pay-integrate";
81
+ import { ninepay } from "@/lib/9pay-config";
82
+ import { repo } from "@/lib/order-repository";
83
+
84
+ export async function POST(request: Request) {
85
+ const body = await request.json();
86
+
87
+ // 1. Validate checkout form data, calculate pricing, etc.
88
+ // 2. Create order in your database
89
+ const order = await repo.createOrder({
90
+ totalAmount: body.amount,
91
+ currency: body.currency,
92
+ paymentMethod: "9pay",
93
+ metadata: body,
94
+ });
95
+
96
+ // 3. Generate 9Pay payment URL
97
+ const paymentUrl = createPaymentUrl(ninepay, {
98
+ orderId: order.id, // or order.documentId, whatever your DB returns
99
+ amount: body.amount,
100
+ currency: body.currency,
101
+ description: "Order Payment",
102
+ });
103
+
104
+ return NextResponse.json({ success: true, orderId: order.id, paymentUrl });
105
+ }
106
+ ```
107
+
108
+ ### 5. Wire Up Routes
109
+
110
+ #### Callback Route (GET)
111
+
112
+ Handles the browser redirect after payment on 9Pay's portal.
113
+
114
+ ```typescript
115
+ // app/api/checkout/9pay/callback/route.ts
116
+ import { NextResponse } from "next/server";
117
+ import { verifyNinePayCallback } from "9pay-integrate/next";
118
+ import { ninepay } from "@/lib/9pay-config";
119
+
120
+ export async function GET(request: Request) {
121
+ const result = verifyNinePayCallback(request, ninepay);
122
+
123
+ if (!result.success) {
124
+ return NextResponse.redirect(new URL("/payment/error", request.url));
125
+ }
126
+
127
+ return NextResponse.redirect(
128
+ new URL(`/payment/process?orderId=${result.orderId}`, request.url)
129
+ );
130
+ }
131
+ ```
132
+
133
+ #### IPN Webhook (POST)
134
+
135
+ Handles the server-to-server webhook from 9Pay. This is the source of truth for payment status.
136
+
137
+ ```typescript
138
+ // app/api/checkout/9pay/ipn/route.ts
139
+ import { handle9PayIpn } from "9pay-integrate/next";
140
+ import { ninepay } from "@/lib/9pay-config";
141
+ import { repo } from "@/lib/order-repository";
142
+ import { notifications } from "@/lib/notifications";
143
+
144
+ export async function POST(request: Request) {
145
+ return handle9PayIpn(request, {
146
+ config: ninepay,
147
+ orderRepository: repo,
148
+ notifications,
149
+ });
150
+ }
151
+ ```
152
+
153
+ #### Payment Status (GET)
154
+
155
+ For frontend polling.
156
+
157
+ ```typescript
158
+ // app/api/payment/status/route.ts
159
+ import { handlePaymentStatus } from "9pay-integrate/next";
160
+ import { repo } from "@/lib/order-repository";
161
+
162
+ export async function GET(request: Request) {
163
+ return handlePaymentStatus(request, { orderRepository: repo });
164
+ }
165
+ ```
166
+
167
+ ## API Reference
168
+
169
+ ### Core (`9pay-integrate`)
170
+
171
+ | Export | Description |
172
+ |--------|-------------|
173
+ | `configFromEnv(env)` | Build config from any env-like object |
174
+ | `validateConfig(partial)` | Validate and normalize config |
175
+ | `createPaymentUrl(config, params)` | Generate signed 9Pay redirect URL |
176
+ | `verifyChecksum(result, checksum, key)` | Verify callback/IPN checksum |
177
+ | `verifyCallback(searchParams, config)` | Verify browser redirect callback |
178
+ | `processIpn(payload, options)` | Process IPN (adapter-agnostic) |
179
+ | `buildHttpQuery(data)` | Build sorted query string (advanced) |
180
+ | `buildSignature(baseUrl, time, query, key)` | Build HMAC signature (advanced) |
181
+ | `extractIpnFromFormData(formData)` | Extract IPN from FormData |
182
+ | `extractIpnFromUrlEncoded(body)` | Extract IPN from URL-encoded body |
183
+ | `NINEPAY_STATUS` | Status codes (SUCCESS = 5) |
184
+ | `SUCCESS_STATUS` | Constant for successful payment |
185
+
186
+ ### Next.js Adapter (`9pay-integrate/next`)
187
+
188
+ | Export | Description |
189
+ |--------|-------------|
190
+ | `verifyNinePayCallback(request, config)` | Verify callback, return result object |
191
+ | `handle9PayIpn(request, options)` | Full IPN handler → NextResponse |
192
+ | `handlePaymentStatus(request, options)` | Status polling handler → NextResponse |
193
+
194
+ ### Types
195
+
196
+ All types are exported from the main entry point. Key interfaces:
197
+
198
+ - `NinePayConfig` — configuration
199
+ - `OrderRepository` — implement for your data layer
200
+ - `NotificationHooks` — optional notification callbacks
201
+ - `IpnProcessResult` — IPN processing result
202
+ - `VerifyCallbackResult` — callback verification result
203
+ - `OrderStatus` — `"neworder" | "pending" | "completed" | "failed"`
204
+
205
+ ## Architecture
206
+
207
+ ```
208
+ ┌─────────────────────────────────────────┐
209
+ │ 9Pay Portal │
210
+ │ payment.9pay.vn/portal │
211
+ └────────────┬────────────────┬───────────┘
212
+ │ │
213
+ Browser redirect Server-to-server
214
+ (callback GET) (IPN POST)
215
+ │ │
216
+ ┌────────────▼────────────────▼───────────┐
217
+ │ 9pay-integrate/core │
218
+ │ ┌─────────┐ ┌───────────┐ ┌───────┐ │
219
+ │ │ signing │ │ verify │ │ ipn │ │
220
+ │ │ +payment │ │ callback │ │ proc │ │
221
+ │ └─────────┘ └───────────┘ └───────┘ │
222
+ │ (zero framework deps) │
223
+ └────────────────┬────────────────────────┘
224
+
225
+ ┌────────────────▼────────────────────────┐
226
+ │ 9pay-integrate/next │
227
+ │ (thin glue: parse req → call core) │
228
+ └─────────────────────────────────────────┘
229
+ ```
230
+
231
+ ### Design Principles
232
+
233
+ 1. **Core has zero framework dependencies** — works in Express, Fastify, Hono, Bun, Deno
234
+ 2. **Inversion of Control** — package never hardcodes ORM/CMS/notification service
235
+ 3. **Option B for callback** — returns data, consumer controls redirect
236
+ 4. **Option C for IPN** — encapsulates all 6 steps, but consumer can bypass and call `processIpn()` directly from core
237
+ 5. **Fail fast** — config validation at init time, not during live payment
238
+
239
+ ## License
240
+
241
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,317 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
34
+ NINEPAY_STATUS: () => NINEPAY_STATUS,
35
+ SUCCESS_STATUS: () => SUCCESS_STATUS,
36
+ buildHttpQuery: () => buildHttpQuery,
37
+ buildSignature: () => buildSignature,
38
+ configFromEnv: () => configFromEnv,
39
+ createPaymentUrl: () => createPaymentUrl,
40
+ extractIpnFromFormData: () => extractIpnFromFormData,
41
+ extractIpnFromUrlEncoded: () => extractIpnFromUrlEncoded,
42
+ processIpn: () => processIpn,
43
+ validateConfig: () => validateConfig,
44
+ verifyCallback: () => verifyCallback,
45
+ verifyChecksum: () => verifyChecksum
46
+ });
47
+ module.exports = __toCommonJS(index_exports);
48
+
49
+ // src/core/constants.ts
50
+ var NINEPAY_STATUS = {
51
+ PENDING: 1,
52
+ PROCESSING: 2,
53
+ CANCELLED: 3,
54
+ REFUNDED: 4,
55
+ SUCCESS: 5,
56
+ FAILED: 6
57
+ };
58
+ var SUCCESS_STATUS = NINEPAY_STATUS.SUCCESS;
59
+ var DEFAULT_BASE_URL = "https://payment.9pay.vn";
60
+
61
+ // src/core/config.ts
62
+ function validateConfig(config) {
63
+ const errors = [];
64
+ if (!config.merchantKey) errors.push("merchantKey is required");
65
+ if (!config.secretKey) errors.push("secretKey is required");
66
+ if (!config.checksumKey) errors.push("checksumKey is required");
67
+ if (!config.returnUrl) errors.push("returnUrl is required");
68
+ if (errors.length > 0) {
69
+ throw new Error(
70
+ `9pay-integrate configuration error:
71
+ - ${errors.join("\n - ")}`
72
+ );
73
+ }
74
+ return {
75
+ merchantKey: config.merchantKey,
76
+ secretKey: config.secretKey,
77
+ checksumKey: config.checksumKey,
78
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL,
79
+ returnUrl: config.returnUrl,
80
+ cancelUrl: config.cancelUrl || config.returnUrl
81
+ };
82
+ }
83
+ function configFromEnv(env) {
84
+ return validateConfig({
85
+ merchantKey: env.NINEPAY_MERCHANT_KEY,
86
+ secretKey: env.NINEPAY_SECRET_KEY,
87
+ checksumKey: env.NINEPAY_CHECKSUM_KEY,
88
+ baseUrl: env.NINEPAY_BASE_URL,
89
+ returnUrl: env.NINEPAY_RETURN_URL,
90
+ cancelUrl: env.NINEPAY_CANCEL_URL
91
+ });
92
+ }
93
+
94
+ // src/core/signing.ts
95
+ var crypto = __toESM(require("crypto"), 1);
96
+ function buildHttpQuery(data) {
97
+ const params = new URLSearchParams();
98
+ const sortedKeys = Object.keys(data).sort();
99
+ for (const key of sortedKeys) {
100
+ if (data[key] !== void 0 && data[key] !== null) {
101
+ params.append(key, String(data[key]));
102
+ }
103
+ }
104
+ return params.toString();
105
+ }
106
+ function buildSignature(baseUrl, time, httpQuery, secretKey) {
107
+ const message = `POST
108
+ ${baseUrl}/payments/create
109
+ ${time}
110
+ ${httpQuery}`;
111
+ return crypto.createHmac("sha256", secretKey).update(message).digest().toString("base64");
112
+ }
113
+
114
+ // src/core/payment-url.ts
115
+ function createPaymentUrl(config, params) {
116
+ const time = Math.floor(Date.now() / 1e3);
117
+ const parameters = {
118
+ merchantKey: config.merchantKey,
119
+ time,
120
+ invoice_no: params.orderId,
121
+ amount: params.amount,
122
+ currency: params.currency,
123
+ description: params.description,
124
+ return_url: config.returnUrl,
125
+ ...config.cancelUrl ? { back_url: config.cancelUrl } : {},
126
+ ...params.options
127
+ };
128
+ const httpQuery = buildHttpQuery(parameters);
129
+ const signature = buildSignature(
130
+ config.baseUrl || "https://payment.9pay.vn",
131
+ time,
132
+ httpQuery,
133
+ config.secretKey
134
+ );
135
+ const sortedParameters = {};
136
+ Object.keys(parameters).sort().forEach((key) => {
137
+ sortedParameters[key] = parameters[key];
138
+ });
139
+ const baseEncode = Buffer.from(JSON.stringify(sortedParameters)).toString(
140
+ "base64"
141
+ );
142
+ const redirectParams = new URLSearchParams({ baseEncode, signature });
143
+ return `${config.baseUrl || "https://payment.9pay.vn"}/portal?${redirectParams.toString()}`;
144
+ }
145
+
146
+ // src/core/verification.ts
147
+ var crypto2 = __toESM(require("crypto"), 1);
148
+ function verifyChecksum(result, checksum, checksumKey) {
149
+ if (!result || !checksum) {
150
+ return { valid: false, data: null, error: "Missing result or checksum" };
151
+ }
152
+ const expected = crypto2.createHash("sha256").update(result + checksumKey).digest("hex").toUpperCase();
153
+ if (expected !== checksum) {
154
+ return { valid: false, data: null, error: "Checksum mismatch" };
155
+ }
156
+ try {
157
+ const decoded = JSON.parse(
158
+ Buffer.from(result, "base64").toString("utf-8")
159
+ );
160
+ return { valid: true, data: decoded };
161
+ } catch {
162
+ return { valid: false, data: null, error: "Failed to decode result" };
163
+ }
164
+ }
165
+
166
+ // src/core/verify-callback.ts
167
+ function verifyCallback(searchParams, config) {
168
+ const result = searchParams.get("result") || "";
169
+ const checksum = searchParams.get("checksum") || "";
170
+ const verification = verifyChecksum(result, checksum, config.checksumKey);
171
+ if (!verification.valid || !verification.data) {
172
+ return {
173
+ success: false,
174
+ rawParams: {},
175
+ error: verification.error || "Invalid callback payload"
176
+ };
177
+ }
178
+ const paymentInfo = verification.data;
179
+ const invoiceNo = paymentInfo.invoice_no;
180
+ const orderId = typeof invoiceNo === "string" && invoiceNo.trim().length > 0 ? invoiceNo : typeof invoiceNo === "number" ? String(invoiceNo) : null;
181
+ if (!orderId) {
182
+ return {
183
+ success: false,
184
+ rawParams: paymentInfo,
185
+ error: "Missing invoice_no in callback payload"
186
+ };
187
+ }
188
+ return {
189
+ success: true,
190
+ orderId,
191
+ amount: Number(paymentInfo.amount ?? 0),
192
+ currency: String(paymentInfo.currency ?? ""),
193
+ status: Number(paymentInfo.status ?? 0),
194
+ rawParams: paymentInfo
195
+ };
196
+ }
197
+
198
+ // src/core/ipn.ts
199
+ function extractIpnFromFormData(formData) {
200
+ return {
201
+ result: String(formData.get("result") || ""),
202
+ checksum: String(formData.get("checksum") || "")
203
+ };
204
+ }
205
+ function extractIpnFromUrlEncoded(body) {
206
+ const params = new URLSearchParams(body);
207
+ return {
208
+ result: params.get("result") || "",
209
+ checksum: params.get("checksum") || ""
210
+ };
211
+ }
212
+
213
+ // src/core/ipn-processor.ts
214
+ async function processIpn(payload, options) {
215
+ const { config, orderRepository, notifications } = options;
216
+ const verification = verifyChecksum(
217
+ payload.result,
218
+ payload.checksum,
219
+ config.checksumKey
220
+ );
221
+ if (!verification.valid || !verification.data) {
222
+ return {
223
+ success: false,
224
+ message: verification.error || "Invalid checksum",
225
+ statusCode: 403
226
+ };
227
+ }
228
+ const paymentInfo = verification.data;
229
+ const invoiceNo = paymentInfo.invoice_no;
230
+ const orderId = typeof invoiceNo === "string" && invoiceNo.trim().length > 0 ? invoiceNo : typeof invoiceNo === "number" ? String(invoiceNo) : null;
231
+ if (!orderId) {
232
+ return {
233
+ success: false,
234
+ message: "Missing invoice_no in IPN payload",
235
+ statusCode: 400
236
+ };
237
+ }
238
+ const isSuccess = Number(paymentInfo.status) === SUCCESS_STATUS;
239
+ const amount = Number(paymentInfo.amount ?? 0);
240
+ const currency = String(paymentInfo.currency ?? "");
241
+ if (isSuccess) {
242
+ try {
243
+ await orderRepository.updateOrderStatus(orderId, "completed");
244
+ } catch (error) {
245
+ return {
246
+ success: false,
247
+ message: `Failed to update order: ${error instanceof Error ? error.message : String(error)}`,
248
+ statusCode: 500,
249
+ orderId
250
+ };
251
+ }
252
+ if (notifications?.onSuccess) {
253
+ try {
254
+ await notifications.onSuccess({
255
+ orderId,
256
+ status: "completed",
257
+ amount,
258
+ currency,
259
+ paymentMethod: "9pay",
260
+ metadata: paymentInfo
261
+ });
262
+ } catch {
263
+ }
264
+ }
265
+ return {
266
+ success: true,
267
+ message: "Order updated successfully",
268
+ statusCode: 200,
269
+ orderId
270
+ };
271
+ }
272
+ try {
273
+ await orderRepository.updateOrderStatus(orderId, "failed");
274
+ } catch (error) {
275
+ return {
276
+ success: false,
277
+ message: `Failed to update order: ${error instanceof Error ? error.message : String(error)}`,
278
+ statusCode: 500,
279
+ orderId
280
+ };
281
+ }
282
+ if (notifications?.onFailure) {
283
+ try {
284
+ await notifications.onFailure({
285
+ orderId,
286
+ status: "failed",
287
+ amount,
288
+ currency,
289
+ paymentMethod: "9pay",
290
+ metadata: paymentInfo
291
+ });
292
+ } catch {
293
+ }
294
+ }
295
+ return {
296
+ success: false,
297
+ message: "Payment failed",
298
+ statusCode: 200,
299
+ orderId
300
+ };
301
+ }
302
+ // Annotate the CommonJS export names for ESM import in node:
303
+ 0 && (module.exports = {
304
+ DEFAULT_BASE_URL,
305
+ NINEPAY_STATUS,
306
+ SUCCESS_STATUS,
307
+ buildHttpQuery,
308
+ buildSignature,
309
+ configFromEnv,
310
+ createPaymentUrl,
311
+ extractIpnFromFormData,
312
+ extractIpnFromUrlEncoded,
313
+ processIpn,
314
+ validateConfig,
315
+ verifyCallback,
316
+ verifyChecksum
317
+ });