webspresso 0.0.93 → 0.0.95

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.
Files changed (36) hide show
  1. package/bin/commands/polar-migrate.js +50 -0
  2. package/bin/webspresso.js +2 -0
  3. package/core/auth/tokens.js +16 -0
  4. package/core/orm/cache/layer.js +1 -1
  5. package/core/orm/index.js +4 -1
  6. package/core/orm/transaction.js +3 -1
  7. package/core/ssr/stream.js +58 -14
  8. package/package.json +15 -4
  9. package/plugins/admin-panel/auth.js +10 -0
  10. package/plugins/content/index.js +5 -3
  11. package/plugins/data-exchange/import.js +76 -39
  12. package/plugins/index.js +2 -0
  13. package/plugins/mcp/transports/sse.js +23 -6
  14. package/plugins/polar/.env.example +12 -0
  15. package/plugins/polar/CHANGELOG.md +56 -0
  16. package/plugins/polar/README.md +181 -0
  17. package/plugins/polar/index.js +149 -0
  18. package/plugins/polar/src/api.js +54 -0
  19. package/plugins/polar/src/billing-hooks.js +51 -0
  20. package/plugins/polar/src/checkout.js +111 -0
  21. package/plugins/polar/src/config.js +90 -0
  22. package/plugins/polar/src/env.schema.js +36 -0
  23. package/plugins/polar/src/middleware.js +37 -0
  24. package/plugins/polar/src/migration.js +58 -0
  25. package/plugins/polar/src/nunjucks-filters.js +56 -0
  26. package/plugins/polar/src/portal.js +51 -0
  27. package/plugins/polar/src/rate-limit.js +138 -0
  28. package/plugins/polar/src/routes.js +213 -0
  29. package/plugins/polar/src/sync.js +138 -0
  30. package/plugins/polar/src/urls.js +141 -0
  31. package/plugins/polar/src/user-resolver.js +199 -0
  32. package/plugins/polar/src/webhooks.js +248 -0
  33. package/plugins/rate-limit/index.js +5 -1
  34. package/src/file-router.js +19 -2
  35. package/src/helpers.js +60 -13
  36. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
