@volter/twin-polar 2.0.36 → 3.0.0
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 +24 -27
- package/dist/generated/pack-facts.json +69 -0
- package/dist/journeys/coverage.json +223 -0
- package/dist/journeys/customer-life.json +1222 -0
- package/dist/journeys/customer-life.outline.md +69 -0
- package/dist/journeys/customer-life.outline.verdict.json +41 -0
- package/dist/journeys/customer-life.verdict.json +71 -0
- package/dist/journeys/decisions.json +149 -0
- package/dist/journeys/demand.json +45 -0
- package/dist/journeys/score.json +249 -0
- package/dist/journeys/unreachable.json +13 -0
- package/dist/journeys/vendor-examples.json +9621 -0
- package/dist/journeys/vendor-examples.verdict.json +33 -0
- package/dist/spec/SOURCE.md +25 -0
- package/dist/spec/doc-examples.json +681 -0
- package/dist/spec/openapi.json.gz +0 -0
- package/dist/spec/sources.json +71 -0
- package/dist/src/cli.js +7 -16
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +24 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +5 -9
- package/dist/src/index.js +10 -55
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +123 -0
- package/dist/src/screens/checkout.d.ts +2 -0
- package/dist/src/screens/checkout.js +25 -0
- package/dist/src/screens/portal.d.ts +2 -0
- package/dist/src/screens/portal.js +18 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +25 -0
- package/dist/src/semantics/checkouts.d.ts +14 -0
- package/dist/src/semantics/checkouts.js +131 -0
- package/dist/src/semantics/clock.d.ts +2 -0
- package/dist/src/semantics/clock.js +22 -0
- package/dist/src/semantics/customer-sessions.d.ts +2 -0
- package/dist/src/semantics/customer-sessions.js +12 -0
- package/dist/src/semantics/customers.d.ts +9 -0
- package/dist/src/semantics/customers.js +61 -0
- package/dist/src/semantics/doors.d.ts +11 -0
- package/dist/src/semantics/doors.js +47 -0
- package/dist/src/semantics/events.d.ts +12 -0
- package/dist/src/semantics/events.js +60 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +27 -0
- package/dist/src/semantics/orders.d.ts +4 -0
- package/dist/src/semantics/orders.js +11 -0
- package/dist/src/semantics/products.d.ts +5 -0
- package/dist/src/semantics/products.js +43 -0
- package/dist/src/semantics/shared.d.ts +82 -0
- package/dist/src/semantics/shared.js +327 -0
- package/dist/src/semantics/states.d.ts +27 -0
- package/dist/src/semantics/states.js +43 -0
- package/dist/src/semantics/subscriptions.d.ts +4 -0
- package/dist/src/semantics/subscriptions.js +15 -0
- package/dist/src/semantics/tenant.d.ts +2 -0
- package/dist/src/semantics/tenant.js +2 -0
- package/dist/src/semantics/webhooks.d.ts +4 -0
- package/dist/src/semantics/webhooks.js +30 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +69 -0
- package/journeys/coverage.json +223 -0
- package/journeys/customer-life.json +1222 -0
- package/journeys/customer-life.outline.md +69 -0
- package/journeys/customer-life.outline.verdict.json +41 -0
- package/journeys/customer-life.verdict.json +71 -0
- package/journeys/decisions.json +149 -0
- package/journeys/demand.json +45 -0
- package/journeys/score.json +249 -0
- package/journeys/unreachable.json +13 -0
- package/journeys/vendor-examples.json +9621 -0
- package/journeys/vendor-examples.verdict.json +33 -0
- package/package.json +19 -31
- package/spec/SOURCE.md +25 -0
- package/spec/doc-examples.json +681 -0
- package/spec/openapi.json.gz +0 -0
- package/spec/sources.json +71 -0
- package/src/cli.ts +7 -14
- package/src/fetch.ts +25 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +11 -85
- package/src/manifest.ts +135 -0
- package/src/screens/checkout.ts +33 -0
- package/src/screens/portal.ts +20 -0
- package/src/semantics/around.ts +25 -0
- package/src/semantics/checkouts.ts +133 -0
- package/src/semantics/clock.ts +24 -0
- package/src/semantics/customer-sessions.ts +13 -0
- package/src/semantics/customers.ts +68 -0
- package/src/semantics/doors.ts +51 -0
- package/src/semantics/events.ts +62 -0
- package/src/semantics/index.ts +31 -0
- package/src/semantics/orders.ts +15 -0
- package/src/semantics/products.ts +45 -0
- package/src/semantics/shared.ts +352 -0
- package/src/semantics/states.ts +50 -0
- package/src/semantics/subscriptions.ts +18 -0
- package/src/semantics/tenant.ts +5 -0
- package/src/semantics/webhooks.ts +29 -0
- package/src/server.ts +8 -0
- package/dist/src/polar-budget.d.ts +0 -83
- package/dist/src/polar-budget.js +0 -413
- package/dist/src/polar-capabilities.d.ts +0 -4
- package/dist/src/polar-capabilities.js +0 -389
- package/dist/src/polar-conformance.d.ts +0 -7
- package/dist/src/polar-conformance.js +0 -25
- package/dist/src/polar-connector.d.ts +0 -113
- package/dist/src/polar-connector.js +0 -146
- package/dist/src/polar-server.d.ts +0 -14
- package/dist/src/polar-server.js +0 -52
- package/dist/src/polar-twin.d.ts +0 -24
- package/dist/src/polar-twin.js +0 -941
- package/src/polar-budget.ts +0 -459
- package/src/polar-capabilities.ts +0 -448
- package/src/polar-conformance.ts +0 -33
- package/src/polar-connector.ts +0 -172
- package/src/polar-server.ts +0 -57
- package/src/polar-twin.ts +0 -938
package/dist/src/index.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export
|
|
6
|
-
export { POLAR_BUDGETED_METHODS, POLAR_BUDGET_CEILING, POLAR_BUDGET_MAX_RETRY_AFTER_S, POLAR_BUDGET_WINDOW_MS, POLAR_CALL_WEIGHTS, POLAR_RATE_BUDGET, PolarBudget, PolarBudgetError, polarBudgetPath, polarCallWeight, polarClientBudget, guardPolarClient, } from './polar-budget.js';
|
|
7
|
-
export type { PolarBudgetErrorKind, PolarBudgetOptions, PolarBudgetReservation, PolarBudgetSnapshot } from './polar-budget.js';
|
|
8
|
-
import { type TwinPack } from '@volter/world-core';
|
|
9
|
-
export declare const pack: TwinPack;
|
|
1
|
+
import { manifest } from './manifest.js';
|
|
2
|
+
export { createPolarFetch } from './fetch.js';
|
|
3
|
+
export { createPolarTwinServer } from './server.js';
|
|
4
|
+
export { manifest };
|
|
5
|
+
export declare const pack: import("@volter/world-core").PackDescriptor;
|
package/dist/src/index.js
CHANGED
|
@@ -1,55 +1,10 @@
|
|
|
1
|
-
// @volter/twin-polar — the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// DECLARATION (window/ceiling/per-method weights) plus `guardPolarClient`, the choke point the
|
|
12
|
-
// connector entrypoints apply unconditionally. Exported so an operator can inspect spend
|
|
13
|
-
// (`snapshot`) and a caller can catch `PolarBudgetError` by type; there is deliberately no export
|
|
14
|
-
// that disables the guard.
|
|
15
|
-
export { POLAR_BUDGETED_METHODS, POLAR_BUDGET_CEILING, POLAR_BUDGET_MAX_RETRY_AFTER_S, POLAR_BUDGET_WINDOW_MS, POLAR_CALL_WEIGHTS, POLAR_RATE_BUDGET, PolarBudget, PolarBudgetError, polarBudgetPath, polarCallWeight, polarClientBudget, guardPolarClient, } from "./polar-budget.js";
|
|
16
|
-
// Registry descriptor: the pack self-describes so tooling can discover it.
|
|
17
|
-
import { registerPack } from '@volter/world-core';
|
|
18
|
-
import { POLAR_RATE_BUDGET as RATE_BUDGET } from "./polar-budget.js";
|
|
19
|
-
import { performPolarAction as perform, syncPolarFromRemote as refresh } from "./polar-connector.js";
|
|
20
|
-
export const pack = {
|
|
21
|
-
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
|
|
22
|
-
// state system: perform one entry against Polar, refresh the root from it. Moved 2026-09-08.
|
|
23
|
-
protocol: '2',
|
|
24
|
-
refresh: { every: '5m', webhook: true, onDemand: { atMost: '30s' } },
|
|
25
|
-
stateSystem: { perform, refresh },
|
|
26
|
-
roundTrip: { method: 'POST', path: '/v1/products', body: { name: 'round trip', recurring_interval: 'month' }, headers: { authorization: 'Bearer polar_oat_round_trip' } },
|
|
27
|
-
// rule 5: a checkout names its product; when the head adopts Polar's id for the product, the kernel resolves it first
|
|
28
|
-
referenceTrip: { method: 'POST', path: '/v1/checkouts', body: { products: ['{{id}}'] }, headers: { authorization: 'Bearer polar_oat_round_trip' } },
|
|
29
|
-
references: [{ type: 'checkout', to: 'product', key: (f) => (typeof f.product_id === 'string' ? `product:${f.product_id}` : undefined), adopt: (_f, vendorId) => ({ product_id: vendorId.replace(/^product:/, '') }) }],
|
|
30
|
-
parityOrigin: 'http://twin',
|
|
31
|
-
shapeParity: 'held',
|
|
32
|
-
// The SAME object polar-budget.ts declares at module load — one source of truth, so registering
|
|
33
|
-
// the pack and importing the connector can never arm two different ceilings.
|
|
34
|
-
rateBudget: RATE_BUDGET,
|
|
35
|
-
vendor: 'polar',
|
|
36
|
-
transport: 'rest',
|
|
37
|
-
archetype: 'crud',
|
|
38
|
-
bin: 'world-polar',
|
|
39
|
-
resources: ['customer', 'product', 'subscription', 'order', 'checkout', 'benefit', 'discount', 'meter', 'event', 'customer_session', 'webhook_endpoint'],
|
|
40
|
-
specSource: 'polar-conformance.ts (endpoint/resource inventory from the Polar API docs at docs.polar.sh)',
|
|
41
|
-
description: 'Polar merchant-of-record billing twin — customers, products, subscriptions, orders, checkouts (with confirm → subscription/order), benefits, discounts, meters, usage events, customer sessions, and webhook endpoints. Kernel-backed.',
|
|
42
|
-
// Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
43
|
-
// 2026-08-31). Both the production API host and Polar's own sandbox host are
|
|
44
|
-
// claimed — an integration picks one by configuration, and claiming only production would let
|
|
45
|
-
// sandbox traffic escape the world.
|
|
46
|
-
adoption: {
|
|
47
|
-
// Polar's official Python SDK (polarsource/polar-python).
|
|
48
|
-
pypi: ['polar-sdk'],
|
|
49
|
-
sdks: ['@polar-sh/sdk'], scopes: ['@polar-sh/'], envStems: ['POLAR'],
|
|
50
|
-
},
|
|
51
|
-
hosts: [{ host: 'api.polar.sh' }, { host: 'sandbox-api.polar.sh' }],
|
|
52
|
-
browserRouting: { apiPathPrefix: '/v1', loaderHost: 'https://api.polar.sh' },
|
|
53
|
-
};
|
|
54
|
-
// registered at import: the kernel learns the pack's state system and its references (protocol 2)
|
|
55
|
-
registerPack(pack);
|
|
1
|
+
// @volter/twin-polar — the polar twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
|
|
2
|
+
// kernel derives the vendor-backed half from the generated surface, unless the manifest declares none (vendorBacked.none;
|
|
3
|
+
// architecture, "The real-system adapters").
|
|
4
|
+
import { packOf, registerPack } from '@volter/world-core';
|
|
5
|
+
import { manifest } from "./manifest.js";
|
|
6
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
7
|
+
export { createPolarFetch } from "./fetch.js";
|
|
8
|
+
export { createPolarTwinServer } from "./server.js";
|
|
9
|
+
export { manifest };
|
|
10
|
+
export const pack = registerPack(packOf(manifest, surface));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { data, values } from "./semantics/events.js";
|
|
2
|
+
import { states } from "./semantics/states.js";
|
|
3
|
+
export const manifest = {
|
|
4
|
+
vendor: 'polar',
|
|
5
|
+
service: 'polar',
|
|
6
|
+
tenant: { field: 'organization_id' },
|
|
7
|
+
body: { json: 'always' },
|
|
8
|
+
// every id Polar answers is `format: uuid4`
|
|
9
|
+
ids: { template: '{uuid}' },
|
|
10
|
+
time: 'iso',
|
|
11
|
+
// spec ResourceNotFound, NotPermitted, Unauthorized: `{ error: <kind>, detail }`
|
|
12
|
+
error: { error: '{code}', detail: '{message}' },
|
|
13
|
+
readOnly: { status: 403, code: 'NotPermitted', message: 'This twin was started read-only; writes are refused.' },
|
|
14
|
+
malformedBody: { status: 422, code: 'RequestValidationError', message: 'JSON decode error' },
|
|
15
|
+
notFound: { status: 404, code: 'ResourceNotFound', message: 'Not found' },
|
|
16
|
+
// `{ items, pagination: { total_count, max_page } }`, 1-based `page`, `limit` 10 by default (spec: products:list)
|
|
17
|
+
list: { style: 'envelope', envelope: { items: '{data}', pagination: { total_count: '{total_count}', max_page: '{max_page}' } }, limit: { param: 'limit', default: 10, max: 100 }, page: { param: 'page', link: false } },
|
|
18
|
+
deleted: {},
|
|
19
|
+
auth: {
|
|
20
|
+
header: 'authorization', scheme: 'Bearer', gateWhenAbsent: false, invalidKeys: [],
|
|
21
|
+
// spec securitySchemes: `oat` and `pat` are bearer tokens; the front (./semantics/around.ts) checks each operation's
|
|
22
|
+
// credential by what its security names, so the kernel's gate lets every request through to it
|
|
23
|
+
missing: { status: 401, code: 'Unauthorized', message: 'Unauthorized' },
|
|
24
|
+
invalid: { status: 401, code: 'Unauthorized', message: 'Unauthorized' },
|
|
25
|
+
},
|
|
26
|
+
events: {
|
|
27
|
+
scheme: { kind: 'webhook-id', prefix: 'webhook', secretKey: 'utf8' },
|
|
28
|
+
types: {
|
|
29
|
+
'checkout.create': 'checkout.created', 'order.create': 'order.created', 'order.pay': 'order.paid',
|
|
30
|
+
},
|
|
31
|
+
// source: spec:/components/schemas/WebhookOrderPaidPayload "Sent when an order is paid."
|
|
32
|
+
envelope: { type: '$type', timestamp: '$time.iso', data: '$data' },
|
|
33
|
+
endpoints: { storedAs: 'webhook_endpoint', url: 'url', secret: 'secret', filter: 'events', enabled: 'enabled', match: { _environment: '$environment' } },
|
|
34
|
+
record: '_webhook_delivery',
|
|
35
|
+
render: data, values,
|
|
36
|
+
},
|
|
37
|
+
// the core would serve these from the resources below; no application of the demand calls them (journeys/demand.json)
|
|
38
|
+
unmodeled: [
|
|
39
|
+
'checkouts:update', 'meters:get', 'meters:update', 'orders:create', 'orders:get', 'orders:update',
|
|
40
|
+
'subscriptions:create', 'subscriptions:get', 'subscriptions:update', 'subscriptions:revoke',
|
|
41
|
+
'webhooks:get_webhook_endpoint', 'webhooks:update_webhook_endpoint', 'webhooks:delete_webhook_endpoint',
|
|
42
|
+
],
|
|
43
|
+
// a vendor-backed World's root: Polar's webhooks sent to the World's ingest door, signed as every Polar event is
|
|
44
|
+
// (Standard Webhooks, as `events.scheme`), each naming its type in `type` and carrying its object at `data`
|
|
45
|
+
// source: spec:/components/schemas/WebhookOrderPaidPayload "Sent when an order is paid."
|
|
46
|
+
ingest: {
|
|
47
|
+
scheme: { kind: 'webhook-id', prefix: 'webhook', secretKey: 'utf8' },
|
|
48
|
+
type: { body: 'type' },
|
|
49
|
+
object: 'data',
|
|
50
|
+
types: {
|
|
51
|
+
'customer.created': { resource: 'customer' }, 'customer.updated': { resource: 'customer' }, 'customer.deleted': { resource: 'customer', deleted: true },
|
|
52
|
+
'product.created': { resource: 'product' }, 'product.updated': { resource: 'product' },
|
|
53
|
+
'checkout.created': { resource: 'Checkout' }, 'checkout.updated': { resource: 'Checkout' },
|
|
54
|
+
'order.created': { resource: 'Order' }, 'order.paid': { resource: 'Order' }, 'order.updated': { resource: 'Order' }, 'order.refunded': { resource: 'Order' },
|
|
55
|
+
'subscription.created': { resource: 'Subscription' }, 'subscription.active': { resource: 'Subscription' }, 'subscription.updated': { resource: 'Subscription' },
|
|
56
|
+
'subscription.canceled': { resource: 'Subscription' }, 'subscription.uncanceled': { resource: 'Subscription' }, 'subscription.revoked': { resource: 'Subscription' },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
// the fail-closed ceiling on live calls against Polar (world-core rateBudget.ts, architecture D8): the sandbox's limit,
|
|
60
|
+
// the lower of the two Polar documents, a fifth of production's
|
|
61
|
+
// source: https://polar.sh/docs/api-reference/introduction "500 requests per minute"
|
|
62
|
+
// source: https://polar.sh/docs/api-reference/introduction "100 requests per minute"
|
|
63
|
+
rateBudget: {
|
|
64
|
+
// the vendor's documented allowance this budget stays inside (cited above)
|
|
65
|
+
allowance: { perMinute: 100, source: 'https://polar.sh/docs/api-reference/introduction' },
|
|
66
|
+
windowMs: 60_000, ceiling: 90, defaultWeight: 1, maxRetryAfterSeconds: 300, rules: [],
|
|
67
|
+
reason: 'Polar documents 500 requests a minute per organization, customer or OAuth2 client in production and 100 in the sandbox, answering 429 with Retry-After over it (polar.sh/docs/api-reference/introduction, Rate Limits); 90 a minute stays under the sandbox\'s limit.',
|
|
68
|
+
},
|
|
69
|
+
// GET /twin: what the twin is of and what it keeps
|
|
70
|
+
discovery: {
|
|
71
|
+
twinOf: "Polar (api.polar.sh) as Volter's billing and Open Autonomy's patronage call it: meters and products, checkouts paid on the hosted checkout page and read back, the orders they make, customers and their state by the application's own id, customer sessions for the portal, usage events, and the signed webhooks of an organization's endpoints",
|
|
72
|
+
stores: "the organization's meters and products, customers, checkouts and the subscriptions and orders they make, usage events, customer sessions, webhook endpoints, and the webhooks it sent",
|
|
73
|
+
},
|
|
74
|
+
doors: [
|
|
75
|
+
{ id: 'accessTokens', method: 'POST', path: '/_twin/access-tokens', note: '{ comment, expires_in_days? }: an Organization Access Token made on the settings page (shown once)' },
|
|
76
|
+
{ id: 'revokeToken', method: 'DELETE', path: '/_twin/access-tokens/{n}', note: 'a token revoked on the settings page' },
|
|
77
|
+
{ id: 'appCredentials', method: 'POST', path: '/_twin/app-credentials', note: 'the access token an application holds, as the runtime issues it' },
|
|
78
|
+
{ id: 'deliveries', method: 'GET', path: '/_twin/deliveries', note: '?to=<url>&type=<event>: every webhook Polar sent, in order, as the receiving server got it' },
|
|
79
|
+
],
|
|
80
|
+
screens: [
|
|
81
|
+
{ id: 'checkout', kind: 'flow', host: 'buy.polar.sh', path: '/{client_secret}', demand: "every checkout an application sends a customer to (Twin's biller, Open Autonomy's patronage)", status: 'done', controls: ['email', 'pay'], source: 'https://polar.sh/docs/features/checkout/session' },
|
|
82
|
+
{ id: 'portal', kind: 'flow', host: 'polar.sh', path: '/{slug}/portal', demand: "a customer session's customer_portal_url (Twin's biller, Manage billing)", status: 'done', controls: [], source: 'https://polar.sh/docs/features/customer-portal' },
|
|
83
|
+
],
|
|
84
|
+
resources: {
|
|
85
|
+
_access_token: { idPrefix: 'oat_', ids: '{prefix}{n}' },
|
|
86
|
+
_customer_meter: { idPrefix: '', ids: '{uuid}' },
|
|
87
|
+
_order_item: { idPrefix: '', ids: '{uuid}' },
|
|
88
|
+
_event_key: { idPrefix: 'event_key_' },
|
|
89
|
+
_price: { idPrefix: '', ids: '{uuid}' },
|
|
90
|
+
_seq_order: { idPrefix: '', ids: '{n}' },
|
|
91
|
+
// the stored types the handlers make, declared so their ids are minted (ctx.mint)
|
|
92
|
+
customer: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, idPrefix: '', refresh: { list: 'customers:list', items: 'items' } },
|
|
93
|
+
product: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, idPrefix: '', refresh: { list: 'products:list' } },
|
|
94
|
+
event: { orderBy: { param: 'sorting', default: '-timestamp', fields: ['timestamp'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, idPrefix: '', refresh: { list: 'events:list', items: 'items' } },
|
|
95
|
+
customer_session: { idPrefix: '', refresh: { none: "a customer session is a token answered once, by its create; the API lists none and reads none back" } },
|
|
96
|
+
// a meter as the spec's Meter requires it: what the server assigns beside what the request gives
|
|
97
|
+
Meter: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at', 'name'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } },
|
|
98
|
+
storedAs: 'meter', idPrefix: '', ...states.Meter, filters: ['name'], order: { field: 'created_at', direction: 'desc' }, refresh: { list: 'meters:list' },
|
|
99
|
+
// source: spec:/components/schemas/MeterCreate/properties/unit "scalar"
|
|
100
|
+
assigned: { unit: { value: 'scalar' }, created_at: { now: true }, modified_at: { value: null }, archived_at: { value: null }, metadata: { value: {} } },
|
|
101
|
+
},
|
|
102
|
+
WebhookEndpoint: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, storedAs: 'webhook_endpoint', idPrefix: '', ...states.WebhookEndpoint, order: { field: 'created_at', direction: 'desc' }, refresh: { list: 'webhooks:list_webhook_endpoints' } },
|
|
103
|
+
Checkout: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, storedAs: 'checkout', idPrefix: '', ...states.Checkout, refresh: { list: 'checkouts:list' } },
|
|
104
|
+
Subscription: { orderBy: { param: 'sorting', default: '-started_at', fields: ['started_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, storedAs: 'subscription', idPrefix: '', ...states.Subscription, refresh: { list: 'subscriptions:list' } },
|
|
105
|
+
Order: { orderBy: { param: 'sorting', default: '-created_at', fields: ['created_at'], invalid: { status: 422, code: 'RequestValidationError', message: 'Invalid sorting criterion' } }, storedAs: 'order', idPrefix: '', ...states.Order, refresh: { list: 'orders:list' } },
|
|
106
|
+
},
|
|
107
|
+
// the descriptor, as data (twin-world's architecture, "The descriptor"): the pack registers packOf(manifest, surface)
|
|
108
|
+
descriptor: {
|
|
109
|
+
protocol: '3',
|
|
110
|
+
transport: 'rest',
|
|
111
|
+
archetype: 'crud',
|
|
112
|
+
bin: 'world-polar',
|
|
113
|
+
resources: ['customer', 'product', 'checkout', 'subscription', 'order', 'event', 'meter', 'customer_session', 'webhook_endpoint'],
|
|
114
|
+
specSource: 'Polar\'s OpenAPI document, 2026-04 (spec/openapi.json.gz, provenance in spec/SOURCE.md)',
|
|
115
|
+
description: 'Polar twin — meters and products with their prices, checkouts with the hosted checkout page, the orders and subscriptions a paid checkout makes, customers and their state (subscriptions and meters) by the application\'s own id, customer sessions, usage events, webhook endpoints, and webhooks signed as Polar\'s SDK verifies them.',
|
|
116
|
+
adoption: { sdks: ['@polar-sh/sdk'], pypi: ['polar-sdk'], scopes: ['@polar-sh/'], envStems: ['POLAR'] },
|
|
117
|
+
// a World's applications hold an Organization Access Token the settings page made; the API refuses any other
|
|
118
|
+
credentialDoor: { path: '/_twin/app-credentials', body: {}, fill: { POLAR_ACCESS_TOKEN: 'access_token', POLAR_API_KEY: 'access_token', POLAR_ORGANIZATION_TOKEN: 'access_token' } },
|
|
119
|
+
// the API (production and sandbox) and the hosted checkout
|
|
120
|
+
hosts: [{ host: 'api.polar.sh' }, { host: 'sandbox-api.polar.sh' }, { host: 'buy.polar.sh' }, { host: 'polar.sh', pathPattern: '^/[^/]+/portal' }, { host: 'sandbox.polar.sh' }],
|
|
121
|
+
endpointEnvNone: '@polar-sh/sdk reads no base-URL variable: its `server` option picks production or sandbox, both claimed by the host rules.',
|
|
122
|
+
},
|
|
123
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { checkouts_client_confirm, checkouts_client_get } from "../semantics/checkouts.js";
|
|
2
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
|
3
|
+
export async function screen(ctx) {
|
|
4
|
+
const secret = String(ctx.call.params.client_secret);
|
|
5
|
+
const paying = new URL(ctx.call.request.url).pathname.replace(/\/+$/, '').endsWith('/pay');
|
|
6
|
+
const base = ctx.publicBase.replace(/\/+$/, '');
|
|
7
|
+
const read = await checkouts_client_get(ctx);
|
|
8
|
+
if (!read.ok)
|
|
9
|
+
return new Response('This checkout does not exist.', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
10
|
+
const c = (await read.json());
|
|
11
|
+
if (paying && ctx.call.request.method === 'POST') {
|
|
12
|
+
const form = new URLSearchParams(ctx.text);
|
|
13
|
+
// the page pays as Polar's own page does: the client API's confirm, with the email the customer gave
|
|
14
|
+
const done = await checkouts_client_confirm({ ...ctx, body: { customer_email: form.get('email') || c.customer_email, confirmation_token_id: await ctx.secret(`test-stripe-confirmation:${c.id}`) } });
|
|
15
|
+
if (!done.ok)
|
|
16
|
+
return new Response(`The payment could not be completed (${done.status}).`, { status: done.status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
17
|
+
return new Response(null, { status: 303, headers: { location: c.success_url.replace('{CHECKOUT_ID}', c.id) } });
|
|
18
|
+
}
|
|
19
|
+
const money = `$${(Number(c.amount) / 100).toFixed(2)}`;
|
|
20
|
+
const per = c.product?.recurring_interval ? ` / ${c.product.recurring_interval}` : '';
|
|
21
|
+
const body = c.status !== 'open'
|
|
22
|
+
? `<p>This checkout is ${esc(c.status)}.</p>`
|
|
23
|
+
: `<form method="post" action="${esc(base)}/${esc(secret)}/pay"><label>Email<input name="email" type="email" required value="${esc(c.customer_email)}"></label><label>Card<input value="4242 4242 4242 4242" readonly></label><button type="submit">Pay ${esc(money)}${esc(per)}</button></form>`;
|
|
24
|
+
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Checkout</title><style>body{font:16px/1.5 system-ui,sans-serif;background:#f7f7f8;margin:0}main{max-width:440px;margin:48px auto;background:#fff;border:1px solid #e5e5e8;border-radius:12px;padding:28px}h1{font-size:20px;margin:0}p.price{font-size:30px;font-weight:600;margin:10px 0 20px}label{display:block;font-size:13px;color:#555;margin:12px 0}input{display:block;width:100%;box-sizing:border-box;padding:9px;border:1px solid #ccc;border-radius:8px;font:inherit;margin-top:4px}button{margin-top:16px;width:100%;padding:11px;border:0;border-radius:8px;background:#0062ff;color:#fff;font:inherit;cursor:pointer}</style></head><body><main><h1>${esc(c.product?.name ?? 'Checkout')}</h1><p class="price">${esc(money)}<small>${esc(per)}</small></p>${body}</main></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { rowOf, CUSTOMER, customerView, orderView, ORDER, productView, PRODUCT, rowsOf, SESSION, SUBSCRIPTION } from "../semantics/shared.js";
|
|
2
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
|
3
|
+
// source: https://polar.sh/docs/features/customer-portal/navigate-customers "no email code required"
|
|
4
|
+
// source: https://polar.sh/docs/features/customer-portal "active subscriptions"
|
|
5
|
+
export async function screen(ctx) {
|
|
6
|
+
const token = new URL(ctx.call.request.url).searchParams.get('customer_session_token');
|
|
7
|
+
const session = rowsOf(ctx, SESSION).find((s) => s.token === token && String(s.expires_at) > ctx.occurredAt);
|
|
8
|
+
if (!session)
|
|
9
|
+
return new Response('Unauthorized', { status: 401 });
|
|
10
|
+
const customer = rowOf(ctx, CUSTOMER, String(session.customer_id));
|
|
11
|
+
if (!customer)
|
|
12
|
+
return new Response('Customer not found', { status: 404 });
|
|
13
|
+
const subscriptions = rowsOf(ctx, SUBSCRIPTION).filter((s) => s.customer_id === customer.id && ['active', 'trialing'].includes(String(s.status)));
|
|
14
|
+
const orders = rowsOf(ctx, ORDER).filter((o) => o.customer_id === customer.id).sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
15
|
+
const plans = subscriptions.map((s) => `<li>${esc(productView(ctx, rowOf(ctx, PRODUCT, String(s.product_id))).name)}: ${esc(s.status)}</li>`).join('');
|
|
16
|
+
const history = orders.map((o) => { const v = orderView(ctx, o); return `<li>${esc(v.invoice_number)} — ${esc(v.currency)} ${(Number(v.total_amount) / 100).toFixed(2)}</li>`; }).join('');
|
|
17
|
+
return new Response(`<!doctype html><html lang="en"><title>Customer portal</title><main><h1>Billing for ${esc(customerView(ctx, customer).email)}</h1><h2>Subscriptions</h2><ul>${plans || '<li>No active subscriptions</li>'}</ul><h2>Purchase history</h2><ul>${history || '<li>No purchases</li>'}</ul><a href="${esc(session.return_url)}">Return to the application</a></main></html>`, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// POLAR'S FRONT: every request's credential, by what its operation takes (the operation's `credentials`, the security
|
|
2
|
+
// schemes the published document names for it, as the derive keeps them). An organization's operation (`oat`, `pat`)
|
|
3
|
+
// needs one of the organization's access tokens (made on the settings page: a World door, kept by its SHA-256); a
|
|
4
|
+
// customer portal operation (`customer_session`, `member_session`) needs a live customer session's token; the
|
|
5
|
+
// checkout's client operations, OAuth's endpoints and the portal's email verification need none. A missing or unknown
|
|
6
|
+
// credential is Polar's 401. Where the documentation stops: the refusal's words, as the API answers them. One operation
|
|
7
|
+
// is read otherwise: the checkout's confirm names a signed-in Polar user's credential (`oidc`, `pat`), which the buyer
|
|
8
|
+
// on the checkout page does not have and no organization token stands for; the twin takes the checkout's client secret,
|
|
9
|
+
// in its path, as the confirm's credential, as the page's other client operations do.
|
|
10
|
+
// source: spec:/components/securitySchemes/oat "Organization Access Token"
|
|
11
|
+
import { sha256 } from '@volter/world-core';
|
|
12
|
+
import { environment } from "./shared.js";
|
|
13
|
+
const unauthorized = () => Response.json({ error: 'Unauthorized', detail: 'Unauthorized' }, { status: 401 });
|
|
14
|
+
export async function around(ctx, next) {
|
|
15
|
+
const takes = ctx.call.operation.id === 'checkouts:client_confirm' ? [] : ctx.call.operation.credentials ?? [];
|
|
16
|
+
const bearer = /^Bearer\s+(\S+)/i.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
17
|
+
if (takes.includes('oat') || takes.includes('pat')) {
|
|
18
|
+
if (!bearer)
|
|
19
|
+
return unauthorized();
|
|
20
|
+
const held = ctx.rowsRaw('_access_token').some((t) => (t._environment ?? 'production') === environment(ctx) && t._sha256 === sha256(bearer) && t.deleted !== true && (t.expires_at === null || Date.parse(String(t.expires_at)) > Date.parse(ctx.occurredAt)));
|
|
21
|
+
if (!held)
|
|
22
|
+
return unauthorized();
|
|
23
|
+
}
|
|
24
|
+
return next();
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
/** `POST /v1/checkouts/`: a session for the first product named, at its first price, open for 24 hours. */
|
|
3
|
+
export declare function checkouts_create(ctx: HandlerContext): Promise<Response>;
|
|
4
|
+
/** `GET /v1/checkouts/{id}`: the session as the application reads it back (its status once the customer has paid). */
|
|
5
|
+
export declare function checkouts_get(ctx: HandlerContext): Promise<Response>;
|
|
6
|
+
/** `GET /v1/checkouts/`: the organization's checkout sessions, newest first, by customer, product, status or the
|
|
7
|
+
* customer's email. */
|
|
8
|
+
export declare function checkouts_list(ctx: HandlerContext): Promise<Response>;
|
|
9
|
+
export declare function checkouts_client_get(ctx: HandlerContext): Promise<Response>;
|
|
10
|
+
/** `POST /v1/checkouts/client/{client_secret}/confirm`: the customer pays. The customer is found or made, the order
|
|
11
|
+
* is paid, a recurring product's subscription starts (trialing through the product's trial), and the checkout
|
|
12
|
+
* succeeds. A product's benefits are granted too; no application of the demand gives a product one (the benefits'
|
|
13
|
+
* API is the gap), so none is granted here. */
|
|
14
|
+
export declare function checkouts_client_confirm(ctx: HandlerContext): Promise<Response>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { rowOf, among, bodyOf, checkoutFields, customerFields, subscriptionFields, createSession, environment, CHECKOUT, checkoutView, newest, CUSTOMER, customerBy, fail, invalid, missing, newId, notFound, organizationView, priceView, PRODUCT, rowsOf, secret, SUBSCRIPTION, addInterval, makeOrder, } from "./shared.js";
|
|
2
|
+
const DAY = 86_400_000;
|
|
3
|
+
// source: https://raw.githubusercontent.com/polarsource/polar/main/server/polar/config.py "CHECKOUT_TTL_SECONDS: int = 60 * 60 * 24"
|
|
4
|
+
/** `POST /v1/checkouts/`: a session for the first product named, at its first price, open for 24 hours. */
|
|
5
|
+
// source: spec:checkouts:create "Create a checkout session."
|
|
6
|
+
export async function checkouts_create(ctx) {
|
|
7
|
+
const body = bodyOf(ctx);
|
|
8
|
+
const products = Array.isArray(body.products) ? body.products.map(String) : [];
|
|
9
|
+
// source: spec:/components/schemas/CheckoutProductsCreate "products"
|
|
10
|
+
if (products.length === 0)
|
|
11
|
+
return missing('products');
|
|
12
|
+
const product = rowOf(ctx, PRODUCT, products[0]);
|
|
13
|
+
// the spec documents no 404 here: a product or customer that does not exist is the request's validation error, on
|
|
14
|
+
// the field that named it (where the documentation stops: the message's words)
|
|
15
|
+
// source: spec:checkouts:create "Validation Error"
|
|
16
|
+
if (!product || product.deleted === true)
|
|
17
|
+
return invalid(['products', 0], 'Product does not exist.');
|
|
18
|
+
// source: https://polar.sh/docs/features/checkout/session "Ad-hoc prices are temporary prices that exist only for that specific checkout session"
|
|
19
|
+
const adHoc = [];
|
|
20
|
+
for (const p of body.prices?.[products[0]] ?? []) {
|
|
21
|
+
const priceId = ctx.mint('_price');
|
|
22
|
+
await ctx.record('_price', {}, priceId);
|
|
23
|
+
adHoc.push({ ...p, id: priceId, source: 'ad_hoc' });
|
|
24
|
+
}
|
|
25
|
+
const price = (adHoc.length ? adHoc : product.prices ?? [])[0];
|
|
26
|
+
const amount = price?.amount_type === 'fixed' ? Number(price.price_amount ?? 0) : Number(body.amount ?? 0);
|
|
27
|
+
const customer = body.customer_id !== undefined ? customerBy(ctx, { id: body.customer_id }) : undefined;
|
|
28
|
+
if (body.customer_id !== undefined && !customer)
|
|
29
|
+
return invalid(['customer_id'], 'Customer does not exist.');
|
|
30
|
+
const id = newId(ctx, CHECKOUT);
|
|
31
|
+
await ctx.write(CHECKOUT, id, { ...checkoutFields(ctx, {
|
|
32
|
+
id, created_at: ctx.occurredAt, modified_at: null, status: 'open', client_secret: await secret(ctx, 'polar_c_', `checkout:${id}`),
|
|
33
|
+
expires_at: new Date(Date.parse(ctx.occurredAt) + DAY).toISOString(), product_id: product.id, products, product_price_id: price?.id ?? null, amount,
|
|
34
|
+
prices: { [String(product.id)]: (adHoc.length ? adHoc.map((p) => priceView(ctx, p, product)) : product.prices) }, currency: price?.price_currency ?? 'usd', success_url: body.success_url ?? null, return_url: body.return_url ?? null, embed_origin: body.embed_origin ?? null, customer_ip_address: body.customer_ip_address ?? null, allow_discount_codes: body.allow_discount_codes ?? true, require_billing_address: body.require_billing_address ?? false, allow_trial: body.allow_trial ?? null, is_business_customer: body.is_business_customer ?? false,
|
|
35
|
+
customer_id: customer?.id ?? null, customer_email: body.customer_email ?? customer?.email ?? null, customer_name: body.customer_name ?? null,
|
|
36
|
+
external_customer_id: body.external_customer_id ?? body.customer_external_id ?? null, metadata: body.metadata ?? {}, customer_metadata: body.customer_metadata ?? {},
|
|
37
|
+
}), _environment: environment(ctx) }, 'checkout.create');
|
|
38
|
+
return Response.json(checkoutView(ctx, rowOf(ctx, CHECKOUT, id)), { status: 201 });
|
|
39
|
+
}
|
|
40
|
+
const bySecret = (ctx) => rowsOf(ctx, CHECKOUT).find((c) => c.client_secret === ctx.call.params.client_secret);
|
|
41
|
+
/** The client API answers CheckoutPublic: the checkout and its organization. */
|
|
42
|
+
// source: spec:/components/schemas/CheckoutPublic "CheckoutPublic"
|
|
43
|
+
const publicView = (ctx, c) => {
|
|
44
|
+
const { trial_interval: _ti, trial_interval_count: _tc, metadata: _m, external_customer_id: _e, subscription_id: _s, customer_metadata: _cm, ...visible } = checkoutView(ctx, c);
|
|
45
|
+
return { ...visible, organization: organizationView(ctx) };
|
|
46
|
+
};
|
|
47
|
+
/** `GET /v1/checkouts/{id}`: the session as the application reads it back (its status once the customer has paid). */
|
|
48
|
+
// source: spec:checkouts:get "Get a checkout session by ID."
|
|
49
|
+
export async function checkouts_get(ctx) {
|
|
50
|
+
const c = rowOf(ctx, CHECKOUT, String(ctx.call.params.id));
|
|
51
|
+
return c && c.deleted !== true ? Response.json(checkoutView(ctx, c)) : notFound('Checkout');
|
|
52
|
+
}
|
|
53
|
+
/** `GET /v1/checkouts/`: the organization's checkout sessions, newest first, by customer, product, status or the
|
|
54
|
+
* customer's email. */
|
|
55
|
+
// source: spec:checkouts:list "List checkout sessions."
|
|
56
|
+
export async function checkouts_list(ctx) {
|
|
57
|
+
const q = String(ctx.params.query ?? '').toLowerCase();
|
|
58
|
+
const rows = rowsOf(ctx, CHECKOUT)
|
|
59
|
+
.filter((c) => among(ctx, 'customer_id', c.customer_id) && among(ctx, 'external_customer_id', c.external_customer_id) && among(ctx, 'product_id', c.product_id) && among(ctx, 'status', c.status))
|
|
60
|
+
// source: spec:checkouts:list "Filter by customer email."
|
|
61
|
+
.filter((c) => q === '' || String(c.customer_email ?? '').toLowerCase().includes(q))
|
|
62
|
+
.sort(newest('created_at'));
|
|
63
|
+
return ctx.list('Checkout', rows.map((c) => checkoutView(ctx, c)));
|
|
64
|
+
}
|
|
65
|
+
// source: spec:checkouts:client_get "Get a checkout session by client secret."
|
|
66
|
+
export async function checkouts_client_get(ctx) {
|
|
67
|
+
const c = bySecret(ctx);
|
|
68
|
+
return c ? Response.json(publicView(ctx, c)) : notFound('Checkout');
|
|
69
|
+
}
|
|
70
|
+
/** `POST /v1/checkouts/client/{client_secret}/confirm`: the customer pays. The customer is found or made, the order
|
|
71
|
+
* is paid, a recurring product's subscription starts (trialing through the product's trial), and the checkout
|
|
72
|
+
* succeeds. A product's benefits are granted too; no application of the demand gives a product one (the benefits'
|
|
73
|
+
* API is the gap), so none is granted here. */
|
|
74
|
+
// source: spec:checkouts:client_confirm "Orders and subscriptions will be processed."
|
|
75
|
+
export async function checkouts_client_confirm(ctx) {
|
|
76
|
+
const c = bySecret(ctx);
|
|
77
|
+
if (!c)
|
|
78
|
+
return notFound('Checkout');
|
|
79
|
+
// source: spec:checkouts:client_confirm "The checkout session is expired."
|
|
80
|
+
if (c.status === 'expired')
|
|
81
|
+
return fail(410, 'ExpiredCheckoutError', 'The checkout session is expired.');
|
|
82
|
+
const refused = ctx.legal('Checkout', 'status', 'checkouts:client_confirm', c.status, 'confirmed', String(c.id), 'external');
|
|
83
|
+
if (refused)
|
|
84
|
+
return ctx.refuse(refused);
|
|
85
|
+
const body = bodyOf(ctx);
|
|
86
|
+
// source: spec:/components/schemas/CheckoutConfirmStripe/properties/confirmation_token_id "Required for fixed prices and custom prices."
|
|
87
|
+
if (c.is_payment_required && !body.confirmation_token_id)
|
|
88
|
+
return missing('confirmation_token_id');
|
|
89
|
+
const email = (body.customer_email ?? c.customer_email);
|
|
90
|
+
// the customer: the one named, the one the application's own id names, or the one with the email; else a new one
|
|
91
|
+
let customer = c.customer_id ? customerBy(ctx, { id: c.customer_id }) : c.external_customer_id ? customerBy(ctx, { external: c.external_customer_id }) : undefined;
|
|
92
|
+
if (!customer && email)
|
|
93
|
+
customer = rowsOf(ctx, CUSTOMER).find((x) => String(x.email).toLowerCase() === email.toLowerCase());
|
|
94
|
+
if (!customer) {
|
|
95
|
+
if (!email)
|
|
96
|
+
return missing('customer_email');
|
|
97
|
+
const cid = newId(ctx, CUSTOMER);
|
|
98
|
+
await ctx.write(CUSTOMER, cid, { ...customerFields(ctx, { id: cid, created_at: ctx.occurredAt, modified_at: null, email, name: body.customer_name ?? c.customer_name ?? null, external_id: c.external_customer_id ?? null, email_verified: false, metadata: {} }), _environment: environment(ctx) }, 'customer.create');
|
|
99
|
+
customer = rowOf(ctx, CUSTOMER, cid);
|
|
100
|
+
}
|
|
101
|
+
const customerId = String(customer.id);
|
|
102
|
+
const product = rowOf(ctx, PRODUCT, String(c.product_id));
|
|
103
|
+
// source: spec:checkouts:client_confirm "the customer already has an active subscription"
|
|
104
|
+
const existing = product.recurring_interval != null ? rowsOf(ctx, SUBSCRIPTION).find((s) => s.customer_id === customerId && s.product_id === product.id && ['trialing', 'active'].includes(String(s.status))) : undefined;
|
|
105
|
+
if (existing)
|
|
106
|
+
return ctx.refuse(ctx.legal('Subscription', 'status', 'checkouts:client_confirm', existing.status, undefined, String(existing.id), 'external'));
|
|
107
|
+
const at = ctx.occurredAt;
|
|
108
|
+
await ctx.write(CHECKOUT, String(c.id), { status: 'confirmed', customer_id: customerId, customer_email: customer.email, modified_at: at }, 'checkout.update');
|
|
109
|
+
// the subscription of a recurring product: its period from now, or its trial first
|
|
110
|
+
let subscriptionId = null;
|
|
111
|
+
if (product.recurring_interval) {
|
|
112
|
+
subscriptionId = newId(ctx, SUBSCRIPTION);
|
|
113
|
+
const trial = product.trial_interval != null;
|
|
114
|
+
const end = addInterval(at, String(trial ? product.trial_interval : product.recurring_interval), Number(trial ? product.trial_interval_count ?? 1 : product.recurring_interval_count ?? 1));
|
|
115
|
+
await ctx.write(SUBSCRIPTION, subscriptionId, { ...subscriptionFields(ctx, {
|
|
116
|
+
id: subscriptionId, created_at: at, modified_at: null, amount: c.amount, currency: c.currency ?? 'usd', recurring_interval: product.recurring_interval,
|
|
117
|
+
recurring_interval_count: product.recurring_interval_count ?? 1, status: 'incomplete', current_period_start: at, current_period_end: end,
|
|
118
|
+
trial_start: trial ? at : null, trial_end: trial ? end : null, cancel_at_period_end: false, canceled_at: null, started_at: at, ends_at: null, ended_at: null,
|
|
119
|
+
customer_id: customerId, product_id: product.id, checkout_id: c.id, prices: c.prices[String(product.id)], metadata: c.metadata ?? {},
|
|
120
|
+
}), _environment: environment(ctx) }, 'subscription.create');
|
|
121
|
+
// source: spec:/components/schemas/SubscriptionStatus "trialing"
|
|
122
|
+
await ctx.write(SUBSCRIPTION, subscriptionId, { status: trial ? 'trialing' : 'active', modified_at: at }, 'subscription.activate');
|
|
123
|
+
}
|
|
124
|
+
// source: https://polar.sh/docs/features/subscriptions/trials "they will not be charged immediately"
|
|
125
|
+
if (!product.trial_interval)
|
|
126
|
+
await makeOrder(ctx, { ...c, customer_id: customerId, subscription_id: subscriptionId }, subscriptionId ? 'subscription_create' : 'purchase');
|
|
127
|
+
const session = await createSession(ctx, customer);
|
|
128
|
+
const confirmed = { ...publicView(ctx, rowOf(ctx, CHECKOUT, String(c.id))), customer_session_token: session.token };
|
|
129
|
+
await ctx.write(CHECKOUT, String(c.id), { status: 'succeeded', subscription_id: subscriptionId, modified_at: at }, 'checkout.update');
|
|
130
|
+
return Response.json(confirmed);
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { addInterval, CHECKOUT, makeOrder, rowsOf, SUBSCRIPTION } from "./shared.js";
|
|
2
|
+
export async function clock(ctx) {
|
|
3
|
+
// source: spec:checkouts:client_confirm "The checkout session is expired."
|
|
4
|
+
for (const c of rowsOf(ctx, CHECKOUT).filter((c) => c.status === 'open' && String(c.expires_at) <= ctx.occurredAt)) {
|
|
5
|
+
const due = await ctx.at(String(c.expires_at));
|
|
6
|
+
await due.write(CHECKOUT, String(c.id), { status: 'expired', modified_at: due.occurredAt }, 'checkout.update');
|
|
7
|
+
}
|
|
8
|
+
// source: https://polar.sh/docs/features/subscriptions/introduction "At the end of each billing period, Polar advances the subscription to the next cycle and creates a new order"
|
|
9
|
+
for (const s of rowsOf(ctx, SUBSCRIPTION).filter((s) => ['trialing', 'active'].includes(String(s.status)))) {
|
|
10
|
+
let end = String(s.current_period_end);
|
|
11
|
+
while (end <= ctx.occurredAt) {
|
|
12
|
+
const due = await ctx.at(end);
|
|
13
|
+
const next = addInterval(end, String(s.recurring_interval), Number(s.recurring_interval_count ?? 1));
|
|
14
|
+
// source: https://polar.sh/docs/features/subscriptions/trials "Once the trial period ends, the customer will be automatically charged for the subscription, and their billing cycle will begin."
|
|
15
|
+
await due.write(SUBSCRIPTION, String(s.id), { status: 'active', current_period_start: end, current_period_end: next, modified_at: end }, 'subscription.update');
|
|
16
|
+
await makeOrder(due, { ...s, subscription_id: s.id }, s.status === 'trialing' ? 'subscription_create' : 'subscription_cycle', s.status === 'trialing' ? undefined : { start: String(s.current_period_start), end });
|
|
17
|
+
s.current_period_start = end;
|
|
18
|
+
s.status = 'active';
|
|
19
|
+
end = next;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { bodyOf, customerBy, createSession, invalid, missing } from "./shared.js";
|
|
2
|
+
// source: spec:customer-sessions:create "Create a customer session."
|
|
3
|
+
export async function customer_sessions_create(ctx) {
|
|
4
|
+
const body = bodyOf(ctx);
|
|
5
|
+
if (body.customer_id === undefined && body.external_customer_id === undefined)
|
|
6
|
+
return missing('customer_id');
|
|
7
|
+
const customer = body.customer_id !== undefined ? customerBy(ctx, { id: body.customer_id }) : customerBy(ctx, { external: body.external_customer_id });
|
|
8
|
+
// source: spec:customer-sessions:create "Validation Error"
|
|
9
|
+
if (!customer)
|
|
10
|
+
return invalid([body.customer_id !== undefined ? 'customer_id' : 'external_customer_id'], 'Customer does not exist.');
|
|
11
|
+
return Response.json(await createSession(ctx, customer, body.return_url), { status: 201 });
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
/** `POST /v1/customers/`: an individual customer, its email required. */
|
|
3
|
+
export declare function customers_create(ctx: HandlerContext): Promise<Response>;
|
|
4
|
+
/** `GET /v1/customers/{id}`: the customer as the API answers it. */
|
|
5
|
+
export declare function customers_get(ctx: HandlerContext): Promise<Response>;
|
|
6
|
+
export declare function customers_get_state_external(ctx: HandlerContext): Promise<Response>;
|
|
7
|
+
/** `GET /v1/customers/`: the organization's customers, newest first, by exact email, a query over name, email and
|
|
8
|
+
* external id, or whether they hold a live subscription. */
|
|
9
|
+
export declare function customers_list(ctx: HandlerContext): Promise<Response>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { rowOf, bodyOf, customerFields, environment, CUSTOMER, customerBy, customerState, customerView, invalid, missing, newest, newId, notFound, rowsOf, SUBSCRIPTION } from "./shared.js";
|
|
2
|
+
const FIELDS = ['email', 'name', 'external_id', 'billing_name', 'billing_address', 'tax_id', 'locale', 'metadata'];
|
|
3
|
+
/** A customer's email or external id already held by another: Polar's 422. */
|
|
4
|
+
function taken(ctx, fields, except) {
|
|
5
|
+
const emailTaken = typeof fields.email === 'string' && rowsOf(ctx, CUSTOMER).some((c) => c.id !== except && String(c.email).toLowerCase() === String(fields.email).toLowerCase());
|
|
6
|
+
return emailTaken ? duplicateEmail() : fields.external_id != null && rowsOf(ctx, CUSTOMER).some((c) => c.id !== except && c.external_id === fields.external_id) ? duplicateExternal() : undefined;
|
|
7
|
+
}
|
|
8
|
+
/** `POST /v1/customers/`: an individual customer, its email required. */
|
|
9
|
+
// source: spec:customers:create "Create a customer."
|
|
10
|
+
export async function customers_create(ctx) {
|
|
11
|
+
const body = bodyOf(ctx);
|
|
12
|
+
if (typeof body.email !== 'string' || body.email === '')
|
|
13
|
+
return missing('email');
|
|
14
|
+
const refused = taken(ctx, body);
|
|
15
|
+
if (refused)
|
|
16
|
+
return refused;
|
|
17
|
+
const id = newId(ctx, CUSTOMER);
|
|
18
|
+
const fields = { id, created_at: ctx.occurredAt, modified_at: null, email_verified: false };
|
|
19
|
+
for (const k of FIELDS)
|
|
20
|
+
if (k in body)
|
|
21
|
+
fields[k] = body[k];
|
|
22
|
+
await ctx.write(CUSTOMER, id, { ...customerFields(ctx, fields), _environment: environment(ctx) }, 'customer.create');
|
|
23
|
+
return Response.json(customerView(ctx, rowOf(ctx, CUSTOMER, id)), { status: 201 });
|
|
24
|
+
}
|
|
25
|
+
const byExternal = (ctx) => customerBy(ctx, { external: decodeURIComponent(ctx.call.params.external_id ?? '') });
|
|
26
|
+
/** `GET /v1/customers/{id}`: the customer as the API answers it. */
|
|
27
|
+
// source: spec:customers:get "Get a customer by ID."
|
|
28
|
+
export async function customers_get(ctx) {
|
|
29
|
+
const c = customerBy(ctx, { id: ctx.call.params.id });
|
|
30
|
+
return c ? Response.json(customerView(ctx, c)) : notFound('Customer');
|
|
31
|
+
}
|
|
32
|
+
// source: spec:customers:get_state_external "Get a customer state by external ID."
|
|
33
|
+
export async function customers_get_state_external(ctx) {
|
|
34
|
+
const c = byExternal(ctx);
|
|
35
|
+
return c ? Response.json(await customerState(ctx, c)) : notFound('Customer');
|
|
36
|
+
}
|
|
37
|
+
/** `GET /v1/customers/`: the organization's customers, newest first, by exact email, a query over name, email and
|
|
38
|
+
* external id, or whether they hold a live subscription. */
|
|
39
|
+
// source: spec:customers:list "List customers."
|
|
40
|
+
export async function customers_list(ctx) {
|
|
41
|
+
const q = String(ctx.params.query ?? '').toLowerCase();
|
|
42
|
+
// source: spec:customers:list "Filter by active customers, i.e. customers with at least one trialing, active or past_due subscription."
|
|
43
|
+
const live = new Set(rowsOf(ctx, SUBSCRIPTION).filter((s) => ['trialing', 'active', 'past_due'].includes(String(s.status))).map((s) => s.customer_id));
|
|
44
|
+
const active = ctx.params.active === undefined ? undefined : String(ctx.params.active) === 'true';
|
|
45
|
+
const rows = rowsOf(ctx, CUSTOMER)
|
|
46
|
+
.filter((c) => ctx.params.email === undefined || String(c.email ?? '').toLowerCase() === String(ctx.params.email).toLowerCase())
|
|
47
|
+
.filter((c) => q === '' || [c.name, c.email, c.external_id].some((v) => typeof v === 'string' && v.toLowerCase().includes(q)))
|
|
48
|
+
.filter((c) => active === undefined || live.has(c.id) === active)
|
|
49
|
+
.sort(newest('created_at'));
|
|
50
|
+
return ctx.list(CUSTOMER, rows.map((c) => customerView(ctx, c)));
|
|
51
|
+
}
|
|
52
|
+
// source: spec:customers:create "Validation Error"
|
|
53
|
+
// Where the documentation stops: the error wording for the duplicate external id.
|
|
54
|
+
function duplicateExternal() {
|
|
55
|
+
return invalid(['external_id'], 'A customer with this external ID already exists.');
|
|
56
|
+
}
|
|
57
|
+
// source: spec:customers:create "Validation Error"
|
|
58
|
+
// Where documentation stops: the duplicate-email error message.
|
|
59
|
+
function duplicateEmail() {
|
|
60
|
+
return invalid(['email'], 'A customer with this email address already exists.');
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type HandlerContext } from '@volter/world-core';
|
|
2
|
+
/** POST /_twin/access-tokens {comment, expires_in_days?}: an Organization Access Token made on the settings page. */
|
|
3
|
+
export declare function accessTokens(ctx: HandlerContext): Promise<Response>;
|
|
4
|
+
/** DELETE /_twin/access-tokens/{n}: a token revoked on the settings page. */
|
|
5
|
+
export declare function revokeToken(ctx: HandlerContext): Promise<Response>;
|
|
6
|
+
/** POST /_twin/app-credentials: the access token an application holds, as the runtime issues it (the descriptor's
|
|
7
|
+
* credentialDoor). */
|
|
8
|
+
export declare function appCredentials(ctx: HandlerContext): Promise<Response>;
|
|
9
|
+
/** GET /_twin/deliveries[?to=<url>][&type=<event>]: every webhook Polar sent (the kernel's record of each delivery, in
|
|
10
|
+
* the order sent): what the application's server was told, and when. */
|
|
11
|
+
export declare function deliveries(ctx: HandlerContext): Promise<Response>;
|