@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
package/src/polar-twin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Polar API twin REQUEST HANDLER — the canonical api.polar.sh surface for the twin, backed by
|
|
2
|
-
// the event/action-log kernel (@volter/
|
|
2
|
+
// the event/action-log kernel (@volter/world-core). Contract:
|
|
3
3
|
// handlePolarTwinRequest({ method, path, body, root, readOnly }) -> { status, body }
|
|
4
4
|
// Response SHAPES mirror real Polar (UUID4 ids, `{ items, pagination }` list envelopes, the
|
|
5
5
|
// FastAPI-style error envelopes) so the real `@polar-sh/sdk` works unmodified. HTTP wrapper:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// required for correctness) — while the PUBLIC `id` emitted to clients is the bare UUID4 that
|
|
18
18
|
// Polar returns. `kid()` builds the subject id; rows() strips the prefix back off for the view.
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
|
-
import { applyTwinWrite, projectResources, worldNow } from '@volter/
|
|
20
|
+
import { applyTwinWrite, projectResources, resolveSubjectId, worldNow } from '@volter/world-core';
|
|
21
21
|
|
|
22
22
|
const SERVICE = 'polar';
|
|
23
23
|
const ORG_ID = '00000000-0000-4000-8000-0000000000a1'; // the twin's single organization id
|
|
@@ -26,6 +26,9 @@ export type PolarRequest = {
|
|
|
26
26
|
method: string;
|
|
27
27
|
path: string;
|
|
28
28
|
body?: string;
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/** where the twin is reached when served over HTTP (`twinPublicBase`): the checkout's hosted page is there */
|
|
31
|
+
publicBase?: string;
|
|
29
32
|
occurredAt?: string;
|
|
30
33
|
root?: string;
|
|
31
34
|
readOnly?: boolean;
|
|
@@ -44,7 +47,7 @@ export type PolarResourceType = typeof POLAR_RESOURCE_TYPES[number];
|
|
|
44
47
|
// Polar returns `{ type: 'ResourceNotFound', detail: '... not found.' }` for 404 and a
|
|
45
48
|
// FastAPI `HTTPValidationError` (`{ detail: [{ type, loc, msg }] }`) for 422.
|
|
46
49
|
function notFound(detail: string): PolarResponse {
|
|
47
|
-
return { status: 404, body: {
|
|
50
|
+
return { status: 404, body: { error: 'ResourceNotFound', detail } };
|
|
48
51
|
}
|
|
49
52
|
function validationError(loc: (string | number)[], msg: string, type = 'value_error'): PolarResponse {
|
|
50
53
|
return { status: 422, body: { detail: [{ type, loc: ['body', ...loc], msg, input: null }] } };
|
|
@@ -111,11 +114,11 @@ function kid(type: string, id: string): string {
|
|
|
111
114
|
|
|
112
115
|
// ── projection helpers ──────────────────────────────────────────────────────────────
|
|
113
116
|
// Live (non-soft-deleted) rows of a type, with the kernel subject prefix stripped back to the
|
|
114
|
-
// bare public id.
|
|
117
|
+
// bare public id. A delete is the kernel's tombstone (`deleted: true`, cleared by the next write — protocol 2).
|
|
115
118
|
function rows(type: string, root?: string): Array<Record<string, unknown>> {
|
|
116
119
|
const prefix = `${type}:`;
|
|
117
120
|
return projectResources(SERVICE, root)
|
|
118
|
-
.filter((r) => r.type === type && r.id.startsWith(prefix) && (r as Record<string, unknown>).
|
|
121
|
+
.filter((r) => r.type === type && r.id.startsWith(prefix) && (r as Record<string, unknown>).deleted !== true)
|
|
119
122
|
.map((r) => ({ ...r, id: r.id.slice(prefix.length) }));
|
|
120
123
|
}
|
|
121
124
|
function getRow(type: string, id: string, root?: string): Record<string, unknown> | undefined {
|
|
@@ -173,7 +176,7 @@ function customerView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
173
176
|
};
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
function productView(r: Record<string, unknown
|
|
179
|
+
function productView(r: Record<string, unknown>, root?: string): Record<string, unknown> {
|
|
177
180
|
const v = view(r);
|
|
178
181
|
return {
|
|
179
182
|
id: v.id,
|
|
@@ -182,16 +185,40 @@ function productView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
182
185
|
name: v.name ?? null,
|
|
183
186
|
description: v.description ?? null,
|
|
184
187
|
recurring_interval: v.recurring_interval ?? null,
|
|
188
|
+
recurring_interval_count: v.recurring_interval != null ? Number(v.recurring_interval_count ?? 1) : null,
|
|
185
189
|
is_recurring: v.recurring_interval != null,
|
|
186
190
|
is_archived: v.is_archived ?? false,
|
|
191
|
+
visibility: v.visibility ?? 'public',
|
|
192
|
+
trial_interval: v.trial_interval ?? null, trial_interval_count: v.trial_interval_count ?? null,
|
|
193
|
+
meter_interval: null, meter_interval_count: null,
|
|
187
194
|
organization_id: ORG_ID,
|
|
188
195
|
metadata: v.metadata ?? {},
|
|
189
|
-
prices: Array.isArray(v.prices) ? v.prices : [],
|
|
196
|
+
prices: (Array.isArray(v.prices) ? (v.prices as Array<Record<string, unknown>>) : []).map((pr) => priceView(pr, String(v.id), v.recurring_interval as string | null, String(v.created_at), root)),
|
|
190
197
|
benefits: Array.isArray(v.benefits) ? v.benefits : [],
|
|
191
198
|
medias: [],
|
|
192
199
|
attached_custom_fields: [],
|
|
193
200
|
};
|
|
194
201
|
}
|
|
202
|
+
/** A price as Polar returns it (ProductPriceFixed for a fixed amount, ProductPriceFree for none): the stored row filled to the wire shape. */
|
|
203
|
+
function priceView(pr: Record<string, unknown>, productId: string, recurring: string | null, createdAt: string, root?: string): Record<string, unknown> {
|
|
204
|
+
const amount = pr.price_amount != null ? Number(pr.price_amount) : null;
|
|
205
|
+
if (pr.amount_type === 'metered_unit' || pr.meter_id != null) { // a metered price (usage beyond the plan): Polar's ProductPriceMeteredUnit, with its meter
|
|
206
|
+
const meter = pr.meter_id ? getRow('meter', String(pr.meter_id), root) : undefined;
|
|
207
|
+
return {
|
|
208
|
+
id: pr.id ?? uuidFrom('price', `${productId}:metered:${String(pr.meter_id)}`), created_at: pr.created_at ?? createdAt, modified_at: pr.modified_at ?? null,
|
|
209
|
+
amount_type: 'metered_unit', is_archived: pr.is_archived ?? false, product_id: productId, source: 'catalog',
|
|
210
|
+
type: recurring ? 'recurring' : 'one_time', recurring_interval: recurring, tax_behavior: null,
|
|
211
|
+
price_currency: pr.price_currency ?? 'usd', unit_amount: String(pr.unit_amount ?? '0'), cap_amount: pr.cap_amount ?? null,
|
|
212
|
+
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 },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
id: pr.id ?? uuidFrom('price', `${productId}:${amount ?? 'free'}`), created_at: pr.created_at ?? createdAt, modified_at: pr.modified_at ?? null,
|
|
217
|
+
amount_type: amount != null ? 'fixed' : 'free', is_archived: pr.is_archived ?? false, product_id: productId, source: 'catalog',
|
|
218
|
+
type: recurring ? 'recurring' : 'one_time', recurring_interval: recurring, tax_behavior: null,
|
|
219
|
+
...(amount != null ? { price_currency: pr.price_currency ?? 'usd', price_amount: amount } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
195
222
|
|
|
196
223
|
function subscriptionView(r: Record<string, unknown>, root?: string): Record<string, unknown> {
|
|
197
224
|
const v = view(r);
|
|
@@ -211,6 +238,9 @@ function subscriptionView(r: Record<string, unknown>, root?: string): Record<str
|
|
|
211
238
|
started_at: v.started_at ?? v.created_at,
|
|
212
239
|
ends_at: v.ends_at ?? null,
|
|
213
240
|
ended_at: v.ended_at ?? null,
|
|
241
|
+
trial_start: v.trial_start ?? null, trial_end: v.trial_end ?? null,
|
|
242
|
+
customer_cancellation_reason: null, customer_cancellation_comment: null,
|
|
243
|
+
seats: null, units: null, meters: [], custom_field_data: {},
|
|
214
244
|
customer_id: v.customer_id ?? null,
|
|
215
245
|
product_id: v.product_id ?? null,
|
|
216
246
|
discount_id: v.discount_id ?? null,
|
|
@@ -245,9 +275,22 @@ function orderView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
245
275
|
};
|
|
246
276
|
}
|
|
247
277
|
|
|
248
|
-
function checkoutView(r: Record<string, unknown
|
|
278
|
+
function checkoutView(r: Record<string, unknown>, root?: string): Record<string, unknown> {
|
|
249
279
|
const v = view(r);
|
|
280
|
+
const product = v.product_id ? getRow('product', String(v.product_id), root) : undefined;
|
|
281
|
+
const productOut = product ? productView({ ...product, type: 'product' }, root) : null;
|
|
282
|
+
const price = productOut && Array.isArray(productOut.prices) ? ((productOut.prices as Record<string, unknown>[])[0] ?? null) : null;
|
|
283
|
+
const amount = Number(v.amount ?? 0);
|
|
250
284
|
return {
|
|
285
|
+
return_url: v.return_url ?? null, tax_behavior: null, allow_trial: null, active_trial_interval: null, active_trial_interval_count: null, trial_end: null,
|
|
286
|
+
trial_interval: null, trial_interval_count: null, organization_id: ORG_ID,
|
|
287
|
+
product_price_id: price ? String(price.id) : null, allow_discount_codes: true, require_billing_address: false, is_discount_applicable: false,
|
|
288
|
+
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,
|
|
289
|
+
is_business_customer: false, customer_ip_address: null, customer_billing_name: null, customer_billing_address: null, customer_tax_id: null,
|
|
290
|
+
payment_processor_metadata: {}, billing_address_fields: { country: 'required', state: 'disabled', city: 'disabled', postal_code: 'disabled', line1: 'disabled', line2: 'disabled' },
|
|
291
|
+
external_customer_id: v.external_customer_id ?? null, customer_external_id: v.external_customer_id ?? null,
|
|
292
|
+
products: productOut ? [productOut] : [], product: productOut, product_price: price, prices: {}, discount: null, attached_custom_fields: [], customer_metadata: {}, custom_field_data: {},
|
|
293
|
+
net_amount: amount, tax_amount: null, total_amount: amount, currency: v.currency ?? 'usd', subscription_id: v.subscription_id ?? null,
|
|
251
294
|
id: v.id,
|
|
252
295
|
created_at: v.created_at,
|
|
253
296
|
modified_at: v.modified_at ?? null,
|
|
@@ -260,20 +303,12 @@ function checkoutView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
260
303
|
embed_origin: v.embed_origin ?? null,
|
|
261
304
|
amount: v.amount ?? 0,
|
|
262
305
|
discount_amount: 0,
|
|
263
|
-
net_amount: v.amount ?? 0,
|
|
264
|
-
tax_amount: 0,
|
|
265
|
-
total_amount: v.amount ?? 0,
|
|
266
|
-
currency: v.currency ?? 'usd',
|
|
267
306
|
product_id: v.product_id ?? null,
|
|
268
|
-
products: Array.isArray(v.products) ? v.products : [],
|
|
269
307
|
discount_id: v.discount_id ?? null,
|
|
270
308
|
customer_id: v.customer_id ?? null,
|
|
271
309
|
customer_email: v.customer_email ?? null,
|
|
272
310
|
customer_name: v.customer_name ?? null,
|
|
273
|
-
subscription_id: v.subscription_id ?? null,
|
|
274
|
-
organization_id: ORG_ID,
|
|
275
311
|
metadata: v.metadata ?? {},
|
|
276
|
-
customer_metadata: {},
|
|
277
312
|
};
|
|
278
313
|
}
|
|
279
314
|
|
|
@@ -320,6 +355,7 @@ function meterView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
320
355
|
created_at: v.created_at,
|
|
321
356
|
modified_at: v.modified_at ?? null,
|
|
322
357
|
name: v.name ?? null,
|
|
358
|
+
unit: v.unit ?? 'unit', custom_label: null, custom_multiplier: null, archived_at: null,
|
|
323
359
|
aggregation: v.aggregation ?? { func: 'count' },
|
|
324
360
|
filter: v.filter ?? { conjunction: 'and', clauses: [] },
|
|
325
361
|
organization_id: ORG_ID,
|
|
@@ -358,7 +394,7 @@ function webhookView(r: Record<string, unknown>): Record<string, unknown> {
|
|
|
358
394
|
function customerState(customerId: string, root?: string): Record<string, unknown> {
|
|
359
395
|
const cust = getRow('customer', customerId, root);
|
|
360
396
|
const subs = rows('subscription', root)
|
|
361
|
-
.filter((s) => s.customer_id === customerId && s.status === 'active')
|
|
397
|
+
.filter((s) => s.customer_id === customerId && (s.status === 'active' || s.status === 'trialing')) // Polar's active statuses are {trialing, active}
|
|
362
398
|
.map((s) => subscriptionView(s, root));
|
|
363
399
|
const meters = rows('event', root)
|
|
364
400
|
.filter((e) => e.customer_id === customerId)
|
|
@@ -367,20 +403,70 @@ function customerState(customerId: string, root?: string): Record<string, unknow
|
|
|
367
403
|
if (Number.isFinite(cost)) { if (cost < 0) acc.credited += Math.abs(cost); else acc.consumed += cost; }
|
|
368
404
|
return acc;
|
|
369
405
|
}, { consumed: 0, credited: 0 });
|
|
406
|
+
// Polar's real customer state carries one entry PER METER, each meter aggregating the
|
|
407
|
+
// customer's events its filter admits by its aggregation function (sum/count/max/min/avg over a
|
|
408
|
+
// metadata property). A root with no meters keeps the earlier placeholder balance (cost_usd).
|
|
409
|
+
const defined = rows('meter', root);
|
|
410
|
+
const mine = rows('event', root).filter((e) => e.customer_id === customerId);
|
|
411
|
+
const active_meters = defined.length === 0
|
|
412
|
+
? [{ 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 }]
|
|
413
|
+
: defined.map((m) => {
|
|
414
|
+
const matched = mine.filter((e) => meterAdmits(m.filter as Record<string, unknown> | undefined, e));
|
|
415
|
+
const consumed = meterAggregate(m.aggregation as Record<string, unknown> | undefined, matched);
|
|
416
|
+
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 };
|
|
417
|
+
});
|
|
370
418
|
return {
|
|
371
419
|
...(cust ? customerView(cust) : { id: customerId }),
|
|
372
420
|
active_subscriptions: subs,
|
|
373
421
|
granted_benefits: [],
|
|
374
|
-
active_meters
|
|
375
|
-
id: uuidFrom('customer_meter', customerId),
|
|
376
|
-
meter_id: '00000000-0000-4000-8000-0000000000b1',
|
|
377
|
-
consumed_units: meters.consumed,
|
|
378
|
-
credited_units: meters.credited,
|
|
379
|
-
balance: meters.credited - meters.consumed,
|
|
380
|
-
}],
|
|
422
|
+
active_meters,
|
|
381
423
|
};
|
|
382
424
|
}
|
|
383
425
|
|
|
426
|
+
/** Does a meter's filter admit an event? Polar's filter: `{conjunction: and|or, clauses: [{property, operator, value} | filter]}`;
|
|
427
|
+
* 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. */
|
|
428
|
+
function meterAdmits(filter: Record<string, unknown> | undefined, e: Record<string, unknown>): boolean {
|
|
429
|
+
const clauses = Array.isArray(filter?.clauses) ? (filter!.clauses as Array<Record<string, unknown>>) : [];
|
|
430
|
+
if (clauses.length === 0) return true;
|
|
431
|
+
const one = (c: Record<string, unknown>): boolean => {
|
|
432
|
+
if ('clauses' in c) return meterAdmits(c, e);
|
|
433
|
+
const prop = String(c.property ?? '');
|
|
434
|
+
const actual = prop === 'name' || prop === 'source' ? e[prop] : ((e.metadata as Record<string, unknown> | undefined)?.[prop.replace(/^metadata\./, '')]);
|
|
435
|
+
const want = c.value; const op = String(c.operator ?? 'eq');
|
|
436
|
+
switch (op) {
|
|
437
|
+
case 'eq': return actual === want || String(actual) === String(want);
|
|
438
|
+
case 'ne': return !(actual === want || String(actual) === String(want));
|
|
439
|
+
case 'gt': return Number(actual) > Number(want); case 'gte': return Number(actual) >= Number(want);
|
|
440
|
+
case 'lt': return Number(actual) < Number(want); case 'lte': return Number(actual) <= Number(want);
|
|
441
|
+
case 'like': return String(actual ?? '').includes(String(want)); case 'not_like': return !String(actual ?? '').includes(String(want));
|
|
442
|
+
default: return false;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
return String(filter?.conjunction ?? 'and') === 'or' ? clauses.some(one) : clauses.every(one);
|
|
446
|
+
}
|
|
447
|
+
/** A meter's aggregation over the admitted events: count, or sum/max/min/avg of a metadata property. */
|
|
448
|
+
function meterAggregate(agg: Record<string, unknown> | undefined, events: Array<Record<string, unknown>>): number {
|
|
449
|
+
const func = String(agg?.func ?? 'count');
|
|
450
|
+
if (func === 'count') return events.length;
|
|
451
|
+
const prop = String(agg?.property ?? '').replace(/^metadata\./, '');
|
|
452
|
+
const values = events.map((e) => Number((e.metadata as Record<string, unknown> | undefined)?.[prop])).filter((n) => Number.isFinite(n));
|
|
453
|
+
if (values.length === 0) return 0;
|
|
454
|
+
if (func === 'sum') return values.reduce((a, b) => a + b, 0);
|
|
455
|
+
if (func === 'max') return Math.max(...values); if (func === 'min') return Math.min(...values);
|
|
456
|
+
if (func === 'avg') return values.reduce((a, b) => a + b, 0) / values.length;
|
|
457
|
+
return values.reduce((a, b) => a + b, 0);
|
|
458
|
+
}
|
|
459
|
+
/** The customer an external id names, or one created for it — Polar creates the customer on first use of an external id. */
|
|
460
|
+
async function customerForExternalId(ext: string, req: PolarRequest, email?: string | null): Promise<Record<string, unknown>> {
|
|
461
|
+
const existing = rows('customer', req.root).find((c) => c.external_id === ext);
|
|
462
|
+
if (existing) return existing;
|
|
463
|
+
const cid = mintId('customer', req, ext);
|
|
464
|
+
return write('customer', cid, {
|
|
465
|
+
created_at: nowIso(req.occurredAt), modified_at: null, email: email ?? `${ext}@external.twin`, name: null, external_id: ext,
|
|
466
|
+
customer_type: 'individual', email_verified: false, locale: 'en', metadata: {},
|
|
467
|
+
}, 'customer.create', req);
|
|
468
|
+
}
|
|
469
|
+
|
|
384
470
|
// ── route handler ────────────────────────────────────────────────────────────────────
|
|
385
471
|
export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarResponse> {
|
|
386
472
|
const method = req.method.toUpperCase();
|
|
@@ -391,6 +477,10 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
391
477
|
// segments after the /v1 prefix, e.g. /v1/customers/abc → ["customers","abc"]
|
|
392
478
|
const all = path.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
393
479
|
const seg = all[0] === 'v1' ? all.slice(1) : all;
|
|
480
|
+
// protocol 2: after a landing adopted the vendor's id, a caller on a branch may still address a product or a
|
|
481
|
+
// customer by the local id — resolved through the alias map once, here at the boundary (a `type:` prefix is tolerated)
|
|
482
|
+
const bare = (type: string, id: string): string => resolveSubjectId(SERVICE, type, kid(type, id.replace(new RegExp(`^${type}:`), '')), req.root).replace(new RegExp(`^${type}:`), '');
|
|
483
|
+
if ((seg[0] === 'products' || seg[0] === 'customers') && seg[1] && seg[1] !== 'external') seg[1] = bare(seg[0].slice(0, -1), decodeURIComponent(seg[1]));
|
|
394
484
|
const idAt = (i: number) => decodeURIComponent(seg[i] ?? '');
|
|
395
485
|
|
|
396
486
|
if (req.readOnly && method !== 'GET') {
|
|
@@ -431,6 +521,12 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
431
521
|
return { status: 200, body: listEnvelope(items) };
|
|
432
522
|
}
|
|
433
523
|
// GET /customers/:id/state
|
|
524
|
+
// GET /customers/external/:external_id and …/state — the customer by YOUR id (Polar's real routes)
|
|
525
|
+
if (seg[1] === 'external' && (seg.length === 3 || (seg.length === 4 && seg[3] === 'state')) && method === 'GET') {
|
|
526
|
+
const c = rows('customer', req.root).find((x) => x.external_id === idAt(2));
|
|
527
|
+
if (!c) return notFound('Customer not found.');
|
|
528
|
+
return { status: 200, body: seg.length === 4 ? customerState(String(c.id), req.root) : customerView({ ...c, type: 'customer' }) };
|
|
529
|
+
}
|
|
434
530
|
if (seg.length === 3 && seg[2] === 'state' && method === 'GET') {
|
|
435
531
|
if (!getRow('customer', idAt(1), req.root)) return notFound('Customer not found.');
|
|
436
532
|
return { status: 200, body: customerState(idAt(1), req.root) };
|
|
@@ -450,7 +546,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
450
546
|
if (seg.length === 2 && method === 'DELETE') {
|
|
451
547
|
const c = getRow('customer', idAt(1), req.root);
|
|
452
548
|
if (!c) return notFound('Customer not found.');
|
|
453
|
-
await write('customer', idAt(1), {
|
|
549
|
+
await write('customer', idAt(1), { deleted: true }, 'customer.delete', req);
|
|
454
550
|
return { status: 204, body: null };
|
|
455
551
|
}
|
|
456
552
|
}
|
|
@@ -467,18 +563,19 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
467
563
|
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
468
564
|
name: body.name, description: body.description ?? null,
|
|
469
565
|
recurring_interval: body.recurring_interval ?? null,
|
|
566
|
+
trial_interval: body.trial_interval ?? null, trial_interval_count: body.trial_interval_count ?? null, // the product's trial, as Polar keeps it
|
|
470
567
|
is_archived: false, prices, benefits: [], metadata: body.metadata ?? {},
|
|
471
568
|
}, 'product.create', req);
|
|
472
|
-
return { status: 201, body: productView({ ...p, type: 'product' }) };
|
|
569
|
+
return { status: 201, body: productView({ ...p, type: 'product' }, req.root) };
|
|
473
570
|
}
|
|
474
571
|
if (seg.length === 1 && method === 'GET') {
|
|
475
572
|
let items = rows('product', req.root);
|
|
476
573
|
if (query.is_archived !== undefined) items = items.filter((p) => Boolean(p.is_archived) === (query.is_archived === 'true'));
|
|
477
|
-
return { status: 200, body: listEnvelope(items.map((p) => productView({ ...p, type: 'product' }))) };
|
|
574
|
+
return { status: 200, body: listEnvelope(items.map((p) => productView({ ...p, type: 'product' }, req.root))) };
|
|
478
575
|
}
|
|
479
576
|
if (seg.length === 2 && method === 'GET') {
|
|
480
577
|
const p = getRow('product', idAt(1), req.root);
|
|
481
|
-
return p ? { status: 200, body: productView({ ...p, type: 'product' }) } : notFound('Product not found.');
|
|
578
|
+
return p ? { status: 200, body: productView({ ...p, type: 'product' }, req.root) } : notFound('Product not found.');
|
|
482
579
|
}
|
|
483
580
|
if (seg.length === 2 && method === 'PATCH') {
|
|
484
581
|
const p = getRow('product', idAt(1), req.root);
|
|
@@ -486,7 +583,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
486
583
|
const patch: Record<string, unknown> = { modified_at: nowIso(req.occurredAt) };
|
|
487
584
|
for (const k of ['name', 'description', 'recurring_interval', 'is_archived', 'metadata']) if (k in body) patch[k] = body[k];
|
|
488
585
|
const updated = await write('product', idAt(1), patch, 'product.update', req);
|
|
489
|
-
return { status: 200, body: productView({ ...updated, type: 'product' }) };
|
|
586
|
+
return { status: 200, body: productView({ ...updated, type: 'product' }, req.root) };
|
|
490
587
|
}
|
|
491
588
|
}
|
|
492
589
|
|
|
@@ -512,7 +609,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
512
609
|
if (seg.length === 2 && method === 'DELETE') {
|
|
513
610
|
const b = getRow('benefit', idAt(1), req.root);
|
|
514
611
|
if (!b) return notFound('Benefit not found.');
|
|
515
|
-
await write('benefit', idAt(1), {
|
|
612
|
+
await write('benefit', idAt(1), { deleted: true }, 'benefit.delete', req);
|
|
516
613
|
return { status: 204, body: null };
|
|
517
614
|
}
|
|
518
615
|
}
|
|
@@ -551,7 +648,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
551
648
|
if (seg.length === 2 && method === 'DELETE') {
|
|
552
649
|
const d = getRow('discount', idAt(1), req.root);
|
|
553
650
|
if (!d) return notFound('Discount not found.');
|
|
554
|
-
await write('discount', idAt(1), {
|
|
651
|
+
await write('discount', idAt(1), { deleted: true }, 'discount.delete', req);
|
|
555
652
|
return { status: 204, body: null };
|
|
556
653
|
}
|
|
557
654
|
}
|
|
@@ -583,25 +680,30 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
583
680
|
if (seg.length === 2 && seg[1] === 'ingest' && method === 'POST') {
|
|
584
681
|
const events = Array.isArray(body.events) ? (body.events as Array<Record<string, unknown>>) : null;
|
|
585
682
|
if (!events) return validationError(['events'], 'Field required', 'missing');
|
|
586
|
-
let inserted = 0;
|
|
587
|
-
for (const e of events) {
|
|
588
|
-
if (!e.name) return validationError(['events', 'name'], 'Field required', 'missing');
|
|
589
|
-
|
|
683
|
+
let inserted = 0; let duplicates = 0;
|
|
684
|
+
for (const [i, e] of events.entries()) {
|
|
685
|
+
if (!e.name) return validationError(['events', i, 'name'], 'Field required', 'missing');
|
|
686
|
+
// `external_id` is YOUR id for the event — a second ingest of it is a duplicate, not a second event
|
|
687
|
+
if (e.external_id && rows('event', req.root).some((x) => x.external_id === e.external_id)) { duplicates += 1; continue; }
|
|
688
|
+
// an external customer id names (or creates) the customer; the event lands on that customer
|
|
689
|
+
let customerId = e.customer_id ? String(e.customer_id) : null;
|
|
690
|
+
if (!customerId && e.external_customer_id) customerId = String((await customerForExternalId(String(e.external_customer_id), req)).id);
|
|
691
|
+
const id = mintId('event', req, String(e.external_id ?? i));
|
|
590
692
|
await write('event', id, {
|
|
591
693
|
timestamp: e.timestamp ?? nowIso(req.occurredAt), created_at: nowIso(req.occurredAt),
|
|
592
|
-
name: e.name, customer_id: e.
|
|
694
|
+
name: e.name, customer_id: customerId, external_id: e.external_id ?? null,
|
|
593
695
|
external_customer_id: e.external_customer_id ?? null,
|
|
594
696
|
source: 'user', metadata: e.metadata ?? {},
|
|
595
697
|
}, 'event.ingest', req);
|
|
596
698
|
inserted += 1;
|
|
597
699
|
}
|
|
598
|
-
return { status: 200, body: { inserted, duplicates
|
|
700
|
+
return { status: 200, body: { inserted, duplicates } };
|
|
599
701
|
}
|
|
600
702
|
if (seg.length === 1 && method === 'GET') {
|
|
601
703
|
const customerId = query.customer_id;
|
|
602
|
-
const name = query.name;
|
|
704
|
+
const name = query.name; const ext = query.external_customer_id;
|
|
603
705
|
const items = rows('event', req.root)
|
|
604
|
-
.filter((e) => (!customerId || e.customer_id === customerId) && (!name || e.name === name))
|
|
706
|
+
.filter((e) => (!customerId || e.customer_id === customerId) && (!name || e.name === name) && (!ext || e.external_customer_id === ext))
|
|
605
707
|
.map((e) => eventView({ ...e, type: 'event' }));
|
|
606
708
|
return { status: 200, body: listEnvelope(items) };
|
|
607
709
|
}
|
|
@@ -623,7 +725,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
623
725
|
const token = `polar_cst_${mintSecret('customer_session_token', req, String(cust.id))}`;
|
|
624
726
|
const s = await write('customer_session', id, {
|
|
625
727
|
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
626
|
-
token, expires_at: laterIso(req.occurredAt), customer_id: cust.id,
|
|
728
|
+
token, expires_at: laterIso(req.occurredAt), customer_id: cust.id, return_url: body.return_url ?? null,
|
|
627
729
|
customer_portal_url: `https://polar.sh/twin/portal?customer_session_token=${token}`,
|
|
628
730
|
}, 'customer_session.create', req);
|
|
629
731
|
const v = view({ ...s, type: 'customer_session' });
|
|
@@ -634,7 +736,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
634
736
|
// ── CHECKOUTS ──
|
|
635
737
|
if (seg[0] === 'checkouts') {
|
|
636
738
|
if (seg.length === 1 && method === 'POST') {
|
|
637
|
-
const products = Array.isArray(body.products) ? (body.products as string[]) : (body.product_id ? [String(body.product_id)] : []);
|
|
739
|
+
const products = (Array.isArray(body.products) ? (body.products as string[]) : (body.product_id ? [String(body.product_id)] : [])).map((id) => bare('product', String(id)));
|
|
638
740
|
if (products.length === 0) return validationError(['products'], 'Field required', 'missing');
|
|
639
741
|
const productId = products[0]!;
|
|
640
742
|
const product = getRow('product', productId, req.root);
|
|
@@ -645,21 +747,23 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
645
747
|
const c = await write('checkout', id, {
|
|
646
748
|
created_at: nowIso(req.occurredAt), modified_at: null,
|
|
647
749
|
status: 'open', client_secret: `polar_checkout_${mintSecret('checkout_client_secret', req, id)}`,
|
|
648
|
-
|
|
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),
|
|
649
752
|
success_url: body.success_url ?? null, embed_origin: body.embed_origin ?? null,
|
|
650
753
|
amount, currency: 'usd', product_id: productId, products,
|
|
651
754
|
customer_id: body.customer_id ?? null, customer_email: body.customer_email ?? null,
|
|
652
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
|
|
653
757
|
metadata: body.metadata ?? {},
|
|
654
758
|
}, 'checkout.create', req);
|
|
655
|
-
return { status: 201, body: checkoutView({ ...c, type: 'checkout' }) };
|
|
759
|
+
return { status: 201, body: checkoutView({ ...c, type: 'checkout' }, req.root) };
|
|
656
760
|
}
|
|
657
761
|
if (seg.length === 1 && method === 'GET') {
|
|
658
|
-
return { status: 200, body: listEnvelope(rows('checkout', req.root).map((c) => checkoutView({ ...c, type: 'checkout' }))) };
|
|
762
|
+
return { status: 200, body: listEnvelope(rows('checkout', req.root).map((c) => checkoutView({ ...c, type: 'checkout' }, req.root))) };
|
|
659
763
|
}
|
|
660
764
|
if (seg.length === 2 && method === 'GET') {
|
|
661
765
|
const c = getRow('checkout', idAt(1), req.root);
|
|
662
|
-
return c ? { status: 200, body: checkoutView({ ...c, type: 'checkout' }) } : notFound('Checkout not found.');
|
|
766
|
+
return c ? { status: 200, body: checkoutView({ ...c, type: 'checkout' }, req.root) } : notFound('Checkout not found.');
|
|
663
767
|
}
|
|
664
768
|
// POST /checkouts/:id/confirm — confirm the checkout: create customer (if needed),
|
|
665
769
|
// subscription/order, and flip the checkout to confirmed (the twin "completes" payment).
|
|
@@ -670,6 +774,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
670
774
|
// Resolve / create the customer.
|
|
671
775
|
let customerId = c.customer_id ? String(c.customer_id) : null;
|
|
672
776
|
const email = body.customer_email ?? c.customer_email ?? null;
|
|
777
|
+
if (!customerId && c.external_customer_id) customerId = String((await customerForExternalId(String(c.external_customer_id), req, email as string | null)).id);
|
|
673
778
|
if (!customerId && email) {
|
|
674
779
|
const existing = rows('customer', req.root).find((x) => x.email === email);
|
|
675
780
|
if (existing) customerId = String(existing.id);
|
|
@@ -686,13 +791,18 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
686
791
|
const product = c.product_id ? getRow('product', String(c.product_id), req.root) : undefined;
|
|
687
792
|
const recurring = product?.recurring_interval ?? null;
|
|
688
793
|
const at = nowIso(req.occurredAt);
|
|
794
|
+
// 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
|
|
795
|
+
const trialUnit = product?.trial_interval ? String(product.trial_interval) : null; const trialCount = Number(product?.trial_interval_count ?? 0);
|
|
796
|
+
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;
|
|
797
|
+
const trialEnd = trialMs ? new Date(Date.parse(at) + trialMs).toISOString() : null;
|
|
689
798
|
let subscriptionId: string | null = null;
|
|
690
799
|
if (recurring) {
|
|
691
800
|
subscriptionId = mintId('subscription', req);
|
|
692
801
|
await write('subscription', subscriptionId, {
|
|
693
|
-
created_at: at, modified_at: null, status: 'active',
|
|
802
|
+
created_at: at, modified_at: null, status: trialEnd ? 'trialing' : 'active',
|
|
694
803
|
amount: c.amount ?? 0, currency: 'usd', recurring_interval: recurring,
|
|
695
|
-
current_period_start: at, current_period_end: laterIso(req.occurredAt),
|
|
804
|
+
current_period_start: at, current_period_end: trialEnd ?? laterIso(req.occurredAt),
|
|
805
|
+
...(trialEnd ? { trial_start: at, trial_end: trialEnd } : {}),
|
|
696
806
|
started_at: at, cancel_at_period_end: false, canceled_at: null,
|
|
697
807
|
customer_id: customerId, product_id: c.product_id, checkout_id: c.id, metadata: {},
|
|
698
808
|
}, 'subscription.create', req);
|
|
@@ -706,7 +816,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
706
816
|
}, 'order.create', req);
|
|
707
817
|
await write('checkout', idAt(1), { status: 'confirmed', modified_at: at, customer_id: customerId, subscription_id: subscriptionId }, 'checkout.confirm', req);
|
|
708
818
|
const updated = getRow('checkout', idAt(1), req.root)!;
|
|
709
|
-
return { status: 200, body: checkoutView({ ...updated, type: 'checkout' }) };
|
|
819
|
+
return { status: 200, body: checkoutView({ ...updated, type: 'checkout' }, req.root) };
|
|
710
820
|
}
|
|
711
821
|
}
|
|
712
822
|
|
|
@@ -792,7 +902,7 @@ export async function handlePolarTwinRequest(req: PolarRequest): Promise<PolarRe
|
|
|
792
902
|
if (seg.length === 3 && method === 'DELETE') {
|
|
793
903
|
const w = getRow('webhook_endpoint', idAt(2), req.root);
|
|
794
904
|
if (!w) return notFound('Webhook endpoint not found.');
|
|
795
|
-
await write('webhook_endpoint', idAt(2), {
|
|
905
|
+
await write('webhook_endpoint', idAt(2), { deleted: true }, 'webhook_endpoint.delete', req);
|
|
796
906
|
return { status: 204, body: null };
|
|
797
907
|
}
|
|
798
908
|
}
|