feat(billing): free is a subscription too, and the product says which plan

Every team now exists with the payment provider, free ones included. An upgrade
then changes a subscription rather than inventing a customer, and there is one
question to ask about anybody instead of two.

A subscription at nothing a month needs no payment, so it is created outright
rather than by sending somebody through a checkout to pay zero.

It runs after the team rows are committed and cannot affect them. Signing up is
not allowed to depend on a third party being reachable, so this cannot fail the
call and does not retry — a team that misses it is free, which is what it would
have been anyway, and the next call puts it right because the operation is
idempotent.

This broke the webhook mapping, which read the plan off the event type. A free
subscription announces itself with the same `subscription.created` a paid one
does, so every new signup would have landed on the paid allowance. The plan now
comes from the product, and a product we do not sell is left alone rather than
guessed at — somebody selling something else through the same account must not
be able to change what a team may run by doing so.

The external id stays the team. It is the billing subject, and keying on the
user would collapse somebody with two teams into one customer with no way to
say which subscription belonged to which.
This commit is contained in:
Wanjohi
2026-09-19 01:07:01 +03:00
parent b01d8eabb3
commit 60c4f61bfa
5 changed files with 161 additions and 34 deletions

View File

@@ -42,6 +42,9 @@ EMAIL_DEV_LOG=true
POLAR_SERVER=sandbox POLAR_SERVER=sandbox
POLAR_ACCESS_TOKEN= POLAR_ACCESS_TOKEN=
POLAR_PRODUCT_ID= POLAR_PRODUCT_ID=
# The product every team is put on at signup, priced at nothing. A free
# subscription needs no checkout, so it is created outright.
POLAR_FREE_PRODUCT_ID=
# Signs every webhook delivery. Without it the webhook route refuses everything, # Signs every webhook delivery. Without it the webhook route refuses everything,
# on purpose: nothing else stands in front of it. # on purpose: nothing else stands in front of it.
POLAR_WEBHOOK_SECRET= POLAR_WEBHOOK_SECRET=

View File