@@ -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
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ function dateLabel(value) {
4
+ if (!value) return '';
5
+ if (value instanceof Date) {
6
+ return Number.isNaN(value.getTime()) ? '' : value.toISOString().slice(0, 10);
7
+ }
8
+ const str = String(value);
9
+ if (/^\d{4}-\d{2}-\d{2}/.test(str)) return str.slice(0, 10);
10
+ const parsed = new Date(str);
11
+ return Number.isNaN(parsed.getTime()) ? str.slice(0, 10) : parsed.toISOString().slice(0, 10);
12
+ }
13
+
14
+ function createSafeTruncateFilter(originalTruncate) {
15
+ return function safeTruncate(value, length, end) {
16
+ if (value == null) return '';
17
+ if (value instanceof Date) {
18
+ return dateLabel(value);
19
+ }
20
+ if (typeof value === 'object') {
21
+ try {
22
+ return originalTruncate(JSON.stringify(value), length, end);
23
+ } catch {
24
+ return originalTruncate(String(value), length, end);
25
+ }
26
+ }
27
+ return originalTruncate(String(value), length, end);
28
+ };
29
+ }
30
+
31
+ function defaultTruncate(value, length = 20, end = '…') {
32
+ const str = value instanceof Date ? dateLabel(value) : String(value ?? '');
33
+ if (str.length <= length) return str;
34
+ return `${str.slice(0, length)}${end}`;
35
+ }
36
+
37
+ function registerNunjucksFilters(nunjucksEnv) {
38
+ nunjucksEnv.addFilter('dateLabel', dateLabel);
39
+
40
+ let existing = null;
41
+ if (typeof nunjucksEnv.getFilter === 'function') {
42
+ existing = nunjucksEnv.getFilter('truncate');
43
+ }
44
+
45
+ const truncateFn = typeof existing === 'function'
46
+ ? createSafeTruncateFilter(existing)
47
+ : defaultTruncate;
48
+
49
+ nunjucksEnv.addFilter('truncate', truncateFn, true);
50
+ }
51
+
52
+ module.exports = {
53
+ dateLabel,
54
+ createSafeTruncateFilter,
55
+ registerNunjucksFilters,
56
+ };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const { polarApiRequest } = require('./api');
4
+ const { isValidPortalRedirectUrl } = require('./urls');
5
+
6
+ function getDefaultPortalUrl(config) {
7
+ if (!config.orgSlug) return null;
8
+ const host = config.sandbox ? 'https://sandbox.polar.sh' : 'https://polar.sh';
9
+ return `${host}/${config.orgSlug}/portal`;
10
+ }
11
+
12
+ async function getPolarPortalUrl(user, baseUrl, config) {
13
+ const defaultPortal = getDefaultPortalUrl(config);
14
+ const f = config.fields;
15
+
16
+ if (config.accessToken && user[f.polarCustomerId]) {
17
+ try {
18
+ const payload = { customer_id: user[f.polarCustomerId] };
19
+ if (baseUrl) {
20
+ const returnPath = config.urls.return.startsWith('http')
21
+ ? config.urls.return
22
+ : `${baseUrl}${config.urls.return}`;
23
+ payload.return_url = returnPath;
24
+ }
25
+
26
+ const data = await polarApiRequest('/customer-sessions/', {
27
+ method: 'POST',
28
+ body: payload,
29
+ accessToken: config.accessToken,
30
+ sandbox: config.sandbox,
31
+ });
32
+
33
+ if (data.customer_portal_url && isValidPortalRedirectUrl(data.customer_portal_url)) {
34
+ return data.customer_portal_url;
35
+ }
36
+ } catch (err) {
37
+ console.error('[polar] Portal API error:', err.message);
38
+ }
39
+ }
40
+
41
+ if (defaultPortal && isValidPortalRedirectUrl(defaultPortal)) {
42
+ return defaultPortal;
43
+ }
44
+
45
+ throw new Error('Polar customer portal is not configured. Set POLAR_ORG_SLUG to your Polar organization slug.');
46
+ }
47
+
48
+ module.exports = {
49
+ getDefaultPortalUrl,
50
+ getPolarPortalUrl,
51
+ };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const { loadPeer } = require('../../rate-limit/index.js');
4
+
5
+ const DEFAULT_IPV6_SUBNET = 56;
6
+
7
+ /**
8
+ * @param {Function} ipKeyGenerator
9
+ * @param {string} prefix
10
+ */
11
+ function polarIpKey(ipKeyGenerator, prefix) {
12
+ return (req) => `${prefix}:${ipKeyGenerator(req.ip, DEFAULT_IPV6_SUBNET)}`;
13
+ }
14
+
15
+ /**
16
+ * Prefer authenticated user id; fall back to IPv6-safe IP key (express-rate-limit v8).
17
+ * @param {Function} ipKeyGenerator
18
+ * @param {string} prefix
19
+ */
20
+ function polarUserOrIpKey(ipKeyGenerator, prefix) {
21
+ const ipKey = polarIpKey(ipKeyGenerator, prefix);
22
+ return (req) => (req.user?.id != null ? `${prefix}:${req.user.id}` : ipKey(req));
23
+ }
24
+
25
+ /**
26
+ * @param {Function} ipKeyGenerator
27
+ */
28
+ function buildDefaultRateLimits(ipKeyGenerator) {
29
+ return {
30
+ webhook: {
31
+ limit: 120,
32
+ windowMs: 60_000,
33
+ message: { error: 'Too many webhook requests' },
34
+ keyGenerator: polarIpKey(ipKeyGenerator, 'polar:webhook'),
35
+ },
36
+ checkout: {
37
+ limit: 5,
38
+ windowMs: 60_000,
39
+ message: { error: 'Too many checkout attempts. Please try again later.' },
40
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:checkout'),
41
+ },
42
+ portal: {
43
+ limit: 10,
44
+ windowMs: 60_000,
45
+ message: { error: 'Too many portal requests. Please try again later.' },
46
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:portal'),
47
+ },
48
+ status: {
49
+ limit: 60,
50
+ windowMs: 60_000,
51
+ message: { error: 'Too many billing status requests' },
52
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:status'),
53
+ },
54
+ };
55
+ }
56
+
57
+ /** @type {ReturnType<typeof buildDefaultRateLimits>|null} */
58
+ let cachedDefaults = null;
59
+
60
+ function loadIpKeyGenerator() {
61
+ try {
62
+ const { ipKeyGenerator } = loadPeer();
63
+ return ipKeyGenerator;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function getDefaultRateLimits() {
70
+ const ipKeyGenerator = loadIpKeyGenerator();
71
+ if (!ipKeyGenerator) {
72
+ return null;
73
+ }
74
+ if (!cachedDefaults) {
75
+ cachedDefaults = buildDefaultRateLimits(ipKeyGenerator);
76
+ }
77
+ return cachedDefaults;
78
+ }
79
+
80
+ /**
81
+ * Build per-route limiter middleware from rateLimitPlugin factory.
82
+ * @param {Object} ctx - plugin onRoutesReady context
83
+ * @param {import('./config').PolarConfig} config
84
+ */
85
+ function resolvePolarRateLimiters(ctx, config) {
86
+ const rlOpt = config.rateLimit;
87
+ if (rlOpt === false || rlOpt?.enabled === false) {
88
+ return {};
89
+ }
90
+
91
+ const factory = ctx.middlewares?.rateLimit;
92
+ if (typeof factory !== 'function') {
93
+ if (rlOpt === true || (rlOpt && typeof rlOpt === 'object')) {
94
+ console.warn(
95
+ '[polar] rateLimit is enabled but rateLimitPlugin is not loaded — add rateLimitPlugin() before polarPlugin in plugins[]'
96
+ );
97
+ }
98
+ return {};
99
+ }
100
+
101
+ const defaults = getDefaultRateLimits();
102
+ if (!defaults) {
103
+ console.warn(
104
+ '[polar] rateLimit is enabled but express-rate-limit >= 8 is not installed — skipping polar rate limiters'
105
+ );
106
+ return {};
107
+ }
108
+
109
+ const overrides = rlOpt === true ? {} : (rlOpt || {});
110
+ const routes = ['webhook', 'checkout', 'portal', 'status'];
111
+ const out = {};
112
+
113
+ for (const route of routes) {
114
+ if (overrides[route] === false) continue;
115
+ const opts = {
116
+ ...defaults[route],
117
+ ...(typeof overrides[route] === 'object' ? overrides[route] : {}),
118
+ };
119
+ out[route] = factory(opts);
120
+ }
121
+
122
+ return out;
123
+ }
124
+
125
+ module.exports = {
126
+ loadIpKeyGenerator,
127
+ polarIpKey,
128
+ polarUserOrIpKey,
129
+ buildDefaultRateLimits,
130
+ getDefaultRateLimits,
131
+ resolvePolarRateLimiters,
132
+ /** @deprecated use getDefaultRateLimits() */
133
+ get DEFAULT_RATE_LIMITS() {
134
+ return getDefaultRateLimits() || buildDefaultRateLimits(
135
+ loadIpKeyGenerator() || ((ip) => String(ip))
136
+ );
137
+ },
138
+ };
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ const { verifyPolarWebhook, handlePolarWebhookEvent } = require('./webhooks');
4
+ const { getPolarCheckoutUrl } = require('./checkout');
5
+ const { getPolarPortalUrl } = require('./portal');
6
+ const { syncPolarBillingForUser } = require('./sync');
7
+ const { userHasProTier } = require('./user-resolver');
8
+ const { userIsPaid } = require('./billing-hooks');
9
+
10
+ function resolveKnex(db) {
11
+ return db?.knex || (typeof db?.raw === 'function' ? db : null);
12
+ }
13
+
14
+ function wantsJson(req) {
15
+ return req.xhr
16
+ || req.headers.accept?.includes('application/json')
17
+ || req.is('json');
18
+ }
19
+
20
+ function resolveBaseUrl(req, config) {
21
+ return config.baseUrl || `${req.protocol}://${req.get('host')}`;
22
+ }
23
+
24
+ function createInjectDb(db) {
25
+ return (req, res, next) => {
26
+ if (db && !req.db) req.db = db;
27
+ next();
28
+ };
29
+ }
30
+
31
+ function createWebhookHandler(config) {
32
+ return async function polarWebhookHandler(req, res) {
33
+ try {
34
+ const secret = config.webhookSecret;
35
+ if (!secret) {
36
+ return res.status(500).json({ error: 'POLAR_WEBHOOK_SECRET is not configured' });
37
+ }
38
+
39
+ const rawPayload = req.rawBody
40
+ ? (Buffer.isBuffer(req.rawBody) ? req.rawBody.toString('utf8') : String(req.rawBody))
41
+ : (typeof req.body === 'string' ? req.body : JSON.stringify(req.body));
42
+
43
+ if (!verifyPolarWebhook(rawPayload, req.headers, secret)) {
44
+ return res.status(401).json({ error: 'Invalid webhook signature', received: false });
45
+ }
46
+
47
+ const knex = resolveKnex(config.db);
48
+ if (!knex) {
49
+ return res.status(500).json({ error: 'Database unavailable' });
50
+ }
51
+
52
+ const event = typeof req.body === 'object' && req.body && !Buffer.isBuffer(req.body)
53
+ ? req.body
54
+ : JSON.parse(rawPayload);
55
+
56
+ const result = await handlePolarWebhookEvent(event, knex, config);
57
+ return res.status(200).json({
58
+ received: true,
59
+ processed: result.processed,
60
+ action: result.action,
61
+ userId: result.userId,
62
+ });
63
+ } catch (err) {
64
+ console.error('[polar] Webhook error:', err);
65
+ return res.status(400).json({ error: err.message || 'Webhook processing failed' });
66
+ }
67
+ };
68
+ }
69
+
70
+ function createCheckoutHandler(config) {
71
+ return async function polarCheckoutHandler(req, res) {
72
+ try {
73
+ if (!req.user) {
74
+ if (wantsJson(req)) return res.status(401).json({ error: 'Authentication required' });
75
+ return res.redirect(config.urls.login);
76
+ }
77
+
78
+ const knex = resolveKnex(config.db);
79
+ const f = config.fields;
80
+ let user = req.user;
81
+
82
+ if (knex) {
83
+ const dbUser = await knex(config.userTable).where(f.id, req.user[f.id] || req.user.id).first();
84
+ if (dbUser) user = dbUser;
85
+ if (config.accessToken && config.syncBeforeCheckout) {
86
+ user = await syncPolarBillingForUser(user, knex, config);
87
+ }
88
+ }
89
+
90
+ if (await userIsPaid(user, knex, config)) {
91
+ const alreadyUrl = config.urls.alreadySubscribed.startsWith('http')
92
+ ? config.urls.alreadySubscribed
93
+ : config.urls.alreadySubscribed;
94
+ if (wantsJson(req)) {
95
+ return res.status(409).json({
96
+ error: 'You already have an active subscription.',
97
+ tier: user[f.tier],
98
+ polarStatus: user[f.polarStatus] || 'active',
99
+ });
100
+ }
101
+ return res.redirect(alreadyUrl);
102
+ }
103
+
104
+ const plan = req.body?.plan || req.query?.plan || 'pro_monthly';
105
+ const baseUrl = resolveBaseUrl(req, config);
106
+ const checkoutUrl = await getPolarCheckoutUrl({ user, plan, baseUrl, config });
107
+
108
+ if (wantsJson(req)) {
109
+ return res.json({ url: checkoutUrl });
110
+ }
111
+ return res.redirect(checkoutUrl);
112
+ } catch (err) {
113
+ console.error('[polar] Checkout error:', err.message);
114
+ if (wantsJson(req)) {
115
+ return res.status(503).json({ error: 'Checkout is temporarily unavailable. Please try again later.' });
116
+ }
117
+ const errUrl = config.urls.return.includes('?')
118
+ ? `${config.urls.return}&error=checkout_unavailable`
119
+ : `${config.urls.return}?error=checkout_unavailable`;
120
+ return res.redirect(errUrl);
121
+ }
122
+ };
123
+ }
124
+
125
+ function createPortalHandler(config) {
126
+ return async function polarPortalHandler(req, res) {
127
+ try {
128
+ if (!req.user) {
129
+ return res.redirect(config.urls.login);
130
+ }
131
+
132
+ const baseUrl = resolveBaseUrl(req, config);
133
+ const portalUrl = await getPolarPortalUrl(req.user, baseUrl, config);
134
+ return res.redirect(portalUrl);
135
+ } catch (err) {
136
+ console.error('[polar] Portal error:', err.message);
137
+ const errUrl = config.urls.return.includes('?')
138
+ ? `${config.urls.return}&error=portal_unavailable`
139
+ : `${config.urls.return}?error=portal_unavailable`;
140
+ return res.redirect(errUrl);
141
+ }
142
+ };
143
+ }
144
+
145
+ function createStatusHandler(config) {
146
+ return async function polarStatusHandler(req, res, next) {
147
+ try {
148
+ const knex = resolveKnex(config.db);
149
+ const f = config.fields;
150
+ let user = req.user;
151
+
152
+ if (!user) {
153
+ return res.status(401).json({ error: 'Unauthorized' });
154
+ }
155
+
156
+ if (knex) {
157
+ const dbUser = await knex(config.userTable).where(f.id, user[f.id] || user.id).first();
158
+ if (dbUser) user = dbUser;
159
+ if (config.accessToken) {
160
+ user = await syncPolarBillingForUser(user, knex, config);
161
+ }
162
+ }
163
+
164
+ const tier = user[f.tier] || config.freeTierName;
165
+ const isPro = userHasProTier(user, config);
166
+
167
+ return res.json({
168
+ userId: user[f.id],
169
+ publicId: user[f.publicId],
170
+ tier,
171
+ isPro,
172
+ polarStatus: user[f.polarStatus] || 'none',
173
+ polarCustomerId: user[f.polarCustomerId] || null,
174
+ polarSubscriptionId: user[f.polarSubscriptionId] || null,
175
+ polarCurrentPeriodEnd: user[f.polarCurrentPeriodEnd] || null,
176
+ polarCancelAtPeriodEnd: Boolean(user[f.polarCancelAtPeriodEnd]),
177
+ });
178
+ } catch (err) {
179
+ next(err);
180
+ }
181
+ };
182
+ }
183
+
184
+ function createRequireAuth(config, ctx) {
185
+ if (typeof config.requireAuth === 'function') {
186
+ return config.requireAuth;
187
+ }
188
+
189
+ const authFactory = ctx.middlewares?.auth;
190
+ if (typeof authFactory === 'function') {
191
+ return (req, res, next) => {
192
+ const isApi = req.path.startsWith('/api/') || wantsJson(req);
193
+ return authFactory({ api: isApi })(req, res, next);
194
+ };
195
+ }
196
+
197
+ return (req, res, next) => {
198
+ if (req.user) return next();
199
+ if (wantsJson(req) || req.path.startsWith('/api/')) {
200
+ return res.status(401).json({ error: 'Authentication required' });
201
+ }
202
+ return res.redirect(config.urls.login);
203
+ };
204
+ }
205
+
206
+ module.exports = {
207
+ createInjectDb,
208
+ createWebhookHandler,
209
+ createCheckoutHandler,
210
+ createPortalHandler,
211
+ createStatusHandler,
212
+ createRequireAuth,
213
+ };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const { polarApiRequest } = require('./api');
4
+ const {
5
+ userHasProTier,
6
+ listPolarSubscriptions,
7
+ pickBestActiveSubscription,
8
+ normalizePolarTimestamp,
9
+ collectExternalCustomerIds,
10
+ } = require('./user-resolver');
11
+
12
+ async function applyPolarProSubscription(knex, user, customerState, subscription, config) {
13
+ const f = config.fields;
14
+ const update = {
15
+ [f.tier]: config.proTierName,
16
+ [f.polarStatus]: subscription.status || 'active',
17
+ [f.polarCustomerId]: customerState?.id || subscription.customer_id || user[f.polarCustomerId] || null,
18
+ [f.polarSubscriptionId]: subscription.id || user[f.polarSubscriptionId] || null,
19
+ [f.polarCurrentPeriodEnd]: normalizePolarTimestamp(subscription.current_period_end),
20
+ [f.polarCancelAtPeriodEnd]: Boolean(subscription.cancel_at_period_end),
21
+ updated_at: new Date(),
22
+ };
23
+
24
+ if (config.hooks.onSubscriptionChange) {
25
+ await config.hooks.onSubscriptionChange({
26
+ user,
27
+ tier: config.proTierName,
28
+ polarStatus: update[f.polarStatus],
29
+ subscription,
30
+ knex,
31
+ update,
32
+ });
33
+ } else {
34
+ await knex(config.userTable).where(f.id, user[f.id]).update(update);
35
+ }
36
+
37
+ return knex(config.userTable).where(f.id, user[f.id]).first();
38
+ }
39
+
40
+ async function syncFromCustomerState(user, knex, customerState, config) {
41
+ if (!customerState) return user;
42
+
43
+ const activeSub = pickBestActiveSubscription(customerState.active_subscriptions, config);
44
+ if (activeSub) {
45
+ return applyPolarProSubscription(knex, user, customerState, activeSub, config);
46
+ }
47
+
48
+ const f = config.fields;
49
+ if (customerState.id && customerState.id !== user[f.polarCustomerId]) {
50
+ const patch = {
51
+ [f.polarCustomerId]: customerState.id,
52
+ updated_at: new Date(),
53
+ };
54
+ await knex(config.userTable).where(f.id, user[f.id]).update(patch);
55
+ return { ...user, [f.polarCustomerId]: customerState.id };
56
+ }
57
+
58
+ return user;
59
+ }
60
+
61
+ async function syncPolarBillingForUser(user, knex, config) {
62
+ if (!user || !knex || !config.accessToken) {
63
+ return user;
64
+ }
65
+
66
+ const apiOpts = { accessToken: config.accessToken, sandbox: config.sandbox };
67
+ const externalIds = collectExternalCustomerIds(user, config);
68
+
69
+ for (const externalId of externalIds) {
70
+ try {
71
+ const state = await polarApiRequest(
72
+ `/customers/external/${encodeURIComponent(externalId)}/state`,
73
+ apiOpts
74
+ );
75
+ user = await syncFromCustomerState(user, knex, state, config);
76
+ if (userHasProTier(user, config)) return user;
77
+ } catch (err) {
78
+ if (!String(err.message || '').includes('404')) {
79
+ console.error('[polar] Sync error (external state):', externalId, err.message);
80
+ }
81
+ }
82
+ }
83
+
84
+ const f = config.fields;
85
+ if (user[f.polarCustomerId]) {
86
+ try {
87
+ const state = await polarApiRequest(`/customers/${user[f.polarCustomerId]}/state`, apiOpts);
88
+ user = await syncFromCustomerState(user, knex, state, config);
89
+ if (userHasProTier(user, config)) return user;
90
+ } catch (err) {
91
+ if (!String(err.message || '').includes('404')) {
92
+ console.error('[polar] Sync error (customer state):', user[f.polarCustomerId], err.message);
93
+ }
94
+ }
95
+ }
96
+
97
+ if (user[f.email]) {
98
+ try {
99
+ const customersData = await polarApiRequest(
100
+ `/customers/?email=${encodeURIComponent(String(user[f.email]).trim())}&limit=5`,
101
+ apiOpts
102
+ );
103
+ for (const customer of listPolarSubscriptions(customersData)) {
104
+ if (!customer?.id) continue;
105
+ const state = await polarApiRequest(`/customers/${customer.id}/state`, apiOpts);
106
+ user = await syncFromCustomerState(user, knex, state, config);
107
+ if (userHasProTier(user, config)) return user;
108
+ }
109
+ } catch (err) {
110
+ console.error('[polar] Sync error (email lookup):', user[f.email], err.message);
111
+ }
112
+ }
113
+
114
+ for (const externalId of externalIds) {
115
+ try {
116
+ const subsData = await polarApiRequest(
117
+ `/subscriptions/?external_customer_id=${encodeURIComponent(externalId)}&limit=10`,
118
+ apiOpts
119
+ );
120
+ const activeSub = pickBestActiveSubscription(listPolarSubscriptions(subsData), config);
121
+ if (activeSub) {
122
+ return applyPolarProSubscription(knex, user, { id: activeSub.customer_id }, activeSub, config);
123
+ }
124
+ } catch (err) {
125
+ if (!String(err.message || '').includes('404')) {
126
+ console.error('[polar] Sync error (subscriptions list):', externalId, err.message);
127
+ }
128
+ }
129
+ }
130
+
131
+ return user;
132
+ }
133
+
134
+ module.exports = {
135
+ applyPolarProSubscription,
136
+ syncFromCustomerState,
137
+ syncPolarBillingForUser,
138
+ };