@volter/twin-polar 0.1.2 → 2.0.1
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 +2 -2
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +55 -0
- package/dist/src/polar-budget.d.ts +83 -0
- package/dist/src/polar-budget.js +413 -0
- package/dist/src/polar-capabilities.d.ts +4 -0
- package/dist/src/polar-capabilities.js +389 -0
- package/dist/src/polar-conformance.d.ts +7 -0
- package/dist/src/polar-conformance.js +25 -0
- package/dist/src/polar-connector.d.ts +113 -0
- package/dist/src/polar-connector.js +146 -0
- package/dist/src/polar-server.d.ts +14 -0
- package/dist/src/polar-server.js +52 -0
- package/dist/src/polar-twin.d.ts +24 -0
- package/dist/src/polar-twin.js +941 -0
- package/package.json +16 -9
- package/src/cli.ts +6 -6
- package/src/index.ts +22 -6
- package/src/polar-budget.ts +22 -6
- package/src/polar-capabilities.ts +54 -19
- package/src/polar-connector.ts +43 -26
- package/src/polar-server.ts +28 -4
- package/src/polar-twin.ts +159 -49
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
// Polar API twin REQUEST HANDLER — the canonical api.polar.sh surface for the twin, backed by
|
|
2
|
+
// the event/action-log kernel (@volter/world-core). Contract:
|
|
3
|
+
// handlePolarTwinRequest({ method, path, body, root, readOnly }) -> { status, body }
|
|
4
|
+
// Response SHAPES mirror real Polar (UUID4 ids, `{ items, pagination }` list envelopes, the
|
|
5
|
+
// FastAPI-style error envelopes) so the real `@polar-sh/sdk` works unmodified. HTTP wrapper:
|
|
6
|
+
// polar-server.ts → createPolarTwinServer.
|
|
7
|
+
//
|
|
8
|
+
// State lives ENTIRELY in the kernel action log: writes go through `applyTwinWrite`, reads are
|
|
9
|
+
// the projection (`projectResources`). There is NO Map/array side-store — the projection over
|
|
10
|
+
// the append-only log is the single source of truth (D1). No real Polar is ever contacted.
|
|
11
|
+
//
|
|
12
|
+
// Honesty (D2): an unmodeled route/op returns Polar's REAL error envelope (ResourceNotFound /
|
|
13
|
+
// HTTPValidationError), never a fabricated `{ ok: true }`. readOnly rejects writes with 405.
|
|
14
|
+
//
|
|
15
|
+
// Kernel SUBJECT ids are type-prefixed (`customer:<uuid>`) — a pack convention that keeps
|
|
16
|
+
// subject ids self-describing (the kernel resolves writes by (type, id), so the prefix is not
|
|
17
|
+
// required for correctness) — while the PUBLIC `id` emitted to clients is the bare UUID4 that
|
|
18
|
+
// Polar returns. `kid()` builds the subject id; rows() strips the prefix back off for the view.
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { applyTwinWrite, projectResources, resolveSubjectId, worldNow } from '@volter/world-core';
|
|
21
|
+
const SERVICE = 'polar';
|
|
22
|
+
const ORG_ID = '00000000-0000-4000-8000-0000000000a1'; // the twin's single organization id
|
|
23
|
+
// Resource types the twin serves (kernel subject `type`s). The PUBLIC ids are bare UUIDs; the
|
|
24
|
+
// kernel subject id is `${type}:${uuid}` (collision-safe).
|
|
25
|
+
export const POLAR_RESOURCE_TYPES = [
|
|
26
|
+
'customer', 'product', 'subscription', 'order', 'checkout', 'benefit',
|
|
27
|
+
'discount', 'customer_session', 'event', 'meter', 'webhook_endpoint',
|
|
28
|
+
];
|
|
29
|
+
// ── error envelopes (Polar / FastAPI shapes, D2) ─────────────────────────────────────
|
|
30
|
+
// Polar returns `{ type: 'ResourceNotFound', detail: '... not found.' }` for 404 and a
|
|
31
|
+
// FastAPI `HTTPValidationError` (`{ detail: [{ type, loc, msg }] }`) for 422.
|
|
32
|
+
function notFound(detail) {
|
|
33
|
+
return { status: 404, body: { error: 'ResourceNotFound', detail } };
|
|
34
|
+
}
|
|
35
|
+
function validationError(loc, msg, type = 'value_error') {
|
|
36
|
+
return { status: 422, body: { detail: [{ type, loc: ['body', ...loc], msg, input: null }] } };
|
|
37
|
+
}
|
|
38
|
+
function notPermitted(detail) {
|
|
39
|
+
return { status: 403, body: { type: 'NotPermitted', detail } };
|
|
40
|
+
}
|
|
41
|
+
// ── body / time / id helpers ─────────────────────────────────────────────────────────
|
|
42
|
+
function parseBody(body) {
|
|
43
|
+
if (!body)
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
const v = JSON.parse(body);
|
|
47
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The instant this request happened at: the caller's `occurredAt` (the kernel fetch adapter
|
|
54
|
+
* stamps it from the world clock) or, for a direct in-process call that supplies none, the WORLD
|
|
55
|
+
* clock — never `new Date()`. Every stored/served timestamp and every minted id derives from it. */
|
|
56
|
+
function nowIso(occurredAt) {
|
|
57
|
+
return occurredAt ?? worldNow();
|
|
58
|
+
}
|
|
59
|
+
function laterIso(occurredAt) {
|
|
60
|
+
return new Date(Date.parse(nowIso(occurredAt)) + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
61
|
+
}
|
|
62
|
+
// ── ids: DERIVED FROM THE WRITE, never drawn ─────────────────────────────────────────
|
|
63
|
+
// Polar's public ids are bare UUID4s, so the SHAPE is a uuid4 (version nibble `4`, variant
|
|
64
|
+
// nibble `8`-`b`). The BITS are a SHA-256 of the write — the subject type, the world instant the
|
|
65
|
+
// request occurred at, and the ordinal the subject takes among its type — never `Math.random`.
|
|
66
|
+
//
|
|
67
|
+
// That is invariant R9 at the resource level: a served id must be a function of (request, stored
|
|
68
|
+
// state) and of nothing else, so two identical worlds replaying the same writes serve the same
|
|
69
|
+
// bytes. The earlier version drew every id from `Math.random` and called collisions the only
|
|
70
|
+
// risk; the real cost was that a replay could never be compared at all, and one `Math.random`
|
|
71
|
+
// sat on a READ path (`/customers/:id/state`'s `active_meters[].id`), so the same GET differed
|
|
72
|
+
// between two serves of unchanged state.
|
|
73
|
+
//
|
|
74
|
+
// The ordinal is the count of live rows of that type BEFORE the insert, which is what keeps two
|
|
75
|
+
// subjects created in the SAME millisecond apart; every call site mints and then AWAITS its
|
|
76
|
+
// write, so the next mint of that type sees a count one larger.
|
|
77
|
+
function digest(kind, seed) {
|
|
78
|
+
return createHash('sha256').update(`polar-twin-id:${kind}:${seed}`).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
function uuidFrom(kind, seed) {
|
|
81
|
+
const h = digest(kind, seed);
|
|
82
|
+
const variant = ((parseInt(h[16], 16) & 0x3) | 0x8).toString(16);
|
|
83
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
84
|
+
}
|
|
85
|
+
/** The public id for a NEW subject of `type`, derived from this request and the ordinal it takes. */
|
|
86
|
+
function mintId(type, req, extra = '') {
|
|
87
|
+
return uuidFrom(type, `${nowIso(req.occurredAt)}:${rows(type, req.root).length}:${extra}`);
|
|
88
|
+
}
|
|
89
|
+
/** A 32-hex secret (session token, checkout client secret), derived the same way. */
|
|
90
|
+
function mintSecret(kind, req, extra = '') {
|
|
91
|
+
return digest(kind, `${nowIso(req.occurredAt)}:${extra}`).slice(0, 32);
|
|
92
|
+
}
|
|
93
|
+
// Kernel subject id (type-prefixed → collision-safe across types).
|
|
94
|
+
function kid(type, id) {
|
|
95
|
+
return `${type}:${id}`;
|
|
96
|
+
}
|
|
97
|
+
// ── projection helpers ──────────────────────────────────────────────────────────────
|
|
98
|
+
// Live (non-soft-deleted) rows of a type, with the kernel subject prefix stripped back to the
|
|
99
|
+
// bare public id. A delete is the kernel's tombstone (`deleted: true`, cleared by the next write — protocol 2).
|
|
100
|
+
function rows(type, root) {
|
|
101
|
+
const prefix = `${type}:`;
|
|
102
|
+
return projectResources(SERVICE, root)
|
|
103
|
+
.filter((r) => r.type === type && r.id.startsWith(prefix) && r.deleted !== true)
|
|
104
|
+
.map((r) => ({ ...r, id: r.id.slice(prefix.length) }));
|
|
105
|
+
}
|
|
106
|
+
function getRow(type, id, root) {
|
|
107
|
+
return rows(type, root).find((r) => r.id === id);
|
|
108
|
+
}
|
|
109
|
+
// The client view: drop kernel meta (`type`, `updatedAt`) + twin-internal `_`-prefixed fields.
|
|
110
|
+
function view(r) {
|
|
111
|
+
const { type: _t, updatedAt: _u, ...rest } = r;
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, v] of Object.entries(rest))
|
|
114
|
+
if (!k.startsWith('_'))
|
|
115
|
+
out[k] = v;
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
async function write(type, id, fields, op, req) {
|
|
119
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: kid(type, id), fields, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), actor: { kind: 'agent' } }, req.root);
|
|
120
|
+
return view({ ...resource, id });
|
|
121
|
+
}
|
|
122
|
+
// Polar's list envelope: `{ items: [...], pagination: { total_count, max_page } }`.
|
|
123
|
+
function listEnvelope(items) {
|
|
124
|
+
return { items, pagination: { total_count: items.length, max_page: items.length > 0 ? 1 : 0 } };
|
|
125
|
+
}
|
|
126
|
+
// ── object shapes (mirror real Polar response bodies) ────────────────────────────────
|
|
127
|
+
function customerView(r) {
|
|
128
|
+
const v = view(r);
|
|
129
|
+
return {
|
|
130
|
+
id: v.id,
|
|
131
|
+
created_at: v.created_at,
|
|
132
|
+
modified_at: v.modified_at ?? null,
|
|
133
|
+
metadata: v.metadata ?? {},
|
|
134
|
+
external_id: v.external_id ?? null,
|
|
135
|
+
email: v.email ?? null,
|
|
136
|
+
email_verified: v.email_verified ?? false,
|
|
137
|
+
type: v.customer_type ?? 'individual',
|
|
138
|
+
name: v.name ?? null,
|
|
139
|
+
billing_name: v.billing_name ?? null,
|
|
140
|
+
billing_address: v.billing_address ?? null,
|
|
141
|
+
tax_id: v.tax_id ?? null,
|
|
142
|
+
locale: v.locale ?? 'en',
|
|
143
|
+
organization_id: ORG_ID,
|
|
144
|
+
default_payment_method_id: null,
|
|
145
|
+
deleted_at: null,
|
|
146
|
+
avatar_url: `https://www.gravatar.com/avatar?d=blank`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function productView(r, root) {
|
|
150
|
+
const v = view(r);
|
|
151
|
+
return {
|
|
152
|
+
id: v.id,
|
|
153
|
+
created_at: v.created_at,
|
|
154
|
+
modified_at: v.modified_at ?? null,
|
|
155
|
+
name: v.name ?? null,
|
|
156
|
+
description: v.description ?? null,
|
|
157
|
+
recurring_interval: v.recurring_interval ?? null,
|
|
158
|
+
recurring_interval_count: v.recurring_interval != null ? Number(v.recurring_interval_count ?? 1) : null,
|
|
159
|
+
is_recurring: v.recurring_interval != null,
|
|
160
|
+
is_archived: v.is_archived ?? false,
|
|
161
|
+
visibility: v.visibility ?? 'public',
|
|
162
|
+
trial_interval: v.trial_interval ?? null, trial_interval_count: v.trial_interval_count ?? null,
|
|
163
|
+
meter_interval: null, meter_interval_count: null,
|
|
164
|
+
organization_id: ORG_ID,
|
|
165
|
+
metadata: v.metadata ?? {},
|
|
166
|
+
prices: (Array.isArray(v.prices) ? v.prices : []).map((pr) => priceView(pr, String(v.id), v.recurring_interval, String(v.created_at), root)),
|
|
167
|
+
benefits: Array.isArray(v.benefits) ? v.benefits : [],
|
|
168
|
+
medias: [],
|
|
169
|
+
attached_custom_fields: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** A price as Polar returns it (ProductPriceFixed for a fixed amount, ProductPriceFree for none): the stored row filled to the wire shape. */
|
|
173
|
+
function priceView(pr, productId, recurring, createdAt, root) {
|
|
174
|
+
const amount = pr.price_amount != null ? Number(pr.price_amount) : null;
|
|
175
|
+
if (pr.amount_type === 'metered_unit' || pr.meter_id != null) { // a metered price (usage beyond the plan): Polar's ProductPriceMeteredUnit, with its meter
|
|
176
|
+
const meter = pr.meter_id ? getRow('meter', String(pr.meter_id), root) : undefined;
|
|
177
|
+
return {
|
|
178
|
+
id: pr.id ?? uuidFrom('price', `${productId}:metered:${String(pr.meter_id)}`), created_at: pr.created_at ?? createdAt, modified_at: pr.modified_at ?? null,
|
|
179
|
+
amount_type: 'metered_unit', is_archived: pr.is_archived ?? false, product_id: productId, source: 'catalog',
|
|
180
|
+
type: recurring ? 'recurring' : 'one_time', recurring_interval: recurring, tax_behavior: null,
|
|
181
|
+
price_currency: pr.price_currency ?? 'usd', unit_amount: String(pr.unit_amount ?? '0'), cap_amount: pr.cap_amount ?? null,
|
|
182
|
+
meter_id: pr.meter_id ?? null, meter: { id: pr.meter_id ?? null, name: meter?.name ?? 'meter', unit: meter?.unit ?? 'unit', custom_label: null, custom_multiplier: null },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
id: pr.id ?? uuidFrom('price', `${productId}:${amount ?? 'free'}`), created_at: pr.created_at ?? createdAt, modified_at: pr.modified_at ?? null,
|
|
187
|
+
amount_type: amount != null ? 'fixed' : 'free', is_archived: pr.is_archived ?? false, product_id: productId, source: 'catalog',
|
|
188
|
+
type: recurring ? 'recurring' : 'one_time', recurring_interval: recurring, tax_behavior: null,
|
|
189
|
+
...(amount != null ? { price_currency: pr.price_currency ?? 'usd', price_amount: amount } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function subscriptionView(r, root) {
|
|
193
|
+
const v = view(r);
|
|
194
|
+
const cust = v.customer_id ? getRow('customer', String(v.customer_id), root) : undefined;
|
|
195
|
+
return {
|
|
196
|
+
id: v.id,
|
|
197
|
+
created_at: v.created_at,
|
|
198
|
+
modified_at: v.modified_at ?? null,
|
|
199
|
+
amount: v.amount ?? 0,
|
|
200
|
+
currency: v.currency ?? 'usd',
|
|
201
|
+
recurring_interval: v.recurring_interval ?? 'month',
|
|
202
|
+
status: v.status ?? 'active',
|
|
203
|
+
current_period_start: v.current_period_start ?? v.created_at,
|
|
204
|
+
current_period_end: v.current_period_end ?? null,
|
|
205
|
+
cancel_at_period_end: v.cancel_at_period_end ?? false,
|
|
206
|
+
canceled_at: v.canceled_at ?? null,
|
|
207
|
+
started_at: v.started_at ?? v.created_at,
|
|
208
|
+
ends_at: v.ends_at ?? null,
|
|
209
|
+
ended_at: v.ended_at ?? null,
|
|
210
|
+
trial_start: v.trial_start ?? null, trial_end: v.trial_end ?? null,
|
|
211
|
+
customer_cancellation_reason: null, customer_cancellation_comment: null,
|
|
212
|
+
seats: null, units: null, meters: [], custom_field_data: {},
|
|
213
|
+
customer_id: v.customer_id ?? null,
|
|
214
|
+
product_id: v.product_id ?? null,
|
|
215
|
+
discount_id: v.discount_id ?? null,
|
|
216
|
+
checkout_id: v.checkout_id ?? null,
|
|
217
|
+
metadata: v.metadata ?? {},
|
|
218
|
+
customer: cust ? customerView(cust) : null,
|
|
219
|
+
prices: [],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function orderView(r) {
|
|
223
|
+
const v = view(r);
|
|
224
|
+
return {
|
|
225
|
+
id: v.id,
|
|
226
|
+
created_at: v.created_at,
|
|
227
|
+
modified_at: v.modified_at ?? null,
|
|
228
|
+
status: v.status ?? 'paid',
|
|
229
|
+
paid: v.status === 'refunded' ? false : (v.paid ?? true),
|
|
230
|
+
subtotal_amount: v.subtotal_amount ?? v.amount ?? 0,
|
|
231
|
+
discount_amount: v.discount_amount ?? 0,
|
|
232
|
+
net_amount: v.net_amount ?? v.amount ?? 0,
|
|
233
|
+
tax_amount: v.tax_amount ?? 0,
|
|
234
|
+
total_amount: v.total_amount ?? v.amount ?? 0,
|
|
235
|
+
refunded_amount: v.refunded_amount ?? 0,
|
|
236
|
+
currency: v.currency ?? 'usd',
|
|
237
|
+
billing_reason: v.billing_reason ?? 'purchase',
|
|
238
|
+
customer_id: v.customer_id ?? null,
|
|
239
|
+
product_id: v.product_id ?? null,
|
|
240
|
+
subscription_id: v.subscription_id ?? null,
|
|
241
|
+
checkout_id: v.checkout_id ?? null,
|
|
242
|
+
metadata: v.metadata ?? {},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function checkoutView(r, root) {
|
|
246
|
+
const v = view(r);
|
|
247
|
+
const product = v.product_id ? getRow('product', String(v.product_id), root) : undefined;
|
|
248
|
+
const productOut = product ? productView({ ...product, type: 'product' }, root) : null;
|
|
249
|
+
const price = productOut && Array.isArray(productOut.prices) ? (productOut.prices[0] ?? null) : null;
|
|
250
|
+
const amount = Number(v.amount ?? 0);
|
|
251
|
+
return {
|
|
252
|
+
return_url: v.return_url ?? null, tax_behavior: null, allow_trial: null, active_trial_interval: null, active_trial_interval_count: null, trial_end: null,
|
|
253
|
+
trial_interval: null, trial_interval_count: null, organization_id: ORG_ID,
|
|
254
|
+
product_price_id: price ? String(price.id) : null, allow_discount_codes: true, require_billing_address: false, is_discount_applicable: false,
|
|
255
|
+
is_free_product_price: amount === 0, is_payment_required: amount > 0, is_payment_setup_required: productOut?.is_recurring === true, is_payment_form_required: amount > 0,
|
|
256
|
+
is_business_customer: false, customer_ip_address: null, customer_billing_name: null, customer_billing_address: null, customer_tax_id: null,
|
|
257
|
+
payment_processor_metadata: {}, billing_address_fields: { country: 'required', state: 'disabled', city: 'disabled', postal_code: 'disabled', line1: 'disabled', line2: 'disabled' },
|
|
258
|
+
external_customer_id: v.external_customer_id ?? null, customer_external_id: v.external_customer_id ?? null,
|
|
259
|
+
products: productOut ? [productOut] : [], product: productOut, product_price: price, prices: {}, discount: null, attached_custom_fields: [], customer_metadata: {}, custom_field_data: {},
|
|
260
|
+
net_amount: amount, tax_amount: null, total_amount: amount, currency: v.currency ?? 'usd', subscription_id: v.subscription_id ?? null,
|
|
261
|
+
id: v.id,
|
|
262
|
+
created_at: v.created_at,
|
|
263
|
+
modified_at: v.modified_at ?? null,
|
|
264
|
+
payment_processor: 'stripe',
|
|
265
|
+
status: v.status ?? 'open',
|
|
266
|
+
client_secret: v.client_secret,
|
|
267
|
+
url: v.url,
|
|
268
|
+
expires_at: v.expires_at,
|
|
269
|
+
success_url: v.success_url ?? null,
|
|
270
|
+
embed_origin: v.embed_origin ?? null,
|
|
271
|
+
amount: v.amount ?? 0,
|
|
272
|
+
discount_amount: 0,
|
|
273
|
+
product_id: v.product_id ?? null,
|
|
274
|
+
discount_id: v.discount_id ?? null,
|
|
275
|
+
customer_id: v.customer_id ?? null,
|
|
276
|
+
customer_email: v.customer_email ?? null,
|
|
277
|
+
customer_name: v.customer_name ?? null,
|
|
278
|
+
metadata: v.metadata ?? {},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function benefitView(r) {
|
|
282
|
+
const v = view(r);
|
|
283
|
+
return {
|
|
284
|
+
id: v.id,
|
|
285
|
+
created_at: v.created_at,
|
|
286
|
+
modified_at: v.modified_at ?? null,
|
|
287
|
+
type: v.benefit_type ?? 'custom',
|
|
288
|
+
description: v.description ?? null,
|
|
289
|
+
selectable: v.selectable ?? true,
|
|
290
|
+
deletable: v.deletable ?? true,
|
|
291
|
+
organization_id: ORG_ID,
|
|
292
|
+
properties: v.properties ?? {},
|
|
293
|
+
metadata: v.metadata ?? {},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function discountView(r) {
|
|
297
|
+
const v = view(r);
|
|
298
|
+
return {
|
|
299
|
+
id: v.id,
|
|
300
|
+
created_at: v.created_at,
|
|
301
|
+
modified_at: v.modified_at ?? null,
|
|
302
|
+
name: v.name ?? null,
|
|
303
|
+
code: v.code ?? null,
|
|
304
|
+
type: v.discount_type ?? 'percentage',
|
|
305
|
+
basis_points: v.basis_points ?? null,
|
|
306
|
+
amount: v.amount ?? null,
|
|
307
|
+
currency: v.currency ?? null,
|
|
308
|
+
duration: v.duration ?? 'once',
|
|
309
|
+
max_redemptions: v.max_redemptions ?? null,
|
|
310
|
+
redemptions_count: v.redemptions_count ?? 0,
|
|
311
|
+
organization_id: ORG_ID,
|
|
312
|
+
metadata: v.metadata ?? {},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function meterView(r) {
|
|
316
|
+
const v = view(r);
|
|
317
|
+
return {
|
|
318
|
+
id: v.id,
|
|
319
|
+
created_at: v.created_at,
|
|
320
|
+
modified_at: v.modified_at ?? null,
|
|
321
|
+
name: v.name ?? null,
|
|
322
|
+
unit: v.unit ?? 'unit', custom_label: null, custom_multiplier: null, archived_at: null,
|
|
323
|
+
aggregation: v.aggregation ?? { func: 'count' },
|
|
324
|
+
filter: v.filter ?? { conjunction: 'and', clauses: [] },
|
|
325
|
+
organization_id: ORG_ID,
|
|
326
|
+
metadata: v.metadata ?? {},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function eventView(r) {
|
|
330
|
+
const v = view(r);
|
|
331
|
+
return {
|
|
332
|
+
id: v.id,
|
|
333
|
+
timestamp: v.timestamp ?? v.created_at,
|
|
334
|
+
organization_id: ORG_ID,
|
|
335
|
+
customer_id: v.customer_id ?? null,
|
|
336
|
+
external_customer_id: v.external_customer_id ?? null,
|
|
337
|
+
name: v.name ?? null,
|
|
338
|
+
source: v.source ?? 'user',
|
|
339
|
+
metadata: v.metadata ?? {},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function webhookView(r) {
|
|
343
|
+
const v = view(r);
|
|
344
|
+
return {
|
|
345
|
+
id: v.id,
|
|
346
|
+
created_at: v.created_at,
|
|
347
|
+
modified_at: v.modified_at ?? null,
|
|
348
|
+
url: v.url ?? null,
|
|
349
|
+
format: v.format ?? 'raw',
|
|
350
|
+
events: Array.isArray(v.events) ? v.events : [],
|
|
351
|
+
organization_id: ORG_ID,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ── customer state (meter balances) ──────────────────────────────────────────────────
|
|
355
|
+
function customerState(customerId, root) {
|
|
356
|
+
const cust = getRow('customer', customerId, root);
|
|
357
|
+
const subs = rows('subscription', root)
|
|
358
|
+
.filter((s) => s.customer_id === customerId && (s.status === 'active' || s.status === 'trialing')) // Polar's active statuses are {trialing, active}
|
|
359
|
+
.map((s) => subscriptionView(s, root));
|
|
360
|
+
const meters = rows('event', root)
|
|
361
|
+
.filter((e) => e.customer_id === customerId)
|
|
362
|
+
.reduce((acc, e) => {
|
|
363
|
+
const cost = Number(e.metadata?.cost_usd ?? 0);
|
|
364
|
+
if (Number.isFinite(cost)) {
|
|
365
|
+
if (cost < 0)
|
|
366
|
+
acc.credited += Math.abs(cost);
|
|
367
|
+
else
|
|
368
|
+
acc.consumed += cost;
|
|
369
|
+
}
|
|
370
|
+
return acc;
|
|
371
|
+
}, { consumed: 0, credited: 0 });
|
|
372
|
+
// Polar's real customer state carries one entry PER METER, each meter aggregating the
|
|
373
|
+
// customer's events its filter admits by its aggregation function (sum/count/max/min/avg over a
|
|
374
|
+
// metadata property). A root with no meters keeps the earlier placeholder balance (cost_usd).
|
|
375
|
+
const defined = rows('meter', root);
|
|
376
|
+
const mine = rows('event', root).filter((e) => e.customer_id === customerId);
|
|
377
|
+
const active_meters = defined.length === 0
|
|
378
|
+
? [{ id: uuidFrom('customer_meter', customerId), meter_id: '00000000-0000-4000-8000-0000000000b1', consumed_units: meters.consumed, credited_units: meters.credited, balance: meters.credited - meters.consumed }]
|
|
379
|
+
: defined.map((m) => {
|
|
380
|
+
const matched = mine.filter((e) => meterAdmits(m.filter, e));
|
|
381
|
+
const consumed = meterAggregate(m.aggregation, matched);
|
|
382
|
+
return { id: uuidFrom('customer_meter', `${customerId}:${m.id}`), created_at: m.created_at, modified_at: null, meter_id: m.id, consumed_units: consumed, credited_units: 0, balance: -consumed };
|
|
383
|
+
});
|
|
384
|
+
return {
|
|
385
|
+
...(cust ? customerView(cust) : { id: customerId }),
|
|
386
|
+
active_subscriptions: subs,
|
|
387
|
+
granted_benefits: [],
|
|
388
|
+
active_meters,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** Does a meter's filter admit an event? Polar's filter: `{conjunction: and|or, clauses: [{property, operator, value} | filter]}`;
|
|
392
|
+
* a clause's property is `name`, `source`, or a metadata key (`metadata.x` or bare). Operators: eq, ne, gt, gte, lt, lte, like, not_like. */
|
|
393
|
+
function meterAdmits(filter, e) {
|
|
394
|
+
const clauses = Array.isArray(filter?.clauses) ? filter.clauses : [];
|
|
395
|
+
if (clauses.length === 0)
|
|
396
|
+
return true;
|
|
397
|
+
const one = (c) => {
|
|
398
|
+
if ('clauses' in c)
|
|
399
|
+
return meterAdmits(c, e);
|
|
400
|
+
const prop = String(c.property ?? '');
|
|
401
|
+
const actual = prop === 'name' || prop === 'source' ? e[prop] : (e.metadata?.[prop.replace(/^metadata\./, '')]);
|
|
402
|
+
const want = c.value;
|
|
403
|
+
const op = String(c.operator ?? 'eq');
|
|
404
|
+
switch (op) {
|
|
405
|
+
case 'eq': return actual === want || String(actual) === String(want);
|
|
406
|
+
case 'ne': return !(actual === want || String(actual) === String(want));
|
|
407
|
+
case 'gt': return Number(actual) > Number(want);
|
|
408
|
+
case 'gte': return Number(actual) >= Number(want);
|
|
409
|
+
case 'lt': return Number(actual) < Number(want);
|
|
410
|
+
case 'lte': return Number(actual) <= Number(want);
|
|
411
|
+
case 'like': return String(actual ?? '').includes(String(want));
|
|
412
|
+
case 'not_like': return !String(actual ?? '').includes(String(want));
|
|
413
|
+
default: return false;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
return String(filter?.conjunction ?? 'and') === 'or' ? clauses.some(one) : clauses.every(one);
|
|
417
|
+
}
|
|
418
|
+
/** A meter's aggregation over the admitted events: count, or sum/max/min/avg of a metadata property. */
|
|
419
|
+
function meterAggregate(agg, events) {
|
|
420
|
+
const func = String(agg?.func ?? 'count');
|
|
421
|
+
if (func === 'count')
|
|
422
|
+
return events.length;
|
|
423
|
+
const prop = String(agg?.property ?? '').replace(/^metadata\./, '');
|
|
424
|
+
const values = events.map((e) => Number(e.metadata?.[prop])).filter((n) => Number.isFinite(n));
|
|
425
|
+
if (values.length === 0)
|
|
426
|
+
return 0;
|
|
427
|
+
if (func === 'sum')
|
|
428
|
+
return values.reduce((a, b) => a + b, 0);
|
|
429
|
+
if (func === 'max')
|
|
430
|
+
return Math.max(...values);
|
|
431
|
+
if (func === 'min')
|
|
432
|
+
return Math.min(...values);
|
|
433
|
+
if (func === 'avg')
|
|
434
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
435
|
+
return values.reduce((a, b) => a + b, 0);
|
|
436
|
+
}
|
|
437
|
+
/** The customer an external id names, or one created for it — Polar creates the customer on first use of an external id. */
|
|
438
|
+
async function customerForExternalId(ext, req, email) {
|
|
439
|
+
const existing = rows('customer', req.root).find((c) => c.external_id === ext);
|
|
440
|
+
if (existing)
|
|
441
|
+
return existing;
|
|
442
|
+
const cid = mintId('customer', req, ext);
|
|
443
|
+
return write('customer', cid, {
|
|
444
|
+
created_at: nowIso(req.occurredAt), modified_at: null, email: email ?? `${ext}@external.twin`, name: null, external_id: ext,
|
|
445
|
+
customer_type: 'individual', email_verified: false, locale: 'en', metadata: {},
|
|
446
|
+
}, 'customer.create', req);
|
|
447
|
+
}
|
|
448
|
+
// ── route handler ────────────────────────────────────────────────────────────────────
|
|
449
|
+
export async function handlePolarTwinRequest(req) {
|
|
450
|
+
const method = req.method.toUpperCase();
|
|
451
|
+
const [rawPath, rawQuery] = req.path.split('?');
|
|
452
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
453
|
+
const query = Object.fromEntries(new URLSearchParams(rawQuery ?? ''));
|
|
454
|
+
const body = parseBody(req.body);
|
|
455
|
+
// segments after the /v1 prefix, e.g. /v1/customers/abc → ["customers","abc"]
|
|
456
|
+
const all = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
457
|
+
const seg = all[0] === 'v1' ? all.slice(1) : all;
|
|
458
|
+
// protocol 2: after a landing adopted the vendor's id, a caller on a branch may still address a product or a
|
|
459
|
+
// customer by the local id — resolved through the alias map once, here at the boundary (a `type:` prefix is tolerated)
|
|
460
|
+
const bare = (type, id) => resolveSubjectId(SERVICE, type, kid(type, id.replace(new RegExp(`^${type}:`), '')), req.root).replace(new RegExp(`^${type}:`), '');
|
|
461
|
+
if ((seg[0] === 'products' || seg[0] === 'customers') && seg[1] && seg[1] !== 'external')
|
|
462
|
+
seg[1] = bare(seg[0].slice(0, -1), decodeURIComponent(seg[1]));
|
|
463
|
+
const idAt = (i) => decodeURIComponent(seg[i] ?? '');
|
|
464
|
+
if (req.readOnly && method !== 'GET') {
|
|
465
|
+
return { status: 405, body: { type: 'NotPermitted', detail: 'twin is read-only; omit readOnly to accept writes' } };
|
|
466
|
+
}
|
|
467
|
+
if (path === '/' || seg.length === 0) {
|
|
468
|
+
return { status: 200, body: { service: 'polar', object: 'twin', organization_id: ORG_ID } };
|
|
469
|
+
}
|
|
470
|
+
// ── CUSTOMERS ──
|
|
471
|
+
if (seg[0] === 'customers') {
|
|
472
|
+
if (seg.length === 1 && method === 'POST') {
|
|
473
|
+
if (!body.email && (body.type ?? 'individual') === 'individual') {
|
|
474
|
+
return validationError(['email'], 'Field required', 'missing');
|
|
475
|
+
}
|
|
476
|
+
const email = body.email ? String(body.email) : null;
|
|
477
|
+
if (email && rows('customer', req.root).some((c) => c.email === email)) {
|
|
478
|
+
return { status: 422, body: { type: 'PolarRequestValidationError', detail: [{ type: 'value_error', loc: ['body', 'email'], msg: 'A customer with this email address already exists.' }] } };
|
|
479
|
+
}
|
|
480
|
+
const id = mintId('customer', req);
|
|
481
|
+
const c = await write('customer', id, {
|
|
482
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
483
|
+
email, name: body.name ?? null, external_id: body.external_id ?? null,
|
|
484
|
+
customer_type: body.type ?? 'individual', email_verified: false,
|
|
485
|
+
billing_name: body.billing_name ?? null, billing_address: body.billing_address ?? null,
|
|
486
|
+
tax_id: body.tax_id ?? null, locale: body.locale ?? 'en',
|
|
487
|
+
metadata: body.metadata ?? {},
|
|
488
|
+
}, 'customer.create', req);
|
|
489
|
+
return { status: 201, body: customerView({ ...c, type: 'customer' }) };
|
|
490
|
+
}
|
|
491
|
+
if (seg.length === 1 && method === 'GET') {
|
|
492
|
+
const email = query.email;
|
|
493
|
+
const extId = query.external_id;
|
|
494
|
+
const items = rows('customer', req.root)
|
|
495
|
+
.filter((c) => (!email || c.email === email) && (!extId || c.external_id === extId))
|
|
496
|
+
.map((c) => customerView({ ...c, type: 'customer' }));
|
|
497
|
+
return { status: 200, body: listEnvelope(items) };
|
|
498
|
+
}
|
|
499
|
+
// GET /customers/:id/state
|
|
500
|
+
// GET /customers/external/:external_id and …/state — the customer by YOUR id (Polar's real routes)
|
|
501
|
+
if (seg[1] === 'external' && (seg.length === 3 || (seg.length === 4 && seg[3] === 'state')) && method === 'GET') {
|
|
502
|
+
const c = rows('customer', req.root).find((x) => x.external_id === idAt(2));
|
|
503
|
+
if (!c)
|
|
504
|
+
return notFound('Customer not found.');
|
|
505
|
+
return { status: 200, body: seg.length === 4 ? customerState(String(c.id), req.root) : customerView({ ...c, type: 'customer' }) };
|
|
506
|
+
}
|
|
507
|
+
if (seg.length === 3 && seg[2] === 'state' && method === 'GET') {
|
|
508
|
+
if (!getRow('customer', idAt(1), req.root))
|
|
509
|
+
return notFound('Customer not found.');
|
|
510
|
+
return { status: 200, body: customerState(idAt(1), req.root) };
|
|
511
|
+
}
|
|
512
|
+
if (seg.length === 2 && method === 'GET') {
|
|
513
|
+
const c = getRow('customer', idAt(1), req.root);
|
|
514
|
+
return c ? { status: 200, body: customerView({ ...c, type: 'customer' }) } : notFound('Customer not found.');
|
|
515
|
+
}
|
|
516
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
517
|
+
const c = getRow('customer', idAt(1), req.root);
|
|
518
|
+
if (!c)
|
|
519
|
+
return notFound('Customer not found.');
|
|
520
|
+
const patch = { modified_at: nowIso(req.occurredAt) };
|
|
521
|
+
for (const k of ['email', 'name', 'external_id', 'billing_name', 'billing_address', 'tax_id', 'locale', 'metadata'])
|
|
522
|
+
if (k in body)
|
|
523
|
+
patch[k] = body[k];
|
|
524
|
+
const updated = await write('customer', idAt(1), patch, 'customer.update', req);
|
|
525
|
+
return { status: 200, body: customerView({ ...updated, type: 'customer' }) };
|
|
526
|
+
}
|
|
527
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
528
|
+
const c = getRow('customer', idAt(1), req.root);
|
|
529
|
+
if (!c)
|
|
530
|
+
return notFound('Customer not found.');
|
|
531
|
+
await write('customer', idAt(1), { deleted: true }, 'customer.delete', req);
|
|
532
|
+
return { status: 204, body: null };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// ── PRODUCTS ──
|
|
536
|
+
if (seg[0] === 'products') {
|
|
537
|
+
if (seg.length === 1 && method === 'POST') {
|
|
538
|
+
if (!body.name)
|
|
539
|
+
return validationError(['name'], 'Field required', 'missing');
|
|
540
|
+
const prices = Array.isArray(body.prices)
|
|
541
|
+
? body.prices.map((p, i) => ({ id: uuidFrom('price', `${nowIso(req.occurredAt)}:${rows('product', req.root).length}:${i}`), ...p }))
|
|
542
|
+
: [];
|
|
543
|
+
const id = mintId('product', req);
|
|
544
|
+
const p = await write('product', id, {
|
|
545
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
546
|
+
name: body.name, description: body.description ?? null,
|
|
547
|
+
recurring_interval: body.recurring_interval ?? null,
|
|
548
|
+
trial_interval: body.trial_interval ?? null, trial_interval_count: body.trial_interval_count ?? null, // the product's trial, as Polar keeps it
|
|
549
|
+
is_archived: false, prices, benefits: [], metadata: body.metadata ?? {},
|
|
550
|
+
}, 'product.create', req);
|
|
551
|
+
return { status: 201, body: productView({ ...p, type: 'product' }, req.root) };
|
|
552
|
+
}
|
|
553
|
+
if (seg.length === 1 && method === 'GET') {
|
|
554
|
+
let items = rows('product', req.root);
|
|
555
|
+
if (query.is_archived !== undefined)
|
|
556
|
+
items = items.filter((p) => Boolean(p.is_archived) === (query.is_archived === 'true'));
|
|
557
|
+
return { status: 200, body: listEnvelope(items.map((p) => productView({ ...p, type: 'product' }, req.root))) };
|
|
558
|
+
}
|
|
559
|
+
if (seg.length === 2 && method === 'GET') {
|
|
560
|
+
const p = getRow('product', idAt(1), req.root);
|
|
561
|
+
return p ? { status: 200, body: productView({ ...p, type: 'product' }, req.root) } : notFound('Product not found.');
|
|
562
|
+
}
|
|
563
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
564
|
+
const p = getRow('product', idAt(1), req.root);
|
|
565
|
+
if (!p)
|
|
566
|
+
return notFound('Product not found.');
|
|
567
|
+
const patch = { modified_at: nowIso(req.occurredAt) };
|
|
568
|
+
for (const k of ['name', 'description', 'recurring_interval', 'is_archived', 'metadata'])
|
|
569
|
+
if (k in body)
|
|
570
|
+
patch[k] = body[k];
|
|
571
|
+
const updated = await write('product', idAt(1), patch, 'product.update', req);
|
|
572
|
+
return { status: 200, body: productView({ ...updated, type: 'product' }, req.root) };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// ── BENEFITS ──
|
|
576
|
+
if (seg[0] === 'benefits') {
|
|
577
|
+
if (seg.length === 1 && method === 'POST') {
|
|
578
|
+
if (!body.type)
|
|
579
|
+
return validationError(['type'], 'Field required', 'missing');
|
|
580
|
+
const id = mintId('benefit', req);
|
|
581
|
+
const b = await write('benefit', id, {
|
|
582
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
583
|
+
benefit_type: body.type, description: body.description ?? null,
|
|
584
|
+
properties: body.properties ?? {}, metadata: body.metadata ?? {},
|
|
585
|
+
}, 'benefit.create', req);
|
|
586
|
+
return { status: 201, body: benefitView({ ...b, type: 'benefit' }) };
|
|
587
|
+
}
|
|
588
|
+
if (seg.length === 1 && method === 'GET') {
|
|
589
|
+
return { status: 200, body: listEnvelope(rows('benefit', req.root).map((b) => benefitView({ ...b, type: 'benefit' }))) };
|
|
590
|
+
}
|
|
591
|
+
if (seg.length === 2 && method === 'GET') {
|
|
592
|
+
const b = getRow('benefit', idAt(1), req.root);
|
|
593
|
+
return b ? { status: 200, body: benefitView({ ...b, type: 'benefit' }) } : notFound('Benefit not found.');
|
|
594
|
+
}
|
|
595
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
596
|
+
const b = getRow('benefit', idAt(1), req.root);
|
|
597
|
+
if (!b)
|
|
598
|
+
return notFound('Benefit not found.');
|
|
599
|
+
await write('benefit', idAt(1), { deleted: true }, 'benefit.delete', req);
|
|
600
|
+
return { status: 204, body: null };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// ── DISCOUNTS ──
|
|
604
|
+
if (seg[0] === 'discounts') {
|
|
605
|
+
if (seg.length === 1 && method === 'POST') {
|
|
606
|
+
if (!body.name)
|
|
607
|
+
return validationError(['name'], 'Field required', 'missing');
|
|
608
|
+
if (!body.type)
|
|
609
|
+
return validationError(['type'], 'Field required', 'missing');
|
|
610
|
+
const id = mintId('discount', req);
|
|
611
|
+
const d = await write('discount', id, {
|
|
612
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
613
|
+
name: body.name, code: body.code ?? null, discount_type: body.type,
|
|
614
|
+
basis_points: body.basis_points ?? null, amount: body.amount ?? null,
|
|
615
|
+
currency: body.currency ?? null, duration: body.duration ?? 'once',
|
|
616
|
+
max_redemptions: body.max_redemptions ?? null, redemptions_count: 0,
|
|
617
|
+
metadata: body.metadata ?? {},
|
|
618
|
+
}, 'discount.create', req);
|
|
619
|
+
return { status: 201, body: discountView({ ...d, type: 'discount' }) };
|
|
620
|
+
}
|
|
621
|
+
if (seg.length === 1 && method === 'GET') {
|
|
622
|
+
return { status: 200, body: listEnvelope(rows('discount', req.root).map((d) => discountView({ ...d, type: 'discount' }))) };
|
|
623
|
+
}
|
|
624
|
+
if (seg.length === 2 && method === 'GET') {
|
|
625
|
+
const d = getRow('discount', idAt(1), req.root);
|
|
626
|
+
return d ? { status: 200, body: discountView({ ...d, type: 'discount' }) } : notFound('Discount not found.');
|
|
627
|
+
}
|
|
628
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
629
|
+
const d = getRow('discount', idAt(1), req.root);
|
|
630
|
+
if (!d)
|
|
631
|
+
return notFound('Discount not found.');
|
|
632
|
+
const patch = { modified_at: nowIso(req.occurredAt) };
|
|
633
|
+
for (const k of ['name', 'code', 'basis_points', 'amount', 'duration', 'max_redemptions', 'metadata'])
|
|
634
|
+
if (k in body)
|
|
635
|
+
patch[k] = body[k];
|
|
636
|
+
const updated = await write('discount', idAt(1), patch, 'discount.update', req);
|
|
637
|
+
return { status: 200, body: discountView({ ...updated, type: 'discount' }) };
|
|
638
|
+
}
|
|
639
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
640
|
+
const d = getRow('discount', idAt(1), req.root);
|
|
641
|
+
if (!d)
|
|
642
|
+
return notFound('Discount not found.');
|
|
643
|
+
await write('discount', idAt(1), { deleted: true }, 'discount.delete', req);
|
|
644
|
+
return { status: 204, body: null };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// ── METERS ──
|
|
648
|
+
if (seg[0] === 'meters') {
|
|
649
|
+
if (seg.length === 1 && method === 'POST') {
|
|
650
|
+
if (!body.name)
|
|
651
|
+
return validationError(['name'], 'Field required', 'missing');
|
|
652
|
+
const id = mintId('meter', req);
|
|
653
|
+
const m = await write('meter', id, {
|
|
654
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
655
|
+
name: body.name, aggregation: body.aggregation ?? { func: 'count' },
|
|
656
|
+
filter: body.filter ?? { conjunction: 'and', clauses: [] }, metadata: body.metadata ?? {},
|
|
657
|
+
}, 'meter.create', req);
|
|
658
|
+
return { status: 201, body: meterView({ ...m, type: 'meter' }) };
|
|
659
|
+
}
|
|
660
|
+
if (seg.length === 1 && method === 'GET') {
|
|
661
|
+
return { status: 200, body: listEnvelope(rows('meter', req.root).map((m) => meterView({ ...m, type: 'meter' }))) };
|
|
662
|
+
}
|
|
663
|
+
if (seg.length === 2 && method === 'GET') {
|
|
664
|
+
const m = getRow('meter', idAt(1), req.root);
|
|
665
|
+
return m ? { status: 200, body: meterView({ ...m, type: 'meter' }) } : notFound('Meter not found.');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// ── EVENTS ──
|
|
669
|
+
if (seg[0] === 'events') {
|
|
670
|
+
// POST /events/ingest
|
|
671
|
+
if (seg.length === 2 && seg[1] === 'ingest' && method === 'POST') {
|
|
672
|
+
const events = Array.isArray(body.events) ? body.events : null;
|
|
673
|
+
if (!events)
|
|
674
|
+
return validationError(['events'], 'Field required', 'missing');
|
|
675
|
+
let inserted = 0;
|
|
676
|
+
let duplicates = 0;
|
|
677
|
+
for (const [i, e] of events.entries()) {
|
|
678
|
+
if (!e.name)
|
|
679
|
+
return validationError(['events', i, 'name'], 'Field required', 'missing');
|
|
680
|
+
// `external_id` is YOUR id for the event — a second ingest of it is a duplicate, not a second event
|
|
681
|
+
if (e.external_id && rows('event', req.root).some((x) => x.external_id === e.external_id)) {
|
|
682
|
+
duplicates += 1;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
// an external customer id names (or creates) the customer; the event lands on that customer
|
|
686
|
+
let customerId = e.customer_id ? String(e.customer_id) : null;
|
|
687
|
+
if (!customerId && e.external_customer_id)
|
|
688
|
+
customerId = String((await customerForExternalId(String(e.external_customer_id), req)).id);
|
|
689
|
+
const id = mintId('event', req, String(e.external_id ?? i));
|
|
690
|
+
await write('event', id, {
|
|
691
|
+
timestamp: e.timestamp ?? nowIso(req.occurredAt), created_at: nowIso(req.occurredAt),
|
|
692
|
+
name: e.name, customer_id: customerId, external_id: e.external_id ?? null,
|
|
693
|
+
external_customer_id: e.external_customer_id ?? null,
|
|
694
|
+
source: 'user', metadata: e.metadata ?? {},
|
|
695
|
+
}, 'event.ingest', req);
|
|
696
|
+
inserted += 1;
|
|
697
|
+
}
|
|
698
|
+
return { status: 200, body: { inserted, duplicates } };
|
|
699
|
+
}
|
|
700
|
+
if (seg.length === 1 && method === 'GET') {
|
|
701
|
+
const customerId = query.customer_id;
|
|
702
|
+
const name = query.name;
|
|
703
|
+
const ext = query.external_customer_id;
|
|
704
|
+
const items = rows('event', req.root)
|
|
705
|
+
.filter((e) => (!customerId || e.customer_id === customerId) && (!name || e.name === name) && (!ext || e.external_customer_id === ext))
|
|
706
|
+
.map((e) => eventView({ ...e, type: 'event' }));
|
|
707
|
+
return { status: 200, body: listEnvelope(items) };
|
|
708
|
+
}
|
|
709
|
+
if (seg.length === 2 && method === 'GET') {
|
|
710
|
+
const e = getRow('event', idAt(1), req.root);
|
|
711
|
+
return e ? { status: 200, body: eventView({ ...e, type: 'event' }) } : notFound('Event not found.');
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ── CUSTOMER SESSIONS ──
|
|
715
|
+
if (seg[0] === 'customer-sessions') {
|
|
716
|
+
if (seg.length === 1 && method === 'POST') {
|
|
717
|
+
const customerId = body.customer_id ? String(body.customer_id) : null;
|
|
718
|
+
const extId = body.external_customer_id ? String(body.external_customer_id) : null;
|
|
719
|
+
let cust = customerId ? getRow('customer', customerId, req.root) : undefined;
|
|
720
|
+
if (!cust && extId)
|
|
721
|
+
cust = rows('customer', req.root).find((c) => c.external_id === extId);
|
|
722
|
+
if (!cust)
|
|
723
|
+
return notFound('Customer not found.');
|
|
724
|
+
const id = mintId('customer_session', req, String(cust.id));
|
|
725
|
+
const token = `polar_cst_${mintSecret('customer_session_token', req, String(cust.id))}`;
|
|
726
|
+
const s = await write('customer_session', id, {
|
|
727
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
728
|
+
token, expires_at: laterIso(req.occurredAt), customer_id: cust.id, return_url: body.return_url ?? null,
|
|
729
|
+
customer_portal_url: `https://polar.sh/twin/portal?customer_session_token=${token}`,
|
|
730
|
+
}, 'customer_session.create', req);
|
|
731
|
+
const v = view({ ...s, type: 'customer_session' });
|
|
732
|
+
return { status: 201, body: { ...v, customer: customerView({ ...cust, type: 'customer' }) } };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// ── CHECKOUTS ──
|
|
736
|
+
if (seg[0] === 'checkouts') {
|
|
737
|
+
if (seg.length === 1 && method === 'POST') {
|
|
738
|
+
const products = (Array.isArray(body.products) ? body.products : (body.product_id ? [String(body.product_id)] : [])).map((id) => bare('product', String(id)));
|
|
739
|
+
if (products.length === 0)
|
|
740
|
+
return validationError(['products'], 'Field required', 'missing');
|
|
741
|
+
const productId = products[0];
|
|
742
|
+
const product = getRow('product', productId, req.root);
|
|
743
|
+
const amount = product && Array.isArray(product.prices) && product.prices[0]
|
|
744
|
+
? Number(product.prices[0].price_amount ?? 1000)
|
|
745
|
+
: Number(body.amount ?? 1000);
|
|
746
|
+
const id = mintId('checkout', req);
|
|
747
|
+
const c = await write('checkout', id, {
|
|
748
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
749
|
+
status: 'open', client_secret: `polar_checkout_${mintSecret('checkout_client_secret', req, id)}`,
|
|
750
|
+
// the hosted checkout page: at the twin's own address when it is served over HTTP (a person in a walk can open it), else the vendor's
|
|
751
|
+
url: req.publicBase ? `${req.publicBase}/twin/checkout/${id}` : `https://polar.sh/twin/checkout/${id}`, expires_at: laterIso(req.occurredAt),
|
|
752
|
+
success_url: body.success_url ?? null, embed_origin: body.embed_origin ?? null,
|
|
753
|
+
amount, currency: 'usd', product_id: productId, products,
|
|
754
|
+
customer_id: body.customer_id ?? null, customer_email: body.customer_email ?? null,
|
|
755
|
+
customer_name: body.customer_name ?? null, discount_id: body.discount_id ?? null,
|
|
756
|
+
external_customer_id: body.external_customer_id ?? null, // your id for the customer: confirm links or creates the customer by it
|
|
757
|
+
metadata: body.metadata ?? {},
|
|
758
|
+
}, 'checkout.create', req);
|
|
759
|
+
return { status: 201, body: checkoutView({ ...c, type: 'checkout' }, req.root) };
|
|
760
|
+
}
|
|
761
|
+
if (seg.length === 1 && method === 'GET') {
|
|
762
|
+
return { status: 200, body: listEnvelope(rows('checkout', req.root).map((c) => checkoutView({ ...c, type: 'checkout' }, req.root))) };
|
|
763
|
+
}
|
|
764
|
+
if (seg.length === 2 && method === 'GET') {
|
|
765
|
+
const c = getRow('checkout', idAt(1), req.root);
|
|
766
|
+
return c ? { status: 200, body: checkoutView({ ...c, type: 'checkout' }, req.root) } : notFound('Checkout not found.');
|
|
767
|
+
}
|
|
768
|
+
// POST /checkouts/:id/confirm — confirm the checkout: create customer (if needed),
|
|
769
|
+
// subscription/order, and flip the checkout to confirmed (the twin "completes" payment).
|
|
770
|
+
if (seg.length === 3 && seg[2] === 'confirm' && method === 'POST') {
|
|
771
|
+
const c = getRow('checkout', idAt(1), req.root);
|
|
772
|
+
if (!c)
|
|
773
|
+
return notFound('Checkout not found.');
|
|
774
|
+
if (c.status !== 'open')
|
|
775
|
+
return notPermitted('This checkout has already been confirmed.');
|
|
776
|
+
// Resolve / create the customer.
|
|
777
|
+
let customerId = c.customer_id ? String(c.customer_id) : null;
|
|
778
|
+
const email = body.customer_email ?? c.customer_email ?? null;
|
|
779
|
+
if (!customerId && c.external_customer_id)
|
|
780
|
+
customerId = String((await customerForExternalId(String(c.external_customer_id), req, email)).id);
|
|
781
|
+
if (!customerId && email) {
|
|
782
|
+
const existing = rows('customer', req.root).find((x) => x.email === email);
|
|
783
|
+
if (existing)
|
|
784
|
+
customerId = String(existing.id);
|
|
785
|
+
else {
|
|
786
|
+
const cid = mintId('customer', req);
|
|
787
|
+
await write('customer', cid, {
|
|
788
|
+
created_at: nowIso(req.occurredAt), modified_at: null, email,
|
|
789
|
+
name: body.customer_name ?? c.customer_name ?? null, external_id: null,
|
|
790
|
+
customer_type: 'individual', email_verified: false, locale: 'en', metadata: {},
|
|
791
|
+
}, 'customer.create', req);
|
|
792
|
+
customerId = cid;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
const product = c.product_id ? getRow('product', String(c.product_id), req.root) : undefined;
|
|
796
|
+
const recurring = product?.recurring_interval ?? null;
|
|
797
|
+
const at = nowIso(req.occurredAt);
|
|
798
|
+
// a product with a trial starts the subscription `trialing` with trial_start/trial_end, as Polar does; the first charge comes when the trial ends
|
|
799
|
+
const trialUnit = product?.trial_interval ? String(product.trial_interval) : null;
|
|
800
|
+
const trialCount = Number(product?.trial_interval_count ?? 0);
|
|
801
|
+
const trialMs = trialUnit && trialCount > 0 ? trialCount * (trialUnit === 'day' ? 86_400_000 : trialUnit === 'week' ? 7 * 86_400_000 : trialUnit === 'month' ? 30 * 86_400_000 : 365 * 86_400_000) : 0;
|
|
802
|
+
const trialEnd = trialMs ? new Date(Date.parse(at) + trialMs).toISOString() : null;
|
|
803
|
+
let subscriptionId = null;
|
|
804
|
+
if (recurring) {
|
|
805
|
+
subscriptionId = mintId('subscription', req);
|
|
806
|
+
await write('subscription', subscriptionId, {
|
|
807
|
+
created_at: at, modified_at: null, status: trialEnd ? 'trialing' : 'active',
|
|
808
|
+
amount: c.amount ?? 0, currency: 'usd', recurring_interval: recurring,
|
|
809
|
+
current_period_start: at, current_period_end: trialEnd ?? laterIso(req.occurredAt),
|
|
810
|
+
...(trialEnd ? { trial_start: at, trial_end: trialEnd } : {}),
|
|
811
|
+
started_at: at, cancel_at_period_end: false, canceled_at: null,
|
|
812
|
+
customer_id: customerId, product_id: c.product_id, checkout_id: c.id, metadata: {},
|
|
813
|
+
}, 'subscription.create', req);
|
|
814
|
+
}
|
|
815
|
+
const orderId = mintId('order', req);
|
|
816
|
+
await write('order', orderId, {
|
|
817
|
+
created_at: at, modified_at: null, status: 'paid', paid: true,
|
|
818
|
+
amount: c.amount ?? 0, currency: 'usd', billing_reason: recurring ? 'subscription_create' : 'purchase',
|
|
819
|
+
customer_id: customerId, product_id: c.product_id, subscription_id: subscriptionId,
|
|
820
|
+
checkout_id: c.id, metadata: {},
|
|
821
|
+
}, 'order.create', req);
|
|
822
|
+
await write('checkout', idAt(1), { status: 'confirmed', modified_at: at, customer_id: customerId, subscription_id: subscriptionId }, 'checkout.confirm', req);
|
|
823
|
+
const updated = getRow('checkout', idAt(1), req.root);
|
|
824
|
+
return { status: 200, body: checkoutView({ ...updated, type: 'checkout' }, req.root) };
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// ── SUBSCRIPTIONS ──
|
|
828
|
+
if (seg[0] === 'subscriptions') {
|
|
829
|
+
if (seg.length === 1 && method === 'GET') {
|
|
830
|
+
const customerId = query.customer_id;
|
|
831
|
+
const items = rows('subscription', req.root)
|
|
832
|
+
.filter((s) => !customerId || s.customer_id === customerId)
|
|
833
|
+
.map((s) => subscriptionView(s, req.root));
|
|
834
|
+
return { status: 200, body: listEnvelope(items) };
|
|
835
|
+
}
|
|
836
|
+
if (seg.length === 2 && method === 'GET') {
|
|
837
|
+
const s = getRow('subscription', idAt(1), req.root);
|
|
838
|
+
return s ? { status: 200, body: subscriptionView(s, req.root) } : notFound('Subscription not found.');
|
|
839
|
+
}
|
|
840
|
+
if (seg.length === 2 && method === 'PATCH') {
|
|
841
|
+
const s = getRow('subscription', idAt(1), req.root);
|
|
842
|
+
if (!s)
|
|
843
|
+
return notFound('Subscription not found.');
|
|
844
|
+
const patch = { modified_at: nowIso(req.occurredAt) };
|
|
845
|
+
if ('product_id' in body)
|
|
846
|
+
patch.product_id = body.product_id;
|
|
847
|
+
if ('cancel_at_period_end' in body) {
|
|
848
|
+
patch.cancel_at_period_end = Boolean(body.cancel_at_period_end);
|
|
849
|
+
patch.canceled_at = body.cancel_at_period_end ? nowIso(req.occurredAt) : null;
|
|
850
|
+
}
|
|
851
|
+
if ('metadata' in body)
|
|
852
|
+
patch.metadata = body.metadata;
|
|
853
|
+
if ('discount_id' in body)
|
|
854
|
+
patch.discount_id = body.discount_id;
|
|
855
|
+
const updated = await write('subscription', idAt(1), patch, 'subscription.update', req);
|
|
856
|
+
return { status: 200, body: subscriptionView({ ...updated, customer_id: s.customer_id }, req.root) };
|
|
857
|
+
}
|
|
858
|
+
// DELETE /subscriptions/:id — cancel (immediately, or revoke). Polar returns the canceled sub.
|
|
859
|
+
if (seg.length === 2 && method === 'DELETE') {
|
|
860
|
+
const s = getRow('subscription', idAt(1), req.root);
|
|
861
|
+
if (!s)
|
|
862
|
+
return notFound('Subscription not found.');
|
|
863
|
+
const at = nowIso(req.occurredAt);
|
|
864
|
+
const updated = await write('subscription', idAt(1), { status: 'canceled', canceled_at: at, ended_at: at, ends_at: at, modified_at: at }, 'subscription.cancel', req);
|
|
865
|
+
return { status: 200, body: subscriptionView({ ...updated, customer_id: s.customer_id }, req.root) };
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
// ── ORDERS ──
|
|
869
|
+
if (seg[0] === 'orders') {
|
|
870
|
+
if (seg.length === 1 && method === 'GET') {
|
|
871
|
+
const customerId = query.customer_id;
|
|
872
|
+
const subscriptionId = query.subscription_id;
|
|
873
|
+
const items = rows('order', req.root)
|
|
874
|
+
.filter((o) => (!customerId || o.customer_id === customerId) && (!subscriptionId || o.subscription_id === subscriptionId))
|
|
875
|
+
.map((o) => orderView({ ...o, type: 'order' }));
|
|
876
|
+
return { status: 200, body: listEnvelope(items) };
|
|
877
|
+
}
|
|
878
|
+
if (seg.length === 2 && method === 'GET') {
|
|
879
|
+
const o = getRow('order', idAt(1), req.root);
|
|
880
|
+
return o ? { status: 200, body: orderView({ ...o, type: 'order' }) } : notFound('Order not found.');
|
|
881
|
+
}
|
|
882
|
+
// POST /orders/:id/refund — refund an order (the twin marks it refunded offline)
|
|
883
|
+
if (seg.length === 3 && seg[2] === 'refund' && method === 'POST') {
|
|
884
|
+
const o = getRow('order', idAt(1), req.root);
|
|
885
|
+
if (!o)
|
|
886
|
+
return notFound('Order not found.');
|
|
887
|
+
const amount = Number(body.amount ?? o.total_amount ?? o.amount ?? 0);
|
|
888
|
+
const updated = await write('order', idAt(1), { status: 'refunded', paid: false, refunded_amount: amount, modified_at: nowIso(req.occurredAt) }, 'order.refund', req);
|
|
889
|
+
return { status: 200, body: orderView({ ...updated, type: 'order' }) };
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// ── WEBHOOK ENDPOINTS ──
|
|
893
|
+
if (seg[0] === 'webhooks' && seg[1] === 'endpoints') {
|
|
894
|
+
if (seg.length === 2 && method === 'POST') {
|
|
895
|
+
if (!body.url)
|
|
896
|
+
return validationError(['url'], 'Field required', 'missing');
|
|
897
|
+
const id = mintId('webhook_endpoint', req);
|
|
898
|
+
const w = await write('webhook_endpoint', id, {
|
|
899
|
+
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
900
|
+
url: body.url, format: body.format ?? 'raw', events: Array.isArray(body.events) ? body.events : [],
|
|
901
|
+
}, 'webhook_endpoint.create', req);
|
|
902
|
+
return { status: 201, body: webhookView({ ...w, type: 'webhook_endpoint' }) };
|
|
903
|
+
}
|
|
904
|
+
if (seg.length === 2 && method === 'GET') {
|
|
905
|
+
return { status: 200, body: listEnvelope(rows('webhook_endpoint', req.root).map((w) => webhookView({ ...w, type: 'webhook_endpoint' }))) };
|
|
906
|
+
}
|
|
907
|
+
if (seg.length === 3 && method === 'GET') {
|
|
908
|
+
const w = getRow('webhook_endpoint', idAt(2), req.root);
|
|
909
|
+
return w ? { status: 200, body: webhookView({ ...w, type: 'webhook_endpoint' }) } : notFound('Webhook endpoint not found.');
|
|
910
|
+
}
|
|
911
|
+
if (seg.length === 3 && method === 'DELETE') {
|
|
912
|
+
const w = getRow('webhook_endpoint', idAt(2), req.root);
|
|
913
|
+
if (!w)
|
|
914
|
+
return notFound('Webhook endpoint not found.');
|
|
915
|
+
await write('webhook_endpoint', idAt(2), { deleted: true }, 'webhook_endpoint.delete', req);
|
|
916
|
+
return { status: 204, body: null };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// Unmodeled route → Polar's ResourceNotFound envelope (D2 — never fabricated success).
|
|
920
|
+
return notFound(`Cannot ${method} ${path}`);
|
|
921
|
+
}
|
|
922
|
+
// Endpoint inventory used by the conformance snapshot.
|
|
923
|
+
export function polarTwinSnapshot() {
|
|
924
|
+
return {
|
|
925
|
+
resourceTypes: POLAR_RESOURCE_TYPES,
|
|
926
|
+
implementedEndpoints: [
|
|
927
|
+
'POST /v1/customers/', 'GET /v1/customers/', 'GET /v1/customers/:id',
|
|
928
|
+
'PATCH /v1/customers/:id', 'DELETE /v1/customers/:id', 'GET /v1/customers/:id/state',
|
|
929
|
+
'POST /v1/products/', 'GET /v1/products/', 'GET /v1/products/:id', 'PATCH /v1/products/:id',
|
|
930
|
+
'POST /v1/benefits/', 'GET /v1/benefits/', 'GET /v1/benefits/:id', 'DELETE /v1/benefits/:id',
|
|
931
|
+
'POST /v1/discounts/', 'GET /v1/discounts/', 'GET /v1/discounts/:id', 'PATCH /v1/discounts/:id', 'DELETE /v1/discounts/:id',
|
|
932
|
+
'POST /v1/meters/', 'GET /v1/meters/', 'GET /v1/meters/:id',
|
|
933
|
+
'POST /v1/events/ingest', 'GET /v1/events/', 'GET /v1/events/:id',
|
|
934
|
+
'POST /v1/customer-sessions/',
|
|
935
|
+
'POST /v1/checkouts/', 'GET /v1/checkouts/', 'GET /v1/checkouts/:id', 'POST /v1/checkouts/:id/confirm',
|
|
936
|
+
'GET /v1/subscriptions/', 'GET /v1/subscriptions/:id', 'PATCH /v1/subscriptions/:id', 'DELETE /v1/subscriptions/:id',
|
|
937
|
+
'GET /v1/orders/', 'GET /v1/orders/:id', 'POST /v1/orders/:id/refund',
|
|
938
|
+
'POST /v1/webhooks/endpoints/', 'GET /v1/webhooks/endpoints/', 'GET /v1/webhooks/endpoints/:id', 'DELETE /v1/webhooks/endpoints/:id',
|
|
939
|
+
],
|
|
940
|
+
};
|
|
941
|
+
}
|