feat(core,api): take payment, and let the provider decide who is paid up

Checkout, the customer portal, and the webhook that moves a team's plan.

Nothing money-shaped is stored. No price, no currency, no card detail — a
subscription's existence and its state are the whole of what crosses back,
because they are the only two facts the product needs and anything more would
be a second copy of a record somebody else is authoritative for.

Currency is deliberately not ours to hold. A product carries a price per
currency on their side and the customer's location picks one at checkout, so
there is no figure in this API that could drift from the one somebody is
charged. The team id travels as the customer's external id, which keeps the
mapping on their side rather than putting a foreign primary key in our schema.

Access follows their state, and the interesting cases are where that is not the
same as "paying right now". Cancelling keeps the plan: they paid to the end of
the period and turning them off when they click it takes something they bought.
A failed card keeps it too, because a retry that ends in payment should not
have cost them access in the middle. Only a revoked subscription takes it away,
which is the one moment nothing is left that was paid for. An event we do not
recognise changes nothing at all — new types are added by people who do not
know what we do with them, and a default that moved a plan would eventually
cancel an account nobody cancelled.

The webhook is the only route here no session protects, because its caller has
no account and never will. A signature over the raw body stands in for one, and
it is checked before the body is looked at — a body that has been parsed and
re-serialized is not the body that was signed. With no secret configured it
refuses everything rather than accepting anything, since otherwise knowing the
URL would be enough to set somebody's plan. Note also what is absent: no route
sets a plan, so there is no endpoint for granting yourself a subscription.

The product is written down as a definition with a script rather than clicked
into a dashboard, because the two environments are separate servers and nothing
made in one can be moved to the other. Promoting it is running the same script
with the other token, which is the only version of that which cannot drift. It
writes nothing without --apply and refuses to add a second product with a name
already taken.
This commit is contained in:
Wanjohi
2026-09-19 00:57:57 +03:00
parent 4c22586d59
commit 757ff79233
11 changed files with 702 additions and 3 deletions

View File

@@ -35,3 +35,13 @@ EMAIL_SEND_URL=
EMAIL_API_KEY= EMAIL_API_KEY=
EMAIL_FROM= EMAIL_FROM=
EMAIL_DEV_LOG=true EMAIL_DEV_LOG=true
# Billing. The provider's sandbox and production are separate servers with
# separate data, so a token from one is refused by the other and a product id
# from one means nothing to it. `POLAR_SERVER` says which you are talking to.
POLAR_SERVER=sandbox
POLAR_ACCESS_TOKEN=
POLAR_PRODUCT_ID=
# Signs every webhook delivery. Without it the webhook route refuses everything,
# on purpose: nothing else stands in front of it.
POLAR_WEBHOOK_SECRET=

View File

@@ -10,6 +10,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status';
import { auth } from './middleware/auth.js'; import { auth } from './middleware/auth.js';
import { AccessTokenApi } from './routes/access-token.js'; import { AccessTokenApi } from './routes/access-token.js';
import { BillingApi } from './routes/billing.js';
import { EnrolmentApi } from './routes/enrolment.js'; import { EnrolmentApi } from './routes/enrolment.js';
import { GameApi } from './routes/game.js'; import { GameApi } from './routes/game.js';
import { IndexApi } from './routes/index.js'; import { IndexApi } from './routes/index.js';
@@ -43,6 +44,7 @@ const routes = app
.route('/steam', SteamApi.route) .route('/steam', SteamApi.route)
.route('/library', LibraryApi.route) .route('/library', LibraryApi.route)
.route('/games', GameApi.route) .route('/games', GameApi.route)
.route('/billing', BillingApi.route)
.route('/organisation', OrganisationApi.route) .route('/organisation', OrganisationApi.route)
.route('/machine', MachineApi.route) .route('/machine', MachineApi.route)
.route('/machine', SessionApi.machineRoute) .route('/machine', SessionApi.machineRoute)

View File

