stripe-no-webhooks 0.0.8 → 0.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stripe-no-webhooks",
3
- "version": "0.0.8",
3
+ "version": "0.0.11",
4
4
  "author": "Ramon Garate",
5
5
  "description": "Stripe integration without dealing with webhooks",
6
6
  "main": "./dist/index.js",
@@ -29,18 +29,24 @@
29
29
  "scripts": {
30
30
  "build": "tsup src/index.ts src/client.ts --format cjs,esm --dts",
31
31
  "dev": "tsup src/index.ts src/client.ts --format cjs,esm --dts --watch",
32
+ "test": "bun test",
33
+ "test:db:up": "docker compose -f tests/docker-compose.yml up -d && sleep 2",
34
+ "test:db:down": "docker compose -f tests/docker-compose.yml down",
35
+ "test:credits": "bun test tests/credits.test.ts",
32
36
  "prepublishOnly": "npm run build",
33
37
  "release": "npm version patch && npm publish"
34
38
  },
35
39
  "dependencies": {
36
- "@supabase/stripe-sync-engine": "^0.47.0",
37
- "dotenv": "^17.2.3"
40
+ "@pretzelai/stripe-sync-engine": "^0.48.1",
41
+ "dotenv": "^17.2.3",
42
+ "pg": "^8.13.1"
38
43
  },
39
44
  "peerDependencies": {
40
45
  "stripe": ">=14.0.0"
41
46
  },
