stripe-no-webhooks 0.0.8 → 0.0.9

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/README.md CHANGED
@@ -1,152 +1,90 @@
1
1
  # stripe-no-webhooks
2
2
 
3
- Stripe integration without dealing with webhooks. Automatically syncs Stripe data to your PostgreSQL database and provides simple callbacks for subscription events.
3
+ Opinionated & Open Source library that automatically syncs Stripe to your database and gives you useful helpers to implement subscriptions.
4
4
 
5
- ## Installation
5
+ ## Why this library?
6
+
7
+ Stripe documentation lacks the ability to clearly point you to an easy way to implement Stripe. Depending on what you google you might end up in a weird place and shoot yourself in the foot.
8
+
9
+ ## Setup
10
+
11
+ ### 1. Install
6
12
 
7
13
  ```bash
8
14
  npm install stripe-no-webhooks stripe
9
15
  ```
10
16
 
11
- ## Setup
12
-
13
- ### 1. Create Stripe schema and tables
17
+ Note: make sure you also have `.env` or `.env.local` in your project so it can save the generated secrets there.
14
18
 
15
- **Option 1:** Run the migration command
19
+ ### 2. Create tables where all Stripe data will be automatically synced
16
20
 
17
21
  ```bash
18
22
  npx stripe-no-webhooks migrate postgresql://postgres.[USER]:[PASSWORD]@[DB_URL]/postgres
19
23
  ```
20
24
 
21
- **Option 2:** Copy `stripe_schema.sql` and run the query manually
22
-
23
- ### 2. Set up the webhook handler
24
-
25
- Create a webhook endpoint in your Next.js app:
26
-
27
- #### App Router (recommended)
28
-
29
- ```ts
30
- // app/api/stripe/webhook/route.ts
31
- import { createStripeWebhookHandler } from "stripe-no-webhooks";
32
-
33
- const handler = createStripeWebhookHandler({
34
- databaseUrl: process.env.DATABASE_URL!,
35
- stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
36
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
37
- callbacks: {
38
- onSubscriptionCreated: async (subscription) => {
39
- // Called when a new subscription is created
40
- console.log("New subscription:", subscription.id);
41
- // e.g., send welcome email, provision resources, etc.
42
- },
43
- onSubscriptionCancelled: async (subscription) => {
44
- // Called when a subscription is cancelled
45
- console.log("Subscription cancelled:", subscription.id);
46
- // e.g., send cancellation email, revoke access, etc.
47
- },
48
- },
49
- });
25
+ ### 3. Run `config` to generate files & webhook
50
26
 
51
- export const POST = handler;
27
+ ```bash
28
+ npx stripe-no-webhooks config
52
29
  ```
53
30
 
54
- #### Pages Router
55
-
56
- ```ts
57
- // pages/api/stripe/webhook.ts
58
- import { createStripeWebhookHandler } from "stripe-no-webhooks";
59
- import type { NextApiRequest, NextApiResponse } from "next";
60
-
61
- const handler = createStripeWebhookHandler({
62
- databaseUrl: process.env.DATABASE_URL!,
63
- stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
64
- stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
65
- callbacks: {
66
- onSubscriptionCreated: async (subscription) => {
67
- console.log("New subscription:", subscription.id);
68
- },
69
- onSubscriptionCancelled: async (subscription) => {
70
- console.log("Subscription cancelled:", subscription.id);
71
- },
72
- },
73
- });
74
-
75
- // Disable body parsing, we need the raw body for webhook verification
76
- export const config = {
77
- api: {
78
- bodyParser: false,
31
+ ### 4. Create your plans
32
+
33
+ ```javascript
34
+ // billing.config.ts (automatically created during config)
35
+ import type { BillingConfig } from "stripe-no-webhooks";
36
+ const billingConfig: BillingConfig = {
37
+ test: {
38
+ plans: [
39
+ {
40
+ name: "Premium",
41
+ description: "Access to all features",
42
+ price: [
43
+ {
44
+ amount: 1000, // $10
45
+ currency: "usd",
46
+ interval: "month",
47
+ },
48
+ {
49
+ amount: 10000, // $100
50
+ currency: "usd",
51
+ interval: "year",
52
+ },
53
+ ],
54
+ },
55
+ ],
79
56
  },
80
57
  };
81
-
82
- export default async function webhookHandler(
83
- req: NextApiRequest,
84
- res: NextApiResponse
85
- ) {
86
- if (req.method !== "POST") {
87
- return res.status(405).json({ error: "Method not allowed" });
88
- }
89
-
90
- // Convert NextApiRequest to Request for the handler
91
- const body = await new Promise<string>((resolve) => {
92
- let data = "";
93
- req.on("data", (chunk) => (data += chunk));
94
- req.on("end", () => resolve(data));
95
- });
96
-
97
- const request = new Request(`https://${req.headers.host}${req.url}`, {
98
- method: "POST",
99
- headers: new Headers(req.headers as Record<string, string>),
100
- body,
101
- });
102
-
103
- const response = await handler(request);
104
- res.status(response.status).send(await response.text());
105
- }
58
+ export default billingConfig;
106
59
  ```
107
60
 
108
- ### 3. Configure Stripe webhook
109
-
110
- Run the config command to automatically create a webhook in your Stripe account:
61
+ Run sync:
111
62
 
112
63
  ```bash
