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.
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/tokens.js +16 -0
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/index.js +4 -1
- package/core/orm/transaction.js +3 -1
- package/core/ssr/stream.js +58 -14
- package/package.json +15 -4
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/import.js +76 -39
- package/plugins/index.js +2 -0
- package/plugins/mcp/transports/sse.js +23 -6
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +56 -0
- package/plugins/polar/README.md +181 -0
- package/plugins/polar/index.js +149 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +90 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +138 -0
- package/plugins/polar/src/routes.js +213 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +141 -0
- package/plugins/polar/src/user-resolver.js +199 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/plugins/rate-limit/index.js +5 -1
- package/src/file-router.js +19 -2
- package/src/helpers.js +60 -13
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
|
@@ -0,0 +1,181 @@
|
|
|
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 (see POST vs GET below) |
|
|
62
|
+
| GET | `/settings/billing/portal` | session/JWT | Customer portal redirect |
|
|
63
|
+
| GET | `/api/v1/billing/status` | session/JWT | Sync + JSON billing status |
|
|
64
|
+
|
|
65
|
+
## Checkout: POST vs GET
|
|
66
|
+
|
|
67
|
+
Both `/settings/billing/checkout` methods run the same handler:
|
|
68
|
+
|
|
69
|
+
| Method | Typical use | CSP note |
|
|
70
|
+
|--------|-------------|----------|
|
|
71
|
+
| **POST** | `<form method="post" action="…">` | Requires `form-action 'self'` (included in `polarCspDirectives()`) |
|
|
72
|
+
| **GET** | `<a href="…">Upgrade</a>` | No form submission — simpler when CSP is strict |
|
|
73
|
+
|
|
74
|
+
## Template usage
|
|
75
|
+
|
|
76
|
+
```njk
|
|
77
|
+
{% if user.tier == 'pro' %}
|
|
78
|
+
<p>Pro until {{ user.polar_current_period_end | dateLabel }}</p>
|
|
79
|
+
<a href="/settings/billing/portal">Manage subscription</a>
|
|
80
|
+
{% else %}
|
|
81
|
+
<form method="post" action="/settings/billing/checkout">
|
|
82
|
+
<button type="submit">Upgrade to Pro</button>
|
|
83
|
+
</form>
|
|
84
|
+
{% endif %}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## SDK exports (`plugin.api` or direct imports)
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const polar = polarPlugin({ db });
|
|
91
|
+
polar.api.verifyPolarWebhook(raw, headers, secret);
|
|
92
|
+
polar.api.syncPolarBillingForUser(user, knex);
|
|
93
|
+
polar.api.syncBillingForAppUser(user, knex, { hooks }); // dashboard page-load sync
|
|
94
|
+
polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
|
|
95
|
+
polar.api.handlePolarWebhookEvent(event, knex);
|
|
96
|
+
polar.api.generateMigration();
|
|
97
|
+
polar.api.parseEnv(process.env);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Webhook raw body (production)
|
|
101
|
+
|
|
102
|
+
For strict HMAC verification, mount **before** `express.json()`:
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const { createWebhookRawBodyMiddleware } = require('webspresso/plugins/polar/src/webhooks');
|
|
106
|
+
|
|
107
|
+
app.use(createWebhookRawBodyMiddleware('/api/v1/polar/webhook'));
|
|
108
|
+
app.use(express.json());
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Without this, the plugin falls back to `JSON.stringify(req.body)` (works for most cases; key order edge cases possible).
|
|
112
|
+
|
|
113
|
+
## Rate limiting
|
|
114
|
+
|
|
115
|
+
Enabled by default (`rateLimit: true`). Requires `rateLimitPlugin()` registered **before** `polarPlugin`:
|
|
116
|
+
|
|
117
|
+
| Route | Default |
|
|
118
|
+
|-------|---------|
|
|
119
|
+
| Webhook | 120 req/min (by IP) |
|
|
120
|
+
| Checkout | 5 req/min (by user) |
|
|
121
|
+
| Portal | 10 req/min (by user) |
|
|
122
|
+
| Status | 60 req/min (by user) |
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
polarPlugin({ db, rateLimit: false }); // disable
|
|
126
|
+
polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Optional hooks
|
|
130
|
+
|
|
131
|
+
Billing columns on `users` work out of the box. If your paid tier lives elsewhere (e.g. `subscriptions.plan_tier`), use hooks:
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
polarPlugin({
|
|
135
|
+
db,
|
|
136
|
+
syncBeforeCheckout: false, // skip Polar API sync before redirect (default: true)
|
|
137
|
+
hooks: {
|
|
138
|
+
/** Override paid check at checkout (default: users.tier vs tierMapping) */
|
|
139
|
+
async isPaidUser({ user, knex }) {
|
|
140
|
+
const sub = await knex('subscriptions').where('user_id', user.id).first();
|
|
141
|
+
return sub?.plan_tier === 'pro';
|
|
142
|
+
},
|
|
143
|
+
onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
|
|
144
|
+
// Sync external tables when webhooks fire
|
|
145
|
+
return knex('users').where('id', user.id).update(update);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
syncMiddleware: {
|
|
149
|
+
when: (req) => req.query.tab === 'billing',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Checkout metadata sends `user_id` as a string (supports nanoid/UUID primary keys, not only integers).
|
|
155
|
+
|
|
156
|
+
## CSP (Helmet)
|
|
157
|
+
|
|
158
|
+
`polarCspDirectives()` always includes `'self'` so local login/register/checkout forms keep working. Polar domains are added for hosted checkout redirects.
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
const { mergePolarCspDirectives } = require('webspresso/plugins/polar/src/urls');
|
|
162
|
+
|
|
163
|
+
createApp({
|
|
164
|
+
helmet: {
|
|
165
|
+
contentSecurityPolicy: {
|
|
166
|
+
directives: mergePolarCspDirectives({
|
|
167
|
+
formAction: ["'self'"],
|
|
168
|
+
scriptSrc: ["'self'"],
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Or rely on the plugin's built-in `csp` export — the plugin manager **unions** sources per directive (does not replace your Helmet config).
|
|
176
|
+
|
|
177
|
+
## Non-goals (v1)
|
|
178
|
+
|
|
179
|
+
- Admin UI (use Polar dashboard)
|
|
180
|
+
- Multi-org / team seat billing
|
|
181
|
+
- `@polar-sh/sdk` dependency
|
|
@@ -0,0 +1,149 @@
|
|
|
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 { syncBillingForAppUser } = require('./src/billing-hooks');
|
|
16
|
+
const {
|
|
17
|
+
resolveUserFromPolarData,
|
|
18
|
+
buildCheckoutMetadata,
|
|
19
|
+
isProSubscriptionStatus,
|
|
20
|
+
userHasProTier,
|
|
21
|
+
pickBestActiveSubscription,
|
|
22
|
+
} = require('./src/user-resolver');
|
|
23
|
+
const {
|
|
24
|
+
isValidCheckoutRedirectUrl,
|
|
25
|
+
isValidPortalRedirectUrl,
|
|
26
|
+
extractCheckoutUrlFromResponse,
|
|
27
|
+
polarCspDirectives,
|
|
28
|
+
mergePolarCspDirectives,
|
|
29
|
+
} = require('./src/urls');
|
|
30
|
+
const { generatePolarMigration } = require('./src/migration');
|
|
31
|
+
const { polarSyncMiddleware } = require('./src/middleware');
|
|
32
|
+
const { registerNunjucksFilters, dateLabel } = require('./src/nunjucks-filters');
|
|
33
|
+
const {
|
|
34
|
+
createInjectDb,
|
|
35
|
+
createWebhookHandler,
|
|
36
|
+
createCheckoutHandler,
|
|
37
|
+
createPortalHandler,
|
|
38
|
+
createStatusHandler,
|
|
39
|
+
createRequireAuth,
|
|
40
|
+
} = require('./src/routes');
|
|
41
|
+
const { resolvePolarRateLimiters, DEFAULT_RATE_LIMITS } = require('./src/rate-limit');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Object} options
|
|
45
|
+
* @param {import('../../core/orm/database').Database} options.db
|
|
46
|
+
* @param {string} [options.userModel='User']
|
|
47
|
+
* @param {string} [options.userTable='users']
|
|
48
|
+
* @param {Record<string, string>} [options.plans]
|
|
49
|
+
* @param {Record<string, string[]>} [options.tierMapping]
|
|
50
|
+
* @param {Object} [options.routes]
|
|
51
|
+
* @param {Object} [options.urls]
|
|
52
|
+
* @param {Object} [options.fields]
|
|
53
|
+
* @param {Object} [options.hooks]
|
|
54
|
+
* @param {Function} [options.requireAuth]
|
|
55
|
+
* @param {boolean} [options.enabled=true]
|
|
56
|
+
*/
|
|
57
|
+
function polarPlugin(options = {}) {
|
|
58
|
+
const config = resolvePolarConfig(options);
|
|
59
|
+
let runtimeConfig = config;
|
|
60
|
+
|
|
61
|
+
const sdk = {
|
|
62
|
+
verifyPolarWebhook,
|
|
63
|
+
polarApiRequest: (path, opts = {}) => polarApiRequest(path, {
|
|
64
|
+
...opts,
|
|
65
|
+
accessToken: opts.accessToken || runtimeConfig.accessToken,
|
|
66
|
+
sandbox: opts.sandbox !== undefined ? opts.sandbox : runtimeConfig.sandbox,
|
|
67
|
+
}),
|
|
68
|
+
getPolarCheckoutUrl: (params) => getPolarCheckoutUrl({ ...params, config: runtimeConfig }),
|
|
69
|
+
getPolarPortalUrl: (user, baseUrl) => getPolarPortalUrl(user, baseUrl, runtimeConfig),
|
|
70
|
+
syncPolarBillingForUser: (user, knex) => syncPolarBillingForUser(user, knex, runtimeConfig),
|
|
71
|
+
/** Dashboard/page-load sync — optional per-call hooks override */
|
|
72
|
+
syncBillingForAppUser: (user, knex, opts) => syncBillingForAppUser(user, knex, runtimeConfig, opts),
|
|
73
|
+
handlePolarWebhookEvent: (event, knex, hooks) => handlePolarWebhookEvent(event, knex, runtimeConfig, hooks),
|
|
74
|
+
resolveUserFromPolarData: (data, knex) => resolveUserFromPolarData(data, knex, runtimeConfig),
|
|
75
|
+
extractCheckoutUrlFromResponse: (data) => extractCheckoutUrlFromResponse(data, runtimeConfig.sandbox),
|
|
76
|
+
isValidCheckoutRedirectUrl,
|
|
77
|
+
isValidPortalRedirectUrl,
|
|
78
|
+
buildCheckoutMetadata: (user, customFields) => buildCheckoutMetadata(user, runtimeConfig, customFields),
|
|
79
|
+
userHasProTier: (user) => userHasProTier(user, runtimeConfig),
|
|
80
|
+
isProSubscriptionStatus,
|
|
81
|
+
pickBestActiveSubscription: (subs) => pickBestActiveSubscription(subs, runtimeConfig),
|
|
82
|
+
generateMigration: (opts) => generatePolarMigration({ ...opts, fields: runtimeConfig.fields }),
|
|
83
|
+
polarSyncMiddleware: (opts) => polarSyncMiddleware(runtimeConfig, opts),
|
|
84
|
+
polarCspDirectives,
|
|
85
|
+
mergePolarCspDirectives,
|
|
86
|
+
createWebhookRawBodyMiddleware,
|
|
87
|
+
registerNunjucksFilters,
|
|
88
|
+
dateLabel,
|
|
89
|
+
parseEnv: parsePolarEnv,
|
|
90
|
+
envSchema: polarEnvSchema,
|
|
91
|
+
getConfig: () => ({ ...runtimeConfig }),
|
|
92
|
+
defaultRateLimits: DEFAULT_RATE_LIMITS,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
name: 'polar',
|
|
97
|
+
version: '1.0.1',
|
|
98
|
+
description: 'Polar.sh subscription billing — checkout, portal, webhooks, and tier sync',
|
|
99
|
+
|
|
100
|
+
csp: polarCspDirectives(),
|
|
101
|
+
|
|
102
|
+
api: sdk,
|
|
103
|
+
|
|
104
|
+
register(ctx) {
|
|
105
|
+
runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
|
|
106
|
+
if (!runtimeConfig.enabled) return;
|
|
107
|
+
|
|
108
|
+
registerNunjucksFilters(ctx.nunjucksEnv);
|
|
109
|
+
|
|
110
|
+
if (runtimeConfig.syncMiddleware) {
|
|
111
|
+
ctx.app.use(polarSyncMiddleware(runtimeConfig));
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
onRoutesReady(ctx) {
|
|
116
|
+
runtimeConfig = resolvePolarConfig({ ...options, db: options.db || ctx.db });
|
|
117
|
+
if (!runtimeConfig.enabled) return;
|
|
118
|
+
|
|
119
|
+
const db = runtimeConfig.db || ctx.db;
|
|
120
|
+
if (!db) {
|
|
121
|
+
console.warn('[polar] Skipping routes: no database (pass options.db or createApp({ db }))');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const injectDb = createInjectDb(db);
|
|
126
|
+
const requireAuth = createRequireAuth(runtimeConfig, ctx);
|
|
127
|
+
const { routes } = runtimeConfig;
|
|
128
|
+
const rl = resolvePolarRateLimiters(ctx, runtimeConfig);
|
|
129
|
+
|
|
130
|
+
const chain = (...handlers) => handlers.filter(Boolean);
|
|
131
|
+
|
|
132
|
+
ctx.addRoute('post', routes.webhook, ...chain(injectDb, rl.webhook, createWebhookHandler(runtimeConfig)));
|
|
133
|
+
ctx.addRoute('post', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
|
|
134
|
+
ctx.addRoute('get', routes.checkout, ...chain(injectDb, requireAuth, rl.checkout, createCheckoutHandler(runtimeConfig)));
|
|
135
|
+
ctx.addRoute('get', routes.portal, ...chain(injectDb, requireAuth, rl.portal, createPortalHandler(runtimeConfig)));
|
|
136
|
+
ctx.addRoute('get', routes.status, ...chain(injectDb, requireAuth, rl.status, createStatusHandler(runtimeConfig)));
|
|
137
|
+
|
|
138
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
139
|
+
console.log(` Polar billing: POST ${routes.webhook}`);
|
|
140
|
+
console.log(` Polar billing: GET/POST ${routes.checkout}`);
|
|
141
|
+
console.log(` Polar billing: GET ${routes.portal}`);
|
|
142
|
+
console.log(` Polar billing: GET ${routes.status}`);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = polarPlugin;
|
|
149
|
+
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,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { userHasProTier } = require('./user-resolver');
|
|
4
|
+
const { syncPolarBillingForUser } = require('./sync');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Invoke hooks.isPaidUser — supports ({ user, knex, config }) or legacy (user, knex, config).
|
|
8
|
+
* @param {Function} hook
|
|
9
|
+
* @param {Object} user
|
|
10
|
+
* @param {Object} knex
|
|
11
|
+
* @param {import('./config').PolarConfig} config
|
|
12
|
+
*/
|
|
13
|
+
async function invokeIsPaidUserHook(hook, user, knex, config) {
|
|
14
|
+
const ctx = { user, knex, config };
|
|
15
|
+
// Legacy (user, knex, config) when declared with 2+ params; default is ({ user, knex, config })
|
|
16
|
+
const result = hook.length >= 2
|
|
17
|
+
? await hook(user, knex, config)
|
|
18
|
+
: await hook(ctx);
|
|
19
|
+
return Boolean(result);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Paid-user check at checkout: hooks.isPaidUser override, else users.tier via userHasProTier.
|
|
24
|
+
*/
|
|
25
|
+
async function userIsPaid(user, knex, config) {
|
|
26
|
+
if (typeof config.hooks?.isPaidUser === 'function') {
|
|
27
|
+
return invokeIsPaidUserHook(config.hooks.isPaidUser, user, knex, config);
|
|
28
|
+
}
|
|
29
|
+
return userHasProTier(user, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* App-level billing sync for dashboard loaders — merges optional hook overrides per call.
|
|
34
|
+
* @param {Object} user
|
|
35
|
+
* @param {Object} knex
|
|
36
|
+
* @param {import('./config').PolarConfig} config
|
|
37
|
+
* @param {{ hooks?: Object }} [opts]
|
|
38
|
+
*/
|
|
39
|
+
async function syncBillingForAppUser(user, knex, config, opts = {}) {
|
|
40
|
+
const merged = {
|
|
41
|
+
...config,
|
|
42
|
+
hooks: { ...config.hooks, ...(opts.hooks || {}) },
|
|
43
|
+
};
|
|
44
|
+
return syncPolarBillingForUser(user, knex, merged);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
invokeIsPaidUserHook,
|
|
49
|
+
userIsPaid,
|
|
50
|
+
syncBillingForAppUser,
|
|
51
|
+
};
|
|
@@ -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,90 @@
|
|
|
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
|
+
/** Sync from Polar API before checkout redirect (default true). Set false to skip. */
|
|
71
|
+
syncBeforeCheckout: options.syncBeforeCheckout !== false,
|
|
72
|
+
rateLimit: options.rateLimit !== undefined ? options.rateLimit : true,
|
|
73
|
+
env,
|
|
74
|
+
accessToken: options.accessToken || env.POLAR_ACCESS_TOKEN || null,
|
|
75
|
+
webhookSecret: options.webhookSecret || env.POLAR_WEBHOOK_SECRET || null,
|
|
76
|
+
orgSlug: options.orgSlug || env.POLAR_ORG_SLUG || null,
|
|
77
|
+
checkoutUrl: options.checkoutUrl || env.POLAR_CHECKOUT_URL || null,
|
|
78
|
+
priceId: options.priceId || env.POLAR_PRO_PRICE_ID || null,
|
|
79
|
+
sandbox: options.sandbox !== undefined ? options.sandbox : env.POLAR_SANDBOX === true,
|
|
80
|
+
baseUrl: options.baseUrl || env.BASE_URL || null,
|
|
81
|
+
enabled: options.enabled !== false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
DEFAULT_FIELDS,
|
|
87
|
+
DEFAULT_ROUTES,
|
|
88
|
+
DEFAULT_URLS,
|
|
89
|
+
resolvePolarConfig,
|
|
90
|
+
};
|
|
@@ -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
|
+
};
|