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
+23
View File
@@ -2,6 +2,7 @@ import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Actor } from '../actor.js';
import { Polar } from '../billing/polar.js';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
@@ -75,6 +76,28 @@ export namespace Team {
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;
});