specweave 0.24.0 → 0.24.1
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/.claude-plugin/marketplace.json +55 -0
- package/dist/src/core/repo-structure/repo-structure-manager.d.ts.map +1 -1
- package/dist/src/core/repo-structure/repo-structure-manager.js +76 -43
- package/dist/src/core/repo-structure/repo-structure-manager.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave-frontend/agents/frontend-architect/AGENT.md +21 -0
- package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +18 -0
- package/plugins/specweave-payments/commands/stripe-setup.md +931 -0
- package/plugins/specweave-payments/commands/subscription-flow.md +1193 -0
- package/plugins/specweave-payments/commands/subscription-manage.md +386 -0
- package/plugins/specweave-payments/commands/webhook-setup.md +295 -0
- package/plugins/specweave-testing/agents/qa-engineer/AGENT.md +21 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# Subscription Management
|
|
2
|
+
|
|
3
|
+
Generate complete subscription management system.
|
|
4
|
+
|
|
5
|
+
## Task
|
|
6
|
+
|
|
7
|
+
You are a subscription billing expert. Generate production-ready subscription management with billing, upgrades, and cancellations.
|
|
8
|
+
|
|
9
|
+
### Steps:
|
|
10
|
+
|
|
11
|
+
1. **Ask for Requirements**:
|
|
12
|
+
- Pricing tiers (Basic, Pro, Enterprise)
|
|
13
|
+
- Billing interval (monthly, annual)
|
|
14
|
+
- Features per tier
|
|
15
|
+
- Trial period
|
|
16
|
+
|
|
17
|
+
2. **Generate Pricing Configuration**:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// config/pricing.ts
|
|
21
|
+
export const PRICING_PLANS = {
|
|
22
|
+
basic: {
|
|
23
|
+
id: 'basic',
|
|
24
|
+
name: 'Basic',
|
|
25
|
+
description: 'For individuals and small teams',
|
|
26
|
+
prices: {
|
|
27
|
+
monthly: {
|
|
28
|
+
amount: 9,
|
|
29
|
+
stripePriceId: 'price_basic_monthly',
|
|
30
|
+
},
|
|
31
|
+
annual: {
|
|
32
|
+
amount: 90,
|
|
33
|
+
stripePriceId: 'price_basic_annual',
|
|
34
|
+
savings: 18, // 2 months free
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
features: [
|
|
38
|
+
'10 projects',
|
|
39
|
+
'5 GB storage',
|
|
40
|
+
'Basic support',
|
|
41
|
+
],
|
|
42
|
+
limits: {
|
|
43
|
+
projects: 10,
|
|
44
|
+
storage: 5 * 1024 * 1024 * 1024, // 5 GB in bytes
|
|
45
|
+
apiCallsPerMonth: 10000,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
pro: {
|
|
49
|
+
id: 'pro',
|
|
50
|
+
name: 'Pro',
|
|
51
|
+
description: 'For growing teams',
|
|
52
|
+
prices: {
|
|
53
|
+
monthly: {
|
|
54
|
+
amount: 29,
|
|
55
|
+
stripePriceId: 'price_pro_monthly',
|
|
56
|
+
},
|
|
57
|
+
annual: {
|
|
58
|
+
amount: 290,
|
|
59
|
+
stripePriceId: 'price_pro_annual',
|
|
60
|
+
savings: 58,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
features: [
|
|
64
|
+
'Unlimited projects',
|
|
65
|
+
'50 GB storage',
|
|
66
|
+
'Priority support',
|
|
67
|
+
'Advanced analytics',
|
|
68
|
+
],
|
|
69
|
+
limits: {
|
|
70
|
+
projects: Infinity,
|
|
71
|
+
storage: 50 * 1024 * 1024 * 1024,
|
|
72
|
+
apiCallsPerMonth: 100000,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
enterprise: {
|
|
76
|
+
id: 'enterprise',
|
|
77
|
+
name: 'Enterprise',
|
|
78
|
+
description: 'For large organizations',
|
|
79
|
+
prices: {
|
|
80
|
+
monthly: {
|
|
81
|
+
amount: 99,
|
|
82
|
+
stripePriceId: 'price_enterprise_monthly',
|
|
83
|
+
},
|
|
84
|
+
annual: {
|
|
85
|
+
amount: 990,
|
|
86
|
+
stripePriceId: 'price_enterprise_annual',
|
|
87
|
+
savings: 198,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
features: [
|
|
91
|
+
'Unlimited everything',
|
|
92
|
+
'1 TB storage',
|
|
93
|
+
'24/7 dedicated support',
|
|
94
|
+
'Custom integrations',
|
|
95
|
+
'SLA guarantee',
|
|
96
|
+
],
|
|
97
|
+
limits: {
|
|
98
|
+
projects: Infinity,
|
|
99
|
+
storage: 1024 * 1024 * 1024 * 1024,
|
|
100
|
+
apiCallsPerMonth: Infinity,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. **Generate Subscription Service**:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// services/subscription.service.ts
|
|
110
|
+
import Stripe from 'stripe';
|
|
111
|
+
import { PRICING_PLANS } from '../config/pricing';
|
|
112
|
+
|
|
113
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
114
|
+
|
|
115
|
+
export class SubscriptionService {
|
|
116
|
+
// Create subscription
|
|
117
|
+
async create(userId: string, planId: string, interval: 'monthly' | 'annual') {
|
|
118
|
+
const user = await db.users.findUnique({ where: { id: userId } });
|
|
119
|
+
const plan = PRICING_PLANS[planId];
|
|
120
|
+
|
|
121
|
+
if (!plan) throw new Error('Invalid plan');
|
|
122
|
+
|
|
123
|
+
// Create Stripe customer if doesn't exist
|
|
124
|
+
let customerId = user.stripeCustomerId;
|
|
125
|
+
if (!customerId) {
|
|
126
|
+
const customer = await stripe.customers.create({
|
|
127
|
+
email: user.email,
|
|
128
|
+
metadata: { userId },
|
|
129
|
+
});
|
|
130
|
+
customerId = customer.id;
|
|
131
|
+
await db.users.update({
|
|
132
|
+
where: { id: userId },
|
|
133
|
+
data: { stripeCustomerId: customerId },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create subscription
|
|
138
|
+
const subscription = await stripe.subscriptions.create({
|
|
139
|
+
customer: customerId,
|
|
140
|
+
items: [{ price: plan.prices[interval].stripePriceId }],
|
|
141
|
+
trial_period_days: 14, // 14-day trial
|
|
142
|
+
payment_behavior: 'default_incomplete',
|
|
143
|
+
payment_settings: {
|
|
144
|
+
save_default_payment_method: 'on_subscription',
|
|
145
|
+
},
|
|
146
|
+
expand: ['latest_invoice.payment_intent'],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Save to database
|
|
150
|
+
await db.subscriptions.create({
|
|
151
|
+
data: {
|
|
152
|
+
userId,
|
|
153
|
+
stripeSubscriptionId: subscription.id,
|
|
154
|
+
stripePriceId: plan.prices[interval].stripePriceId,
|
|
155
|
+
status: subscription.status,
|
|
156
|
+
planId,
|
|
157
|
+
interval,
|
|
158
|
+
currentPeriodStart: new Date(subscription.current_period_start * 1000),
|
|
159
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
160
|
+
trialEnd: subscription.trial_end
|
|
161
|
+
? new Date(subscription.trial_end * 1000)
|
|
162
|
+
: null,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
subscriptionId: subscription.id,
|
|
168
|
+
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Upgrade/downgrade subscription
|
|
173
|
+
async changePlan(userId: string, newPlanId: string, newInterval: 'monthly' | 'annual') {
|
|
174
|
+
const subscription = await db.subscriptions.findFirst({
|
|
175
|
+
where: { userId, status: 'active' },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!subscription) throw new Error('No active subscription');
|
|
179
|
+
|
|
180
|
+
const newPlan = PRICING_PLANS[newPlanId];
|
|
181
|
+
const newPriceId = newPlan.prices[newInterval].stripePriceId;
|
|
182
|
+
|
|
183
|
+
// Update Stripe subscription
|
|
184
|
+
const stripeSubscription = await stripe.subscriptions.retrieve(
|
|
185
|
+
subscription.stripeSubscriptionId
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const updatedSubscription = await stripe.subscriptions.update(
|
|
189
|
+
subscription.stripeSubscriptionId,
|
|
190
|
+
{
|
|
191
|
+
items: [
|
|
192
|
+
{
|
|
193
|
+
id: stripeSubscription.items.data[0].id,
|
|
194
|
+
price: newPriceId,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
proration_behavior: 'always_invoice', // Prorate charges
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Update database
|
|
202
|
+
await db.subscriptions.update({
|
|
203
|
+
where: { id: subscription.id },
|
|
204
|
+
data: {
|
|
205
|
+
stripePriceId: newPriceId,
|
|
206
|
+
planId: newPlanId,
|
|
207
|
+
interval: newInterval,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return updatedSubscription;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Cancel subscription
|
|
215
|
+
async cancel(userId: string, cancelAtPeriodEnd = true) {
|
|
216
|
+
const subscription = await db.subscriptions.findFirst({
|
|
217
|
+
where: { userId, status: 'active' },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!subscription) throw new Error('No active subscription');
|
|
221
|
+
|
|
222
|
+
if (cancelAtPeriodEnd) {
|
|
223
|
+
// Cancel at end of billing period
|
|
224
|
+
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
|
|
225
|
+
cancel_at_period_end: true,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await db.subscriptions.update({
|
|
229
|
+
where: { id: subscription.id },
|
|
230
|
+
data: { cancelAtPeriodEnd: true },
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
// Cancel immediately
|
|
234
|
+
await stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
|
|
235
|
+
|
|
236
|
+
await db.subscriptions.update({
|
|
237
|
+
where: { id: subscription.id },
|
|
238
|
+
data: { status: 'canceled', canceledAt: new Date() },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Resume canceled subscription
|
|
244
|
+
async resume(userId: string) {
|
|
245
|
+
const subscription = await db.subscriptions.findFirst({
|
|
246
|
+
where: { userId, cancelAtPeriodEnd: true },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!subscription) throw new Error('No subscription to resume');
|
|
250
|
+
|
|
251
|
+
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
|
|
252
|
+
cancel_at_period_end: false,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await db.subscriptions.update({
|
|
256
|
+
where: { id: subscription.id },
|
|
257
|
+
data: { cancelAtPeriodEnd: false },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Get subscription status
|
|
262
|
+
async getStatus(userId: string) {
|
|
263
|
+
const subscription = await db.subscriptions.findFirst({
|
|
264
|
+
where: { userId },
|
|
265
|
+
orderBy: { createdAt: 'desc' },
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (!subscription) return null;
|
|
269
|
+
|
|
270
|
+
const plan = PRICING_PLANS[subscription.planId];
|
|
271
|
+
return {
|
|
272
|
+
...subscription,
|
|
273
|
+
plan,
|
|
274
|
+
isActive: subscription.status === 'active',
|
|
275
|
+
isTrialing: subscription.status === 'trialing',
|
|
276
|
+
isCanceling: subscription.cancelAtPeriodEnd,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check feature access
|
|
281
|
+
async canAccess(userId: string, feature: string, value?: number) {
|
|
282
|
+
const status = await this.getStatus(userId);
|
|
283
|
+
|
|
284
|
+
if (!status || !status.isActive) return false;
|
|
285
|
+
|
|
286
|
+
const limits = status.plan.limits;
|
|
287
|
+
|
|
288
|
+
// Check specific limits
|
|
289
|
+
switch (feature) {
|
|
290
|
+
case 'projects':
|
|
291
|
+
const projectCount = await db.projects.count({ where: { userId } });
|
|
292
|
+
return projectCount < limits.projects;
|
|
293
|
+
|
|
294
|
+
case 'storage':
|
|
295
|
+
const storageUsed = await this.getStorageUsage(userId);
|
|
296
|
+
return storageUsed < limits.storage;
|
|
297
|
+
|
|
298
|
+
case 'api_calls':
|
|
299
|
+
const apiCalls = await this.getApiCallsThisMonth(userId);
|
|
300
|
+
return apiCalls < limits.apiCallsPerMonth;
|
|
301
|
+
|
|
302
|
+
default:
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
4. **Generate Usage Tracking**:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
// Track API usage for metered billing
|
|
313
|
+
export class UsageTracker {
|
|
314
|
+
async recordApiCall(userId: string) {
|
|
315
|
+
const subscription = await db.subscriptions.findFirst({
|
|
316
|
+
where: { userId, status: 'active' },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!subscription) return;
|
|
320
|
+
|
|
321
|
+
// Increment usage
|
|
322
|
+
await db.usageRecords.create({
|
|
323
|
+
data: {
|
|
324
|
+
subscriptionId: subscription.id,
|
|
325
|
+
type: 'api_call',
|
|
326
|
+
quantity: 1,
|
|
327
|
+
timestamp: new Date(),
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Optional: Report to Stripe for metered billing
|
|
332
|
+
if (subscription.meteringEnabled) {
|
|
333
|
+
await stripe.subscriptionItems.createUsageRecord(
|
|
334
|
+
subscription.stripeSubscriptionItemId,
|
|
335
|
+
{
|
|
336
|
+
quantity: 1,
|
|
337
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async getUsage(userId: string, period: 'month' | 'all' = 'month') {
|
|
344
|
+
const subscription = await db.subscriptions.findFirst({
|
|
345
|
+
where: { userId },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (!subscription) return null;
|
|
349
|
+
|
|
350
|
+
const startDate =
|
|
351
|
+
period === 'month'
|
|
352
|
+
? new Date(new Date().setDate(1)) // Start of month
|
|
353
|
+
: undefined;
|
|
354
|
+
|
|
355
|
+
const usage = await db.usageRecords.groupBy({
|
|
356
|
+
by: ['type'],
|
|
357
|
+
where: {
|
|
358
|
+
subscriptionId: subscription.id,
|
|
359
|
+
timestamp: startDate ? { gte: startDate } : undefined,
|
|
360
|
+
},
|
|
361
|
+
_sum: {
|
|
362
|
+
quantity: true,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return usage;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Best Practices Included:
|
|
372
|
+
|
|
373
|
+
- Trial periods
|
|
374
|
+
- Proration on plan changes
|
|
375
|
+
- Cancel at period end vs immediate
|
|
376
|
+
- Usage tracking for metered billing
|
|
377
|
+
- Feature gating based on plan
|
|
378
|
+
- Subscription resumption
|
|
379
|
+
- Clear pricing configuration
|
|
380
|
+
|
|
381
|
+
### Example Usage:
|
|
382
|
+
|
|
383
|
+
```
|
|
384
|
+
User: "Set up subscription with Basic, Pro, Enterprise tiers"
|
|
385
|
+
Result: Complete subscription system with billing, upgrades, trials
|
|
386
|
+
```
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Payment Webhook Configuration
|
|
2
|
+
|
|
3
|
+
Generate secure webhook handlers for payment providers.
|
|
4
|
+
|
|
5
|
+
## Task
|
|
6
|
+
|
|
7
|
+
You are a payment webhook security expert. Generate secure, production-ready webhook handlers.
|
|
8
|
+
|
|
9
|
+
### Steps:
|
|
10
|
+
|
|
11
|
+
1. **Ask for Provider**:
|
|
12
|
+
- Stripe
|
|
13
|
+
- PayPal
|
|
14
|
+
- Square
|
|
15
|
+
- Custom payment gateway
|
|
16
|
+
|
|
17
|
+
2. **Generate Webhook Endpoint** (Stripe):
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import crypto from 'crypto';
|
|
21
|
+
import express from 'express';
|
|
22
|
+
import Stripe from 'stripe';
|
|
23
|
+
|
|
24
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
25
|
+
const app = express();
|
|
26
|
+
|
|
27
|
+
// CRITICAL: Use raw body for webhook signature verification
|
|
28
|
+
app.post(
|
|
29
|
+
'/api/webhooks/stripe',
|
|
30
|
+
express.raw({ type: 'application/json' }),
|
|
31
|
+
async (req, res) => {
|
|
32
|
+
const sig = req.headers['stripe-signature'];
|
|
33
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
|
34
|
+
|
|
35
|
+
let event;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Verify webhook signature
|
|
39
|
+
event = stripe.webhooks.constructEvent(
|
|
40
|
+
req.body,
|
|
41
|
+
sig!,
|
|
42
|
+
webhookSecret
|
|
43
|
+
);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`Webhook signature verification failed: ${err.message}`);
|
|
46
|
+
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle event idempotently
|
|
50
|
+
const eventId = event.id;
|
|
51
|
+
const existingEvent = await db.webhookEvents.findUnique({
|
|
52
|
+
where: { stripeEventId: eventId },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (existingEvent) {
|
|
56
|
+
console.log(`Duplicate webhook event: ${eventId}`);
|
|
57
|
+
return res.status(200).json({ received: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Store event (prevents duplicate processing)
|
|
61
|
+
await db.webhookEvents.create({
|
|
62
|
+
data: {
|
|
63
|
+
stripeEventId: eventId,
|
|
64
|
+
type: event.type,
|
|
65
|
+
processedAt: new Date(),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Process event in background to return 200 quickly
|
|
70
|
+
processWebhookEvent(event).catch((error) => {
|
|
71
|
+
console.error(`Failed to process webhook: ${error.message}`);
|
|
72
|
+
// Alert ops team
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
res.status(200).json({ received: true });
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
async function processWebhookEvent(event: Stripe.Event) {
|
|
80
|
+
switch (event.type) {
|
|
81
|
+
case 'checkout.session.completed':
|
|
82
|
+
await handleCheckoutComplete(event.data.object);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case 'customer.subscription.created':
|
|
86
|
+
case 'customer.subscription.updated':
|
|
87
|
+
await handleSubscriptionChange(event.data.object);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'customer.subscription.deleted':
|
|
91
|
+
await handleSubscriptionCanceled(event.data.object);
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case 'invoice.paid':
|
|
95
|
+
await handleInvoicePaid(event.data.object);
|
|
96
|
+
break;
|
|
97
|
+
|
|
98
|
+
case 'invoice.payment_failed':
|
|
99
|
+
await handleInvoicePaymentFailed(event.data.object);
|
|
100
|
+
break;
|
|
101
|
+
|
|
102
|
+
case 'payment_intent.succeeded':
|
|
103
|
+
await handlePaymentSuccess(event.data.object);
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case 'payment_intent.payment_failed':
|
|
107
|
+
await handlePaymentFailed(event.data.object);
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case 'charge.dispute.created':
|
|
111
|
+
await handleDisputeCreated(event.data.object);
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case 'customer.created':
|
|
115
|
+
await handleCustomerCreated(event.data.object);
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
default:
|
|
119
|
+
console.log(`Unhandled event type: ${event.type}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
3. **Generate PayPal Webhook**:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import crypto from 'crypto';
|
|
128
|
+
|
|
129
|
+
app.post('/api/webhooks/paypal', express.json(), async (req, res) => {
|
|
130
|
+
const webhookId = process.env.PAYPAL_WEBHOOK_ID!;
|
|
131
|
+
const webhookEvent = req.body;
|
|
132
|
+
|
|
133
|
+
// Verify PayPal webhook signature
|
|
134
|
+
const isValid = await verifyPayPalWebhook(req, webhookId);
|
|
135
|
+
|
|
136
|
+
if (!isValid) {
|
|
137
|
+
return res.status(400).send('Invalid webhook signature');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const eventType = webhookEvent.event_type;
|
|
141
|
+
|
|
142
|
+
switch (eventType) {
|
|
143
|
+
case 'PAYMENT.CAPTURE.COMPLETED':
|
|
144
|
+
await handlePayPalPaymentCompleted(webhookEvent.resource);
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 'BILLING.SUBSCRIPTION.CREATED':
|
|
148
|
+
await handlePayPalSubscriptionCreated(webhookEvent.resource);
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case 'BILLING.SUBSCRIPTION.CANCELLED':
|
|
152
|
+
await handlePayPalSubscriptionCancelled(webhookEvent.resource);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
default:
|
|
156
|
+
console.log(`Unhandled PayPal event: ${eventType}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
res.status(200).json({ received: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
async function verifyPayPalWebhook(req, webhookId) {
|
|
163
|
+
const transmissionId = req.headers['paypal-transmission-id'];
|
|
164
|
+
const timestamp = req.headers['paypal-transmission-time'];
|
|
165
|
+
const signature = req.headers['paypal-transmission-sig'];
|
|
166
|
+
const certUrl = req.headers['paypal-cert-url'];
|
|
167
|
+
|
|
168
|
+
const response = await fetch(
|
|
169
|
+
`https://api.paypal.com/v1/notifications/verify-webhook-signature`,
|
|
170
|
+
{
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
Authorization: `Bearer ${await getPayPalAccessToken()}`,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
transmission_id: transmissionId,
|
|
178
|
+
transmission_time: timestamp,
|
|
179
|
+
cert_url: certUrl,
|
|
180
|
+
auth_algo: req.headers['paypal-auth-algo'],
|
|
181
|
+
transmission_sig: signature,
|
|
182
|
+
webhook_id: webhookId,
|
|
183
|
+
webhook_event: req.body,
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const data = await response.json();
|
|
189
|
+
return data.verification_status === 'SUCCESS';
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
4. **Generate Webhook Testing Script**:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// test-webhook.ts
|
|
197
|
+
import { exec } from 'child_process';
|
|
198
|
+
import util from 'util';
|
|
199
|
+
|
|
200
|
+
const execPromise = util.promisify(exec);
|
|
201
|
+
|
|
202
|
+
async function testWebhook() {
|
|
203
|
+
// Install Stripe CLI: brew install stripe/stripe-cli/stripe
|
|
204
|
+
|
|
205
|
+
// Listen to webhooks
|
|
206
|
+
const { stdout } = await execPromise('stripe listen --forward-to localhost:3000/api/webhooks/stripe');
|
|
207
|
+
console.log(stdout);
|
|
208
|
+
|
|
209
|
+
// Trigger test events
|
|
210
|
+
await execPromise('stripe trigger payment_intent.succeeded');
|
|
211
|
+
await execPromise('stripe trigger customer.subscription.created');
|
|
212
|
+
await execPromise('stripe trigger invoice.payment_failed');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
testWebhook();
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
5. **Generate Monitoring & Alerting**:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// Monitor webhook failures
|
|
222
|
+
async function monitorWebhooks() {
|
|
223
|
+
const failedEvents = await db.webhookEvents.findMany({
|
|
224
|
+
where: {
|
|
225
|
+
processed: false,
|
|
226
|
+
createdAt: {
|
|
227
|
+
lt: new Date(Date.now() - 5 * 60 * 1000), // 5 minutes ago
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (failedEvents.length > 0) {
|
|
233
|
+
await sendAlert({
|
|
234
|
+
type: 'webhook_failure',
|
|
235
|
+
count: failedEvents.length,
|
|
236
|
+
events: failedEvents.map((e) => e.type),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Retry failed webhooks
|
|
242
|
+
async function retryFailedWebhooks() {
|
|
243
|
+
const failedEvents = await db.webhookEvents.findMany({
|
|
244
|
+
where: { processed: false },
|
|
245
|
+
take: 10,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
for (const event of failedEvents) {
|
|
249
|
+
try {
|
|
250
|
+
await processWebhookEvent(event.data);
|
|
251
|
+
await db.webhookEvents.update({
|
|
252
|
+
where: { id: event.id },
|
|
253
|
+
data: { processed: true, processedAt: new Date() },
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error(`Retry failed for event ${event.id}: ${error.message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
6. **Generate Webhook Schema** (Database):
|
|
263
|
+
|
|
264
|
+
```sql
|
|
265
|
+
CREATE TABLE webhook_events (
|
|
266
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
267
|
+
stripe_event_id VARCHAR(255) UNIQUE NOT NULL,
|
|
268
|
+
type VARCHAR(100) NOT NULL,
|
|
269
|
+
data JSONB NOT NULL,
|
|
270
|
+
processed BOOLEAN DEFAULT FALSE,
|
|
271
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
272
|
+
processed_at TIMESTAMP
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
CREATE INDEX idx_webhook_events_type ON webhook_events(type);
|
|
276
|
+
CREATE INDEX idx_webhook_events_processed ON webhook_events(processed);
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Security Best Practices:
|
|
280
|
+
|
|
281
|
+
- ✅ Verify webhook signatures (prevent spoofing)
|
|
282
|
+
- ✅ Use raw request body for signature validation
|
|
283
|
+
- ✅ Idempotency (track event IDs, prevent duplicate processing)
|
|
284
|
+
- ✅ Return 200 immediately (process in background)
|
|
285
|
+
- ✅ Retry logic for failures
|
|
286
|
+
- ✅ Monitoring and alerting
|
|
287
|
+
- ✅ HTTPS only (secure transmission)
|
|
288
|
+
- ✅ IP whitelisting (optional but recommended)
|
|
289
|
+
|
|
290
|
+
### Example Usage:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
User: "Set up secure Stripe webhook handler"
|
|
294
|
+
Result: Complete webhook endpoint with signature verification, idempotency, and monitoring
|
|
295
|
+
```
|
|
@@ -795,3 +795,24 @@ it('should return 404 when user not found', () => { ... });
|
|
|
795
795
|
- [ ] Rollback plan tested
|
|
796
796
|
|
|
797
797
|
You are ready to ensure world-class quality through comprehensive testing strategies!
|
|
798
|
+
|
|
799
|
+
## How to Invoke This Agent
|
|
800
|
+
|
|
801
|
+
Use the Task tool with the following subagent type:
|
|
802
|
+
|
|
803
|
+
```typescript
|
|
804
|
+
Task({
|
|
805
|
+
subagent_type: "specweave-testing:qa-engineer:qa-engineer",
|
|
806
|
+
prompt: "Your QA/testing task here",
|
|
807
|
+
description: "Brief task description"
|
|
808
|
+
})
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
**Example**:
|
|
812
|
+
```typescript
|
|
813
|
+
Task({
|
|
814
|
+
subagent_type: "specweave-testing:qa-engineer:qa-engineer",
|
|
815
|
+
prompt: "Create a comprehensive test strategy for an e-commerce checkout flow using Playwright E2E and Vitest unit tests",
|
|
816
|
+
description: "Design test strategy for checkout"
|
|
817
|
+
})
|
|
818
|
+
```
|