feat: migrate payment provider to easy-pay, add order history and refund support
- Replace zpay with easy-pay payment provider (new lib/easy-pay/ module) - Add order history page for users (pay/orders) - Add GET /api/orders/my endpoint to list user's own orders - Add GET /api/users/[id] endpoint for sub2api user lookup - Add order status tracking module (lib/order/status.ts) - Update config to support easy-pay credentials (merchant ID, key, gateway) - Update PaymentForm and PaymentQRCode components for easy-pay flow - Update pay page and admin page with new order management UI - Update order service to support easy-pay, cancellation, and refund
This commit is contained in:
21
src/__tests__/lib/order/code-gen.test.ts
Normal file
21
src/__tests__/lib/order/code-gen.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateRechargeCode } from '@/lib/order/code-gen';
|
||||
|
||||
describe('generateRechargeCode', () => {
|
||||
it('should generate code with s2p_ prefix', () => {
|
||||
const code = generateRechargeCode('cm1234567890');
|
||||
expect(code).toBe('s2p_cm1234567890');
|
||||
});
|
||||
|
||||
it('should truncate long order IDs to fit 32 chars', () => {
|
||||
const longId = 'a'.repeat(50);
|
||||
const code = generateRechargeCode(longId);
|
||||
expect(code.length).toBeLessThanOrEqual(32);
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const code = generateRechargeCode('');
|
||||
expect(code).toBe('s2p_');
|
||||
});
|
||||
});
|
||||
80
src/__tests__/lib/sub2api/client.test.ts
Normal file
80
src/__tests__/lib/sub2api/client.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
SUB2API_BASE_URL: 'https://test.sub2api.com',
|
||||
SUB2API_ADMIN_API_KEY: 'admin-testkey123',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||
|
||||
describe('Sub2API Client', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getUser should return user data', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
status: 'active',
|
||||
balance: 100,
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: mockUser }),
|
||||
});
|
||||
|
||||
const user = await getUser(1);
|
||||
expect(user.username).toBe('testuser');
|
||||
expect(user.status).toBe('active');
|
||||
});
|
||||
|
||||
it('getUser should throw USER_NOT_FOUND for 404', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('createAndRedeem should send correct request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
code: 1,
|
||||
redeem_code: {
|
||||
id: 1,
|
||||
code: 's2p_test123',
|
||||
type: 'balance',
|
||||
value: 100,
|
||||
status: 'used',
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||
expect(result.code).toBe('s2p_test123');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
||||
const headers = fetchCall[1].headers;
|
||||
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
|
||||
});
|
||||
|
||||
it('subtractBalance should send subtract request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
|
||||
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.operation).toBe('subtract');
|
||||
expect(body.amount).toBe(50);
|
||||
});
|
||||
});
|
||||
90
src/__tests__/lib/zpay/client.test.ts
Normal file
90
src/__tests__/lib/zpay/client.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ZPAY_PID: 'test_pid',
|
||||
ZPAY_PKEY: 'test_pkey',
|
||||
ZPAY_API_BASE: 'https://test.zpay.com',
|
||||
ZPAY_NOTIFY_URL: 'https://test.com/api/zpay/notify',
|
||||
ZPAY_RETURN_URL: 'https://test.com/pay/result',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createPayment, queryOrder, refund } from '@/lib/zpay/client';
|
||||
|
||||
describe('ZPAY Client', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('createPayment should post to mapi.php and return result', async () => {
|
||||
const mockResponse = {
|
||||
code: 1,
|
||||
trade_no: 'zpay_123',
|
||||
payurl: 'https://pay.example.com',
|
||||
qrcode: 'https://qr.example.com',
|
||||
img: 'https://img.example.com/qr.jpg',
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
const result = await createPayment({
|
||||
outTradeNo: 'test_order_1',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Test Product',
|
||||
});
|
||||
|
||||
expect(result.trade_no).toBe('zpay_123');
|
||||
expect(result.payurl).toBe('https://pay.example.com');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://test.zpay.com/mapi.php',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('createPayment should throw on error response', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({ code: 0, msg: 'insufficient balance' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
outTradeNo: 'test_order_2',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Test Product',
|
||||
}),
|
||||
).rejects.toThrow('ZPAY create payment failed');
|
||||
});
|
||||
|
||||
it('queryOrder should fetch order status', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({
|
||||
code: 1,
|
||||
trade_no: 'zpay_123',
|
||||
out_trade_no: 'test_order_1',
|
||||
status: 1,
|
||||
money: '10.00',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await queryOrder('test_order_1');
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.money).toBe('10.00');
|
||||
});
|
||||
|
||||
it('refund should post refund request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({ code: 1, msg: '退款成功' }),
|
||||
});
|
||||
|
||||
const result = await refund('zpay_123', 'test_order_1', '10.00');
|
||||
expect(result.code).toBe(1);
|
||||
});
|
||||
});
|
||||
56
src/__tests__/lib/zpay/sign.test.ts
Normal file
56
src/__tests__/lib/zpay/sign.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateSign, verifySign } from '@/lib/zpay/sign';
|
||||
|
||||
describe('ZPAY Sign', () => {
|
||||
const pkey = 'YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k';
|
||||
|
||||
it('should generate correct sign with sorted params', () => {
|
||||
const params = {
|
||||
pid: '2026022720004756',
|
||||
type: 'alipay',
|
||||
out_trade_no: '20160806151343349',
|
||||
notify_url: 'http://www.aaa.com/notify_url.php',
|
||||
name: 'test product',
|
||||
money: '1.00',
|
||||
return_url: 'http://www.aaa.com/return_url.php',
|
||||
};
|
||||
const sign = generateSign(params, pkey);
|
||||
expect(sign).toMatch(/^[a-f0-9]{32}$/); // md5 lowercase hex
|
||||
});
|
||||
|
||||
it('should filter out empty values, sign and sign_type', () => {
|
||||
const params = {
|
||||
a: '1',
|
||||
b: '',
|
||||
sign: 'xxx',
|
||||
sign_type: 'MD5',
|
||||
c: '3',
|
||||
};
|
||||
const sign = generateSign(params, pkey);
|
||||
// Should only use a=1&c=3 + pkey
|
||||
const expected = generateSign({ a: '1', c: '3' }, pkey);
|
||||
expect(sign).toBe(expected);
|
||||
});
|
||||
|
||||
it('should sort params by ASCII order', () => {
|
||||
const params1 = { z: '1', a: '2', m: '3' };
|
||||
const params2 = { a: '2', m: '3', z: '1' };
|
||||
expect(generateSign(params1, pkey)).toBe(generateSign(params2, pkey));
|
||||
});
|
||||
|
||||
it('should verify valid signature', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
const sign = generateSign(params, pkey);
|
||||
expect(verifySign(params, pkey, sign)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid signature', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
expect(verifySign(params, pkey, 'invalidsignature1234567890123456')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject signature with wrong length', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
expect(verifySign(params, pkey, 'short')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user