@@ -1,29 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Env } from '../env.js'; import { Env } from '../env.js';
import { Polar } from './polar.js'; import { Polar } from './polar.js';
const PAID = 'prod_paid_notreal';
const FREE = 'prod_free_notreal';
function configure(extra: Record<string, string> = {}) {
Env.init({
POLAR_ACCESS_TOKEN: 'polar_oat_notreal',
POLAR_PRODUCT_ID: PAID,
POLAR_FREE_PRODUCT_ID: FREE,
...extra
});
Polar.reset();
}
beforeEach(() => configure());
afterEach(() => { afterEach(() => {
Env.init({}); Env.init({});
Polar.reset(); Polar.reset();
}); });
describe('Which plan a subscription is', () => {
test('the product decides it, not the event', () => {
// Free is a real subscription here, so it announces itself with the same
// `subscription.created` a paid one does. Reading the type alone would
// put every new signup on the paid allowance.
expect(Polar.standingFor('subscription.created', FREE)).toEqual({
plan: 'free',
status: 'active'
});
expect(Polar.standingFor('subscription.created', PAID)).toEqual({
plan: 'paid',
status: 'active'
});
});
test('a product we do not sell changes nothing', () => {
// Somebody selling something else through the same account must not be
// able to change what a team may run by doing so.
expect(Polar.standingFor('subscription.active', 'prod_somethingelse')).toBeNull();
expect(Polar.standingFor('subscription.active', null)).toBeNull();
});
});
describe('What an event means for access', () => { describe('What an event means for access', () => {
test('a live subscription is paid', () => { test('a live subscription keeps its plan', () => {
for (const type of [ for (const type of [
'subscription.created', 'subscription.created',
'subscription.active', 'subscription.active',
'subscription.updated', 'subscription.updated',
'subscription.uncanceled' 'subscription.uncanceled'
]) { ]) {
expect(Polar.standingFor(type)).toEqual({ plan: 'paid', status: 'active' }); expect(Polar.standingFor(type, PAID)).toEqual({ plan: 'paid', status: 'active' });
} }
}); });
test('cancelling keeps the plan until the period is actually over', () => { 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 // They paid to the end of the period. Turning them off the moment they
// click cancel is taking something they bought. // click cancel is taking something they bought.
expect(Polar.standingFor('subscription.canceled')).toEqual({ expect(Polar.standingFor('subscription.canceled', PAID)).toEqual({
plan: 'paid', plan: 'paid',
status: 'canceled' status: 'canceled'
}); });
@@ -32,16 +70,20 @@ describe('What an event means for access', () => {
test('a failed card keeps the plan while it is being retried', () => { 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 // 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. // payment should not have cost them access in the middle of it.
expect(Polar.standingFor('subscription.past_due')).toEqual({ expect(Polar.standingFor('subscription.past_due', PAID)).toEqual({
plan: 'paid', plan: 'paid',
status: 'past_due' status: 'past_due'
}); });
}); });
test('revoked is the one that takes it away', () => { test('revoked drops to free, whatever it was before', () => {
// The provider saying the period is over and unpaid, which is the only // The provider saying the period is over and unpaid, which is the only
// moment there is nothing left that was paid for. // moment there is nothing left that was paid for.
expect(Polar.standingFor('subscription.revoked')).toEqual({ expect(Polar.standingFor('subscription.revoked', PAID)).toEqual({
plan: 'free',
status: 'revoked'
});
expect(Polar.standingFor('subscription.revoked', FREE)).toEqual({
plan: 'free', plan: 'free',
status: 'revoked' status: 'revoked'
}); });
@@ -51,14 +93,8 @@ describe('What an event means for access', () => {
// New types get added by people who do not know what we do with them. A // 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 // default that moved somebody's plan would eventually cancel an account
// nobody cancelled. // nobody cancelled.
for (const type of [ for (const type of ['subscription.something_new', 'order.created', '', 'customer.updated']) {
'subscription.something_new', expect(Polar.standingFor(type, PAID)).toBeNull();
'order.created',
'benefit.granted',
'',
'customer.updated'
]) {
expect(Polar.standingFor(type)).toBeNull();
} }
}); });
}); });
@@ -73,26 +109,19 @@ describe('Configuration', () => {
test('a token without a product is still not configured', () => { test('a token without a product is still not configured', () => {
// Half-configured is the dangerous one: a checkout with no product to // Half-configured is the dangerous one: a checkout with no product to
// sell would fail at the provider, after the person clicked pay. // sell would fail at the provider, after the person clicked pay.
Env.init({ POLAR_ACCESS_TOKEN: 'polar_at_notreal' }); Env.init({ POLAR_ACCESS_TOKEN: 'polar_oat_notreal' });
Polar.reset(); Polar.reset();
expect(Polar.configured()).toBe(false); expect(Polar.configured()).toBe(false);
}); });
test('both together is configured', () => { 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); expect(Polar.configured()).toBe(true);
}); });
}); });
describe('Webhooks', () => { describe('Webhooks', () => {
test('a body that is not signed is refused', () => { test('a body that is not signed is refused', () => {
Env.init({ configure({ POLAR_WEBHOOK_SECRET: 'whsec_notreal' });
POLAR_ACCESS_TOKEN: 'polar_at_notreal',
POLAR_PRODUCT_ID: 'prod_notreal',
POLAR_WEBHOOK_SECRET: 'whsec_notreal'
});
Polar.reset();
expect(() => expect(() =>
Polar.receive({ Polar.receive({
body: JSON.stringify({ type: 'subscription.active', data: {} }), body: JSON.stringify({ type: 'subscription.active', data: {} }),
@@ -105,8 +134,6 @@ describe('Webhooks', () => {
// This route has no session in front of it. If the secret is missing the // 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 // only safe answer is to refuse, because accepting would mean anybody
// who knows the URL can set anybody's plan. // 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/); expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
}); });
}); });

View File

@@ -45,6 +45,7 @@ export namespace Polar {
accessToken: env.POLAR_ACCESS_TOKEN, accessToken: env.POLAR_ACCESS_TOKEN,
server: Server.parse(env.POLAR_SERVER ?? 'sandbox'), server: Server.parse(env.POLAR_SERVER ?? 'sandbox'),
productId: env.POLAR_PRODUCT_ID, productId: env.POLAR_PRODUCT_ID,
freeProductId: env.POLAR_FREE_PRODUCT_ID,
webhookSecret: env.POLAR_WEBHOOK_SECRET webhookSecret: env.POLAR_WEBHOOK_SECRET
}; };
} }
@@ -65,6 +66,51 @@ export namespace Polar {
client.reset(); client.reset();
} }
/**
* Put a team on the free plan with the provider, without a checkout.
*
* A subscription at nothing a month needs no payment, so it is created
* outright rather than by sending somebody to pay zero — a checkout for a
* free account is a step that exists only to be got through.
*
* The point of doing it at all is that every team then exists on their side,
* with our team id as its external id. Free accounts show up in the same
* places paid ones do, an upgrade changes a subscription rather than
* inventing a customer, and there is one question to ask about anybody
* rather than two.
*
* **Idempotent, and quiet when it fails.** It runs after a team is created
* and must never be able to undo that: signing up is not allowed to depend
* on a third party being reachable, so a failure here leaves a team that is
* free anyway — which is exactly what it would have been — and the next call
* fixes it. That is also why it is safe to call on a team that already has
* one.
*/
export const ensureFree = fn(z.object({ teamId: z.string() }), async (input) => {
const { freeProductId } = settings();
if (!freeProductId) {
return { created: false, reason: 'no free product configured' as const };
}
try {
const existing = await client().customers.getStateExternal({
externalId: input.teamId
});
if (existing.activeSubscriptions.length > 0) {
return { created: false, reason: 'already subscribed' as const };
}
} catch {
// No such customer yet, which is the ordinary case the first time.
// Creating the subscription below makes one.
}
await client().subscriptions.create({
productId: freeProductId,
externalCustomerId: input.teamId
});
return { created: true, reason: 'created' as const };
});
/** /**
* A checkout for a team, as the customer they already are. * A checkout for a team, as the customer they already are.
* *
@@ -142,18 +188,38 @@ export namespace Polar {
* changed somebody's plan would be a default that eventually cancels an * changed somebody's plan would be a default that eventually cancels an
* account nobody cancelled. * account nobody cancelled.
*/ */
export function standingFor(eventType: string): Standing | null { export function standingFor(eventType: string, productId: string | null): Standing | null {
// Which plan a subscription *is* comes from the product, never from the
// event. Free is a real subscription here, so it announces itself with
// the same `subscription.created` a paid one does — reading the type
// alone would put every new signup on the paid allowance.
const { productId: paidProduct, freeProductId } = settings();
const plan: Standing['plan'] | null =
productId && productId === paidProduct
? 'paid'
: productId && productId === freeProductId
? 'free'
: null;
// A product we do not recognise is left alone rather than guessed at.
// Somebody selling something else through the same account should not be
// able to change what a team may run by doing so.
if (!plan) {
return null;
}
switch (eventType) { switch (eventType) {
case 'subscription.created': case 'subscription.created':
case 'subscription.active': case 'subscription.active':
case 'subscription.updated': case 'subscription.updated':
case 'subscription.uncanceled': case 'subscription.uncanceled':
return { plan: 'paid', status: 'active' }; return { plan, status: 'active' };
case 'subscription.canceled': case 'subscription.canceled':
return { plan: 'paid', status: 'canceled' }; return { plan, status: 'canceled' };
case 'subscription.past_due': case 'subscription.past_due':
return { plan: 'paid', status: 'past_due' }; return { plan, status: 'past_due' };
case 'subscription.revoked': case 'subscription.revoked':
// Whatever it was, it is over. Free is where everybody lands.
return { plan: 'free', status: 'revoked' }; return { plan: 'free', status: 'revoked' };
default: default:
return null; return null;
@@ -207,12 +273,18 @@ export namespace Polar {
const data = (event as { data?: Record<string, unknown> }).data ?? {}; const data = (event as { data?: Record<string, unknown> }).data ?? {};
const customer = data.customer as { externalId?: string | null } | undefined; const customer = data.customer as { externalId?: string | null } | undefined;
// `externalId` is the team id we sent at checkout. A delivery without // `externalId` is the team id we put on the customer. A delivery
// one is about a customer created some other way — by hand in their // without one is about a customer created some other way — by hand in
// dashboard, most likely — and there is nothing here it can change. // their dashboard, most likely — and there is nothing here it can
// change.
const teamId = customer?.externalId ?? null; const teamId = customer?.externalId ?? null;
return { type: event.type, teamId, standing: standingFor(event.type) }; // Both spellings, because which one a payload carries depends on
// whether the product was expanded into it.
const product = data.product as { id?: string } | undefined;
const productId = (data.productId as string | undefined) ?? product?.id ?? null;
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
} }
); );
} }

View File

@@ -47,6 +47,8 @@ export namespace Env {
POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_ACCESS_TOKEN: z.string().optional(),
POLAR_WEBHOOK_SECRET: z.string().optional(), POLAR_WEBHOOK_SECRET: z.string().optional(),
POLAR_PRODUCT_ID: z.string().optional(), POLAR_PRODUCT_ID: z.string().optional(),
/** The product a team is put on at signup, priced at nothing. */
POLAR_FREE_PRODUCT_ID: z.string().optional(),
POLAR_SERVER: z.enum(['sandbox', 'production']).optional(), POLAR_SERVER: z.enum(['sandbox', 'production']).optional(),
DATABASE_URL: z.string().optional() DATABASE_URL: z.string().optional()

View File

@@ -2,6 +2,7 @@ import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod'; import z from 'zod';
import { Actor } from '../actor.js'; import { Actor } from '../actor.js';
import { Polar } from '../billing/polar.js';
import { Database } from '../db/index.js'; import { Database } from '../db/index.js';
import { Examples } from '../examples.js'; import { Examples } from '../examples.js';
import { fn } from '../fn.js'; import { fn } from '../fn.js';
@@ -75,6 +76,28 @@ export namespace Team {
role: 'owner' role: 'owner'
}); });
}); });
// Register the team with the payment provider, *after* the rows are
// committed and without being able to affect them.
//
// Every team exists on their side, free ones included, so that an
// upgrade changes a subscription rather than inventing a customer and
// there is one question to ask about anybody rather than two.
//
// **Signing up is not allowed to depend on a third party.** So this
// cannot run inside the transaction, cannot fail the call, and does not
// retry: a team that misses it is free, which is what it would have been
// anyway, and the next call puts it right because the operation is
// idempotent.
Database.effect(async () => {
try {
await Polar.ensureFree({ teamId: input.id });
} catch (error) {
// eslint-disable-next-line no-console
console.error('could not register team with the payment provider:', error);
}
});
return input.id; return input.id;
}); });