feat: integrate Stripe payment with bugfixes and active timeout cancellation
- Add Stripe payment provider with Checkout Session flow - Payment provider abstraction layer (EasyPay + Stripe unified interface) - Stripe webhook with proper raw body handling and signature verification - Frontend: Stripe button with URL validation, anti-duplicate click, noopener - Active timeout cancellation: query platform before expiring, recover paid orders - Singleton Stripe client, idempotency keys, Math.round for amounts - Handle async_payment events, return null for unknown webhook events - Set Checkout Session expires_at aligned with order timeout - Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op) - Enable stripe in frontend payment type list
This commit is contained in:
87
src/__tests__/lib/payment/registry.test.ts
Normal file
87
src/__tests__/lib/payment/registry.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
|
||||
class MockProvider implements PaymentProvider {
|
||||
readonly name: string;
|
||||
readonly supportedTypes: PaymentType[];
|
||||
|
||||
constructor(name: string, types: PaymentType[]) {
|
||||
this.name = name;
|
||||
this.supportedTypes = types;
|
||||
}
|
||||
|
||||
async createPayment(_request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
return { tradeNo: 'mock-trade-no' };
|
||||
}
|
||||
async queryOrder(_tradeNo: string): Promise<QueryOrderResponse> {
|
||||
return { tradeNo: 'mock', status: 'pending', amount: 0 };
|
||||
}
|
||||
async verifyNotification(_rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
return { tradeNo: 'mock', orderId: 'mock', amount: 0, status: 'success', rawData: {} };
|
||||
}
|
||||
async refund(_request: RefundRequest): Promise<RefundResponse> {
|
||||
return { refundId: 'mock', status: 'success' };
|
||||
}
|
||||
}
|
||||
|
||||
import { PaymentProviderRegistry } from '@/lib/payment/registry';
|
||||
|
||||
describe('PaymentProviderRegistry', () => {
|
||||
let registry: PaymentProviderRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new PaymentProviderRegistry();
|
||||
});
|
||||
|
||||
it('should register and retrieve a provider', () => {
|
||||
const provider = new MockProvider('test-pay', ['alipay']);
|
||||
registry.register(provider);
|
||||
expect(registry.getProvider('alipay')).toBe(provider);
|
||||
});
|
||||
|
||||
it('should throw for unregistered payment type', () => {
|
||||
expect(() => registry.getProvider('stripe')).toThrow('No payment provider registered for type: stripe');
|
||||
});
|
||||
|
||||
it('should register a provider for multiple types', () => {
|
||||
const provider = new MockProvider('multi-pay', ['alipay', 'wxpay']);
|
||||
registry.register(provider);
|
||||
expect(registry.getProvider('alipay')).toBe(provider);
|
||||
expect(registry.getProvider('wxpay')).toBe(provider);
|
||||
});
|
||||
|
||||
it('hasProvider should return correct boolean', () => {
|
||||
expect(registry.hasProvider('stripe')).toBe(false);
|
||||
const provider = new MockProvider('stripe-mock', ['stripe']);
|
||||
registry.register(provider);
|
||||
expect(registry.hasProvider('stripe')).toBe(true);
|
||||
});
|
||||
|
||||
it('getSupportedTypes should list registered types', () => {
|
||||
const p1 = new MockProvider('easy', ['alipay', 'wxpay']);
|
||||
const p2 = new MockProvider('stripe', ['stripe']);
|
||||
registry.register(p1);
|
||||
registry.register(p2);
|
||||
const types = registry.getSupportedTypes();
|
||||
expect(types).toContain('alipay');
|
||||
expect(types).toContain('wxpay');
|
||||
expect(types).toContain('stripe');
|
||||
});
|
||||
|
||||
it('later registration should override earlier for same type', () => {
|
||||
const p1 = new MockProvider('old-provider', ['alipay']);
|
||||
const p2 = new MockProvider('new-provider', ['alipay']);
|
||||
registry.register(p1);
|
||||
registry.register(p2);
|
||||
expect(registry.getProvider('alipay').name).toBe('new-provider');
|
||||
});
|
||||
});
|
||||
281
src/__tests__/lib/stripe/provider.test.ts
Normal file
281
src/__tests__/lib/stripe/provider.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
STRIPE_SECRET_KEY: 'sk_test_fake_key',
|
||||
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake_secret',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
ORDER_TIMEOUT_MINUTES: 5,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSessionCreate = vi.fn();
|
||||
const mockSessionRetrieve = vi.fn();
|
||||
const mockRefundCreate = vi.fn();
|
||||
const mockWebhooksConstructEvent = vi.fn();
|
||||
|
||||
vi.mock('stripe', () => {
|
||||
const StripeMock = function (this: Record<string, unknown>) {
|
||||
this.checkout = {
|
||||
sessions: {
|
||||
create: mockSessionCreate,
|
||||
retrieve: mockSessionRetrieve,
|
||||
},
|
||||
};
|
||||
this.refunds = {
|
||||
create: mockRefundCreate,
|
||||
};
|
||||
this.webhooks = {
|
||||
constructEvent: mockWebhooksConstructEvent,
|
||||
};
|
||||
};
|
||||
return { default: StripeMock };
|
||||
});
|
||||
|
||||
import { StripeProvider } from '@/lib/stripe/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('StripeProvider', () => {
|
||||
let provider: StripeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = new StripeProvider();
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
it('should have name "stripe"', () => {
|
||||
expect(provider.name).toBe('stripe');
|
||||
});
|
||||
|
||||
it('should support "stripe" payment type', () => {
|
||||
expect(provider.supportedTypes).toEqual(['stripe']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should create a checkout session and return checkoutUrl', async () => {
|
||||
mockSessionCreate.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
url: 'https://checkout.stripe.com/pay/cs_test_abc123',
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 99.99,
|
||||
paymentType: 'stripe',
|
||||
subject: 'Sub2API Balance Recharge 99.99 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('cs_test_abc123');
|
||||
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123');
|
||||
expect(mockSessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
metadata: { orderId: 'order-001' },
|
||||
expires_at: expect.any(Number),
|
||||
line_items: [
|
||||
expect.objectContaining({
|
||||
price_data: expect.objectContaining({
|
||||
currency: 'cny',
|
||||
unit_amount: 9999,
|
||||
}),
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
idempotencyKey: 'checkout-order-001',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle session with null url', async () => {
|
||||
mockSessionCreate.mockResolvedValue({
|
||||
id: 'cs_test_no_url',
|
||||
url: null,
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-002',
|
||||
amount: 10,
|
||||
paymentType: 'stripe',
|
||||
subject: 'Test',
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
expect(result.tradeNo).toBe('cs_test_no_url');
|
||||
expect(result.checkoutUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should return paid status for paid session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_status: 'paid',
|
||||
amount_total: 9999,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_abc123');
|
||||
expect(result.tradeNo).toBe('cs_test_abc123');
|
||||
expect(result.status).toBe('paid');
|
||||
expect(result.amount).toBe(99.99);
|
||||
});
|
||||
|
||||
it('should return failed status for expired session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_expired',
|
||||
payment_status: 'unpaid',
|
||||
status: 'expired',
|
||||
amount_total: 5000,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_expired');
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.amount).toBe(50);
|
||||
});
|
||||
|
||||
it('should return pending status for unpaid session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_pending',
|
||||
payment_status: 'unpaid',
|
||||
status: 'open',
|
||||
amount_total: 1000,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_pending');
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
it('should verify and parse checkout.session.completed event', async () => {
|
||||
const mockEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_test_abc123',
|
||||
metadata: { orderId: 'order-001' },
|
||||
amount_total: 9999,
|
||||
payment_status: 'paid',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockWebhooksConstructEvent.mockReturnValue(mockEvent);
|
||||
|
||||
const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tradeNo).toBe('cs_test_abc123');
|
||||
expect(result!.orderId).toBe('order-001');
|
||||
expect(result!.amount).toBe(99.99);
|
||||
expect(result!.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should return failed status for unpaid session', async () => {
|
||||
const mockEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_test_unpaid',
|
||||
metadata: { orderId: 'order-002' },
|
||||
amount_total: 5000,
|
||||
payment_status: 'unpaid',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockWebhooksConstructEvent.mockReturnValue(mockEvent);
|
||||
|
||||
const result = await provider.verifyNotification('body', { 'stripe-signature': 'sig' });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should return null for unhandled event types', async () => {
|
||||
mockWebhooksConstructEvent.mockReturnValue({
|
||||
type: 'payment_intent.created',
|
||||
data: { object: {} },
|
||||
});
|
||||
|
||||
const result = await provider.verifyNotification('body', { 'stripe-signature': 'sig' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should refund via payment intent from session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_intent: 'pi_test_payment_intent',
|
||||
});
|
||||
|
||||
mockRefundCreate.mockResolvedValue({
|
||||
id: 're_test_refund_001',
|
||||
status: 'succeeded',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: 'cs_test_abc123',
|
||||
orderId: 'order-001',
|
||||
amount: 50,
|
||||
reason: 'customer request',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('re_test_refund_001');
|
||||
expect(result.status).toBe('success');
|
||||
expect(mockRefundCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_test_payment_intent',
|
||||
amount: 5000,
|
||||
reason: 'requested_by_customer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payment intent as object', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_intent: { id: 'pi_test_obj_intent', amount: 10000 },
|
||||
});
|
||||
|
||||
mockRefundCreate.mockResolvedValue({
|
||||
id: 're_test_refund_002',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: 'cs_test_abc123',
|
||||
orderId: 'order-002',
|
||||
amount: 100,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(mockRefundCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payment_intent: 'pi_test_obj_intent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if no payment intent found', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_no_pi',
|
||||
payment_intent: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.refund({
|
||||
tradeNo: 'cs_test_no_pi',
|
||||
orderId: 'order-003',
|
||||
amount: 20,
|
||||
}),
|
||||
).rejects.toThrow('No payment intent found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,17 +45,18 @@ describe('Sub2API Client', () => {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user