diff --git a/.env.example b/.env.example index 58a50384..c66afec8 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ EMAIL_DEV_LOG=true POLAR_SERVER=sandbox POLAR_ACCESS_TOKEN= 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, # on purpose: nothing else stands in front of it. POLAR_WEBHOOK_SECRET= diff --git a/packages/core/src/billing/polar.test.ts b/packages/core/src/billing/polar.test.ts index 365b1209..09a3aa8b 100644 --- a/packages/core/src/billing/polar.test.ts +++ b/packages/core/src/billing/polar.test.ts @@ -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 { Polar } from './polar.js'; +const PAID = 'prod_paid_notreal'; +const FREE = 'prod_free_notreal'; + +function configure(extra: Record = {}) { + Env.init({ + POLAR_ACCESS_TOKEN: 'polar_oat_notreal', + POLAR_PRODUCT_ID: PAID, + POLAR_FREE_PRODUCT_ID: FREE, + ...extra + }); + Polar.reset(); +} + +beforeEach(() => configure()); + afterEach(() => { Env.init({}); 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', () => { - test('a live subscription is paid', () => { + test('a live subscription keeps its plan', () => { for (const type of [ 'subscription.created', 'subscription.active', 'subscription.updated', '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', () => { // 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({ + expect(Polar.standingFor('subscription.canceled', PAID)).toEqual({ plan: 'paid', 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', () => { // 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({ + expect(Polar.standingFor('subscription.past_due', PAID)).toEqual({ plan: 'paid', 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 // 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', 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 // 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(); + for (const type of ['subscription.something_new', 'order.created', '', 'customer.updated']) { + expect(Polar.standingFor(type, PAID)).toBeNull(); } }); }); @@ -73,26 +109,19 @@ describe('Configuration', () => { 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' }); + Env.init({ POLAR_ACCESS_TOKEN: 'polar_oat_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(); + configure({ POLAR_WEBHOOK_SECRET: 'whsec_notreal' }); expect(() => Polar.receive({ 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 // 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/); }); }); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 98b8314b..46b462f6 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -45,6 +45,7 @@ export namespace Polar { accessToken: env.POLAR_ACCESS_TOKEN, server: Server.parse(env.POLAR_SERVER ?? 'sandbox'), productId: env.POLAR_PRODUCT_ID, + freeProductId: env.POLAR_FREE_PRODUCT_ID, webhookSecret: env.POLAR_WEBHOOK_SECRET }; } @@ -65,6 +66,51 @@ export namespace Polar { 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. * @@ -142,18 +188,38 @@ export namespace Polar { * changed somebody's plan would be a default that eventually cancels an * 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) { case 'subscription.created': case 'subscription.active': case 'subscription.updated': case 'subscription.uncanceled': - return { plan: 'paid', status: 'active' }; + return { plan, status: 'active' }; case 'subscription.canceled': - return { plan: 'paid', status: 'canceled' }; + return { plan, status: 'canceled' }; case 'subscription.past_due': - return { plan: 'paid', status: 'past_due' }; + return { plan, status: 'past_due' }; case 'subscription.revoked': + // Whatever it was, it is over. Free is where everybody lands. return { plan: 'free', status: 'revoked' }; default: return null; @@ -207,12 +273,18 @@ export namespace Polar { const data = (event as { data?: Record }).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. + // `externalId` is the team id we put on the customer. 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) }; + // 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) }; } ); } diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index cc737ec4..9e63ae84 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -47,6 +47,8 @@ export namespace Env { POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_WEBHOOK_SECRET: 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(), DATABASE_URL: z.string().optional() diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 4d9de8fe..a85d82ca 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -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; });