@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/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/twin). Contract:
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/twin';
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: { type: 'ResourceNotFound', detail } };
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. `_deleted` is the twin-internal soft-delete overlay (the log is append-only).
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>)._deleted !== true)
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>): 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>): 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), { _deleted: true }, 'customer.delete', req);
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), { _deleted: true }, 'benefit.delete', req);
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), { _deleted: true }, 'discount.delete', req);
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
- const id = mintId('event', req);
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.customer_id ?? null,
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: 0 } };
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
- url: `https://polar.sh/twin/checkout/${id}`, expires_at: laterIso(req.occurredAt),
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), { _deleted: true }, 'webhook_endpoint.delete', req);
905
+ await write('webhook_endpoint', idAt(2), { deleted: true }, 'webhook_endpoint.delete', req);
796
906
  return { status: 204, body: null };
797
907
  }
798
908
  }