42
47
  "devDependencies": {
43
48
  "@types/node": "^22.10.2",
49
+ "@types/pg": "^8.16.0",
44
50
  "stripe": "^17.4.0",
45
51
  "tsup": "^8.3.5",
46
52
  "typescript": "^5.7.2"
@@ -0,0 +1,450 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo, useEffect } from "react";
4
+ import { createCheckoutClient } from "stripe-no-webhooks/client";
5
+ import type { Plan, PriceInterval } from "stripe-no-webhooks";
6
+
7
+ interface PricingPageProps {
8
+ plans: Plan[];
9
+ currentPlanId?: string;
10
+ currentInterval?: PriceInterval;
11
+ onError?: (error: Error) => void;
12
+ /** Countdown duration in seconds before redirecting after plan switch (default: 5) */
13
+ redirectCountdown?: number;
14
+ }
15
+
16
+ const getPlanId = (plan: Plan) =>
17
+ plan.id || plan.name.toLowerCase().replace(/\s+/g, "-");
18
+
19
+ const getPrice = (plan: Plan, interval: PriceInterval) => {
20
+ if (!plan.price || plan.price.length === 0) return null;
21
+ return plan.price.find((p) => p.interval === interval) || plan.price[0];
22
+ };
23
+
24
+ export function PricingPage({
25
+ plans,
26
+ currentPlanId,
27
+ currentInterval = "month",
28
+ onError,
29
+ redirectCountdown = 5,
30
+ }: PricingPageProps) {
31
+ const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const [interval, setInterval] = useState<PriceInterval>(currentInterval);
34
+ const [countdown, setCountdown] = useState<number | null>(null);
35
+ const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
36
+
37
+ // Handle countdown and redirect
38
+ useEffect(() => {
39
+ if (countdown === null || !redirectUrl) return;
40
+
41
+ if (countdown === 0) {
42
+ window.location.href = redirectUrl;
43
+ return;
44
+ }
45
+
46
+ const timer = setTimeout(() => {
47
+ setCountdown(countdown - 1);
48
+ }, 1000);
49
+
50
+ return () => clearTimeout(timer);
51
+ }, [countdown, redirectUrl]);
52
+
53
+ const { checkout, customerPortal } = useMemo(
54
+ () =>
55
+ createCheckoutClient({
56
+ onLoading: (isLoading) => {
57
+ if (!isLoading) setLoadingPlanId(null);
58
+ },
59
+ onError: (err) => {
60
+ setError(err.message);
61
+ onError?.(err);
62
+ },
63
+ onPlanChanged: (url) => {
64
+ setRedirectUrl(url);
65
+ setCountdown(redirectCountdown);
66
+ },
67
+ }),
68
+ [onError, redirectCountdown]
69
+ );
70
+
71
+ const handleCheckout = async (plan: Plan) => {
72
+ setLoadingPlanId(getPlanId(plan));
73
+ setError(null);
74
+ setCountdown(null);
75
+ setRedirectUrl(null);
76
+ await checkout({ planId: getPlanId(plan), interval });
77
+ };
78
+
79
+ const handleManage = async () => {
80
+ setLoadingPlanId("manage");
81
+ setError(null);
82
+ setCountdown(null);
83
+ setRedirectUrl(null);
84
+ await customerPortal();
85
+ };
86
+
87
+ const formatPrice = (amount: number, currency: string) => {
88
+ return new Intl.NumberFormat("en-US", {
89
+ style: "currency",
90
+ currency: currency.toUpperCase(),
91
+ minimumFractionDigits: 0,
92
+ }).format(amount / 100);
93
+ };
94
+
95
+ // Check if we should show interval toggle (must have both month AND year prices)
96
+ const hasMultipleIntervals = useMemo(() => {
97
+ const allIntervals = new Set<PriceInterval>();
98
+ for (const plan of plans) {
99
+ for (const price of plan.price || []) {
100
+ if (price.interval !== "one_time") {
101
+ allIntervals.add(price.interval);
102
+ }
103
+ }
104
+ }
105
+ return allIntervals.has("month") && allIntervals.has("year");
106
+ }, [plans]);
107
+
108
+ return (
109
+ <>
110
+ <style>{`
111
+ /* =================================================================
112
+ CUSTOMIZE YOUR THEME
113
+ Change these variables to match your brand colors and style.
114
+ ================================================================= */
115
+ .snw-pricing-container {
116
+ --snw-primary: #3b82f6;
117
+ --snw-primary-hover: #2563eb;
118
+ --snw-text: #111;
119
+ --snw-text-muted: #666;
120
+ --snw-text-secondary: #374151;
121
+ --snw-border: #e5e7eb;
122
+ --snw-background: white;
123
+ --snw-background-secondary: #f3f4f6;
124
+ --snw-success: #16a34a;
125
+ --snw-success-bg: #f0fdf4;
126
+ --snw-success-border: #bbf7d0;
127
+ --snw-error: #dc2626;
128
+ --snw-error-bg: #fef2f2;
129
+ --snw-error-border: #fecaca;
130
+ --snw-radius: 12px;
131
+ --snw-radius-sm: 8px;
132
+ --snw-font: system-ui, -apple-system, sans-serif;
133
+ }
134
+ /* ================================================================= */
135
+
136
+ .snw-pricing-container {
137
+ max-width: 1200px;
138
+ margin: 0 auto;
139
+ padding: 2rem 1rem;
140
+ font-family: var(--snw-font);
141
+ }
142
+ .snw-pricing-header {
143
+ text-align: center;
144
+ margin-bottom: 2rem;
145
+ }
146
+ .snw-pricing-title {
147
+ font-size: 2rem;
148
+ font-weight: 700;
149
+ color: var(--snw-text);
150
+ margin: 0 0 0.5rem 0;
151
+ }
152
+ .snw-pricing-subtitle {
153
+ color: var(--snw-text-muted);
154
+ font-size: 1.1rem;
155
+ margin: 0;
156
+ }
157
+ .snw-interval-toggle {
158
+ display: flex;
159
+ justify-content: center;
160
+ gap: 0.5rem;
161
+ margin-bottom: 2rem;
162
+ }
163
+ .snw-interval-btn {
164
+ padding: 0.5rem 1rem;
165
+ border: 1px solid var(--snw-border);
166
+ background: var(--snw-background);
167
+ border-radius: 6px;
168
+ cursor: pointer;
169
+ font-size: 0.9rem;
170
+ transition: all 0.15s;
171
+ }
172
+ .snw-interval-btn:hover {
173
+ border-color: var(--snw-primary);
174
+ }
175
+ .snw-interval-btn.active {
176
+ background: var(--snw-primary);
177
+ border-color: var(--snw-primary);
178
+ color: white;
179
+ }
180
+ .snw-pricing-grid {
181
+ display: grid;
182
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
183
+ gap: 1.5rem;
184
+ }
185
+ .snw-pricing-card {
186
+ border: 1px solid var(--snw-border);
187
+ border-radius: var(--snw-radius);
188
+ padding: 1.5rem;
189
+ background: var(--snw-background);
190
+ transition: all 0.2s;
191
+ display: flex;
192
+ flex-direction: column;
193
+ position: relative;
194
+ }
195
+ .snw-pricing-card:hover {
196
+ border-color: var(--snw-primary);
197
+ box-shadow: 0 4px 12px color-mix(in srgb, var(--snw-primary) 10%, transparent);
198
+ }
199
+ .snw-pricing-card.current {
200
+ border-color: var(--snw-primary);
201
+ border-width: 2px;
202
+ }
203
+ .snw-plan-name {
204
+ font-size: 1.25rem;
205
+ font-weight: 600;
206
+ color: var(--snw-text);
207
+ margin: 0 0 0.25rem 0;
208
+ }
209
+ .snw-plan-description {
210
+ color: var(--snw-text-muted);
211
+ font-size: 0.9rem;
212
+ margin: 0 0 1rem 0;
213
+ }
214
+ .snw-plan-price {
215
+ font-size: 2.5rem;
216
+ font-weight: 700;
217
+ color: var(--snw-text);
218
+ margin: 0;
219
+ }
220
+ .snw-plan-interval {
221
+ color: var(--snw-text-muted);
222
+ font-size: 0.9rem;
223
+ }
224
+ .snw-plan-features {
225
+ list-style: none;
226
+ padding: 0;
227
+ margin: 1.5rem 0;
228
+ flex-grow: 1;
229
+ }
230
+ .snw-plan-feature {
231
+ padding: 0.4rem 0;
232
+ color: var(--snw-text-secondary);
233
+ font-size: 0.95rem;
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 0.5rem;
237
+ }
238
+ .snw-plan-feature::before {
239
+ content: "✓";
240
+ color: var(--snw-primary);
241
+ font-weight: bold;
242
+ }
243
+ .snw-plan-btn {
244
+ width: 100%;
245
+ padding: 0.75rem 1rem;
246
+ border: none;
247
+ border-radius: var(--snw-radius-sm);
248
+ font-size: 1rem;
249
+ font-weight: 500;
250
+ cursor: pointer;
251
+ transition: all 0.15s;
252
+ }
253
+ .snw-plan-btn:disabled {
254
+ opacity: 0.7;
255
+ cursor: not-allowed;
256
+ }
257
+ .snw-plan-btn.primary {
258
+ background: var(--snw-primary);
259
+ color: white;
260
+ }
261
+ .snw-plan-btn.primary:hover:not(:disabled) {
262
+ background: var(--snw-primary-hover);
263
+ }
264
+ .snw-plan-btn.secondary {
265
+ background: var(--snw-background-secondary);
266
+ color: var(--snw-text-secondary);
267
+ }
268
+ .snw-plan-btn.secondary:hover:not(:disabled) {
269
+ background: var(--snw-border);
270
+ }
271
+ .snw-current-badge {
272
+ position: absolute;
273
+ top: -0.65rem;
274
+ left: 1.25rem;
275
+ background: var(--snw-primary);
276
+ color: white;
277
+ font-size: 0.7rem;
278
+ font-weight: 600;
279
+ padding: 0.25rem 0.75rem;
280
+ border-radius: 9999px;
281
+ text-transform: uppercase;
282
+ letter-spacing: 0.025em;
283
+ }
284
+ .snw-error {
285
+ background: var(--snw-error-bg);
286
+ border: 1px solid var(--snw-error-border);
287
+ color: var(--snw-error);
288
+ padding: 0.75rem 1rem;
289
+ border-radius: var(--snw-radius-sm);
290
+ margin-bottom: 1rem;
291
+ text-align: center;
292
+ }
293
+ .snw-success {
294
+ background: var(--snw-success-bg);
295
+ border: 1px solid var(--snw-success-border);
296
+ color: var(--snw-success);
297
+ padding: 0.75rem 1rem;
298
+ border-radius: var(--snw-radius-sm);
299
+ margin-bottom: 1rem;
300
+ text-align: center;
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: center;
304
+ gap: 0.5rem;
305
+ animation: snw-fade-in 0.3s ease-out;
306
+ }
307
+ .snw-success-icon {
308
+ width: 20px;
309
+ height: 20px;
310
+ background: var(--snw-success);
311
+ border-radius: 50%;
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ color: white;
316
+ font-size: 12px;
317
+ flex-shrink: 0;
318
+ }
319
+ @keyframes snw-fade-in {
320
+ from { opacity: 0; transform: translateY(-10px); }
321
+ to { opacity: 1; transform: translateY(0); }
322
+ }
323
+ .snw-loading-spinner {
324
+ display: inline-block;
325
+ width: 16px;
326
+ height: 16px;
327
+ border: 2px solid currentColor;
328
+ border-right-color: transparent;
329
+ border-radius: 50%;
330
+ animation: snw-spin 0.6s linear infinite;
331
+ margin-right: 0.5rem;
332
+ }
333
+ @keyframes snw-spin {
334
+ to { transform: rotate(360deg); }
335
+ }
336
+ @media (max-width: 768px) {
337
+ .snw-pricing-grid {
338
+ grid-template-columns: 1fr;
339
+ }
340
+ .snw-pricing-title {
341
+ font-size: 1.5rem;
342
+ }
343
+ .snw-plan-price {
344
+ font-size: 2rem;
345
+ }
346
+ }
347
+ `}</style>
348
+
349
+ <div className="snw-pricing-container">
350
+ <div className="snw-pricing-header">
351
+ <h1 className="snw-pricing-title">Choose your plan</h1>
352
+ <p className="snw-pricing-subtitle">
353
+ Start free, upgrade when you need more
354
+ </p>
355
+ </div>
356
+
357
+ {hasMultipleIntervals && (
358
+ <div className="snw-interval-toggle">
359
+ <button
360
+ className={`snw-interval-btn ${interval === "month" ? "active" : ""}`}
361
+ onClick={() => setInterval("month")}
362
+ >
363
+ Monthly
364
+ </button>
365
+ <button
366
+ className={`snw-interval-btn ${interval === "year" ? "active" : ""}`}
367
+ onClick={() => setInterval("year")}
368
+ >
369
+ Yearly
370
+ </button>
371
+ </div>
372
+ )}
373
+
374
+ {countdown !== null && (
375
+ <div className="snw-success">
376
+ <span className="snw-success-icon">✓</span>
377
+ <span>Plan updated! Redirecting in {countdown}...</span>
378
+ </div>
379
+ )}
380
+
381
+ {error && <div className="snw-error">{error}</div>}
382
+
383
+ <div className="snw-pricing-grid">
384
+ {plans.map((plan) => {
385
+ const planId = getPlanId(plan);
386
+ const price = getPrice(plan, interval);
387
+ const isCurrent = currentPlanId === planId;
388
+ const isLoading = loadingPlanId === planId;
389
+ const isManageLoading = loadingPlanId === "manage";
390
+ const isFree = !price || price.amount === 0;
391
+
392
+ return (
393
+ <div
394
+ key={planId}
395
+ className={`snw-pricing-card ${isCurrent ? "current" : ""}`}
396
+ >
397
+ {isCurrent && <span className="snw-current-badge">Current Plan</span>}
398
+ <h2 className="snw-plan-name">{plan.name}</h2>
399
+ {plan.description && (
400
+ <p className="snw-plan-description">{plan.description}</p>
401
+ )}
402
+ <p className="snw-plan-price">
403
+ {isFree || !price
404
+ ? "Free"
405
+ : formatPrice(price.amount, price.currency)}
406
+ {price && !isFree && price.interval !== "one_time" && (
407
+ <span className="snw-plan-interval">/{price.interval}</span>
408
+ )}
409
+ </p>
410
+
411
+ {plan.credits && (
412
+ <ul className="snw-plan-features">
413
+ {Object.entries(plan.credits).map(([type, config]) => (
414
+ <li key={type} className="snw-plan-feature">
415
+ {config.allocation.toLocaleString()} {config.displayName || type}
416
+ {config.onRenewal === "add"
417
+ ? " (accumulates)"
418
+ : `/${interval === "year" ? "year" : "month"}`}
419
+ </li>
420
+ ))}
421
+ </ul>
422
+ )}
423
+
424
+ {isCurrent ? (
425
+ <button
426
+ className="snw-plan-btn secondary"
427
+ onClick={handleManage}
428
+ disabled={isManageLoading}
429
+ >
430
+ {isManageLoading && <span className="snw-loading-spinner" />}
431
+ Manage Subscription
432
+ </button>
433
+ ) : (
434
+ <button
435
+ className="snw-plan-btn primary"
436
+ onClick={() => handleCheckout(plan)}
437
+ disabled={isLoading || !!loadingPlanId}
438
+ >
439
+ {isLoading && <span className="snw-loading-spinner" />}
440
+ {isFree ? "Get Started" : currentPlanId ? "Switch Plan" : "Subscribe"}
441
+ </button>
442
+ )}
443
+ </div>
444
+ );
445
+ })}
446
+ </div>
447
+ </div>
448
+ </>
449
+ );
450
+ }
@@ -1,20 +1,27 @@
1
1
  // app/api/stripe/[...all]/route.ts