113
- npx stripe-no-webhooks config
64
+ npx stripe-no-webhooks sync
114
65
  ```
115
66
 
116
- This will:
117
-
118
- 1. Ask for your Stripe Secret Key
119
- 2. Ask for your site URL (defaults to `NEXT_PUBLIC_SITE_URL` if set)
120
- 3. Create a webhook endpoint at `https://yoursite.com/api/stripe/webhook` listening to all events
121
- 4. Automatically add `STRIPE_WEBHOOK_SECRET` to your `.env` files (if they exist)
122
-
123
- ## Environment Variables
124
-
125
- ```env
126
- DATABASE_URL=postgresql://user:pass@host:port/db
127
- STRIPE_SECRET_KEY=sk_test_...
128
- STRIPE_WEBHOOK_SECRET=whsec_... # Output from `npx stripe-no-webhooks config`
67
+ ### 5. Implement a checkout button in your frontend:
68
+
69
+ ```javascript
70
+ "use client";
71
+ import { checkout } from "stripe-no-webhooks/client";
72
+
73
+ export default function Home() {
74
+ return (
75
+ <div className="min-h-screen flex items-center justify-center">
76
+ <button
77
+ className="bg-blue-500 text-white px-4 py-2 rounded-md cursor-pointer"
78
+ onClick={() =>
79
+ checkout({
80
+ planName: "Premium",
81
+ interval: "month",
82
+ })
83
+ }
84
+ >
85
+ Checkout
86
+ </button>
87
+ </div>
88
+ );
89
+ }
129
90
  ```
130
-
131
- ## What gets synced?
132
-
133
- All Stripe webhook events are automatically synced to your PostgreSQL database in the `stripe` schema. This includes:
134
-
135
- - Customers
136
- - Subscriptions
137
- - Products
138
- - Prices
139
- - Invoices
140
- - Payment methods
141
- - And more...
142
-
143
- You can query this data directly from your database without making API calls to Stripe.
144
-
145
- ## Callbacks
146
-
147
- | Callback | Event | Description |
148
- | ------------------------- | --------------------------------------------------------------- | ----------------------------------------- |
149
- | `onSubscriptionCreated` | `customer.subscription.created` | Called when a new subscription is created |
150
- | `onSubscriptionCancelled` | `customer.subscription.deleted` or status changes to `canceled` | Called when a subscription is cancelled |
151
-
152
- Both callbacks receive the full Stripe `Subscription` object.
package/bin/cli.js CHANGED
@@ -4,6 +4,7 @@ const { runMigrations } = require("@supabase/stripe-sync-engine");
4
4
  const readline = require("readline");
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
+ const { Client } = require("pg");
7
8
 
8
9
  // Load environment variables from .env files in the user's project directory
9
10
  require("dotenv").config({ path: path.join(process.cwd(), ".env.local") });
@@ -76,7 +77,9 @@ function questionHidden(rl, query, defaultValue = "") {
76
77
  });
77
78
  }
78
79
 
79
- async function migrate(databaseUrl) {
80
+ async function migrate(dbUrl) {
81
+ const SCHEMA = "stripe";
82
+ const databaseUrl = dbUrl || process.env.DATABASE_URL;
80
83
  if (!databaseUrl) {
81
84
  console.error("❌ Missing database URL.\n");
82
85
  console.log(
@@ -89,16 +92,30 @@ async function migrate(databaseUrl) {
89
92
  try {
90
93
  await runMigrations({
91
94
  databaseUrl,
92
- schema: "stripe",
95
+ schema: SCHEMA,
93
96
  logger: console,
94
97
  });
95
- console.log("✅ Migrations completed successfully!");
98
+ const client = new Client({ connectionString: databaseUrl });
99
+ await client.connect();
100
+
101
+ await client.query(`
102
+ CREATE TABLE IF NOT EXISTS ${SCHEMA}.user_stripe_customer_map (
103
+ user_id text PRIMARY KEY,
104
+ stripe_customer_id text UNIQUE NOT NULL,
105
+ created_at timestamptz DEFAULT now(),
106
+ updated_at timestamptz DEFAULT now()
107
+ );
108
+ `);
96
109
 
97
- // Save DATABASE_URL to env files
98
- const envVars = [{ key: "DATABASE_URL", value: databaseUrl }];
99
- const updatedFiles = saveToEnvFiles(envVars);
100
- if (updatedFiles.length > 0) {
101
- console.log(`📝 Updated ${updatedFiles.join(", ")} with DATABASE_URL`);
110
+ await client.end();
111
+ console.log("✅ Stripe schema migrations completed!");
112
+
113
+ if (!process.env.DATABASE_URL) {
114
+ const envVars = [{ key: "DATABASE_URL", value: databaseUrl }];
115
+ const updatedFiles = saveToEnvFiles(envVars);
116
+ if (updatedFiles.length > 0) {
117
+ console.log(`📝 Updated ${updatedFiles.join(", ")} with DATABASE_URL`);
118
+ }
102
119
  }
103
120
  } catch (error) {
104
121
  console.error("❌ Migration failed:");
@@ -127,10 +144,8 @@ function saveToEnvFiles(envVars) {
127
144
  const regex = new RegExp(`^${key}=.*`, "m");
128
145
 
129
146
  if (regex.test(content)) {
130
- // Replace existing value
131
147
  content = content.replace(regex, line);
132
148
  } else {
133
- // Append to file
134
149
  const newline = content.endsWith("\n") ? "" : "\n";
135
150
  content = content + newline + line + "\n";
136
151
  }
@@ -188,8 +203,6 @@ function createApiRoute(routerType, useSrc) {
188
203
  // App Router: app/api/stripe/[...all]/route.ts
189
204
  const routeDir = path.join(baseDir, "app", "api", "stripe", "[...all]");
190
205
  const routeFile = path.join(routeDir, "route.ts");
191
-
192
- // Create directories if they don't exist
193
206
  fs.mkdirSync(routeDir, { recursive: true });
194
207
 
195
208
  // Get template content (remove the comment with file path)
@@ -199,7 +212,6 @@ function createApiRoute(routerType, useSrc) {
199
212
  ""
200
213
  );
201
214
 
202
- // Write the file
203
215
  fs.writeFileSync(routeFile, template);
204
216
 
205
217
  const prefix = useSrc ? "src/" : "";
@@ -209,7 +221,6 @@ function createApiRoute(routerType, useSrc) {
209
221
  const routeDir = path.join(baseDir, "pages", "api", "stripe");
210
222
  const routeFile = path.join(routeDir, "[...all].ts");
211
223
 
212
- // Create directories if they don't exist
213
224
  fs.mkdirSync(routeDir, { recursive: true });
214
225
 
215
226
  // Get template content (remove the comment with file path)
@@ -218,8 +229,6 @@ function createApiRoute(routerType, useSrc) {
218
229
  /^\/\/ pages\/api\/stripe\/\[\.\.\.all\]\.ts\n/,
219
230
  ""
220
231
  );
221
-
222
- // Write the file
223
232
  fs.writeFileSync(routeFile, template);
224
233
 
225
234
  const prefix = useSrc ? "src/" : "";
@@ -245,7 +254,6 @@ async function config() {
245
254
  const srcLabel = useSrc ? " (src/)" : "";
246
255
  console.log(`📂 Detected: ${routerLabel}${srcLabel}\n`);
247
256
 
248
- // Get Stripe API key (hidden input)
249
257
  const existingStripeKey = process.env.STRIPE_SECRET_KEY || "";
250
258
  const stripeSecretKey = await questionHidden(
251
259
  null,
@@ -258,10 +266,8 @@ async function config() {
258
266
  process.exit(1);
259
267
  }
260
268
 
261
- // Create readline for site URL question
262
269
  const rl = createPrompt();
263
270
 
264
- // Get site URL with default from env
265
271
  const defaultSiteUrl = process.env.NEXT_PUBLIC_SITE_URL || "";
266
272
  const siteUrl = await question(rl, "Enter your site URL", defaultSiteUrl);
267
273
 
@@ -271,7 +277,6 @@ async function config() {
271
277
  process.exit(1);
272
278
  }
273
279
 
274
- // Validate URL
275
280
  let webhookUrl;
276
281
  try {
277
282
  const url = new URL(siteUrl);
@@ -282,7 +287,6 @@ async function config() {
282
287
  process.exit(1);
283
288
  }
284
289
 
285
- // Get DATABASE_URL (optional) - skip if already set in env
286
290
  let databaseUrlInput = "";
287
291
  if (process.env.DATABASE_URL) {
288
292
  console.log("✓ DATABASE_URL already set in environment");
@@ -297,7 +301,6 @@ async function config() {
297
301
 
298
302
  rl.close();
299
303
 
300
- // Create the API route
301
304
  console.log(`📁 Creating API route...`);
302
305
  try {
303
306
  const createdFile = createApiRoute(routerType, useSrc);
@@ -307,7 +310,6 @@ async function config() {
307
310
  process.exit(1);
308
311
  }
309
312
 
310
- // Copy billing.config.ts to root
311
313
  console.log(`📁 Creating billing.config.ts...`);
312
314
  try {
313
315
  const billingConfigPath = path.join(process.cwd(), "billing.config.ts");
@@ -329,7 +331,6 @@ async function config() {
329
331
  const stripe = new Stripe(stripeSecretKey);
330
332
 
331
333
  try {
332
- // Check if a webhook with the same URL already exists
333
334
  const existingWebhooks = await stripe.webhookEndpoints.list({ limit: 100 });
334
335
  const existingWebhook = existingWebhooks.data.find(
335
336
  (wh) => wh.url === webhookUrl
@@ -341,7 +342,6 @@ async function config() {
341
342
  console.log(`✅ Deleted existing webhook (${existingWebhook.id})\n`);
342
343
  }
343
344
 
344
- // Create webhook endpoint
345
345
  console.log(`🔄 Creating new webhook endpoint...`);
346
346
  const webhook = await stripe.webhookEndpoints.create({
347
347
  url: webhookUrl,
@@ -350,7 +350,6 @@ async function config() {
350
350
  });
351
351
  console.log("✅ Webhook created successfully!\n");
352
352
 
353
- // Build list of env vars to update
354
353
  const envVars = [
355
354
  { key: "STRIPE_SECRET_KEY", value: stripeSecretKey },
356
355
  { key: "STRIPE_WEBHOOK_SECRET", value: webhook.secret },
@@ -360,7 +359,6 @@ async function config() {
360
359
  envVars.push({ key: "DATABASE_URL", value: databaseUrlInput });
361
360
  }
362
361
 
363
- // Save to env files
364
362
  const updatedFiles = saveToEnvFiles(envVars);
365
363
 
366
364
  const envVarNames = envVars.map((v) => v.key).join(", ");
@@ -401,7 +399,6 @@ async function config() {
401
399
  }
402
400
 
403
401
  function findMatchingBrace(content, startIndex) {
404
- // Find the matching closing brace for an opening brace
405
402
  let depth = 0;
406
403
  for (let i = startIndex; i < content.length; i++) {
407
404
  if (content[i] === "{" || content[i] === "[") depth++;
@@ -462,7 +459,6 @@ function parseBillingConfig(content, mode) {
462
459
  return { config: null, plans: [] };
463
460
  }
464
461
 
465
- // Convert to JSON and parse
466
462
  const jsonString = tsObjectToJson(extracted.raw);
467
463
  let config;
468
464
  try {
@@ -472,13 +468,11 @@ function parseBillingConfig(content, mode) {
472
468
  return { config: null, plans: [] };
473
469
  }
474
470
 
475
- // Get plans for the specified mode
476
471
  const modeConfig = config[mode];
477
472
  if (!modeConfig || !modeConfig.plans || modeConfig.plans.length === 0) {
478
473
  return { config, plans: [], extracted };
479
474
  }
480
475
 
481
- // Return parsed plans with their indices for updating
482
476
  const plans = modeConfig.plans.map((plan, index) => ({
483
477
  plan,
484
478
  index,
@@ -488,7 +482,6 @@ function parseBillingConfig(content, mode) {
488
482
  }
489
483
 
490
484
  function reorderWithIdFirst(obj) {
491
- // Reorder object so 'id' is the first property if it exists
492
485
  if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
493
486
  return obj;
494
487
  }
@@ -556,7 +549,6 @@ function formatConfigToTs(config) {
556
549
  }
557
550
  }
558
551
 
559
- // Convert to TypeScript object literal format (unquoted keys)
560
552
  return toTsObjectLiteral(reorderedConfig, 0);
561
553
  }
562
554
 
@@ -578,7 +570,6 @@ async function sync() {
578
570
  process.exit(1);
579
571
  }
580
572
 
581
- // Get Stripe API key from env or prompt
582
573
  let stripeSecretKey = process.env.STRIPE_SECRET_KEY;
583
574
  if (!stripeSecretKey) {
584
575
  stripeSecretKey = await questionHidden(
@@ -592,7 +583,6 @@ async function sync() {
592
583
  process.exit(1);
593
584
  }
594
585
 
595
- // Determine mode based on Stripe key
596
586
  let mode;
597
587
  try {
598
588
  mode = getMode(stripeSecretKey);
@@ -613,7 +603,6 @@ async function sync() {
613
603
  process.exit(1);
614
604
  }
615
605
 
616
- // Ensure the mode config exists with plans array
617
606
  if (!config[mode]) {
618
607
  config[mode] = { plans: [] };
619
608
  }
@@ -629,20 +618,16 @@ async function sync() {
629
618
  let skippedProducts = 0;
630
619
  let skippedPrices = 0;
631
620
 
632
- // === PULL: Fetch products and prices from Stripe and add missing ones ===
633
621
  console.log("📥 Pulling products from Stripe...\n");
634
622
 
635
623
  try {
636
- // Fetch all active products from Stripe
637
624
  const stripeProducts = await stripe.products.list({
638
625
  active: true,
639
626
  limit: 100,
640
627
  });
641
628
 
642
- // Fetch all active prices from Stripe
643
629
  const stripePrices = await stripe.prices.list({ active: true, limit: 100 });
644
630
 
645
- // Build a map of price by product
646
631
  const pricesByProduct = {};
647
632
  for (const price of stripePrices.data) {
648
633
  const productId =
@@ -653,12 +638,10 @@ async function sync() {
653
638
  pricesByProduct[productId].push(price);
654
639
  }
655
640
 
656
- // Get existing product IDs in config
657
641
  const existingProductIds = new Set(
658
642
  config[mode].plans.filter((p) => p.id).map((p) => p.id)
659
643
  );
660
644
 
661
- // Get existing price IDs in config
662
645
  const existingPriceIds = new Set();
663
646
  for (const plan of config[mode].plans) {
664
647
  if (plan.price) {
@@ -749,10 +732,8 @@ async function sync() {
749
732
  console.error("❌ Failed to fetch products from Stripe:", error.message);
750
733
  }
751
734
 
752
- // === PUSH: Create products and prices in Stripe from config ===
753
735
  console.log("📤 Pushing new plans to Stripe...\n");
754
736
 
755
- // Re-get plans after potential modifications
756
737
  const currentPlans = config[mode].plans || [];
757
738
 
758
739
  if (currentPlans.length === 0) {
@@ -843,7 +824,6 @@ async function sync() {
843
824
  console.log(" No new products or prices to push to Stripe.\n");
844
825
  }
845
826
 
846
- // Write updated config back to file
847
827
  if (configModified) {
848
828
  const newConfigJson = formatConfigToTs(config);
849
829
  const newContent =
package/dist/index.d.mts CHANGED
@@ -2,6 +2,11 @@ import Stripe from 'stripe';
2
2
  import { B as BillingConfig, P as PriceInterval } from './BillingConfig-n6VbfqGY.mjs';
3
3
  export { b as Plan, a as Price } from './BillingConfig-n6VbfqGY.mjs';
4
4
 
5
+ interface User {
6
+ id: string;
7
+ name?: string;
8
+ email?: string;
9
+ }
5
10
  interface StripeWebhookCallbacks {
6
11
  /**
7
12
  * Called when a new subscription is created
@@ -52,6 +57,16 @@ interface StripeHandlerConfig {
52
57
  * Callbacks for subscription events
53
58
  */
54
59
  callbacks?: StripeWebhookCallbacks;
60
+ /**
61
+ * Function to map a user ID to a Stripe customer ID.
62
+ * Used as fallback when user is not found in user_stripe_customer_map table.
63
+ */
64
+ mapUserIdToStripeCustomerId?: (userId: string) => string | Promise<string> | null | Promise<string | null>;
65
+ /**
66
+ * Function to extract user from the request.
67
+ * Useful for extracting user from authentication middleware/session.
68
+ */
69
+ getUser?: (request: Request) => User | Promise<User> | null | Promise<User | null>;
55
70
  }
56
71
  interface CheckoutRequestBody {
57
72
  /**
@@ -90,11 +105,30 @@ interface CheckoutRequestBody {
90
105
  * Existing Stripe customer ID
91
106
  */
92
107
  customerId?: string;
108
+ /**
109
+ * User object to associate with this checkout.
110
+ * Will be used to look up or create a Stripe customer.
111
+ */
112
+ user?: User;
93
113
  /**
94
114
  * Additional metadata to attach to the session
95
115
  */
96
116
  metadata?: Record<string, string>;
97
117
  }
118
+ interface CustomerPortalRequestBody {
119
+ /**
120
+ * Stripe customer ID (cus_...)
121
+ */
122
+ stripe_customer_id?: string;
123
+ /**
124
+ * User object to look up Stripe customer ID
125
+ */
126
+ user?: User;
127
+ /**
128
+ * URL to redirect to after the customer portal session ends
129
+ */
130
+ returnUrl?: string;
131
+ }
98
132
  declare function createStripeHandler(config?: StripeHandlerConfig): (request: Request) => Promise<Response>;
99
133
 
100
- export { BillingConfig, type CheckoutRequestBody, PriceInterval, type StripeHandlerConfig, type StripeWebhookCallbacks, createStripeHandler };
134
+ export { BillingConfig, type CheckoutRequestBody, type CustomerPortalRequestBody, PriceInterval, type StripeHandlerConfig, type StripeWebhookCallbacks, type User, createStripeHandler };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,11 @@ import Stripe from 'stripe';
2
2
  import { B as BillingConfig, P as PriceInterval } from './BillingConfig-n6VbfqGY.js';
3
3
  export { b as Plan, a as Price } from './BillingConfig-n6VbfqGY.js';
4
4
 
5
+ interface User {
6
+ id: string;
7
+ name?: string;
8
+ email?: string;
9
+ }
5
10
  interface StripeWebhookCallbacks {
6
11
  /**
7
12
  * Called when a new subscription is created
@@ -52,6 +57,16 @@ interface StripeHandlerConfig {
52
57
  * Callbacks for subscription events
53
58
  */
54
59
  callbacks?: StripeWebhookCallbacks;
60
+ /**
61
+ * Function to map a user ID to a Stripe customer ID.
62
+ * Used as fallback when user is not found in user_stripe_customer_map table.
63
+ */
64
+ mapUserIdToStripeCustomerId?: (userId: string) => string | Promise<string> | null | Promise<string | null>;
65
+ /**
66
+ * Function to extract user from the request.
67
+ * Useful for extracting user from authentication middleware/session.
68
+ */
69
+ getUser?: (request: Request) => User | Promise<User> | null | Promise<User | null>;
55
70
  }
56
71
  interface CheckoutRequestBody {
57
72
  /**
@@ -90,11 +105,30 @@ interface CheckoutRequestBody {
90
105
  * Existing Stripe customer ID
91
106
  */
92
107
  customerId?: string;
108
+ /**
109
+ * User object to associate with this checkout.
110
+ * Will be used to look up or create a Stripe customer.
111
+ */
112
+ user?: User;
93
113
  /**
94
114
  * Additional metadata to attach to the session
95
115
  */
96
116
  metadata?: Record<string, string>;
97
117
  }
118
+ interface CustomerPortalRequestBody {
119
+ /**
120
+ * Stripe customer ID (cus_...)
121
+ */
122
+ stripe_customer_id?: string;
123
+ /**
124
+ * User object to look up Stripe customer ID
125
+ */
126
+ user?: User;
127
+ /**
128
+ * URL to redirect to after the customer portal session ends
129
+ */
130
+ returnUrl?: string;
131
+ }
98
132
  declare function createStripeHandler(config?: StripeHandlerConfig): (request: Request) => Promise<Response>;
99
133
 
100
- export { BillingConfig, type CheckoutRequestBody, PriceInterval, type StripeHandlerConfig, type StripeWebhookCallbacks, createStripeHandler };
134
+ export { BillingConfig, type CheckoutRequestBody, type CustomerPortalRequestBody, PriceInterval, type StripeHandlerConfig, type StripeWebhookCallbacks, type User, createStripeHandler };
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ module.exports = __toCommonJS(index_exports);
37
37
  // src/handler.ts
38
38
  var import_stripe_sync_engine = require("@supabase/stripe-sync-engine");
39
39
  var import_stripe = __toESM(require("stripe"));
40
+ var import_pg = require("pg");
40
41
 
41
42
  // src/utils.ts
42
43
  var getMode = (stripeKey) => {
@@ -60,9 +61,12 @@ function createStripeHandler(config = {}) {
60
61
  successUrl: defaultSuccessUrl,
61
62
  cancelUrl: defaultCancelUrl,
62
63
  automaticTax = true,
63
- callbacks
64
+ callbacks,
65
+ mapUserIdToStripeCustomerId,
66
+ getUser
64
67
  } = config;
65
68
  const stripe = new import_stripe.default(stripeSecretKey);
69
+ const pool = databaseUrl ? new import_pg.Pool({ connectionString: databaseUrl }) : null;
66
70
  const sync = databaseUrl ? new import_stripe_sync_engine.StripeSync({
67
71
  poolConfig: {
68
72
  connectionString: databaseUrl
@@ -71,6 +75,47 @@ function createStripeHandler(config = {}) {
71
75
  stripeSecretKey,
72
76
  stripeWebhookSecret
73
77
  }) : null;
78
+ async function resolveStripeCustomerId(options) {
79
+ const { user, createIfNotFound } = options;
80
+ const { id: userId, name, email } = user;
81
+ if (pool) {
82
+ const result = await pool.query(
83
+ `SELECT stripe_customer_id FROM ${schema}.user_stripe_customer_map WHERE user_id = $1`,
84
+ [userId]
85
+ );
86
+ if (result.rows.length > 0) {
87
+ return result.rows[0].stripe_customer_id;
88
+ }
89
+ }
90
+ if (mapUserIdToStripeCustomerId) {
91
+ const customerId = await mapUserIdToStripeCustomerId(userId);
92
+ if (customerId) {
93
+ return customerId;
94
+ }
95
+ }
96
+ if (createIfNotFound) {
97
+ const customerParams = {
98
+ metadata: { user_id: userId }
99
+ };
100
+ if (name) {
101
+ customerParams.name = name;
102
+ }
103
+ if (email) {
104
+ customerParams.email = email;
105
+ }
106
+ const customer = await stripe.customers.create(customerParams);
107
+ if (pool) {
108
+ await pool.query(
109
+ `INSERT INTO ${schema}.user_stripe_customer_map (user_id, stripe_customer_id)
110
+ VALUES ($1, $2)
111
+ ON CONFLICT (user_id) DO UPDATE SET stripe_customer_id = $2, updated_at = now()`,
112
+ [userId, customer.id]
113
+ );
114
+ }
115
+ return customer.id;
116
+ }
117
+ return null;
118
+ }
74
119
  function resolvePriceId(body, mode) {
75
120
  if (body.priceId) {
76
121
  return body.priceId;
@@ -133,11 +178,25 @@ function createStripeHandler(config = {}) {
133
178
  cancel_url: cancelUrl,
134
179
  automatic_tax: { enabled: automaticTax }
135
180
  };
136
- if (body.customerEmail) {
137
- sessionParams.customer_email = body.customerEmail;
138
- }
181
+ let customerId = null;
139
182
  if (body.customerId) {
140
- sessionParams.customer = body.customerId;
183
+ customerId = body.customerId;
184
+ } else {
185
+ let user = body.user;
186
+ if (!user && getUser) {
187
+ user = await getUser(request) ?? void 0;
188
+ }
189
+ if (user) {
190
+ customerId = await resolveStripeCustomerId({
191
+ user,
192
+ createIfNotFound: true
193
+ });
194
+ }
195
+ }
196
+ if (customerId) {
197
+ sessionParams.customer = customerId;
198
+ } else if (body.customerEmail) {
199
+ sessionParams.customer_email = body.customerEmail;
141
200
  }
142
201
  if (body.metadata) {
143
202
  sessionParams.metadata = body.metadata;
@@ -212,6 +271,56 @@ function createStripeHandler(config = {}) {
212
271
  return new Response(message, { status: 500 });
213
272
  }
214
273
  }
274
+ async function handleCustomerPortal(request) {
275
+ try {
276
+ const body = await request.json();
277
+ let customerId = null;
278
+ if (body.stripe_customer_id) {
279
+ customerId = body.stripe_customer_id;
280
+ } else {
281
+ let user = body.user;
282
+ if (!user && getUser) {
283
+ user = await getUser(request) ?? void 0;
284
+ }
285
+ if (user) {
286
+ customerId = await resolveStripeCustomerId({
287
+ user,
288
+ createIfNotFound: false
289
+ });
290
+ }
291
+ }
292
+ if (!customerId) {
293
+ return new Response(
294
+ JSON.stringify({
295
+ error: "Provide either stripe_customer_id or user. Alternatively, configure getUser to extract user from the request."
296
+ }),
297
+ { status: 400, headers: { "Content-Type": "application/json" } }
298
+ );
299
+ }
300
+ const origin = request.headers.get("origin") || "";
301
+ const returnUrl = body.returnUrl || `${origin}/`;
302
+ const session = await stripe.billingPortal.sessions.create({
303
+ customer: customerId,
304
+ return_url: returnUrl
305
+ });
306
+ const acceptHeader = request.headers.get("accept") || "";
307
+ if (acceptHeader.includes("application/json")) {
308
+ return new Response(JSON.stringify({ url: session.url }), {
309
+ status: 200,
310
+ headers: { "Content-Type": "application/json" }
311
+ });
312
+ }
313
+ return Response.redirect(session.url, 303);
314
+ } catch (err) {
315
+ console.error("Customer portal error:", err);
316
+ const message = err instanceof Error ? err.message : "Unknown error";
317
+ const status = err && typeof err === "object" && "statusCode" in err ? err.statusCode : 500;
318
+ return new Response(JSON.stringify({ error: message }), {
319
+ status,
320
+ headers: { "Content-Type": "application/json" }
321
+ });
322
+ }
323
+ }
215
324
  return async function handler(request) {
216
325
  const url = new URL(request.url);
217
326
  const pathSegments = url.pathname.split("/").filter(Boolean);
@@ -227,10 +336,12 @@ function createStripeHandler(config = {}) {
227
336
  return handleCheckout(request);
228
337
  case "webhook":
229
338
  return handleWebhook(request);
339
+ case "customer_portal":
340
+ return handleCustomerPortal(request);
230
341
  default:
231
342
  return new Response(
232
343
  JSON.stringify({
233
- error: `Unknown action: ${action}. Supported: checkout, webhook`
344
+ error: `Unknown action: ${action}. Supported: checkout, webhook, customer_portal`
234
345
  }),
235
346
  { status: 404, headers: { "Content-Type": "application/json" } }
236
347
  );
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/handler.ts
2
2
  import { StripeSync } from "@supabase/stripe-sync-engine";
3
3
  import Stripe from "stripe";
4
+ import { Pool } from "pg";
4
5
 
5
6
  // src/utils.ts
6
7
  var getMode = (stripeKey) => {
@@ -24,9 +25,12 @@ function createStripeHandler(config = {}) {
24
25
  successUrl: defaultSuccessUrl,
25
26
  cancelUrl: defaultCancelUrl,
26
27
  automaticTax = true,
27
- callbacks
28
+ callbacks,
29
+ mapUserIdToStripeCustomerId,
30
+ getUser
28
31
  } = config;
29
32
  const stripe = new Stripe(stripeSecretKey);
33
+ const pool = databaseUrl ? new Pool({ connectionString: databaseUrl }) : null;
30
34
  const sync = databaseUrl ? new StripeSync({
31
35
  poolConfig: {
32
36
  connectionString: databaseUrl
@@ -35,6 +39,47 @@ function createStripeHandler(config = {}) {
35
39
  stripeSecretKey,
36
40
  stripeWebhookSecret
37
41
  }) : null;
42
+ async function resolveStripeCustomerId(options) {
43
+ const { user, createIfNotFound } = options;
44
+ const { id: userId, name, email } = user;
45
+ if (pool) {
46
+ const result = await pool.query(
47
+ `SELECT stripe_customer_id FROM ${schema}.user_stripe_customer_map WHERE user_id = $1`,
48
+ [userId]
49
+ );
50
+ if (result.rows.length > 0) {
51
+ return result.rows[0].stripe_customer_id;
52
+ }
53
+ }
54
+ if (mapUserIdToStripeCustomerId) {
55
+ const customerId = await mapUserIdToStripeCustomerId(userId);
56
+ if (customerId) {
57
+ return customerId;
58
+ }
59
+ }
60
+ if (createIfNotFound) {
61
+ const customerParams = {
62
+ metadata: { user_id: userId }
63
+ };
64
+ if (name) {
65
+ customerParams.name = name;
66
+ }
67
+ if (email) {
68
+ customerParams.email = email;
69
+ }
70
+ const customer = await stripe.customers.create(customerParams);
71
+ if (pool) {
72
+ await pool.query(
73
+ `INSERT INTO ${schema}.user_stripe_customer_map (user_id, stripe_customer_id)
74
+ VALUES ($1, $2)
75
+ ON CONFLICT (user_id) DO UPDATE SET stripe_customer_id = $2, updated_at = now()`,
76
+ [userId, customer.id]
77
+ );
78
+ }
79
+ return customer.id;
80
+ }
81
+ return null;
82
+ }
38
83
  function resolvePriceId(body, mode) {
39
84
  if (body.priceId) {
40
85
  return body.priceId;
@@ -97,11 +142,25 @@ function createStripeHandler(config = {}) {
97
142
  cancel_url: cancelUrl,
98
143
  automatic_tax: { enabled: automaticTax }
99
144
  };
100
- if (body.customerEmail) {
101
- sessionParams.customer_email = body.customerEmail;
102
- }
145
+ let customerId = null;
103
146
  if (body.customerId) {
104
- sessionParams.customer = body.customerId;
147
+ customerId = body.customerId;
148
+ } else {
149
+ let user = body.user;
150
+ if (!user && getUser) {
151
+ user = await getUser(request) ?? void 0;
152
+ }
153
+ if (user) {
154
+ customerId = await resolveStripeCustomerId({
155
+ user,
156
+ createIfNotFound: true
157
+ });
158
+ }
159
+ }
160
+ if (customerId) {
161
+ sessionParams.customer = customerId;
162
+ } else if (body.customerEmail) {
163
+ sessionParams.customer_email = body.customerEmail;
105
164
  }
106
165
  if (body.metadata) {
107
166
  sessionParams.metadata = body.metadata;
@@ -176,6 +235,56 @@ function createStripeHandler(config = {}) {
176
235
  return new Response(message, { status: 500 });
177
236
  }
178
237
  }
238
+ async function handleCustomerPortal(request) {
239
+ try {
240
+ const body = await request.json();
241
+ let customerId = null;
242
+ if (body.stripe_customer_id) {
243
+ customerId = body.stripe_customer_id;
244
+ } else {
245
+ let user = body.user;
246
+ if (!user && getUser) {
247
+ user = await getUser(request) ?? void 0;
248
+ }
249
+ if (user) {
250
+ customerId = await resolveStripeCustomerId({
251
+ user,
252
+ createIfNotFound: false
253
+ });
254
+ }
255
+ }
256
+ if (!customerId) {
257
+ return new Response(
258
+ JSON.stringify({
259
+ error: "Provide either stripe_customer_id or user. Alternatively, configure getUser to extract user from the request."
260
+ }),
261
+ { status: 400, headers: { "Content-Type": "application/json" } }
262
+ );
263
+ }
264
+ const origin = request.headers.get("origin") || "";
265
+ const returnUrl = body.returnUrl || `${origin}/`;
266
+ const session = await stripe.billingPortal.sessions.create({
267
+ customer: customerId,
268
+ return_url: returnUrl
269
+ });
270
+ const acceptHeader = request.headers.get("accept") || "";
271
+ if (acceptHeader.includes("application/json")) {
272
+ return new Response(JSON.stringify({ url: session.url }), {
273
+ status: 200,
274
+ headers: { "Content-Type": "application/json" }
275
+ });
276
+ }
277
+ return Response.redirect(session.url, 303);
278
+ } catch (err) {
279
+ console.error("Customer portal error:", err);
280
+ const message = err instanceof Error ? err.message : "Unknown error";
281
+ const status = err && typeof err === "object" && "statusCode" in err ? err.statusCode : 500;
282
+ return new Response(JSON.stringify({ error: message }), {
283
+ status,
284
+ headers: { "Content-Type": "application/json" }
285
+ });
286
+ }
287
+ }
179
288
  return async function handler(request) {
180
289
  const url = new URL(request.url);
181
290
  const pathSegments = url.pathname.split("/").filter(Boolean);
@@ -191,10 +300,12 @@ function createStripeHandler(config = {}) {
191
300
  return handleCheckout(request);
192
301
  case "webhook":
193
302
  return handleWebhook(request);
303
+ case "customer_portal":
304
+ return handleCustomerPortal(request);
194
305
  default:
195
306
  return new Response(
196
307
  JSON.stringify({
197
- error: `Unknown action: ${action}. Supported: checkout, webhook`
308
+ error: `Unknown action: ${action}. Supported: checkout, webhook, customer_portal`
198
309
  }),
199
310
  { status: 404, headers: { "Content-Type": "application/json" } }
200
311
  );
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.9",
4
4
  "author": "Ramon Garate",
5
5
  "description": "Stripe integration without dealing with webhooks",
6
6
  "main": "./dist/index.js",
@@ -34,13 +34,15 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@supabase/stripe-sync-engine": "^0.47.0",
37
- "dotenv": "^17.2.3"
37
+ "dotenv": "^17.2.3",
38
+ "pg": "^8.13.1"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "stripe": ">=14.0.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^22.10.2",
45
+ "@types/pg": "^8.16.0",
44
46
  "stripe": "^17.4.0",
45
47
  "tsup": "^8.3.5",
46
48
  "typescript": "^5.7.2"