Skip to content

Commit 125da32

Browse files
committed
Webhook
1 parent 4e1bae7 commit 125da32

6 files changed

Lines changed: 947 additions & 6 deletions

File tree

.kiro/specs/workwork-ledger-mvp/tasks.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,23 +201,23 @@
201201
- 创建 Checkout Session 并重定向
202202
- _需求: 6.2_
203203

204-
- [ ] 12. Webhook 处理
205-
- [ ] 12.1 实现 Stripe Webhook 端点
204+
- [x] 12. Webhook 处理
205+
- [x] 12.1 实现 Stripe Webhook 端点
206206
- 签名验证
207207
- 幂等性处理(记录已处理的 eventId)
208208
- _需求: 6.4_
209-
- [ ] 12.2 实现支付成功处理逻辑
209+
- [x] 12.2 实现支付成功处理逻辑
210210
- 更新发票状态为 paid
211211
- 创建账本条目
212212
- _需求: 6.4, 5.7_
213-
- [ ] 12.3 编写属性测试:支付完成创建账本条目
213+
- [x] 12.3 编写属性测试:支付完成创建账本条目
214214
- **属性 13: 支付完成创建账本条目**
215215
- **验证: 需求 5.7, 8.1**
216-
- [ ] 12.4 实现支付失败处理逻辑
216+
- [x] 12.4 实现支付失败处理逻辑
217217
- 记录错误日志
218218
- 保持发票状态不变
219219
- _需求: 6.7_
220-
- [ ] 12.5 编写属性测试:支付验证失败状态不变
220+
- [x] 12.5 编写属性测试:支付验证失败状态不变
221221
- **属性 14: 支付验证失败状态不变**
222222
- **验证: 需求 6.7**
223223

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Stripe Webhook Endpoint
3+
* Handles Stripe webhook events with signature verification and idempotency
4+
* _需求: 6.4_
5+
*/
6+
7+
import { NextRequest, NextResponse } from 'next/server';
8+
import { StripeAdapter } from '@/server/payment/stripe-adapter';
9+
import {
10+
processWebhookWithIdempotency,
11+
extractInvoiceIdFromEvent,
12+
isPaymentSuccessEvent,
13+
isPaymentFailedEvent,
14+
} from '@/server/payment/webhook.service';
15+
import {
16+
handlePaymentSuccess,
17+
handlePaymentFailure,
18+
} from '@/server/payment/webhook.handlers';
19+
import { PaymentError, PaymentErrorCodes } from '@/server/payment/types';
20+
21+
/**
22+
* POST /api/webhooks/stripe
23+
* Receives and processes Stripe webhook events
24+
*/
25+
export async function POST(request: NextRequest) {
26+
try {
27+
// Get raw body for signature verification
28+
const rawBody = await request.text();
29+
const signature = request.headers.get('stripe-signature');
30+
31+
if (!signature) {
32+
console.error('[Stripe Webhook] Missing stripe-signature header');
33+
return NextResponse.json(
34+
{ error: 'Missing signature' },
35+
{ status: 400 }
36+
);
37+
}
38+
39+
// Get webhook secret from environment
40+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
41+
if (!webhookSecret) {
42+
console.error('[Stripe Webhook] STRIPE_WEBHOOK_SECRET not configured');
43+
return NextResponse.json(
44+
{ error: 'Webhook not configured' },
45+
{ status: 500 }
46+
);
47+
}
48+
49+
// Create Stripe adapter for webhook verification
50+
const stripeAdapter = new StripeAdapter({
51+
provider: 'stripe',
52+
apiKey: process.env.STRIPE_SECRET_KEY || '',
53+
webhookSecret,
54+
});
55+
56+
// Verify webhook signature and parse event
57+
let event;
58+
try {
59+
event = await stripeAdapter.verifyWebhook(rawBody, signature);
60+
} catch (error) {
61+
if (error instanceof PaymentError && error.code === PaymentErrorCodes.WEBHOOK_VERIFICATION_FAILED) {
62+
console.error('[Stripe Webhook] Signature verification failed:', error.message);
63+
return NextResponse.json(
64+
{ error: 'Invalid signature' },
65+
{ status: 400 }
66+
);
67+
}
68+
throw error;
69+
}
70+
71+
console.log(`[Stripe Webhook] Received event: ${event.type} (${event.id})`);
72+
73+
// Process with idempotency check
74+
const { alreadyProcessed } = await processWebhookWithIdempotency(
75+
'stripe',
76+
event.id,
77+
async () => {
78+
// Extract invoice ID from event metadata
79+
const invoiceId = extractInvoiceIdFromEvent(event);
80+
81+
if (!invoiceId) {
82+
console.log(`[Stripe Webhook] Event ${event.id} has no invoiceId in metadata, skipping`);
83+
return { skipped: true };
84+
}
85+
86+
// Handle payment success events
87+
if (isPaymentSuccessEvent(event.type)) {
88+
console.log(`[Stripe Webhook] Processing payment success for invoice ${invoiceId}`);
89+
const result = await handlePaymentSuccess(invoiceId, {
90+
pspProvider: 'stripe',
91+
pspPaymentId: event.data.paymentId,
92+
checkoutSessionId: event.data.checkoutSessionId,
93+
amount: event.data.amount,
94+
currency: event.data.currency,
95+
});
96+
return result;
97+
}
98+
99+
// Handle payment failure events
100+
if (isPaymentFailedEvent(event.type)) {
101+
console.log(`[Stripe Webhook] Processing payment failure for invoice ${invoiceId}`);
102+
const result = await handlePaymentFailure(invoiceId, {
103+
eventType: event.type,
104+
pspProvider: 'stripe',
105+
errorMessage: `Payment ${event.type.replace('_', ' ')}`,
106+
});
107+
return result;
108+
}
109+
110+
// Unhandled event type
111+
console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`);
112+
return { skipped: true, reason: 'unhandled_event_type' };
113+
}
114+
);
115+
116+
if (alreadyProcessed) {
117+
console.log(`[Stripe Webhook] Event ${event.id} already processed, skipping`);
118+
}
119+
120+
// Always return 200 to acknowledge receipt
121+
return NextResponse.json({ received: true });
122+
} catch (error) {
123+
console.error('[Stripe Webhook] Error processing webhook:', error);
124+
125+
// Return 500 for unexpected errors (Stripe will retry)
126+
return NextResponse.json(
127+
{ error: 'Internal server error' },
128+
{ status: 500 }
129+
);
130+
}
131+
}

src/server/payment/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ export * from './types';
66
export * from './stripe-adapter';
77
export * from './credentials';
88
export { getPaymentGateway } from './gateway-factory';
9+
export * from './webhook.service';
10+
export * from './webhook.handlers';

0 commit comments

Comments
 (0)