@@ -0,0 +1,197 @@
import { Actor } from '@nestri/core/actor';
import { Billing } from '@nestri/core/billing/index';
import { Polar } from '@nestri/core/billing/polar';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Team } from '@nestri/core/team/index';
import { User } from '@nestri/core/user/index';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
/**
* Paying, and seeing what has been spent.
*
* The webhook at the bottom is **the only route in this app that no session
* protects**, and that is not an oversight: it is called by somebody else's
* server, which has no account here and never will. What stands in for a
* session is a signature over the raw body, and it is checked before the body
* is looked at.
*
* Everything else is ordinary and team-scoped. Note what is missing: there is
* no route that sets a plan. A plan is what the provider says it is, so the
* only thing that writes one is a delivery that proved it came from them —
* anything else would be an endpoint for granting yourself a subscription.
*/
export namespace BillingApi {
/** The team the caller is acting for, which is who the bill belongs to. */
async function payingTeam(): Promise<string> {
const actor = Actor.use();
if (actor.type === 'member') {
return actor.properties.teamID;
}
const team = await Team.personalFor(Actor.userID);
if (!team) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'You have no team to bill'
);
}
return team.id;
}
function mustBeConfigured() {
if (!Polar.configured()) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured on this deployment'
);
}
}
export const route = new Hono()
.post(
'/webhook',
describeRoute({
tags: ['Billing'],
summary: 'Receive a subscription event',
description:
'Called by the payment provider, not by you. Authenticated by a signature over the raw body rather than by a session, because the caller has no account here. Deliveries for a customer we did not create are acknowledged and ignored — a retry loop against a delivery nothing can act on helps nobody.',
responses: {
200: { description: 'Delivery accepted' },
401: ErrorResponses[401]
}
}),
async (c) => {
mustBeConfigured();
// The raw body, before any parsing. A body that has been through
// `JSON.parse` and re-serialized is not the body that was signed,
// and the signature is the whole of this route's authentication.
const body = await c.req.text();
const headers: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
headers[key] = value;
});
const delivery = Polar.receive({ body, headers });
// Acknowledged rather than refused. A delivery we cannot act on is
// still a delivery that arrived intact, and answering an error
// would have them retry it for days against a thing that will
// never become actionable.
if (!delivery.teamId || !delivery.standing) {
return c.json({ data: { applied: false, type: delivery.type } });
}
const updated = await Team.setPlan({
id: delivery.teamId,
plan: delivery.standing.plan,
subscriptionStatus: delivery.standing.status
});
return c.json({
data: { applied: Boolean(updated), type: delivery.type }
});
}
)
.get(
'/',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'What you are on, and what you have spent',
description:
'The plan, and where each of the three windows stands. This is what a meter is drawn from — the percentages here are the same numbers that decide whether a run may start, so a full bar and a refusal cannot disagree.',
responses: {
200: {
content: { 'application/json': { schema: Result(Billing.State) } },
description: 'Your standing'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
async (c) => c.json({ data: await Billing.state({ teamId: await payingTeam() }) })
)
.post(
'/checkout',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'Start paying',
description:
'Returns a URL to send the person to. The price they are shown is set on the providers side per currency and chosen from where they are, so nothing here names an amount — there is no figure in this API that could drift from the one they are charged.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
id: z.string().meta({ description: 'The checkout' }),
url: z.url().meta({ description: 'Where to send the person' })
})
)
}
},
description: 'A checkout to send them to'
},
401: ErrorResponses[401],
404: ErrorResponses[404],
500: ErrorResponses[500]
}
}),
validator(
'json',
z
.object({
successUrl: z.url().optional().meta({
description: 'Where to return to once they have paid'
})
})
.strict()
),
async (c) => {
mustBeConfigured();
const teamId = await payingTeam();
const user = await User.fromID(Actor.userID);
return c.json({
data: await Polar.checkout({
teamId,
email: user?.email ?? undefined,
successUrl: c.req.valid('json').successUrl
})
});
}
)
.get(
'/portal',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'Manage what you are paying',
description:
'A URL to the providers own portal, where a card is changed, an invoice is read and a subscription is cancelled. None of that is rebuilt here, because rebuilding it would mean holding payment details in order to show them.',
responses: {
200: {
content: {
'application/json': {
schema: Result(z.object({ url: z.url() }))
}
},
description: 'Where to manage it'
},
401: ErrorResponses[401],
404: ErrorResponses[404],
500: ErrorResponses[500]
}
}),
async (c) => {
mustBeConfigured();
return c.json({ data: await Polar.portal({ teamId: await payingTeam() }) });
}
);
}