2
- import { createStripeHandler } from "stripe-no-webhooks";
3
- import type { Stripe } from "stripe";
4
- import billingConfig from "../../../../billing.config";
2
+ import { billing } from "@/lib/billing";
5
3
 
6
- export const POST = createStripeHandler({
7
- billingConfig,
8
- callbacks: {
9
- onSubscriptionCreated: async (subscription: Stripe.Subscription) => {
10
- // Called when a new subscription is created
11
- console.log("New subscription:", subscription.id);
12
- // e.g., send welcome email, provision resources, etc.
13
- },
14
- onSubscriptionCancelled: async (subscription: Stripe.Subscription) => {
15
- // Called when a subscription is cancelled
16
- console.log("Subscription cancelled:", subscription.id);
17
- // e.g., send cancellation email, revoke access, etc.
18
- },
4
+ // TODO: Import your auth library
5
+ // import { auth } from "@clerk/nextjs/server";
6
+ // import { getServerSession } from "next-auth";
7
+
8
+ export const POST = billing.createHandler({
9
+ // REQUIRED: Resolve the authenticated user from the request
10
+ resolveUser: async () => {
11
+ // Clerk:
12
+ // const { userId } = await auth();
13
+ // return userId ? { id: userId } : null;
14
+
15
+ // NextAuth:
16
+ // const session = await getServerSession();
17
+ // return session?.user?.id ? { id: session.user.id } : null;
18
+
19
+ return null; // TODO: Replace with your auth
19
20
  },
21
+
22
+ // OPTIONAL: Resolve org for team/org billing
23
+ // resolveOrg: async () => {
24
+ // const session = await getSession();
25
+ // return session.currentOrgId ?? null;
26
+ // },
20
27
  });
@@ -0,0 +1,27 @@
1
+ import { Billing } from "stripe-no-webhooks";
2
+ import billingConfig from "../billing.config";
3
+ // import type { Stripe } from "stripe";
4
+
5
+ // Initialize once, use everywhere (for credits/subscriptions API access)
6
+ // If you only need the webhook handler, you can skip this file and use
7
+ // createHandler() directly in your API route.
8
+ export const billing = new Billing({
9
+ billingConfig,
10
+ // Keys and database URL are read from environment variables by default:
11
+ // - STRIPE_SECRET_KEY
12
+ // - STRIPE_WEBHOOK_SECRET
13
+ // - DATABASE_URL
14
+
15
+ // OPTIONAL: Add callbacks for subscription/credit events
16
+ // callbacks: {
17
+ // onSubscriptionCreated: async (subscription: Stripe.Subscription) => {
18
+ // console.log("New subscription:", subscription.id);
19
+ // },
20
+ // onSubscriptionCancelled: async (subscription: Stripe.Subscription) => {
21
+ // console.log("Subscription cancelled:", subscription.id);
22
+ // },
23
+ // onCreditsGranted: ({ userId, creditType, amount }) => {
24
+ // console.log(`Granted ${amount} ${creditType} to ${userId}`);
25
+ // },
26
+ // },
27
+ });
@@ -1,23 +1,30 @@
1
1
  // pages/api/stripe/[...all].ts
2
- import { createStripeHandler } from "stripe-no-webhooks";
2
+ import { billing } from "@/lib/billing";
3
3
  import type { NextApiRequest, NextApiResponse } from "next";
4
- import type { Stripe } from "stripe";
5
- import billingConfig from "../../../billing.config";
6
-
7
- const handler = createStripeHandler({
8
- billingConfig,
9
- callbacks: {
10
- onSubscriptionCreated: async (subscription: Stripe.Subscription) => {
11
- // Called when a new subscription is created
12
- console.log("New subscription:", subscription.id);
13
- // e.g., send welcome email, provision resources, etc.
14
- },
15
- onSubscriptionCancelled: async (subscription: Stripe.Subscription) => {
16
- // Called when a subscription is cancelled
17
- console.log("Subscription cancelled:", subscription.id);
18
- // e.g., send cancellation email, revoke access, etc.
19
- },
4
+
5
+ // TODO: Import your auth library
6
+ // import { getAuth } from "@clerk/nextjs/server";
7
+ // import { getServerSession } from "next-auth";
8
+
9
+ const handler = billing.createHandler({
10
+ // REQUIRED: Resolve the authenticated user from the request
11
+ resolveUser: async () => {
12
+ // Clerk:
13
+ // const { userId } = getAuth(req);
14
+ // return userId ? { id: userId } : null;
15
+
16
+ // NextAuth:
17
+ // const session = await getServerSession(req, res);
18
+ // return session?.user?.id ? { id: session.user.id } : null;
19
+
20
+ return null; // TODO: Replace with your auth
20
21
  },
22
+
23
+ // OPTIONAL: Resolve org for team/org billing
24
+ // resolveOrg: async () => {
25
+ // const session = await getSession(req);
26
+ // return session.currentOrgId ?? null;
27
+ // },
21
28
  });
22
29
 
23
30
  // Disable body parsing, we need the raw body for webhook verification
@@ -34,7 +41,7 @@ export default async function stripeHandler(
34
41
  // Convert NextApiRequest to Request for the handler
35
42
  const body = await new Promise<string>((resolve) => {
36
43
  let data = "";
37
- req.on("data", (chunk) => (data += chunk));
44
+ req.on("data", (chunk: string) => (data += chunk));
38
45
  req.on("end", () => resolve(data));
39
46
  });
40
47