vibes-plug 1.0.0 → 2.5.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.
- package/.github/workflows/publish.yml +20 -0
- package/AGENTS.md +66 -0
- package/BLUEPRINT.md +152 -60
- package/CHANGELOG.md +50 -0
- package/README.md +346 -194
- package/package.json +25 -25
- package/plugin.json +1 -1
- package/skills/ai-cost-token-optimizer/SKILL.md +52 -0
- package/skills/ai-llm-integration-expert/SKILL.md +180 -162
- package/skills/api-design-expert/SKILL.md +314 -310
- package/skills/app-analyzer-optimizer/SKILL.md +193 -189
- package/skills/apple-ecosystem-expert/SKILL.md +142 -0
- package/skills/async-queue-temporal-expert/SKILL.md +54 -0
- package/skills/authentication-identity-expert/SKILL.md +251 -20
- package/skills/auto-doc-updater/SKILL.md +214 -204
- package/skills/autonomous-chaos-monkey/SKILL.md +63 -0
- package/skills/autonomous-red-teamer/SKILL.md +59 -0
- package/skills/autonomous-swarm-director/SKILL.md +69 -0
- package/skills/autonomous-tdd-debugger/SKILL.md +65 -0
- package/skills/bootstrap-to-modern/SKILL.md +90 -86
- package/skills/brainstorming/SKILL.md +373 -353
- package/skills/browser-automation-expert/SKILL.md +46 -0
- package/skills/ci-cd-devops-architect/SKILL.md +72 -45
- package/skills/cloud-hosting-expert/SKILL.md +244 -244
- package/skills/coderabbit/SKILL.md +192 -192
- package/skills/cron-scheduler-expert/SKILL.md +298 -0
- package/skills/data-telemetry-expert/SKILL.md +213 -213
- package/skills/database-orm-expert/SKILL.md +294 -294
- package/skills/dependency-upgrade-migrator/SKILL.md +295 -0
- package/skills/design-system-architect/SKILL.md +27 -10
- package/skills/doku-mcp-server/SKILL.md +251 -0
- package/skills/doku-payment-gateway/SKILL.md +227 -0
- package/skills/e2e-testing-expert/SKILL.md +315 -315
- package/skills/edge-serverless-db-expert/SKILL.md +43 -0
- package/skills/email-notification-expert/SKILL.md +362 -0
- package/skills/error-resilience-expert/SKILL.md +480 -0
- package/skills/event-driven-architect/SKILL.md +81 -81
- package/skills/feature-flag-analytics-expert/SKILL.md +46 -0
- package/skills/file-upload-media-expert/SKILL.md +431 -0
- package/skills/form-validation-expert/SKILL.md +401 -0
- package/skills/fullstack-expert/SKILL.md +202 -202
- package/skills/fullstack-expert/references/api_design_guide.md +466 -466
- package/skills/fullstack-expert/references/multi_language_backend.md +528 -528
- package/skills/fullstack-expert/scripts/api_contract_validator.py +253 -253
- package/skills/fullstack-expert/scripts/architecture_analyzer.py +326 -326
- package/skills/gemini-agent-booster/SKILL.md +135 -135
- package/skills/global-a11y-i18n-expert/SKILL.md +81 -81
- package/skills/glsl-shader-expert/SKILL.md +101 -0
- package/skills/go-programming-expert/SKILL.md +295 -295
- package/skills/graphql-apollo-expert/SKILL.md +108 -0
- package/skills/hig/SKILL.md +188 -188
- package/skills/hyper-context-synthesizer/SKILL.md +55 -0
- package/skills/js-backend-expert/SKILL.md +34 -9
- package/skills/legacy-code-translator/SKILL.md +65 -0
- package/skills/llm-cost-arbitrage-router/SKILL.md +59 -0
- package/skills/logging-error-tracking-expert/SKILL.md +338 -0
- package/skills/mcp-client-orchestrator/SKILL.md +70 -0
- package/skills/mcp-server-architect/SKILL.md +194 -194
- package/skills/micro-frontend-architect/SKILL.md +106 -0
- package/skills/mobile-expo-expert/SKILL.md +186 -186
- package/skills/mobile-push-notification-expert/SKILL.md +51 -0
- package/skills/monday-design-aesthetic/SKILL.md +67 -67
- package/skills/monorepo-architect/SKILL.md +227 -227
- package/skills/mpa-orchestrator/SKILL.md +101 -101
- package/skills/multi-agent-orchestration/SKILL.md +234 -234
- package/skills/multiple-entry-points/SKILL.md +55 -55
- package/skills/mvc-expert/SKILL.md +231 -231
- package/skills/payment-gateway-expert/SKILL.md +45 -45
- package/skills/performance-web-vitals/SKILL.md +332 -332
- package/skills/post-quantum-crypto-migrator/SKILL.md +57 -0
- package/skills/prd-architect/SKILL.md +201 -191
- package/skills/proactive-background-watcher/SKILL.md +62 -0
- package/skills/production-ready-hardener/PRODUCTION_READINESS_REPORT.md +67 -0
- package/skills/production-ready-hardener/SKILL.md +173 -186
- package/skills/production-ready-hardener/references/production_checklist.md +161 -161
- package/skills/production-ready-hardener/scripts/production_readiness_scanner.py +881 -875
- package/skills/project-context-mapper/SKILL.md +79 -0
- package/skills/python-programming-expert/SKILL.md +263 -132
- package/skills/rate-limit-abuse-prevention/SKILL.md +371 -0
- package/skills/realtime-collaboration-expert/SKILL.md +45 -45
- package/skills/rust-programming-expert/SKILL.md +235 -235
- package/skills/saas-billing/SKILL.md +377 -377
- package/skills/saas-multi-tenant/SKILL.md +251 -237
- package/skills/saas-mvp-launcher/SKILL.md +10 -0
- package/skills/saas-transformer/SKILL.md +187 -144
- package/skills/saas-transformer/references/billing_integration_guide.md +401 -401
- package/skills/saas-transformer/references/feature_gating_patterns.md +137 -137
- package/skills/saas-transformer/references/saas_transformation_checklist.md +121 -121
- package/skills/saas-transformer/scripts/saas_transformation_scanner.py +39 -29
- package/skills/scalability-clean-code/SKILL.md +229 -229
- package/skills/self-evolving-memory-graph/SKILL.md +75 -0
- package/skills/self-healing-cloud-orchestrator/SKILL.md +57 -0
- package/skills/senior-frontend/SKILL.md +161 -161
- package/skills/senior-fullstack/SKILL.md +167 -167
- package/skills/seo/SKILL.md +235 -225
- package/skills/seo-geo/SKILL.md +188 -188
- package/skills/session-context-loader/SKILL.md +77 -0
- package/skills/session-handoff-resume/SKILL.md +158 -158
- package/skills/skill_baru/SKILL.md +172 -147
- package/skills/spa-orchestrator/SKILL.md +288 -288
- package/skills/state-management-expert/SKILL.md +272 -272
- package/skills/supabase-security-expert/SKILL.md +243 -243
- package/skills/tailwind-expert/SKILL.md +188 -188
- package/skills/tanstack-query-expert/SKILL.md +199 -199
- package/skills/token-saver/SKILL.md +119 -111
- package/skills/typescript-expert/SKILL.md +324 -279
- package/skills/ui-components-expert/SKILL.md +263 -46
- package/skills/ui-ux-pro-max/SKILL.md +202 -201
- package/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-310.pyc +0 -0
- package/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-310.pyc +0 -0
- package/skills/ui_ux_expert/SKILL.md +17 -6
- package/skills/vector-db-rag-expert/SKILL.md +52 -0
- package/skills/vibe-code-gardener/SKILL.md +181 -173
- package/skills/visual-qa-vision-agent/SKILL.md +65 -0
- package/skills/vue-frontend-expert/SKILL.md +126 -0
- package/skills/web-3d-graphics-expert/SKILL.md +131 -0
- package/skills/web-game-engine-expert/SKILL.md +96 -0
- package/skills/web-scraper/SKILL.md +207 -205
- package/skills/website-design-cloner/SKILL.md +174 -0
- package/skills/webxr-ar-vr-expert/SKILL.md +117 -0
- package/skills/zero-to-prod-orchestrator/SKILL.md +206 -180
- package/skills/zero-trust-secret-vault/SKILL.md +40 -0
- package/vibes-swarm-demo.gif +0 -0
|
@@ -1,401 +1,401 @@
|
|
|
1
|
-
# Billing & Payment Gateway Integration Guide
|
|
2
|
-
|
|
3
|
-
A comprehensive guide to setting up subscription billing, payment gateways, and Merchant of Record (MoR) systems. This guide covers Stripe, Midtrans, PayPal, Paddle, and FastSpring, detailing database structures and webhook verification in a multi-tenant SaaS application.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## 1. Gateway Type: Payment Gateway vs. Merchant of Record (MoR)
|
|
8
|
-
|
|
9
|
-
Before choosing a billing system, it is vital to understand the two main categories of billing platforms:
|
|
10
|
-
|
|
11
|
-
| Feature | Payment Gateway (Stripe, Midtrans, PayPal) | Merchant of Record (Paddle, FastSpring) |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| **Definition** | Processes payments but places legal/financial responsibilities on you. | Acts as the reseller of your software; handles transactions and compliance. |
|
|
14
|
-
| **Sales Tax / VAT** | You must calculate, collect, and file taxes globally (using tools like Stripe Tax). | Automatically handles global taxes, compliance, and invoicing. |
|
|
15
|
-
| **Chargeback Liability**| You manage chargeback disputes directly. | The MoR platform handles chargeback disputes. |
|
|
16
|
-
| **Integration Complexity**| Highly customizable APIs, but requires more backend logic for compliance. | Simple to configure; checkout UI is managed by the MoR. |
|
|
17
|
-
|
|
18
|
-
---
|
|
19
|
-
|
|
20
|
-
## 2. Generic Multi-Gateway Database Schema
|
|
21
|
-
|
|
22
|
-
To support multiple payment gateways, use a generic subscription schema.
|
|
23
|
-
|
|
24
|
-
### Prisma Schema (`prisma/schema.prisma`)
|
|
25
|
-
```prisma
|
|
26
|
-
model Subscription {
|
|
27
|
-
id String @id @default(cuid())
|
|
28
|
-
workspaceId String @unique
|
|
29
|
-
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
30
|
-
gateway String // "stripe", "midtrans", "paypal", "paddle", "fastspring"
|
|
31
|
-
gatewayCustomerId String? @unique
|
|
32
|
-
gatewaySubscriptionId String? @unique
|
|
33
|
-
gatewayPriceId String?
|
|
34
|
-
status String // "active", "trialing", "past_due", "canceled", "unpaid"
|
|
35
|
-
currentPeriodEnd DateTime
|
|
36
|
-
cancelAtPeriodEnd Boolean @default(false)
|
|
37
|
-
createdAt DateTime @default(now())
|
|
38
|
-
updatedAt DateTime @updatedAt
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Drizzle ORM Schema (`lib/db/schema.ts`)
|
|
43
|
-
```typescript
|
|
44
|
-
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
|
45
|
-
|
|
46
|
-
export const subscriptions = pgTable('subscriptions', {
|
|
47
|
-
id: text('id').primaryKey(),
|
|
48
|
-
workspaceId: text('workspace_id').notNull().unique(),
|
|
49
|
-
gateway: text('gateway').notNull(), // "stripe", "midtrans", "paypal", "paddle", "fastspring"
|
|
50
|
-
gatewayCustomerId: text('gateway_customer_id').unique(),
|
|
51
|
-
gatewaySubscriptionId: text('gateway_subscription_id').unique(),
|
|
52
|
-
gatewayPriceId: text('gateway_price_id'),
|
|
53
|
-
status: text('status').notNull(),
|
|
54
|
-
currentPeriodEnd: timestamp('current_period_end').notNull(),
|
|
55
|
-
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
|
|
56
|
-
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
57
|
-
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
58
|
-
});
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## 3. Webhook Implementations
|
|
64
|
-
|
|
65
|
-
Webhooks are crucial to keep your SaaS database in sync with payment updates. Below are the implementation templates for Next.js 15 (App Router).
|
|
66
|
-
|
|
67
|
-
### A. Stripe Integration
|
|
68
|
-
Processes subscriptions and invoices. Verified via the Stripe SDK.
|
|
69
|
-
|
|
70
|
-
```typescript
|
|
71
|
-
import { NextResponse } from 'next/server';
|
|
72
|
-
import Stripe from 'stripe';
|
|
73
|
-
import { db } from '@/lib/db';
|
|
74
|
-
import { subscriptions } from '@/lib/db/schema';
|
|
75
|
-
import { eq } from 'drizzle-orm';
|
|
76
|
-
|
|
77
|
-
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-01' as any });
|
|
78
|
-
|
|
79
|
-
export async function POST(req: Request) {
|
|
80
|
-
const body = await req.text();
|
|
81
|
-
const signature = req.headers.get('Stripe-Signature')!;
|
|
82
|
-
|
|
83
|
-
let event: Stripe.Event;
|
|
84
|
-
try {
|
|
85
|
-
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
86
|
-
} catch (err: any) {
|
|
87
|
-
return NextResponse.json({ error: `Signature verification failed: ${err.message}` }, { status: 400 });
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
switch (event.type) {
|
|
91
|
-
case 'checkout.session.completed': {
|
|
92
|
-
const session = event.data.object as Stripe.Checkout.Session;
|
|
93
|
-
const workspaceId = session.metadata?.workspaceId;
|
|
94
|
-
if (!workspaceId) break;
|
|
95
|
-
|
|
96
|
-
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
|
97
|
-
await db.insert(subscriptions).values({
|
|
98
|
-
id: subscription.id,
|
|
99
|
-
workspaceId,
|
|
100
|
-
gateway: 'stripe',
|
|
101
|
-
gatewayCustomerId: session.customer as string,
|
|
102
|
-
gatewaySubscriptionId: subscription.id,
|
|
103
|
-
gatewayPriceId: subscription.items.data[0].price.id,
|
|
104
|
-
status: subscription.status,
|
|
105
|
-
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
106
|
-
});
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
case 'invoice.payment_succeeded': {
|
|
110
|
-
const invoice = event.data.object as Stripe.Invoice;
|
|
111
|
-
if (!invoice.subscription) break;
|
|
112
|
-
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);
|
|
113
|
-
await db.update(subscriptions)
|
|
114
|
-
.set({
|
|
115
|
-
status: subscription.status,
|
|
116
|
-
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
117
|
-
})
|
|
118
|
-
.where(eq(subscriptions.gatewaySubscriptionId, subscription.id));
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
case 'customer.subscription.deleted': {
|
|
122
|
-
const subscription = event.data.object as Stripe.Subscription;
|
|
123
|
-
await db.update(subscriptions)
|
|
124
|
-
.set({ status: 'canceled' })
|
|
125
|
-
.where(eq(subscriptions.gatewaySubscriptionId, subscription.id));
|
|
126
|
-
break;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return NextResponse.json({ received: true });
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### B. Midtrans Integration
|
|
134
|
-
Southeast Asia's popular payment gateway. Verified via SHA512 signature key matching.
|
|
135
|
-
|
|
136
|
-
```typescript
|
|
137
|
-
import { NextResponse } from 'next/server';
|
|
138
|
-
import crypto from 'crypto';
|
|
139
|
-
import { db } from '@/lib/db';
|
|
140
|
-
import { subscriptions } from '@/lib/db/schema';
|
|
141
|
-
import { eq } from 'drizzle-orm';
|
|
142
|
-
|
|
143
|
-
export async function POST(req: Request) {
|
|
144
|
-
const body = await req.json();
|
|
145
|
-
const { order_id, status_code, gross_amount, signature_key, transaction_status, fraud_status } = body;
|
|
146
|
-
|
|
147
|
-
// Verify signature key: sha512(order_id + status_code + gross_amount + server_key)
|
|
148
|
-
const serverKey = process.env.MIDTRANS_SERVER_KEY!;
|
|
149
|
-
const hashed = crypto
|
|
150
|
-
.createHash('sha512')
|
|
151
|
-
.update(order_id + status_code + gross_amount + serverKey)
|
|
152
|
-
.digest('hex');
|
|
153
|
-
|
|
154
|
-
if (hashed !== signature_key) {
|
|
155
|
-
return NextResponse.json({ error: 'Invalid Midtrans signature' }, { status: 400 });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Update subscription/payment state based on transaction_status
|
|
159
|
-
if (transaction_status === 'capture' || transaction_status === 'settlement') {
|
|
160
|
-
if (fraud_status === 'challenge') {
|
|
161
|
-
await db.update(subscriptions).set({ status: 'unpaid' }).where(eq(subscriptions.id, order_id));
|
|
162
|
-
} else {
|
|
163
|
-
await db.update(subscriptions)
|
|
164
|
-
.set({ status: 'active', currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) })
|
|
165
|
-
.where(eq(subscriptions.id, order_id));
|
|
166
|
-
}
|
|
167
|
-
} else if (['cancel', 'deny', 'expire'].includes(transaction_status)) {
|
|
168
|
-
await db.update(subscriptions).set({ status: 'canceled' }).where(eq(subscriptions.id, order_id));
|
|
169
|
-
} else if (transaction_status === 'pending') {
|
|
170
|
-
await db.update(subscriptions).set({ status: 'unpaid' }).where(eq(subscriptions.id, order_id));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return NextResponse.json({ success: true });
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### C. PayPal Integration
|
|
178
|
-
Global subscription billing. Verified via PayPal API request validation.
|
|
179
|
-
|
|
180
|
-
```typescript
|
|
181
|
-
import { NextResponse } from 'next/server';
|
|
182
|
-
import { db } from '@/lib/db';
|
|
183
|
-
import { subscriptions } from '@/lib/db/schema';
|
|
184
|
-
import { eq } from 'drizzle-orm';
|
|
185
|
-
|
|
186
|
-
async function verifyPayPalSignature(headers: Headers, rawBody: string, webhookId: string) {
|
|
187
|
-
// Call PayPal API to verify signature authenticity
|
|
188
|
-
const endpoint = process.env.PAYPAL_MODE === 'live'
|
|
189
|
-
? 'https://api-m.paypal.com'
|
|
190
|
-
: 'https://api-m.sandbox.paypal.com';
|
|
191
|
-
|
|
192
|
-
const authHeader = headers.get('authorization');
|
|
193
|
-
if (!authHeader) return false;
|
|
194
|
-
|
|
195
|
-
const response = await fetch(`${endpoint}/v1/notifications/verify-webhook-signature`, {
|
|
196
|
-
method: 'POST',
|
|
197
|
-
headers: {
|
|
198
|
-
'Content-Type': 'application/json',
|
|
199
|
-
'Authorization': authHeader,
|
|
200
|
-
},
|
|
201
|
-
body: JSON.stringify({
|
|
202
|
-
auth_algo: headers.get('paypal-auth-algo'),
|
|
203
|
-
cert_url: headers.get('paypal-cert-url'),
|
|
204
|
-
transmission_id: headers.get('paypal-transmission-id'),
|
|
205
|
-
transmission_sig: headers.get('paypal-transmission-sig'),
|
|
206
|
-
transmission_time: headers.get('paypal-transmission-time'),
|
|
207
|
-
webhook_id: webhookId,
|
|
208
|
-
webhook_event: JSON.parse(rawBody),
|
|
209
|
-
}),
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
const verification = await response.json();
|
|
213
|
-
return verification.verification_status === 'SUCCESS';
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export async function POST(req: Request) {
|
|
217
|
-
const rawBody = await req.text();
|
|
218
|
-
const webhookId = process.env.PAYPAL_WEBHOOK_ID!;
|
|
219
|
-
|
|
220
|
-
const isValid = await verifyPayPalSignature(req.headers, rawBody, webhookId);
|
|
221
|
-
if (!isValid) {
|
|
222
|
-
return NextResponse.json({ error: 'PayPal signature invalid' }, { status: 400 });
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const event = JSON.parse(rawBody);
|
|
226
|
-
|
|
227
|
-
switch (event.event_type) {
|
|
228
|
-
case 'BILLING.SUBSCRIPTION.CREATED': {
|
|
229
|
-
const sub = event.resource;
|
|
230
|
-
const workspaceId = sub.custom_id; // Pass workspace ID in custom_id
|
|
231
|
-
if (!workspaceId) break;
|
|
232
|
-
|
|
233
|
-
await db.insert(subscriptions).values({
|
|
234
|
-
id: sub.id,
|
|
235
|
-
workspaceId,
|
|
236
|
-
gateway: 'paypal',
|
|
237
|
-
gatewayCustomerId: sub.subscriber.payer_id,
|
|
238
|
-
gatewaySubscriptionId: sub.id,
|
|
239
|
-
gatewayPriceId: sub.plan_id,
|
|
240
|
-
status: 'active',
|
|
241
|
-
currentPeriodEnd: new Date(sub.billing_info.next_billing_time),
|
|
242
|
-
});
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
case 'BILLING.SUBSCRIPTION.CANCELLED': {
|
|
246
|
-
const sub = event.resource;
|
|
247
|
-
await db.update(subscriptions)
|
|
248
|
-
.set({ status: 'canceled' })
|
|
249
|
-
.where(eq(subscriptions.gatewaySubscriptionId, sub.id));
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return NextResponse.json({ received: true });
|
|
255
|
-
}
|
|
256
|
-
```
|
|
257
|
-
|
|
258
|
-
### D. Paddle Integration (Paddle Billing v2)
|
|
259
|
-
Merchant of Record. Verified locally using an HMAC-SHA256 signature key verification.
|
|
260
|
-
|
|
261
|
-
```typescript
|
|
262
|
-
import { NextResponse } from 'next/server';
|
|
263
|
-
import crypto from 'crypto';
|
|
264
|
-
import { db } from '@/lib/db';
|
|
265
|
-
import { subscriptions } from '@/lib/db/schema';
|
|
266
|
-
import { eq } from 'drizzle-orm';
|
|
267
|
-
|
|
268
|
-
function verifyPaddleSignature(body: string, signatureHeader: string, secret: string): boolean {
|
|
269
|
-
const parts = signatureHeader.split(';');
|
|
270
|
-
const tsPart = parts.find(p => p.startsWith('ts='));
|
|
271
|
-
const h1Part = parts.find(p => p.startsWith('h1='));
|
|
272
|
-
if (!tsPart || !h1Part) return false;
|
|
273
|
-
|
|
274
|
-
const ts = tsPart.split('=')[1];
|
|
275
|
-
const h1 = h1Part.split('=')[1];
|
|
276
|
-
|
|
277
|
-
const payload = `${ts}:${body}`;
|
|
278
|
-
const computedHash = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
279
|
-
return computedHash === h1;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export async function POST(req: Request) {
|
|
283
|
-
const rawBody = await req.text();
|
|
284
|
-
const signature = req.headers.get('Paddle-Signature')!;
|
|
285
|
-
const secret = process.env.PADDLE_WEBHOOK_SECRET!;
|
|
286
|
-
|
|
287
|
-
if (!verifyPaddleSignature(rawBody, signature, secret)) {
|
|
288
|
-
return NextResponse.json({ error: 'Invalid Paddle signature' }, { status: 400 });
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const event = JSON.parse(rawBody);
|
|
292
|
-
const eventType = event.event_type;
|
|
293
|
-
const data = event.data;
|
|
294
|
-
|
|
295
|
-
switch (eventType) {
|
|
296
|
-
case 'subscription.created':
|
|
297
|
-
case 'subscription.updated': {
|
|
298
|
-
const workspaceId = data.custom_data?.workspaceId;
|
|
299
|
-
if (!workspaceId) break;
|
|
300
|
-
|
|
301
|
-
await db.insert(subscriptions).values({
|
|
302
|
-
id: data.id,
|
|
303
|
-
workspaceId,
|
|
304
|
-
gateway: 'paddle',
|
|
305
|
-
gatewayCustomerId: data.customer_id,
|
|
306
|
-
gatewaySubscriptionId: data.id,
|
|
307
|
-
gatewayPriceId: data.items[0].price.id,
|
|
308
|
-
status: data.status, // active, trialing, paused
|
|
309
|
-
currentPeriodEnd: new Date(data.current_billing_period.ends_at),
|
|
310
|
-
}).onConflictDoUpdate({
|
|
311
|
-
target: subscriptions.workspaceId,
|
|
312
|
-
set: {
|
|
313
|
-
status: data.status,
|
|
314
|
-
currentPeriodEnd: new Date(data.current_billing_period.ends_at),
|
|
315
|
-
gatewayPriceId: data.items[0].price.id,
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
case 'subscription.canceled': {
|
|
321
|
-
await db.update(subscriptions)
|
|
322
|
-
.set({ status: 'canceled' })
|
|
323
|
-
.where(eq(subscriptions.gatewaySubscriptionId, data.id));
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return NextResponse.json({ received: true });
|
|
329
|
-
}
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### E. FastSpring Integration
|
|
333
|
-
Merchant of Record. Verified via an HMAC-SHA256 signature key verification.
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
import { NextResponse } from 'next/server';
|
|
337
|
-
import crypto from 'crypto';
|
|
338
|
-
import { db } from '@/lib/db';
|
|
339
|
-
import { subscriptions } from '@/lib/db/schema';
|
|
340
|
-
import { eq } from 'drizzle-orm';
|
|
341
|
-
|
|
342
|
-
function verifyFastSpringSignature(body: string, signature: string, secretKey: string): boolean {
|
|
343
|
-
const hash = crypto.createHmac('sha256', secretKey).update(body).digest('base64');
|
|
344
|
-
return hash === signature;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export async function POST(req: Request) {
|
|
348
|
-
const rawBody = await req.text();
|
|
349
|
-
const signature = req.headers.get('X-FS-Signature')!;
|
|
350
|
-
const secretKey = process.env.FASTSPRING_SHARED_SECRET!;
|
|
351
|
-
|
|
352
|
-
if (!verifyFastSpringSignature(rawBody, signature, secretKey)) {
|
|
353
|
-
return NextResponse.json({ error: 'Invalid FastSpring signature' }, { status: 400 });
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const payload = JSON.parse(rawBody);
|
|
357
|
-
|
|
358
|
-
for (const event of payload.events) {
|
|
359
|
-
switch (event.type) {
|
|
360
|
-
case 'subscription.activated': {
|
|
361
|
-
const sub = event.data;
|
|
362
|
-
const workspaceId = sub.tags?.workspaceId;
|
|
363
|
-
if (!workspaceId) break;
|
|
364
|
-
|
|
365
|
-
await db.insert(subscriptions).values({
|
|
366
|
-
id: sub.id,
|
|
367
|
-
workspaceId,
|
|
368
|
-
gateway: 'fastspring',
|
|
369
|
-
gatewayCustomerId: sub.account,
|
|
370
|
-
gatewaySubscriptionId: sub.id,
|
|
371
|
-
gatewayPriceId: sub.product,
|
|
372
|
-
status: 'active',
|
|
373
|
-
currentPeriodEnd: new Date(sub.nextSession),
|
|
374
|
-
});
|
|
375
|
-
break;
|
|
376
|
-
}
|
|
377
|
-
case 'subscription.canceled': {
|
|
378
|
-
const sub = event.data;
|
|
379
|
-
await db.update(subscriptions)
|
|
380
|
-
.set({ status: 'canceled' })
|
|
381
|
-
.where(eq(subscriptions.gatewaySubscriptionId, sub.id));
|
|
382
|
-
break;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return NextResponse.json({ received: true });
|
|
388
|
-
}
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
---
|
|
392
|
-
|
|
393
|
-
## 4. Best Practices for Webhook Handlers
|
|
394
|
-
|
|
395
|
-
1. **Webhook Idempotency**: Payment gateways can redeliver webhooks. Always store a list of processed event IDs in your database to prevent duplicate actions:
|
|
396
|
-
```typescript
|
|
397
|
-
const processed = await db.query.processedEvents.findFirst({ where: eq(processedEvents.id, eventId) });
|
|
398
|
-
if (processed) return NextResponse.json({ received: true });
|
|
399
|
-
```
|
|
400
|
-
2. **Handle Async Work Gracefully**: Send a fast response (`200 OK`) back to the gateway first, then process any heavy logic asynchronously (or via queue systems like BullMQ) if necessary, to avoid gateway timeouts.
|
|
401
|
-
3. **Database Locks**: Run your update queries inside clean transactions to avoid concurrency anomalies.
|
|
1
|
+
# Billing & Payment Gateway Integration Guide
|
|
2
|
+
|
|
3
|
+
A comprehensive guide to setting up subscription billing, payment gateways, and Merchant of Record (MoR) systems. This guide covers Stripe, Midtrans, PayPal, Paddle, and FastSpring, detailing database structures and webhook verification in a multi-tenant SaaS application.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Gateway Type: Payment Gateway vs. Merchant of Record (MoR)
|
|
8
|
+
|
|
9
|
+
Before choosing a billing system, it is vital to understand the two main categories of billing platforms:
|
|
10
|
+
|
|
11
|
+
| Feature | Payment Gateway (Stripe, Midtrans, PayPal) | Merchant of Record (Paddle, FastSpring) |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **Definition** | Processes payments but places legal/financial responsibilities on you. | Acts as the reseller of your software; handles transactions and compliance. |
|
|
14
|
+
| **Sales Tax / VAT** | You must calculate, collect, and file taxes globally (using tools like Stripe Tax). | Automatically handles global taxes, compliance, and invoicing. |
|
|
15
|
+
| **Chargeback Liability**| You manage chargeback disputes directly. | The MoR platform handles chargeback disputes. |
|
|
16
|
+
| **Integration Complexity**| Highly customizable APIs, but requires more backend logic for compliance. | Simple to configure; checkout UI is managed by the MoR. |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. Generic Multi-Gateway Database Schema
|
|
21
|
+
|
|
22
|
+
To support multiple payment gateways, use a generic subscription schema.
|
|
23
|
+
|
|
24
|
+
### Prisma Schema (`prisma/schema.prisma`)
|
|
25
|
+
```prisma
|
|
26
|
+
model Subscription {
|
|
27
|
+
id String @id @default(cuid())
|
|
28
|
+
workspaceId String @unique
|
|
29
|
+
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
30
|
+
gateway String // "stripe", "midtrans", "paypal", "paddle", "fastspring"
|
|
31
|
+
gatewayCustomerId String? @unique
|
|
32
|
+
gatewaySubscriptionId String? @unique
|
|
33
|
+
gatewayPriceId String?
|
|
34
|
+
status String // "active", "trialing", "past_due", "canceled", "unpaid"
|
|
35
|
+
currentPeriodEnd DateTime
|
|
36
|
+
cancelAtPeriodEnd Boolean @default(false)
|
|
37
|
+
createdAt DateTime @default(now())
|
|
38
|
+
updatedAt DateTime @updatedAt
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Drizzle ORM Schema (`lib/db/schema.ts`)
|
|
43
|
+
```typescript
|
|
44
|
+
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
|
45
|
+
|
|
46
|
+
export const subscriptions = pgTable('subscriptions', {
|
|
47
|
+
id: text('id').primaryKey(),
|
|
48
|
+
workspaceId: text('workspace_id').notNull().unique(),
|
|
49
|
+
gateway: text('gateway').notNull(), // "stripe", "midtrans", "paypal", "paddle", "fastspring"
|
|
50
|
+
gatewayCustomerId: text('gateway_customer_id').unique(),
|
|
51
|
+
gatewaySubscriptionId: text('gateway_subscription_id').unique(),
|
|
52
|
+
gatewayPriceId: text('gateway_price_id'),
|
|
53
|
+
status: text('status').notNull(),
|
|
54
|
+
currentPeriodEnd: timestamp('current_period_end').notNull(),
|
|
55
|
+
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
|
|
56
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
57
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 3. Webhook Implementations
|
|
64
|
+
|
|
65
|
+
Webhooks are crucial to keep your SaaS database in sync with payment updates. Below are the implementation templates for Next.js 15 (App Router).
|
|
66
|
+
|
|
67
|
+
### A. Stripe Integration
|
|
68
|
+
Processes subscriptions and invoices. Verified via the Stripe SDK.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { NextResponse } from 'next/server';
|
|
72
|
+
import Stripe from 'stripe';
|
|
73
|
+
import { db } from '@/lib/db';
|
|
74
|
+
import { subscriptions } from '@/lib/db/schema';
|
|
75
|
+
import { eq } from 'drizzle-orm';
|
|
76
|
+
|
|
77
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-01' as any });
|
|
78
|
+
|
|
79
|
+
export async function POST(req: Request) {
|
|
80
|
+
const body = await req.text();
|
|
81
|
+
const signature = req.headers.get('Stripe-Signature')!;
|
|
82
|
+
|
|
83
|
+
let event: Stripe.Event;
|
|
84
|
+
try {
|
|
85
|
+
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
return NextResponse.json({ error: `Signature verification failed: ${err.message}` }, { status: 400 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
switch (event.type) {
|
|
91
|
+
case 'checkout.session.completed': {
|
|
92
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
93
|
+
const workspaceId = session.metadata?.workspaceId;
|
|
94
|
+
if (!workspaceId) break;
|
|
95
|
+
|
|
96
|
+
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
|
|
97
|
+
await db.insert(subscriptions).values({
|
|
98
|
+
id: subscription.id,
|
|
99
|
+
workspaceId,
|
|
100
|
+
gateway: 'stripe',
|
|
101
|
+
gatewayCustomerId: session.customer as string,
|
|
102
|
+
gatewaySubscriptionId: subscription.id,
|
|
103
|
+
gatewayPriceId: subscription.items.data[0].price.id,
|
|
104
|
+
status: subscription.status,
|
|
105
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
106
|
+
});
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'invoice.payment_succeeded': {
|
|
110
|
+
const invoice = event.data.object as Stripe.Invoice;
|
|
111
|
+
if (!invoice.subscription) break;
|
|
112
|
+
const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);
|
|
113
|
+
await db.update(subscriptions)
|
|
114
|
+
.set({
|
|
115
|
+
status: subscription.status,
|
|
116
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
|
117
|
+
})
|
|
118
|
+
.where(eq(subscriptions.gatewaySubscriptionId, subscription.id));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'customer.subscription.deleted': {
|
|
122
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
123
|
+
await db.update(subscriptions)
|
|
124
|
+
.set({ status: 'canceled' })
|
|
125
|
+
.where(eq(subscriptions.gatewaySubscriptionId, subscription.id));
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return NextResponse.json({ received: true });
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### B. Midtrans Integration
|
|
134
|
+
Southeast Asia's popular payment gateway. Verified via SHA512 signature key matching.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { NextResponse } from 'next/server';
|
|
138
|
+
import crypto from 'crypto';
|
|
139
|
+
import { db } from '@/lib/db';
|
|
140
|
+
import { subscriptions } from '@/lib/db/schema';
|
|
141
|
+
import { eq } from 'drizzle-orm';
|
|
142
|
+
|
|
143
|
+
export async function POST(req: Request) {
|
|
144
|
+
const body = await req.json();
|
|
145
|
+
const { order_id, status_code, gross_amount, signature_key, transaction_status, fraud_status } = body;
|
|
146
|
+
|
|
147
|
+
// Verify signature key: sha512(order_id + status_code + gross_amount + server_key)
|
|
148
|
+
const serverKey = process.env.MIDTRANS_SERVER_KEY!;
|
|
149
|
+
const hashed = crypto
|
|
150
|
+
.createHash('sha512')
|
|
151
|
+
.update(order_id + status_code + gross_amount + serverKey)
|
|
152
|
+
.digest('hex');
|
|
153
|
+
|
|
154
|
+
if (hashed !== signature_key) {
|
|
155
|
+
return NextResponse.json({ error: 'Invalid Midtrans signature' }, { status: 400 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Update subscription/payment state based on transaction_status
|
|
159
|
+
if (transaction_status === 'capture' || transaction_status === 'settlement') {
|
|
160
|
+
if (fraud_status === 'challenge') {
|
|
161
|
+
await db.update(subscriptions).set({ status: 'unpaid' }).where(eq(subscriptions.id, order_id));
|
|
162
|
+
} else {
|
|
163
|
+
await db.update(subscriptions)
|
|
164
|
+
.set({ status: 'active', currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) })
|
|
165
|
+
.where(eq(subscriptions.id, order_id));
|
|
166
|
+
}
|
|
167
|
+
} else if (['cancel', 'deny', 'expire'].includes(transaction_status)) {
|
|
168
|
+
await db.update(subscriptions).set({ status: 'canceled' }).where(eq(subscriptions.id, order_id));
|
|
169
|
+
} else if (transaction_status === 'pending') {
|
|
170
|
+
await db.update(subscriptions).set({ status: 'unpaid' }).where(eq(subscriptions.id, order_id));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return NextResponse.json({ success: true });
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### C. PayPal Integration
|
|
178
|
+
Global subscription billing. Verified via PayPal API request validation.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { NextResponse } from 'next/server';
|
|
182
|
+
import { db } from '@/lib/db';
|
|
183
|
+
import { subscriptions } from '@/lib/db/schema';
|
|
184
|
+
import { eq } from 'drizzle-orm';
|
|
185
|
+
|
|
186
|
+
async function verifyPayPalSignature(headers: Headers, rawBody: string, webhookId: string) {
|
|
187
|
+
// Call PayPal API to verify signature authenticity
|
|
188
|
+
const endpoint = process.env.PAYPAL_MODE === 'live'
|
|
189
|
+
? 'https://api-m.paypal.com'
|
|
190
|
+
: 'https://api-m.sandbox.paypal.com';
|
|
191
|
+
|
|
192
|
+
const authHeader = headers.get('authorization');
|
|
193
|
+
if (!authHeader) return false;
|
|
194
|
+
|
|
195
|
+
const response = await fetch(`${endpoint}/v1/notifications/verify-webhook-signature`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: {
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
'Authorization': authHeader,
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
auth_algo: headers.get('paypal-auth-algo'),
|
|
203
|
+
cert_url: headers.get('paypal-cert-url'),
|
|
204
|
+
transmission_id: headers.get('paypal-transmission-id'),
|
|
205
|
+
transmission_sig: headers.get('paypal-transmission-sig'),
|
|
206
|
+
transmission_time: headers.get('paypal-transmission-time'),
|
|
207
|
+
webhook_id: webhookId,
|
|
208
|
+
webhook_event: JSON.parse(rawBody),
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const verification = await response.json();
|
|
213
|
+
return verification.verification_status === 'SUCCESS';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function POST(req: Request) {
|
|
217
|
+
const rawBody = await req.text();
|
|
218
|
+
const webhookId = process.env.PAYPAL_WEBHOOK_ID!;
|
|
219
|
+
|
|
220
|
+
const isValid = await verifyPayPalSignature(req.headers, rawBody, webhookId);
|
|
221
|
+
if (!isValid) {
|
|
222
|
+
return NextResponse.json({ error: 'PayPal signature invalid' }, { status: 400 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const event = JSON.parse(rawBody);
|
|
226
|
+
|
|
227
|
+
switch (event.event_type) {
|
|
228
|
+
case 'BILLING.SUBSCRIPTION.CREATED': {
|
|
229
|
+
const sub = event.resource;
|
|
230
|
+
const workspaceId = sub.custom_id; // Pass workspace ID in custom_id
|
|
231
|
+
if (!workspaceId) break;
|
|
232
|
+
|
|
233
|
+
await db.insert(subscriptions).values({
|
|
234
|
+
id: sub.id,
|
|
235
|
+
workspaceId,
|
|
236
|
+
gateway: 'paypal',
|
|
237
|
+
gatewayCustomerId: sub.subscriber.payer_id,
|
|
238
|
+
gatewaySubscriptionId: sub.id,
|
|
239
|
+
gatewayPriceId: sub.plan_id,
|
|
240
|
+
status: 'active',
|
|
241
|
+
currentPeriodEnd: new Date(sub.billing_info.next_billing_time),
|
|
242
|
+
});
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'BILLING.SUBSCRIPTION.CANCELLED': {
|
|
246
|
+
const sub = event.resource;
|
|
247
|
+
await db.update(subscriptions)
|
|
248
|
+
.set({ status: 'canceled' })
|
|
249
|
+
.where(eq(subscriptions.gatewaySubscriptionId, sub.id));
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return NextResponse.json({ received: true });
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### D. Paddle Integration (Paddle Billing v2)
|
|
259
|
+
Merchant of Record. Verified locally using an HMAC-SHA256 signature key verification.
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
import { NextResponse } from 'next/server';
|
|
263
|
+
import crypto from 'crypto';
|
|
264
|
+
import { db } from '@/lib/db';
|
|
265
|
+
import { subscriptions } from '@/lib/db/schema';
|
|
266
|
+
import { eq } from 'drizzle-orm';
|
|
267
|
+
|
|
268
|
+
function verifyPaddleSignature(body: string, signatureHeader: string, secret: string): boolean {
|
|
269
|
+
const parts = signatureHeader.split(';');
|
|
270
|
+
const tsPart = parts.find(p => p.startsWith('ts='));
|
|
271
|
+
const h1Part = parts.find(p => p.startsWith('h1='));
|
|
272
|
+
if (!tsPart || !h1Part) return false;
|
|
273
|
+
|
|
274
|
+
const ts = tsPart.split('=')[1];
|
|
275
|
+
const h1 = h1Part.split('=')[1];
|
|
276
|
+
|
|
277
|
+
const payload = `${ts}:${body}`;
|
|
278
|
+
const computedHash = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
279
|
+
return computedHash === h1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function POST(req: Request) {
|
|
283
|
+
const rawBody = await req.text();
|
|
284
|
+
const signature = req.headers.get('Paddle-Signature')!;
|
|
285
|
+
const secret = process.env.PADDLE_WEBHOOK_SECRET!;
|
|
286
|
+
|
|
287
|
+
if (!verifyPaddleSignature(rawBody, signature, secret)) {
|
|
288
|
+
return NextResponse.json({ error: 'Invalid Paddle signature' }, { status: 400 });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const event = JSON.parse(rawBody);
|
|
292
|
+
const eventType = event.event_type;
|
|
293
|
+
const data = event.data;
|
|
294
|
+
|
|
295
|
+
switch (eventType) {
|
|
296
|
+
case 'subscription.created':
|
|
297
|
+
case 'subscription.updated': {
|
|
298
|
+
const workspaceId = data.custom_data?.workspaceId;
|
|
299
|
+
if (!workspaceId) break;
|
|
300
|
+
|
|
301
|
+
await db.insert(subscriptions).values({
|
|
302
|
+
id: data.id,
|
|
303
|
+
workspaceId,
|
|
304
|
+
gateway: 'paddle',
|
|
305
|
+
gatewayCustomerId: data.customer_id,
|
|
306
|
+
gatewaySubscriptionId: data.id,
|
|
307
|
+
gatewayPriceId: data.items[0].price.id,
|
|
308
|
+
status: data.status, // active, trialing, paused
|
|
309
|
+
currentPeriodEnd: new Date(data.current_billing_period.ends_at),
|
|
310
|
+
}).onConflictDoUpdate({
|
|
311
|
+
target: subscriptions.workspaceId,
|
|
312
|
+
set: {
|
|
313
|
+
status: data.status,
|
|
314
|
+
currentPeriodEnd: new Date(data.current_billing_period.ends_at),
|
|
315
|
+
gatewayPriceId: data.items[0].price.id,
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'subscription.canceled': {
|
|
321
|
+
await db.update(subscriptions)
|
|
322
|
+
.set({ status: 'canceled' })
|
|
323
|
+
.where(eq(subscriptions.gatewaySubscriptionId, data.id));
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return NextResponse.json({ received: true });
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### E. FastSpring Integration
|
|
333
|
+
Merchant of Record. Verified via an HMAC-SHA256 signature key verification.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
import { NextResponse } from 'next/server';
|
|
337
|
+
import crypto from 'crypto';
|
|
338
|
+
import { db } from '@/lib/db';
|
|
339
|
+
import { subscriptions } from '@/lib/db/schema';
|
|
340
|
+
import { eq } from 'drizzle-orm';
|
|
341
|
+
|
|
342
|
+
function verifyFastSpringSignature(body: string, signature: string, secretKey: string): boolean {
|
|
343
|
+
const hash = crypto.createHmac('sha256', secretKey).update(body).digest('base64');
|
|
344
|
+
return hash === signature;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function POST(req: Request) {
|
|
348
|
+
const rawBody = await req.text();
|
|
349
|
+
const signature = req.headers.get('X-FS-Signature')!;
|
|
350
|
+
const secretKey = process.env.FASTSPRING_SHARED_SECRET!;
|
|
351
|
+
|
|
352
|
+
if (!verifyFastSpringSignature(rawBody, signature, secretKey)) {
|
|
353
|
+
return NextResponse.json({ error: 'Invalid FastSpring signature' }, { status: 400 });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const payload = JSON.parse(rawBody);
|
|
357
|
+
|
|
358
|
+
for (const event of payload.events) {
|
|
359
|
+
switch (event.type) {
|
|
360
|
+
case 'subscription.activated': {
|
|
361
|
+
const sub = event.data;
|
|
362
|
+
const workspaceId = sub.tags?.workspaceId;
|
|
363
|
+
if (!workspaceId) break;
|
|
364
|
+
|
|
365
|
+
await db.insert(subscriptions).values({
|
|
366
|
+
id: sub.id,
|
|
367
|
+
workspaceId,
|
|
368
|
+
gateway: 'fastspring',
|
|
369
|
+
gatewayCustomerId: sub.account,
|
|
370
|
+
gatewaySubscriptionId: sub.id,
|
|
371
|
+
gatewayPriceId: sub.product,
|
|
372
|
+
status: 'active',
|
|
373
|
+
currentPeriodEnd: new Date(sub.nextSession),
|
|
374
|
+
});
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
case 'subscription.canceled': {
|
|
378
|
+
const sub = event.data;
|
|
379
|
+
await db.update(subscriptions)
|
|
380
|
+
.set({ status: 'canceled' })
|
|
381
|
+
.where(eq(subscriptions.gatewaySubscriptionId, sub.id));
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return NextResponse.json({ received: true });
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## 4. Best Practices for Webhook Handlers
|
|
394
|
+
|
|
395
|
+
1. **Webhook Idempotency**: Payment gateways can redeliver webhooks. Always store a list of processed event IDs in your database to prevent duplicate actions:
|
|
396
|
+
```typescript
|
|
397
|
+
const processed = await db.query.processedEvents.findFirst({ where: eq(processedEvents.id, eventId) });
|
|
398
|
+
if (processed) return NextResponse.json({ received: true });
|
|
399
|
+
```
|
|
400
|
+
2. **Handle Async Work Gracefully**: Send a fast response (`200 OK`) back to the gateway first, then process any heavy logic asynchronously (or via queue systems like BullMQ) if necessary, to avoid gateway timeouts.
|
|
401
|
+
3. **Database Locks**: Run your update queries inside clean transactions to avoid concurrency anomalies.
|