View File

@@ -77,6 +77,7 @@
"name": "@nestri/core", "name": "@nestri/core",
"dependencies": { "dependencies": {
"@nestri/auth": "workspace:", "@nestri/auth": "workspace:",
"@polar-sh/sdk": "catalog:",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"postgresql": "^0.0.1", "postgresql": "^0.0.1",
@@ -95,6 +96,7 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "^5.20260722.1", "@cloudflare/workers-types": "^5.20260722.1",
"@polar-sh/sdk": "^0.49.0",
"@tsconfig/node22": "^22.0.5", "@tsconfig/node22": "^22.0.5",
"@types/bun": "latest", "@types/bun": "latest",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
@@ -383,6 +385,8 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="], "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="],
"@polar-sh/sdk": ["@polar-sh/sdk@0.49.0", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-9UYb70iKjJCtWYlu0OF5HLYBLmkxHwqr2RlXwuxXQgRGqq56IQWlVG+NO7e1YJ7I5GW0CBHhGIjRbQ9hYM6ycQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -403,13 +407,15 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], "@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.5", "", {}, "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw=="], "@tsconfig/node22": ["@tsconfig/node22@22.0.5", "", {}, "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -485,7 +491,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -519,6 +525,8 @@
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -637,6 +645,8 @@
"sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="], "sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="],
"standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"svelte": ["svelte@5.56.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ=="], "svelte": ["svelte@5.56.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ=="],

View File

@@ -14,7 +14,8 @@
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"hono": "^4.12.31", "hono": "^4.12.31",
"typescript": "^7.0.1-rc", "typescript": "^7.0.1-rc",
"zod": "^4.4.3" "zod": "^4.4.3",
"@polar-sh/sdk": "^0.49.0"
} }
}, },
"type": "module", "type": "module",

View File

@@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@nestri/auth": "workspace:", "@nestri/auth": "workspace:",
"@polar-sh/sdk": "catalog:",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"postgresql": "^0.0.1", "postgresql": "^0.0.1",

View File

