webspresso 0.0.93 → 0.0.94

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.
@@ -0,0 +1,158 @@
1
+ # Polar.sh Billing Plugin for Webspresso
2
+
3
+ Production-ready Polar subscription billing for Webspresso. Zero extra dependencies (`crypto` + native `fetch` only).
4
+
5
+ ## Quick start
6
+
7
+ ### 1. Environment
8
+
9
+ Copy `plugins/polar/.env.example` → `.env`:
10
+
11
+ ```bash
12
+ POLAR_ACCESS_TOKEN=polar_oat_...
13
+ POLAR_WEBHOOK_SECRET=whsec_...
14
+ POLAR_PRO_PRODUCT_ID=<product-uuid-not-price-id>
15
+ POLAR_ORG_SLUG=your-org
16
+ POLAR_SANDBOX=false
17
+ BASE_URL=https://example.com
18
+ ```
19
+
20
+ ### 2. Migration
21
+
22
+ ```bash
23
+ webspresso polar:migrate
24
+ webspresso db:migrate
25
+ ```
26
+
27
+ ### 3. Plugin config (~3 lines)
28
+
29
+ ```js
30
+ const { polarPlugin, rateLimitPlugin } = require('webspresso/plugins');
31
+ const { quickAuth } = require('webspresso/core/auth');
32
+
33
+ createApp({
34
+ db,
35
+ auth: quickAuth({ db, jwt: true }),
36
+ plugins: [
37
+ rateLimitPlugin(), // optional but recommended — polar applies per-route limits
38
+ polarPlugin({
39
+ db,
40
+ userModel: 'User',
41
+ plans: { pro_monthly: process.env.POLAR_PRO_PRODUCT_ID },
42
+ tierMapping: { pro: ['pro_monthly'], free: [] },
43
+ }),
44
+ ],
45
+ });
46
+ ```
47
+
48
+ That's it — routes, webhooks, checkout, portal, and status API are auto-mounted.
49
+
50
+ ### 4. Polar Dashboard webhook
51
+
52
+ - **URL:** `https://your-domain.com/api/v1/polar/webhook`
53
+ - **Format:** Raw (not Slack/Discord)
54
+ - **Events:** `customer.state_changed`, `subscription.*`, `order.*`, `checkout.updated`
55
+
56
+ ## Routes (defaults, all configurable)
57
+
58
+ | Method | Path | Auth | Behavior |
59
+ |--------|------|------|----------|
60
+ | POST | `/api/v1/polar/webhook` | HMAC signature | Process Polar events |
61
+ | GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect |
62
+ | GET | `/settings/billing/portal` | session/JWT | Customer portal redirect |
63
+ | GET | `/api/v1/billing/status` | session/JWT | Sync + JSON billing status |
64
+
65
+ ## Template usage
66
+
67
+ ```njk
68
+ {% if user.tier == 'pro' %}
69
+ <p>Pro until {{ user.polar_current_period_end | dateLabel }}</p>
70
+ <a href="/settings/billing/portal">Manage subscription</a>
71
+ {% else %}
72
+ <form method="post" action="/settings/billing/checkout">
73
+ <button type="submit">Upgrade to Pro</button>
74
+ </form>
75
+ {% endif %}
76
+ ```
77
+
78
+ ## SDK exports (`plugin.api` or direct imports)
79
+
80
+ ```js
81
+ const polar = polarPlugin({ db });
82
+ polar.api.verifyPolarWebhook(raw, headers, secret);
83
+ polar.api.syncPolarBillingForUser(user, knex);
84
+ polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
85
+ polar.api.handlePolarWebhookEvent(event, knex);
86
+ polar.api.generateMigration();
87
+ polar.api.parseEnv(process.env);
88
+ ```
89
+
90
+ ## Webhook raw body (production)
91
+
92
+ For strict HMAC verification, mount **before** `express.json()`:
93
+
94
+ ```js
95
+ const { createWebhookRawBodyMiddleware } = require('webspresso/plugins/polar/src/webhooks');
96
+
97
+ app.use(createWebhookRawBodyMiddleware('/api/v1/polar/webhook'));
98
+ app.use(express.json());
99
+ ```
100
+
101
+ Without this, the plugin falls back to `JSON.stringify(req.body)` (works for most cases; key order edge cases possible).
102
+
103
+ ## Rate limiting
104
+
105
+ Enabled by default (`rateLimit: true`). Requires `rateLimitPlugin()` registered **before** `polarPlugin`:
106
+
107
+ | Route | Default |
108
+ |-------|---------|
109
+ | Webhook | 120 req/min (by IP) |
110
+ | Checkout | 5 req/min (by user) |
111
+ | Portal | 10 req/min (by user) |
112
+ | Status | 60 req/min (by user) |
113
+
114
+ ```js
115
+ polarPlugin({ db, rateLimit: false }); // disable
116
+ polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
117
+ ```
118
+
119
+ ## Optional hooks
120
+
121
+ ```js
122
+ polarPlugin({
123
+ db,
124
+ hooks: {
125
+ onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
126
+ // Custom tier logic (default: updates users.* polar columns)
127
+ return knex('users').where('id', user.id).update(update);
128
+ },
129
+ },
130
+ syncMiddleware: {
131
+ when: (req) => req.query.tab === 'billing',
132
+ },
133
+ });
134
+ ```
135
+
136
+ ## CSP (Helmet)
137
+
138
+ ```js
139
+ const { polarCspDirectives } = require('webspresso/plugins/polar/src/urls');
140
+
141
+ createApp({
142
+ helmet: {
143
+ contentSecurityPolicy: {
144
+ directives: {
145
+ ...polarCspDirectives(),
146
+ },
147
+ },
148
+ },
149
+ });
150
+ ```
151
+
152
+ Or rely on the plugin's built-in `csp` export (auto-merged by plugin manager).
153
+
154
+ ## Non-goals (v1)
155
+
156
+ - Admin UI (use Polar dashboard)
157
+ - Multi-org / team seat billing
158
+ - `@polar-sh/sdk` dependency
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Polar.sh billing plugin for Webspresso
5
+ * @module plugins/polar
6
+ */
7
+
8
+ const { resolvePolarConfig } = require('./src/config');
9
+ const { polarEnvSchema, parsePolarEnv } = require('./src/env.schema');
10
+ const { polarApiRequest } = require('./src/api');
11
+ const { verifyPolarWebhook, handlePolarWebhookEvent, createWebhookRawBodyMiddleware } = require('./src/webhooks');
12
+ const { getPolarCheckoutUrl } = require('./src/checkout');
13
+ const { getPolarPortalUrl } = require('./src/portal');
14
+ const { syncPolarBillingForUser } = require('./src/sync');
15
+ const {
16
+ resolveUserFromPolarData,
17
+ buildCheckoutMetadata,
18
+ isProSubscriptionStatus,
19
+ userHasProTier,
20
+ pickBestActiveSubscription,
21
+ } = require('./src/user-resolver');
22
+ const {
23
+ isValidCheckoutRedirectUrl,
24
+ isValidPortalRedirectUrl,
25
+ extractCheckoutUrlFromResponse,
26
+ polarCspDirectives,
27
+ } = require('./src/urls');
28
+ const { generatePolarMigration } = require('./src/migration');
29
+ const { polarSyncMiddleware } = require('./src/middleware');
30
+ const { registerNunjucksFilters, dateLabel } = require('./src/nunjucks-filters');
31
+ const {
32
+ createInjectDb,
33
+ createWebhookHandler,
34
+ createCheckoutHandler,
35
+ createPortalHandler,
36
+ createStatusHandler,
37
+ createRequireAuth,
38
+ } = require('./src/routes');
39
+ const { resolvePolarRateLimiters, DEFAULT_RATE_LIMITS } = require('./src/rate-limit');
40
+
41
+ /**
42
+ * @param {Object} options
43
+ * @param {import('../../core/orm/database').Database} options.db
44
+ * @param {string} [options.userModel='User']
45
+ * @param {string} [options.userTable='users']
46
+ * @param {Record<string, string>} [options.plans]
47
+ * @param {Record<string, string[]>} [options.tierMapping]
48
+ * @param {Object} [options.routes]
49
+ * @param {Object} [options.urls]
50
+ * @param {Object} [options.fields]
51
+ * @param {Object} [options.hooks]
52
+ * @param {Function} [options.requireAuth]
53
+ * @param {boolean} [options.enabled=true]
54
+ */
55
+ function polarPlugin(options = {}) {
56
+ const config = resolvePolarConfig(options);
57
+ let runtimeConfig = config;
58
+
59
+ const sdk = {
60
+ verifyPolarWebhook,
61
+ polarApiRequest: (path, opts = {}) => polarApiRequest(path, {
62
+ ...opts,
63
+ accessToken: opts.accessToken || runtimeConfig.accessToken,
64
+ sandbox: opts.sandbox !== undefined ? opts.sandbox : runtimeConfig.sandbox,
65
+ }),
66
+ getPolarCheckoutUrl: (params) => getPolarCheckoutUrl({ ...params, config: runtimeConfig }),
67
+ getPolarPortalUrl: (user, baseUrl) => getPolarPortalUrl(user, baseUrl, runtimeConfig),
68
+ syncPolarBillingForUser: (user, knex) => syncPolarBillingForUser(user, knex, runtimeConfig),
69
+ handlePolarWebhookEvent: (event, knex, hooks) => handlePolarWebhookEvent(event, knex, runtimeConfig, hooks),
70
+ resolveUserFromPolarData: (data, knex) => resolveUserFromPolarData(data, knex, runtimeConfig),
71
+ extractCheckoutUrlFromResponse: (data) => extractCheckoutUrlFromResponse(data, runtimeConfig.sandbox),
72
+ isValidCheckoutRedirectUrl,
73
+ isValidPortalRedirectUrl,
74
+ buildCheckoutMetadata: (user, customFields) => buildCheckoutMetadata(user, runtimeConfig, customFields),
75
+ userHasProTier: (user) => userHasProTier(user, runtimeConfig),
76
+ isProSubscriptionStatus,
77
+ pickBestActiveSubscription: (subs) => pickBestActiveSubscription(subs, runtimeConfig),
78
+ generateMigration: (opts) => generatePolarMigration({ ...opts, fields: runtimeConfig.fields }),
79
+ polarSyncMiddleware: (opts) => polarSyncMiddleware(runtimeConfig, opts),
80
+ polarCspDirectives,
81
+ createWebhookRawBodyMiddleware,
82
+ registerNunjucksFilters,
83
+ dateLabel,
84
+ parseEnv: parsePolarEnv,
85
+ envSchema: polarEnvSchema,
86
+ getConfig: () => ({ ...runtimeConfig }),
87
+ defaultRateLimits: DEFAULT_RATE_LIMITS,
88
+ };
89
+
90
+ return {
91
+ name: 'polar',
92
+ version: '1.0.0',
93
+ description: 'Polar.sh subscription billing — checkout, portal, webhooks, and tier sync',
94
+
95
+ csp: polarCspDirectives(),
96
+
97
+ api: sdk,
98
+
99
+ register(ctx) {
100
+ runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
101
+ if (!runtimeConfig.enabled) return;
102
+
103
+ registerNunjucksFilters(ctx.nunjucksEnv);
104
+
105
+ if (runtimeConfig.syncMiddleware) {
106
+ ctx.app.use(polarSyncMiddleware(runtimeConfig));
107
+ }
108
+ },
109
+
110
+ onRoutesReady(ctx) {
111
+ runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
112
+ if (!runtimeConfig.enabled) return;
113
+
114
+ const db = runtimeConfig.db || ctx.db;
115
+ if (!db) {
116
+ console.warn('[polar] Skipping routes: no database (pass options.db or createApp({ db }))');
117
+ return;
118
+ }
119
+
120
+ const injectDb = createInjectDb(db);
121
+ const requireAuth = createRequireAuth(runtimeConfig, ctx);
122
+ const { routes } = runtimeConfig;
123
+ const rl = resolvePolarRateLimiters(ctx, runtimeConfig);
124
+
125
+ const chain = (...handlers) => handlers.filter(Boolean);
126
+
127
+ ctx.addRoute('post', routes.webhook, ...chain(injectDb, rl.webhook, createWebhookHandler(runtimeConfig)));
128
+ ctx.addRoute('post', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
129
+ ctx.addRoute('get', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
130
+ ctx.addRoute('get', routes.portal, ...chain(injectDb, requireAuth, rl.portal, createPortalHandler(runtimeConfig)));
131
+ ctx.addRoute('get', routes.status, ...chain(injectDb, requireAuth, rl.status, createStatusHandler(runtimeConfig)));
132
+
133
+ if (process.env.NODE_ENV !== 'production') {
134
+ console.log(` Polar billing: POST ${routes.webhook}`);
135
+ console.log(` Polar billing: GET/POST ${routes.checkout}`);
136
+ console.log(` Polar billing: GET ${routes.portal}`);
137
+ console.log(` Polar billing: GET ${routes.status}`);
138
+ }
139
+ },
140
+ };
141
+ }
142
+
143
+ module.exports = polarPlugin;
144
+ module.exports.polarPlugin = polarPlugin;
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ function getPolarApiBase(sandbox) {
4
+ return sandbox ? 'https://sandbox-api.polar.sh/v1' : 'https://api.polar.sh/v1';
5
+ }
6
+
7
+ /**
8
+ * @param {string} path
9
+ * @param {Object} opts
10
+ * @param {string} opts.method
11
+ * @param {Object} [opts.body]
12
+ * @param {string} opts.accessToken
13
+ * @param {boolean} [opts.sandbox=false]
14
+ */
15
+ async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false } = {}) {
16
+ if (!accessToken) {
17
+ throw new Error('POLAR_ACCESS_TOKEN is not configured');
18
+ }
19
+
20
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
21
+ const res = await fetch(`${getPolarApiBase(sandbox)}${normalizedPath}`, {
22
+ method,
23
+ headers: {
24
+ Authorization: `Bearer ${accessToken}`,
25
+ 'Content-Type': 'application/json',
26
+ Accept: 'application/json',
27
+ },
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+
31
+ const raw = await res.text();
32
+ let data = null;
33
+ if (raw) {
34
+ try {
35
+ data = JSON.parse(raw);
36
+ } catch {
37
+ data = { raw };
38
+ }
39
+ }
40
+
41
+ if (!res.ok) {
42
+ const detail = typeof data?.detail === 'string'
43
+ ? data.detail
44
+ : JSON.stringify(data?.detail || data?.error || data?.error_description || raw).slice(0, 500);
45
+ throw new Error(`Polar API ${res.status}: ${detail}`);
46
+ }
47
+
48
+ return data || {};
49
+ }
50
+
51
+ module.exports = {
52
+ getPolarApiBase,
53
+ polarApiRequest,
54
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { polarApiRequest } = require('./api');
4
+ const { buildCheckoutMetadata } = require('./user-resolver');
5
+ const {
6
+ extractCheckoutUrlFromResponse,
7
+ isValidCheckoutRedirectUrl,
8
+ getPolarBuyBase,
9
+ } = require('./urls');
10
+
11
+ function resolvePlanProductId(plan, config) {
12
+ if (config.plans[plan]) return config.plans[plan];
13
+ if (plan === 'pro_yearly') {
14
+ return config.plans.pro_yearly || config.plans.pro_monthly;
15
+ }
16
+ return config.plans.pro_monthly;
17
+ }
18
+
19
+ function resolveHostedCheckoutBaseUrl(config) {
20
+ if (config.checkoutUrl) return config.checkoutUrl;
21
+
22
+ const productId = resolvePlanProductId('pro_monthly', config);
23
+ if (productId && productId.startsWith('polar_cl_')) {
24
+ return `${getPolarBuyBase(config.sandbox)}/${productId}`;
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ function buildHostedCheckoutUrl(hostedUrl, user, config) {
31
+ const url = new URL(hostedUrl);
32
+ const f = config.fields;
33
+ if (user[f.email]) url.searchParams.set('email', user[f.email]);
34
+ url.searchParams.set('metadata[user_id]', String(user[f.id]));
35
+ if (user[f.publicId]) url.searchParams.set('metadata[public_id]', user[f.publicId]);
36
+ return url.toString();
37
+ }
38
+
39
+ async function createCheckoutSessionViaApi({ user, productId, baseUrl, config }) {
40
+ const metadata = buildCheckoutMetadata(user, config);
41
+ const f = config.fields;
42
+ const shared = {
43
+ success_url: config.urls.success.startsWith('http')
44
+ ? config.urls.success
45
+ : `${baseUrl}${config.urls.success}`,
46
+ return_url: config.urls.return.startsWith('http')
47
+ ? config.urls.return
48
+ : `${baseUrl}${config.urls.return}`,
49
+ metadata,
50
+ external_customer_id: String(user[f.publicId] || user[f.id]),
51
+ };
52
+ if (user[f.email]) shared.customer_email = user[f.email];
53
+
54
+ const attempts = [{ products: [productId], ...shared }];
55
+
56
+ if (config.priceId) {
57
+ attempts.push({
58
+ product_id: productId,
59
+ product_price_id: config.priceId,
60
+ customer_email: user[f.email] || undefined,
61
+ success_url: shared.success_url,
62
+ metadata,
63
+ });
64
+ }
65
+
66
+ const apiOpts = { accessToken: config.accessToken, sandbox: config.sandbox };
67
+ let lastError = null;
68
+
69
+ for (const payload of attempts) {
70
+ try {
71
+ const data = await polarApiRequest('/checkouts/', { method: 'POST', body: payload, ...apiOpts });
72
+ const checkoutUrl = extractCheckoutUrlFromResponse(data, config.sandbox);
73
+ if (checkoutUrl) return checkoutUrl;
74
+ const received = data.url || data.checkout_url || (data.client_secret ? 'client_secret only' : 'none');
75
+ lastError = new Error(`Polar checkout response did not include a valid checkout URL (received: ${received})`);
76
+ } catch (err) {
77
+ lastError = err;
78
+ }
79
+ }
80
+
81
+ throw lastError || new Error('Unable to create Polar checkout session');
82
+ }
83
+
84
+ async function getPolarCheckoutUrl({ user, plan = 'pro_monthly', baseUrl, config }) {
85
+ const productId = resolvePlanProductId(plan, config);
86
+
87
+ if (config.accessToken && productId && !productId.startsWith('polar_cl_')) {
88
+ try {
89
+ return await createCheckoutSessionViaApi({ user, productId, baseUrl, config });
90
+ } catch (err) {
91
+ console.error('[polar] Checkout API error:', err.message);
92
+ }
93
+ }
94
+
95
+ const hostedUrl = resolveHostedCheckoutBaseUrl(config);
96
+ if (hostedUrl && isValidCheckoutRedirectUrl(hostedUrl)) {
97
+ return buildHostedCheckoutUrl(hostedUrl, user, config);
98
+ }
99
+
100
+ throw new Error(
101
+ 'Polar checkout is not configured. Set POLAR_ACCESS_TOKEN + POLAR_PRO_PRODUCT_ID (product UUID), or POLAR_CHECKOUT_URL (https://buy.polar.sh/polar_cl_...).'
102
+ );
103
+ }
104
+
105
+ module.exports = {
106
+ resolvePlanProductId,
107
+ resolveHostedCheckoutBaseUrl,
108
+ buildHostedCheckoutUrl,
109
+ createCheckoutSessionViaApi,
110
+ getPolarCheckoutUrl,
111
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const { parsePolarEnv } = require('./env.schema');
4
+
5
+ const DEFAULT_FIELDS = {
6
+ id: 'id',
7
+ email: 'email',
8
+ publicId: 'public_id',
9
+ tier: 'tier',
10
+ polarCustomerId: 'polar_customer_id',
11
+ polarSubscriptionId: 'polar_subscription_id',
12
+ polarStatus: 'polar_status',
13
+ polarCurrentPeriodEnd: 'polar_current_period_end',
14
+ polarCancelAtPeriodEnd: 'polar_cancel_at_period_end',
15
+ };
16
+
17
+ const DEFAULT_ROUTES = {
18
+ webhook: '/api/v1/polar/webhook',
19
+ checkout: '/settings/billing/checkout',
20
+ portal: '/settings/billing/portal',
21
+ status: '/api/v1/billing/status',
22
+ };
23
+
24
+ const DEFAULT_URLS = {
25
+ success: '/settings?tab=billing&upgraded=1',
26
+ return: '/settings?tab=billing',
27
+ alreadySubscribed: '/settings?tab=billing&success=already_pro',
28
+ login: '/login?error=required',
29
+ };
30
+
31
+ /**
32
+ * @param {Object} options - polarPlugin options
33
+ * @returns {import('./types').PolarConfig}
34
+ */
35
+ function resolvePolarConfig(options = {}) {
36
+ const env = parsePolarEnv({ ...process.env, ...options.env });
37
+
38
+ const fields = { ...DEFAULT_FIELDS, ...(options.fields || {}) };
39
+ const tierMapping = options.tierMapping || { pro: ['pro_monthly'], free: [] };
40
+ const plans = {
41
+ ...(options.plans || {}),
42
+ };
43
+
44
+ if (!plans.pro_monthly && env.POLAR_PRO_PRODUCT_ID) {
45
+ plans.pro_monthly = env.POLAR_PRO_PRODUCT_ID;
46
+ }
47
+ if (!plans.pro_yearly && env.POLAR_PRO_YEARLY_PRODUCT_ID) {
48
+ plans.pro_yearly = env.POLAR_PRO_YEARLY_PRODUCT_ID;
49
+ }
50
+
51
+ const proTiers = Object.keys(tierMapping).filter(
52
+ (t) => t !== 'free' && Array.isArray(tierMapping[t]) && tierMapping[t].length > 0
53
+ );
54
+
55
+ return {
56
+ db: options.db || null,
57
+ userModel: options.userModel || 'User',
58
+ userTable: options.userTable || 'users',
59
+ fields,
60
+ plans,
61
+ tierMapping,
62
+ proTiers,
63
+ proTierName: options.proTierName || proTiers[0] || 'pro',
64
+ freeTierName: options.freeTierName || 'free',
65
+ routes: { ...DEFAULT_ROUTES, ...(options.routes || {}) },
66
+ urls: { ...DEFAULT_URLS, ...(options.urls || {}) },
67
+ hooks: options.hooks || {},
68
+ requireAuth: options.requireAuth || null,
69
+ syncMiddleware: options.syncMiddleware || null,
70
+ rateLimit: options.rateLimit !== undefined ? options.rateLimit : true,
71
+ env,
72
+ accessToken: options.accessToken || env.POLAR_ACCESS_TOKEN || null,
73
+ webhookSecret: options.webhookSecret || env.POLAR_WEBHOOK_SECRET || null,
74
+ orgSlug: options.orgSlug || env.POLAR_ORG_SLUG || null,
75
+ checkoutUrl: options.checkoutUrl || env.POLAR_CHECKOUT_URL || null,
76
+ priceId: options.priceId || env.POLAR_PRO_PRICE_ID || null,
77
+ sandbox: options.sandbox !== undefined ? options.sandbox : env.POLAR_SANDBOX === true,
78
+ baseUrl: options.baseUrl || env.BASE_URL || null,
79
+ enabled: options.enabled !== false,
80
+ };
81
+ }
82
+
83
+ module.exports = {
84
+ DEFAULT_FIELDS,
85
+ DEFAULT_ROUTES,
86
+ DEFAULT_URLS,
87
+ resolvePolarConfig,
88
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+
5
+ const polarEnvSchema = z.object({
6
+ POLAR_ACCESS_TOKEN: z.string().min(1).optional(),
7
+ POLAR_WEBHOOK_SECRET: z.string().min(1).optional(),
8
+ POLAR_PRO_PRODUCT_ID: z.string().optional(),
9
+ POLAR_PRO_YEARLY_PRODUCT_ID: z.string().optional(),
10
+ POLAR_PRO_PRICE_ID: z.string().optional(),
11
+ POLAR_ORG_SLUG: z.string().optional(),
12
+ POLAR_SANDBOX: z
13
+ .union([z.literal('true'), z.literal('false'), z.boolean()])
14
+ .optional()
15
+ .transform((v) => v === true || v === 'true'),
16
+ POLAR_CHECKOUT_URL: z.string().url().optional(),
17
+ BASE_URL: z.string().url().optional(),
18
+ });
19
+
20
+ /**
21
+ * Parse Polar-related env vars (process.env or overrides).
22
+ * @param {Record<string, string|undefined>} [source=process.env]
23
+ */
24
+ function parsePolarEnv(source = process.env) {
25
+ const parsed = polarEnvSchema.safeParse(source);
26
+ if (!parsed.success) {
27
+ const msg = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
28
+ throw new Error(`Invalid Polar env: ${msg}`);
29
+ }
30
+ return parsed.data;
31
+ }
32
+
33
+ module.exports = {
34
+ polarEnvSchema,
35
+ parsePolarEnv,
36
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { syncPolarBillingForUser } = require('./sync');
4
+
5
+ /**
6
+ * Optional middleware: sync billing from Polar on matching requests (webhook delay fallback).
7
+ * @param {import('./config').PolarConfig} config
8
+ * @param {Object} [opts]
9
+ * @param {(req: import('express').Request) => boolean} [opts.when]
10
+ */
11
+ function polarSyncMiddleware(config, opts = {}) {
12
+ const when = opts.when || config.syncMiddleware?.when || (() => false);
13
+
14
+ return async function polarSync(req, res, next) {
15
+ try {
16
+ if (!when(req) || !req.user) return next();
17
+
18
+ const knex = config.db?.knex || config.db;
19
+ if (!knex || !config.accessToken) return next();
20
+
21
+ const f = config.fields;
22
+ let user = req.user;
23
+ const dbUser = await knex(config.userTable).where(f.id, req.user[f.id] || req.user.id).first();
24
+ if (dbUser) user = dbUser;
25
+
26
+ req.user = await syncPolarBillingForUser(user, knex, config);
27
+ next();
28
+ } catch (err) {
29
+ console.error('[polar] Sync middleware error:', err.message);
30
+ next();
31
+ }
32
+ };
33
+ }
34
+
35
+ module.exports = {
36
+ polarSyncMiddleware,
37
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const { DEFAULT_FIELDS } = require('./config');
4
+
5
+ /**
6
+ * Generate idempotent Knex migration for Polar billing columns.
7
+ * @param {Object} [opts]
8
+ * @param {string} [opts.tableName='users']
9
+ * @param {Object} [opts.fields]
10
+ */
11
+ function generatePolarMigration(opts = {}) {
12
+ const tableName = opts.tableName || 'users';
13
+ const f = { ...DEFAULT_FIELDS, ...(opts.fields || {}) };
14
+
15
+ return `/**
16
+ * Migration: Add Polar.sh billing fields to ${tableName}
17
+ * Auto-generated by polar plugin — idempotent (safe to re-run)
18
+ */
19
+
20
+ exports.up = async function up(knex) {
21
+ const hasTable = await knex.schema.hasTable('${tableName}');
22
+ if (!hasTable) return;
23
+
24
+ const hasTier = await knex.schema.hasColumn('${tableName}', '${f.tier}');
25
+ if (!hasTier) {
26
+ await knex.schema.alterTable('${tableName}', (table) => {
27
+ table.string('${f.tier}', 32).notNullable().defaultTo('free').index();
28
+ table.string('${f.polarCustomerId}', 128).nullable().index();
29
+ table.string('${f.polarSubscriptionId}', 128).nullable().index();
30
+ table.string('${f.polarStatus}', 32).notNullable().defaultTo('none').index();
31
+ table.string('${f.polarCurrentPeriodEnd}', 64).nullable();
32
+ table.boolean('${f.polarCancelAtPeriodEnd}').notNullable().defaultTo(false);
33
+ });
34
+ }
35
+ };
36
+
37
+ exports.down = async function down(knex) {
38
+ const hasTable = await knex.schema.hasTable('${tableName}');
39
+ if (!hasTable) return;
40
+
41
+ const hasTier = await knex.schema.hasColumn('${tableName}', '${f.tier}');
42
+ if (hasTier) {
43
+ await knex.schema.alterTable('${tableName}', (table) => {
44
+ table.dropColumn('${f.tier}');
45
+ table.dropColumn('${f.polarCustomerId}');
46
+ table.dropColumn('${f.polarSubscriptionId}');
47
+ table.dropColumn('${f.polarStatus}');
48
+ table.dropColumn('${f.polarCurrentPeriodEnd}');
49
+ table.dropColumn('${f.polarCancelAtPeriodEnd}');
50
+ });
51
+ }
52
+ };
53
+ `;
54
+ }
55
+
56
+ module.exports = {
57
+ generatePolarMigration,
58
+ };