@vobs/payment 0.1.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.
@@ -0,0 +1,157 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * @vobs/payment contract types (aligned with docs/development/payment-factory-design.md §3/§5).
7
+ *
8
+ * Scope boundary: the payment factory only does **forward collection** (order → verify → book → reconcile).
9
+ * Refunds are out of scope — business refunds go directly to the channel's refund API.
10
+ */
11
+ /** Payment scenes (channel support matrix). */
12
+ export type PaymentScene = 'pc' | 'h5' | 'miniapp' | 'app';
13
+ /** Order states (no refund path). */
14
+ export type OrderState = 'created' | 'pending' | 'paid' | 'closed' | 'frozen';
15
+ /**
16
+ * Channel order input.
17
+ * `orderId` is globally unique — used as the channel's `out_trade_no` (channel-side unique constraint = natural idempotency, safe to retry after disconnects).
18
+ */
19
+ export interface CreateOrderInput {
20
+ readonly orderId: string;
21
+ /** Fen (amounts are always integers to avoid float errors; currency CNY, extensible for multi-currency). */
22
+ readonly amount: number;
23
+ readonly subject: string;
24
+ readonly scene: PaymentScene;
25
+ readonly notifyUrl: string;
26
+ readonly extras?: Readonly<Record<string, unknown>>;
27
+ }
28
+ /** One-time payment params (the frontend initiates payment with these; keys never reach the frontend). */
29
+ export type OrderInit = {
30
+ readonly kind: 'jsapi';
31
+ readonly payParams: Readonly<Record<string, string>>;
32
+ } | {
33
+ readonly kind: 'qr';
34
+ readonly qrUrl: string;
35
+ readonly expiresAt?: string;
36
+ } | {
37
+ readonly kind: 'redirect';
38
+ readonly payUrl: string;
39
+ };
40
+ /** Unified payment driver contract (three capabilities: createOrder / verifyCallback / queryOrder). */
41
+ export interface PaymentDriver {
42
+ readonly name: string;
43
+ readonly scenes: readonly PaymentScene[];
44
+ /** Place an order: call the payment channel → return one-time payment params. */
45
+ createOrder(input: CreateOrderInput): Promise<OrderInit>;
46
+ /** Channel callback verification + normalization: NOTIFY request → verify channel signature → normalize order result. */
47
+ verifyCallback(payload: unknown, headers: unknown): VerifiedCallback;
48
+ /** Query order status (reconciliation/compensation). */
49
+ queryOrder(orderId: string): Promise<OrderStatus>;
50
+ /**
51
+ * Channel-agreed callback success text (e.g. WeChat/Jeepay `SUCCESS`, Alipay `success`).
52
+ * Default = JSON response (mock/self-built backends). After a successful callback the HTTP layer
53
+ * returns this channel-agreed format so the channel doesn't misjudge failure and retry.
54
+ */
55
+ readonly successResponseText?: string;
56
+ }
57
+ /** Normalized channel callback result (amounts always in fen). */
58
+ export interface VerifiedCallback {
59
+ readonly ok: boolean;
60
+ readonly reason?: string;
61
+ readonly orderId?: string;
62
+ readonly paidAmount?: number;
63
+ readonly channelTxnId?: string;
64
+ }
65
+ export type OrderStatus = {
66
+ readonly state: OrderState;
67
+ readonly paidAt?: string;
68
+ } | {
69
+ readonly state: 'unknown';
70
+ readonly raw: unknown;
71
+ };
72
+ /** Order record (payment-factory internal state, stored in PaymentStore). */
73
+ export interface OrderRecord {
74
+ readonly orderId: string;
75
+ readonly amount: number;
76
+ readonly subject: string;
77
+ readonly scene: PaymentScene;
78
+ readonly driver: string;
79
+ state: OrderState;
80
+ paidAt?: string;
81
+ createdAt: number;
82
+ init?: OrderInit;
83
+ channelTxnId?: string;
84
+ /** Large-amount audit pending reason (audit). */
85
+ auditReason?: string;
86
+ }
87
+ /** Global policy config (PaymentManager-level, driver-agnostic). */
88
+ export interface PaymentConfig {
89
+ /** Strategy when an unpaid order already exists for the same business order. Default 'ask'. */
90
+ readonly duplicateOrderStrategy?: 'reuse' | 'replace' | 'reject' | 'ask';
91
+ /** Large-amount audit threshold (fen): callback succeeds but paid ≥ this → stay pending until manually confirmed. */
92
+ readonly auditThreshold?: number;
93
+ /** Per-order limit (fen): reject over-limit orders (channel limits are usually lower; configure per driver). */
94
+ readonly maxAmount?: number;
95
+ /** Callback time window (sec) for anti-replay. Default 300. */
96
+ readonly timeWindowSec?: number;
97
+ /** Retention (ms) for idempotency records / callback nonces. Default 24h. */
98
+ readonly idempotencyTtlMs?: number;
99
+ }
100
+ /** Create-order HTTP input. `amount` is only a display/consistency check — the server-side price wins (server-pricing red line). */
101
+ export interface CreateOrderRequest {
102
+ readonly orderId: string;
103
+ readonly amount: number;
104
+ readonly subject: string;
105
+ readonly scene?: PaymentScene;
106
+ readonly driver?: string;
107
+ readonly idempotencyKey?: string;
108
+ }
109
+ /** Create-order result. */
110
+ export type CreateOrderResult = {
111
+ readonly kind: 'ok';
112
+ readonly driver: string;
113
+ readonly orderId: string;
114
+ readonly state: OrderState;
115
+ readonly init?: OrderInit;
116
+ } | {
117
+ readonly kind: 'duplicate';
118
+ readonly existingOrderId: string;
119
+ readonly existingAmount: number;
120
+ };
121
+ /** Callback handling result (incl. HTTP response protocol: persist before responding). */
122
+ export interface CallbackResult {
123
+ readonly ok: boolean;
124
+ readonly status: number;
125
+ readonly body: Readonly<Record<string, unknown>>;
126
+ readonly orderId?: string;
127
+ readonly state?: OrderState;
128
+ readonly reason?: string;
129
+ /** Channel-agreed success text (WeChat/Jeepay `SUCCESS`, Alipay `success`) — when present the HTTP layer returns plain text instead of JSON. */
130
+ readonly text?: string;
131
+ }
132
+ /** Notification after booking (onVerified hook — business deliver/activate; must be idempotent). */
133
+ export interface VerifiedResult {
134
+ readonly orderId: string;
135
+ readonly state: OrderState;
136
+ readonly paidAt?: string;
137
+ readonly channelTxnId?: string;
138
+ }
139
+ /** Alert (onAlert hook — triggered on frozen/conflict/large-amount/signature failure). */
140
+ export interface PaymentAlert {
141
+ readonly level: 'warn' | 'error';
142
+ readonly code: string;
143
+ readonly message: string;
144
+ readonly orderId?: string;
145
+ readonly driver?: string;
146
+ }
147
+ /** Operation audit entry (create/callback-book/freeze/confirm all logged). */
148
+ export interface AuditEntry {
149
+ readonly operation: 'create' | 'callback' | 'close' | 'confirm' | 'freeze';
150
+ readonly orderId?: string;
151
+ readonly from?: OrderState;
152
+ readonly to?: OrderState;
153
+ readonly reason?: string;
154
+ readonly at: number;
155
+ }
156
+ /** Server-pricing hook (business: orderId → amount; undefined = order not found). */
157
+ export type GetOrderAmount = (orderId: string) => number | undefined | Promise<number | undefined>;
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * @vobs/payment contract types (aligned with docs/development/payment-factory-design.md §3/§5).
7
+ *
8
+ * Scope boundary: the payment factory only does **forward collection** (order → verify → book → reconcile).
9
+ * Refunds are out of scope — business refunds go directly to the channel's refund API.
10
+ */
11
+ export {};
@@ -0,0 +1,34 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Callback verification + anti-replay utilities.
7
+ *
8
+ * Protocol: `X-Signature = hex( HMAC-SHA256( apiKey, "{timestamp}.{nonce}.{raw body}" ) )`
9
+ * Check order:
10
+ * ① |now - timestamp| ≤ timeWindowSec else timestamp_expired (time-window anti-replay)
11
+ * ② signature matches else bad_signature
12
+ * ③ nonce unused (deduped by the caller via store) else nonce_reused (anti-replay)
13
+ *
14
+ * Idempotency prevents double-booking; anti-replay prevents forged replays — both are required.
15
+ */
16
+ import type { PaymentConfig } from './types.js';
17
+ export type VerifyResult = {
18
+ readonly ok: true;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly reason: string;
22
+ };
23
+ /** HMAC-SHA256 hex (Web Crypto, globally available in Node 22+). */
24
+ export declare function hmacSha256Hex(key: string, data: string): Promise<string>;
25
+ /** Callback verification + time-window check (nonce dedup is the caller's job — needs concurrency-safe storage). */
26
+ export declare function verifySignedCallback(opts: {
27
+ readonly apiKey: string;
28
+ readonly timestamp: string;
29
+ readonly nonce: string;
30
+ readonly signature: string;
31
+ readonly body: string;
32
+ readonly timeWindowSec?: number;
33
+ }): Promise<VerifyResult>;
34
+ export declare function timeWindowOf(config: PaymentConfig | undefined): number | undefined;
package/dist/verify.js ADDED
@@ -0,0 +1,55 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * Callback verification + anti-replay utilities.
7
+ *
8
+ * Protocol: `X-Signature = hex( HMAC-SHA256( apiKey, "{timestamp}.{nonce}.{raw body}" ) )`
9
+ * Check order:
10
+ * ① |now - timestamp| ≤ timeWindowSec else timestamp_expired (time-window anti-replay)
11
+ * ② signature matches else bad_signature
12
+ * ③ nonce unused (deduped by the caller via store) else nonce_reused (anti-replay)
13
+ *
14
+ * Idempotency prevents double-booking; anti-replay prevents forged replays — both are required.
15
+ */
16
+ const DEFAULT_TIME_WINDOW_SEC = 300;
17
+ /** HMAC-SHA256 hex (Web Crypto, globally available in Node 22+). */
18
+ export async function hmacSha256Hex(key, data) {
19
+ const keyBytes = new TextEncoder().encode(key);
20
+ const dataBytes = new TextEncoder().encode(data);
21
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
22
+ const sig = await crypto.subtle.sign('HMAC', cryptoKey, dataBytes);
23
+ return Array.from(new Uint8Array(sig))
24
+ .map((b) => b.toString(16).padStart(2, '0'))
25
+ .join('');
26
+ }
27
+ /** Callback verification + time-window check (nonce dedup is the caller's job — needs concurrency-safe storage). */
28
+ export async function verifySignedCallback(opts) {
29
+ const windowSec = opts.timeWindowSec ?? DEFAULT_TIME_WINDOW_SEC;
30
+ const ts = Number(opts.timestamp);
31
+ if (!Number.isFinite(ts)) {
32
+ return { ok: false, reason: 'bad_timestamp' };
33
+ }
34
+ if (Math.abs(Date.now() / 1000 - ts) > windowSec) {
35
+ return { ok: false, reason: 'timestamp_expired' };
36
+ }
37
+ const expected = await hmacSha256Hex(opts.apiKey, `${opts.timestamp}.${opts.nonce}.${opts.body}`);
38
+ if (!timingSafeEqual(expected, opts.signature)) {
39
+ return { ok: false, reason: 'bad_signature' };
40
+ }
41
+ return { ok: true };
42
+ }
43
+ /** Constant-time comparison to prevent timing side channels. */
44
+ function timingSafeEqual(a, b) {
45
+ if (a.length !== b.length)
46
+ return false;
47
+ let diff = 0;
48
+ for (let i = 0; i < a.length; i++) {
49
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
50
+ }
51
+ return diff === 0;
52
+ }
53
+ export function timeWindowOf(config) {
54
+ return config?.timeWindowSec;
55
+ }
@@ -0,0 +1,40 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * WeChat Pay official driver (API v3) — Native (PC scan) / JSAPI (official account, miniapp webview) / H5.
7
+ *
8
+ * Covered scenes (web):
9
+ * - `pc` → Native payment → `{kind:'qr', qrUrl: code_url}`
10
+ * - `h5` + openid → JSAPI payment → `{kind:'jsapi', payParams}` (official account / WeChat built-in browser)
11
+ * - `h5` (no openid) → H5 payment → `{kind:'redirect', payUrl: h5_url}`
12
+ * - `miniapp` + openid → JSAPI payment (miniapp webview)
13
+ *
14
+ * Security:
15
+ * - Request signing: Authorization `WECHATPAY2-SHA256-RSA2048` (merchant private key SHA256withRSA)
16
+ * - Callback: AES-256-GCM decryption (apiV3Key) + optional platform-cert verification (enforced when platformPublicKeyPem is set) + time-window/nonce anti-replay
17
+ * - Channel self-verification path: callbacks bypass the framework's generic HMAC protocol; this driver verifies the channel itself
18
+ *
19
+ * Dependencies: node:crypto (Node built-in, zero third-party). Sandbox needs sandbox keys/certs from the merchant platform (configure baseUrl per the sandbox docs when integrating).
20
+ */
21
+ import type { PaymentDriver } from './types.js';
22
+ import type { PaymentStore } from './store.js';
23
+ export interface WechatOfficialOptions {
24
+ readonly appId: string;
25
+ readonly mchId: string;
26
+ /** APIv3 key (32-byte string, used for AES-GCM callback decryption). Inject via KMS/env in production. */
27
+ readonly apiV3Key: string;
28
+ /** Merchant API certificate private key (PEM). */
29
+ readonly privateKeyPem: string;
30
+ /** Merchant API certificate serial number. */
31
+ readonly serialNo: string;
32
+ /** Platform public key (PEM, optional) — when set, callbacks require platform-cert verification; otherwise rely on AES-GCM decryption (apiV3Key confidentiality) + anti-replay. */
33
+ readonly platformPublicKeyPem?: string;
34
+ /** Callback time window (sec, anti-replay). Default 300. */
35
+ readonly timeWindowSec?: number;
36
+ /** Channel gateway URL (sandbox/prod). Defaults to prod. */
37
+ readonly baseUrl?: string;
38
+ readonly fetchImpl?: typeof fetch;
39
+ }
40
+ export declare function createWechatOfficialDriver(store: PaymentStore, options: WechatOfficialOptions): PaymentDriver;
@@ -0,0 +1,207 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/payment
4
+ */
5
+ /**
6
+ * WeChat Pay official driver (API v3) — Native (PC scan) / JSAPI (official account, miniapp webview) / H5.
7
+ *
8
+ * Covered scenes (web):
9
+ * - `pc` → Native payment → `{kind:'qr', qrUrl: code_url}`
10
+ * - `h5` + openid → JSAPI payment → `{kind:'jsapi', payParams}` (official account / WeChat built-in browser)
11
+ * - `h5` (no openid) → H5 payment → `{kind:'redirect', payUrl: h5_url}`
12
+ * - `miniapp` + openid → JSAPI payment (miniapp webview)
13
+ *
14
+ * Security:
15
+ * - Request signing: Authorization `WECHATPAY2-SHA256-RSA2048` (merchant private key SHA256withRSA)
16
+ * - Callback: AES-256-GCM decryption (apiV3Key) + optional platform-cert verification (enforced when platformPublicKeyPem is set) + time-window/nonce anti-replay
17
+ * - Channel self-verification path: callbacks bypass the framework's generic HMAC protocol; this driver verifies the channel itself
18
+ *
19
+ * Dependencies: node:crypto (Node built-in, zero third-party). Sandbox needs sandbox keys/certs from the merchant platform (configure baseUrl per the sandbox docs when integrating).
20
+ */
21
+ import { createDecipheriv, createPrivateKey, createPublicKey, randomBytes, sign, verify, } from 'node:crypto';
22
+ const DEFAULT_BASE = 'https://api.mch.weixin.qq.com';
23
+ function isWxResource(v) {
24
+ if (typeof v !== 'object' || v === null)
25
+ return false;
26
+ const r = v;
27
+ return typeof r['ciphertext'] === 'string' && typeof r['nonce'] === 'string';
28
+ }
29
+ export function createWechatOfficialDriver(store, options) {
30
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE;
31
+ const timeWindowSec = options.timeWindowSec ?? 300;
32
+ const fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
33
+ const privateKey = createPrivateKey(options.privateKeyPem);
34
+ const platformKey = options.platformPublicKeyPem
35
+ ? createPublicKey(options.platformPublicKeyPem)
36
+ : undefined;
37
+ /** API v3 request signature header. */
38
+ function authorizeHeader(method, urlPath, body) {
39
+ const timestamp = Math.floor(Date.now() / 1000);
40
+ const nonce = randomBytes(16).toString('hex');
41
+ const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`;
42
+ const signature = sign('RSA-SHA256', Buffer.from(message), privateKey).toString('base64');
43
+ return `WECHATPAY2-SHA256-RSA2048 mchid="${options.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${options.serialNo}"`;
44
+ }
45
+ async function request(method, urlPath, body) {
46
+ const headers = {
47
+ Authorization: authorizeHeader(method, urlPath, body ?? ''),
48
+ Accept: 'application/json',
49
+ };
50
+ if (body !== undefined)
51
+ headers['Content-Type'] = 'application/json';
52
+ const res = await fetchImpl(`${baseUrl}${urlPath}`, {
53
+ method,
54
+ headers,
55
+ ...(body !== undefined ? { body } : {}),
56
+ });
57
+ const text = await res.text();
58
+ if (!res.ok) {
59
+ throw new Error(`wechat_api_error:${res.status}:${text}`);
60
+ }
61
+ return JSON.parse(text);
62
+ }
63
+ /** JSAPI invocation params (for the frontend wx.requestPayment): paySign signed with the merchant private key. */
64
+ function buildJsapiParams(prepayId) {
65
+ const timeStamp = String(Math.floor(Date.now() / 1000));
66
+ const nonceStr = randomBytes(16).toString('hex');
67
+ const pkg = `prepay_id=${prepayId}`;
68
+ const message = `${options.appId}\n${timeStamp}\n${nonceStr}\n${pkg}\n`;
69
+ const paySign = sign('RSA-SHA256', Buffer.from(message), privateKey).toString('base64');
70
+ return {
71
+ appId: options.appId,
72
+ timeStamp,
73
+ nonceStr,
74
+ package: pkg,
75
+ signType: 'RSA',
76
+ paySign,
77
+ };
78
+ }
79
+ async function createOrder(input) {
80
+ const base = {
81
+ appid: options.appId,
82
+ mchid: options.mchId,
83
+ description: input.subject,
84
+ out_trade_no: input.orderId,
85
+ notify_url: input.notifyUrl,
86
+ amount: { total: input.amount, currency: 'CNY' },
87
+ };
88
+ const openid = input.extras?.['openid'];
89
+ if (input.scene === 'miniapp' || (input.scene === 'h5' && typeof openid === 'string')) {
90
+ // JSAPI (official account / miniapp webview; requires openid)
91
+ const res = await request('POST', '/v3/pay/transactions/jsapi', JSON.stringify({ ...base, payer: { openid } }));
92
+ return { kind: 'jsapi', payParams: buildJsapiParams(res.prepay_id) };
93
+ }
94
+ if (input.scene === 'pc') {
95
+ // Native scan-to-pay
96
+ const res = await request('POST', '/v3/pay/transactions/native', JSON.stringify(base));
97
+ return {
98
+ kind: 'qr',
99
+ qrUrl: res.code_url,
100
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
101
+ };
102
+ }
103
+ // H5 (mobile browser)
104
+ const h5Input = {
105
+ ...base,
106
+ scene_info: {
107
+ payer_client_ip: input.extras?.['clientIp'] ?? '127.0.0.1',
108
+ h5_info: { type: 'Wap' },
109
+ },
110
+ };
111
+ const res = await request('POST', '/v3/pay/transactions/h5', JSON.stringify(h5Input));
112
+ return { kind: 'redirect', payUrl: res.h5_url };
113
+ }
114
+ function verifyCallback(payload, headers) {
115
+ const h = (headers ?? {});
116
+ const ts = h['wechatpay-timestamp'];
117
+ const nonce = h['wechatpay-nonce'];
118
+ const signature = h['wechatpay-signature'];
119
+ if (!ts || !nonce) {
120
+ return { ok: false, reason: 'missing_wechatpay_headers' };
121
+ }
122
+ // time-window check (anti-replay)
123
+ if (Math.abs(Date.now() / 1000 - Number(ts)) > timeWindowSec) {
124
+ return { ok: false, reason: 'timestamp_expired' };
125
+ }
126
+ // nonce anti-replay (channel self-verify path; the driver dedups via the store)
127
+ if (!store.addNonce(`wx:${nonce}`)) {
128
+ return { ok: false, reason: 'nonce_reused' };
129
+ }
130
+ // optional: platform-cert verification (signed string = timestamp\nnonce\nbody\n)
131
+ if (platformKey) {
132
+ const rawBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
133
+ const ok = verify('RSA-SHA256', Buffer.from(`${ts}\n${nonce}\n${rawBody}\n`), platformKey, Buffer.from(signature ?? '', 'base64'));
134
+ if (!ok) {
135
+ return { ok: false, reason: 'bad_platform_signature' };
136
+ }
137
+ }
138
+ // AES-256-GCM decrypt (apiV3Key confidentiality → successful decryption proves the message came from WeChat)
139
+ let obj;
140
+ try {
141
+ obj =
142
+ typeof payload === 'string'
143
+ ? JSON.parse(payload)
144
+ : payload;
145
+ const resource = obj['resource'];
146
+ if (!isWxResource(resource)) {
147
+ return { ok: false, reason: 'bad_resource' };
148
+ }
149
+ // WeChat ciphertext format: ciphertext = encrypted || authTag(16B)
150
+ const buf = Buffer.from(resource.ciphertext, 'base64');
151
+ const decipher = createDecipheriv('aes-256-gcm', Buffer.from(options.apiV3Key), Buffer.from(resource.nonce));
152
+ if (resource.associated_data)
153
+ decipher.setAAD(Buffer.from(resource.associated_data));
154
+ decipher.setAuthTag(buf.subarray(buf.length - 16));
155
+ const plain = Buffer.concat([
156
+ decipher.update(buf.subarray(0, buf.length - 16)),
157
+ decipher.final(),
158
+ ]).toString('utf8');
159
+ const data = JSON.parse(plain);
160
+ if (data['trade_state'] !== 'SUCCESS') {
161
+ return { ok: false, reason: `trade_state:${String(data['trade_state'])}` };
162
+ }
163
+ const amount = data['amount'];
164
+ const orderId = data['out_trade_no'];
165
+ const paidAmount = amount?.['total'];
166
+ if (typeof orderId !== 'string' || typeof paidAmount !== 'number') {
167
+ return { ok: false, reason: 'bad_payload' };
168
+ }
169
+ return {
170
+ ok: true,
171
+ orderId,
172
+ paidAmount,
173
+ ...(typeof data['transaction_id'] === 'string'
174
+ ? { channelTxnId: data['transaction_id'] }
175
+ : {}),
176
+ };
177
+ }
178
+ catch {
179
+ return { ok: false, reason: 'decrypt_failed' };
180
+ }
181
+ }
182
+ async function queryOrder(orderId) {
183
+ try {
184
+ const urlPath = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(orderId)}?mchid=${options.mchId}`;
185
+ const res = await request('GET', urlPath);
186
+ const state = res.trade_state;
187
+ if (state === 'SUCCESS') {
188
+ return { state: 'paid', ...(res.success_time ? { paidAt: res.success_time } : {}) };
189
+ }
190
+ if (state === 'CLOSED' || state === 'PAYERROR') {
191
+ return { state: 'closed' };
192
+ }
193
+ return { state: 'created' };
194
+ }
195
+ catch {
196
+ return { state: 'unknown', raw: { error: 'query_failed' } };
197
+ }
198
+ }
199
+ return {
200
+ name: 'wechat_official',
201
+ scenes: ['pc', 'h5', 'miniapp'],
202
+ createOrder,
203
+ verifyCallback,
204
+ queryOrder,
205
+ successResponseText: 'SUCCESS',
206
+ };
207
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vobs/payment",
3
+ "version": "0.1.0",
4
+ "description": "Payment factory backend SDK for vobs — channel drivers, order state machine, callback verification (signature + replay protection), server-side pricing and amount risk control (Experimental).",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "license": "MIT",
10
+ "author": "vobsjs",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/vobsjs/vobs.git",
14
+ "directory": "packages/integrations/payment"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "vitest": "^4.1.11"
29
+ },
30
+ "engines": {
31
+ "node": ">=20.19.0"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/vobsjs/vobs/issues"
35
+ },
36
+ "homepage": "https://github.com/vobsjs/vobs#readme",
37
+ "main": "./dist/index.js",
38
+ "module": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "sideEffects": false
41
+ }