@@ -0,0 +1,104 @@
/**
* Create the paid product, against whichever environment you point it at.
*
* Sandbox and production are separate servers with separate data, so nothing
* made in one can be moved to the other — a product designed in sandbox has to
* be *recreated* in production, and two things typed twice are two things that
* drift. So the product is written down once here, and promoting it is running
* this again with the other token.
*
* POLAR_ACCESS_TOKEN=$(cat ~/.polar_sandbox_token) \
* POLAR_SERVER=sandbox \
* bun run packages/core/scripts/polar-product.ts
*
* Add `--apply` to actually create it. Without it nothing is written and the
* script prints what it would do, because this talks to a live payment account
* and a product created by accident is visible to customers.
*
* It refuses to make a second product with the same name rather than quietly
* making a duplicate — two products called the same thing is how a checkout
* ends up pointing at the wrong one.
*/
import { Polar } from '@polar-sh/sdk';
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
/**
* The paid rung of the self-serve ladder.
*
* One recurring monthly product, priced per currency. The customer's location
* picks which price they see, so these are *presentment* prices rather than a
* conversion of one another — that is the point of listing three rather than
* charging one and letting a card issuer decide.
*
* Amounts are in minor units: 2000 is 20.00.
*/
const PRODUCT = {
name: 'Nestri Pro',
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
recurringInterval: 'month' as const,
prices: [
{ currency: 'usd', amount: 2000 },
{ currency: 'eur', amount: 2000 },
{ currency: 'gbp', amount: 2000 }
] satisfies { currency: PresentmentCurrency; amount: number }[]
};
const apply = process.argv.includes('--apply');
const accessToken = process.env.POLAR_ACCESS_TOKEN;
const server = (process.env.POLAR_SERVER ?? 'sandbox') as 'sandbox' | 'production';
if (!accessToken) {
console.error('POLAR_ACCESS_TOKEN is not set');
process.exit(1);
}
const polar = new Polar({ accessToken, server });
const organizations = await polar.organizations.listOrganizations({ limit: 2 });
const organization = organizations.result.items.at(0);
if (!organization) {
console.error('that token can see no organization');
process.exit(1);
}
if (organizations.result.items.length > 1) {
// Which one to use would be a guess, and the wrong guess bills the wrong
// company.
console.error('that token can see more than one organization; refusing to choose');
process.exit(1);
}
console.log(`server: ${server}`);
console.log(`organization: ${organization.name} (${organization.id})`);
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
if (clash) {
console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`);
console.log('nothing to do. Archive it first if you meant to replace it.');
process.exit(0);
}
console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`);
for (const price of PRODUCT.prices) {
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
}
if (!apply) {
console.log('\nnothing written. Re-run with --apply to create it.');
process.exit(0);
}
const created = await polar.products.create({
organizationId: organization.id,
name: PRODUCT.name,
description: PRODUCT.description,
recurringInterval: PRODUCT.recurringInterval,
prices: PRODUCT.prices.map((price) => ({
amountType: 'fixed' as const,
priceCurrency: price.currency,
priceAmount: price.amount
}))
});
console.log(`\ncreated: ${created.id}`);
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { Env } from '../env.js';
import { Polar } from './polar.js';
afterEach(() => {
Env.init({});
Polar.reset();
});
describe('What an event means for access', () => {
test('a live subscription is paid', () => {
for (const type of [
'subscription.created',
'subscription.active',
'subscription.updated',
'subscription.uncanceled'
]) {
expect(Polar.standingFor(type)).toEqual({ plan: 'paid', status: 'active' });
}
});
test('cancelling keeps the plan until the period is actually over', () => {
// They paid to the end of the period. Turning them off the moment they
// click cancel is taking something they bought.
expect(Polar.standingFor('subscription.canceled')).toEqual({
plan: 'paid',
status: 'canceled'
});
});
test('a failed card keeps the plan while it is being retried', () => {
// A card that failed may yet work, and a retry cycle that ends in
// payment should not have cost them access in the middle of it.
expect(Polar.standingFor('subscription.past_due')).toEqual({
plan: 'paid',
status: 'past_due'
});
});
test('revoked is the one that takes it away', () => {
// The provider saying the period is over and unpaid, which is the only
// moment there is nothing left that was paid for.
expect(Polar.standingFor('subscription.revoked')).toEqual({
plan: 'free',
status: 'revoked'
});
});
test('an unrecognised event changes nothing', () => {
// New types get added by people who do not know what we do with them. A
// default that moved somebody's plan would eventually cancel an account
// nobody cancelled.
for (const type of [
'subscription.something_new',
'order.created',
'benefit.granted',
'',
'customer.updated'
]) {
expect(Polar.standingFor(type)).toBeNull();
}
});
});
describe('Configuration', () => {
test('unconfigured is a state, not a crash', () => {
Env.init({});
Polar.reset();
expect(Polar.configured()).toBe(false);
});
test('a token without a product is still not configured', () => {
// Half-configured is the dangerous one: a checkout with no product to
// sell would fail at the provider, after the person clicked pay.
Env.init({ POLAR_ACCESS_TOKEN: 'polar_at_notreal' });
Polar.reset();
expect(Polar.configured()).toBe(false);
});
test('both together is configured', () => {
Env.init({ POLAR_ACCESS_TOKEN: 'polar_at_notreal', POLAR_PRODUCT_ID: 'prod_notreal' });
Polar.reset();
expect(Polar.configured()).toBe(true);
});
});
describe('Webhooks', () => {
test('a body that is not signed is refused', () => {
Env.init({
POLAR_ACCESS_TOKEN: 'polar_at_notreal',
POLAR_PRODUCT_ID: 'prod_notreal',
POLAR_WEBHOOK_SECRET: 'whsec_notreal'
});
Polar.reset();
expect(() =>
Polar.receive({
body: JSON.stringify({ type: 'subscription.active', data: {} }),
headers: { 'webhook-id': 'x', 'webhook-timestamp': '1', 'webhook-signature': 'v1,no' }
})
).toThrow();
});
test('no secret configured is a refusal, never an unchecked delivery', () => {
// This route has no session in front of it. If the secret is missing the
// only safe answer is to refuse, because accepting would mean anybody
// who knows the URL can set anybody's plan.
Env.init({ POLAR_ACCESS_TOKEN: 'polar_at_notreal', POLAR_PRODUCT_ID: 'prod_notreal' });
Polar.reset();
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
});
});

