mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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:
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestri/auth": "workspace:",
|
||||
"@polar-sh/sdk": "catalog:",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.9",
|
||||
"postgresql": "^0.0.1",
|
||||
|
||||
104
packages/core/scripts/polar-product.ts
Normal file
104
packages/core/scripts/polar-product.ts
Normal 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.`);
|
||||
112
packages/core/src/billing/polar.test.ts
Normal file
112
packages/core/src/billing/polar.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
218
packages/core/src/billing/polar.ts
Normal file
218
packages/core/src/billing/polar.ts
Normal 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) };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,19 @@ export namespace Env {
|
||||
*/
|
||||
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()
|
||||
});
|
||||
|
||||
|
||||
@@ -170,6 +170,37 @@ export namespace Team {
|
||||
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> {
|
||||
return {
|
||||
id: input.id,
|
||||
|
||||
Reference in New Issue
Block a user