View File

@@ -0,0 +1,218 @@
import { Polar as PolarSdk } from '@polar-sh/sdk';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
import z from 'zod';
import { Env } from '../env.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { memo } from '../utils/memo.js';
/**
* The payment provider, and the only part of this codebase that talks to one.
*
* Everything money-shaped that is *not* here is deliberate. We store no price,
* no currency and no card detail: a subscription's existence and its state are
* the whole of what crosses back, because those are the only two facts the
* product needs and anything more would be a second copy of a record somebody
* else is authoritative for.
*
* **Currency is not our problem, by design.** A product carries a price per
* currency on their side and the customer's location picks one at checkout. So
* there is no currency in this file, none in the database, and no place where a
* rate could be stale — the alternative is holding prices in three currencies
* and discovering one of them is wrong from a customer.
*
* The team id goes over as the customer's `externalCustomerId`, which makes the
* mapping theirs to keep. A Polar customer id in our schema would be a foreign
* primary key we would then have to keep in step with a system we do not
* control.
*/
export namespace Polar {
/** Which Polar instance. Sandbox is a separate server with separate data. */
export const Server = z.enum(['sandbox', 'production']);
export type Server = z.infer<typeof Server>;
function settings() {
const env = Env.get();
if (!env.POLAR_ACCESS_TOKEN) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
return {
accessToken: env.POLAR_ACCESS_TOKEN,
server: Server.parse(env.POLAR_SERVER ?? 'sandbox'),
productId: env.POLAR_PRODUCT_ID,
webhookSecret: env.POLAR_WEBHOOK_SECRET
};
}
/** Whether billing can run at all. Every route here checks it first. */
export function configured(): boolean {
const env = Env.get();
return Boolean(env.POLAR_ACCESS_TOKEN && env.POLAR_PRODUCT_ID);
}
const client = memo(() => {
const { accessToken, server } = settings();
return new PolarSdk({ accessToken, server });
});
/** Reset the memo. Tests change the environment between cases. */
export function reset(): void {
client.reset();
}
/**
* A checkout for a team, as the customer they already are.
*
* The team id travels as `externalCustomerId`, so a second checkout for the
* same team reaches the same customer rather than making another one — which
* is what keeps one team from ending up with two subscriptions and two
* invoices for the same month.
*/
export const checkout = fn(
z.object({
teamId: z.string(),
email: z.email().optional(),
successUrl: z.url().optional()
}),
async (input) => {
const { productId } = settings();
if (!productId) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
const created = await client().checkouts.create({
products: [productId],
externalCustomerId: input.teamId,
customerEmail: input.email,
successUrl: input.successUrl
});
return { id: created.id, url: created.url };
}
);
/**
* A link to where somebody manages what they are already paying.
*
* Cancelling, changing a card and reading an invoice all live there rather
* than here. Rebuilding any of it would mean holding payment details to show
* them, which is the one thing this integration exists to avoid.
*/
export const portal = fn(z.object({ teamId: z.string() }), async (input) => {
const session = await client().customerSessions.create({
externalCustomerId: input.teamId
});
return { url: session.customerPortalUrl };
});
/** The plan and status a team is on, as far as the provider is concerned. */
export const Standing = z.object({
plan: z.enum(['free', 'paid']),
status: z.string()
});
export type Standing = z.infer<typeof Standing>;
/**
* What a subscription event means for what a team may do.
*
* The rule is that **access follows the provider's own state and nothing
* else**, and the interesting cases are the ones where that is not the same
* as "are they paying right now":
*
* - `canceled` keeps the plan. Somebody who cancels has paid to the end of
* the period and turning them off the moment they click it would be taking
* something they bought.
* - `past_due` also keeps it. A failed card is a card that may yet work, and
* a retry cycle that ends in payment should not have cost them access in
* the middle of it.
* - `revoked` is the one that takes it away. That is the provider saying the
* period is over and unpaid, which is the only moment there is nothing
* left that was paid for.
*
* An unknown type returns null rather than guessing. New event types get
* added by people who do not know what we do with them, and a default that
* changed somebody's plan would be a default that eventually cancels an
* account nobody cancelled.
*/
export function standingFor(eventType: string): Standing | null {
switch (eventType) {
case 'subscription.created':
case 'subscription.active':
case 'subscription.updated':
case 'subscription.uncanceled':
return { plan: 'paid', status: 'active' };
case 'subscription.canceled':
return { plan: 'paid', status: 'canceled' };
case 'subscription.past_due':
return { plan: 'paid', status: 'past_due' };
case 'subscription.revoked':
return { plan: 'free', status: 'revoked' };
default:
return null;
}
}
export interface Delivery {
type: string;
/** The team this is about, from the customer's external id. */
teamId: string | null;
standing: Standing | null;
}
/**
* Check a webhook is really from them, and say what it means.
*
* The signature is checked over the **raw body**, before anything is parsed:
* this is the one route in the API that no session protects, so the
* signature is the whole of its authentication, and a body that has been
* through `JSON.parse` and back is not the body that was signed.
*
* A bad signature is an authentication failure and not a server fault — it
* is what an attacker gets, and it must read the same as a stale secret so
* that neither tells anybody which it was.
*/
export const receive = fn(
z.object({ body: z.string(), headers: z.record(z.string(), z.string()) }),
(input): Delivery => {
const { webhookSecret } = settings();
if (!webhookSecret) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
let event;
try {
event = validateEvent(input.body, input.headers, webhookSecret);
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.INVALID_TOKEN,
'Signature does not match'
);
}
throw error;
}
const data = (event as { data?: Record<string, unknown> }).data ?? {};
const customer = data.customer as { externalId?: string | null } | undefined;
// `externalId` is the team id we sent at checkout. A delivery without
// one is about a customer created some other way — by hand in their
// dashboard, most likely — and there is nothing here it can change.
const teamId = customer?.externalId ?? null;
return { type: event.type, teamId, standing: standingFor(event.type) };
}
);
}

View File

@@ -36,6 +36,19 @@ export namespace Env {
*/ */
BURN_LIMITS: z.string().optional(), BURN_LIMITS: z.string().optional(),
/**
* The payment provider.
*
* `POLAR_SERVER` picks the instance and the two are entirely separate
* servers with separate data, so a token from one is refused by the
* other and a product id from one means nothing to it. Getting this
* wrong fails loudly rather than quietly charging somebody.
*/
POLAR_ACCESS_TOKEN: z.string().optional(),
POLAR_WEBHOOK_SECRET: z.string().optional(),
POLAR_PRODUCT_ID: z.string().optional(),
POLAR_SERVER: z.enum(['sandbox', 'production']).optional(),
DATABASE_URL: z.string().optional() DATABASE_URL: z.string().optional()
}); });

View File

@@ -170,6 +170,37 @@ export namespace Team {
return create({ id, name: `${input.displayName}'s Team`, slug }); return create({ id, name: `${input.displayName}'s Team`, slug });
}); });
/**
* Record what the payment provider says a team is on.
*
* The only writer is the webhook, and it writes both fields together: a plan
* without the status it came from cannot say whether "paid" means paying,
* cancelled-but-paid-up, or behind on a card, and every one of those wants a
* different sentence in front of a person.
*
* Deliberately not reached from anywhere a user can call. A plan that could
* be set by a request is a plan somebody can set on themselves.
*/
export const setPlan = fn(
Info.pick({ id: true }).extend({
plan: z.string(),
subscriptionStatus: z.string()
}),
async (input) => {
return Database.use(async (tx) => {
return tx
.update(TeamTable)
.set({ plan: input.plan, subscriptionStatus: input.subscriptionStatus })
.where(and(eq(TeamTable.id, input.id), isNull(TeamTable.timeDeleted)))
.returning()
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
}
);
export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> { export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> {
return { return {
id: input.id, id: input.id,