mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
fix(billing): a customer may already exist, and may belong to somebody else
Registering a team assumed creating a subscription would create the customer it names. It does not — the customer has to exist first, and creating one fails if the address is already taken, which it often is: a checkout taken before the team existed leaves one behind, and so does making one by hand. So the lookup is now in three steps. A customer already carrying this team id is used. Otherwise one is found by address and adopted. Otherwise one is made. **A customer carrying a different team's id is left alone.** Taking it would move where that subscription is billed, and the team that lost it would go quiet rather than fail — which is the kind of thing found a month later in a revenue figure that does not add up. A team whose owner has no address yet gets no customer, reported rather than guessed around: an invented address makes a customer nobody can be reached at, and the next call fixes it once there is a real one. Checked against the provider rather than only in tests: all four paths — new, repeated, taken by another team, and no address — behave as written.
This commit is contained in:
@@ -87,30 +87,79 @@ export namespace Polar {
|
||||
* 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 };
|
||||
export const ensureFree = fn(
|
||||
z.object({ teamId: z.string(), email: z.email().optional() }),
|
||||
async (input) => {
|
||||
const { freeProductId } = settings();
|
||||
if (!freeProductId) {
|
||||
return { created: false, reason: 'no free product configured' 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 };
|
||||
});
|
||||
// Already ours, and already subscribed to something.
|
||||
try {
|
||||
const state = await client().customers.getStateExternal({
|
||||
externalId: input.teamId
|
||||
});
|
||||
if (state.activeSubscriptions.length > 0) {
|
||||
return { created: false, reason: 'already subscribed' as const };
|
||||
}
|
||||
await client().subscriptions.create({
|
||||
productId: freeProductId,
|
||||
externalCustomerId: input.teamId
|
||||
});
|
||||
return { created: true, reason: 'subscribed an existing customer' as const };
|
||||
} catch {
|
||||
// No customer carries this team id yet, which is the ordinary
|
||||
// case the first time. Fall through and find or make one.
|
||||
}
|
||||
|
||||
// A customer may already exist under this address without being
|
||||
// linked to anything of ours — made by hand, or left behind by a
|
||||
// checkout taken before the team existed. Their addresses are unique,
|
||||
// so creating a second one is refused rather than allowed, and the
|
||||
// only way forward is to adopt the one that is there.
|
||||
let customerId: string | null = null;
|
||||
if (input.email) {
|
||||
const found = await client().customers.list({ email: input.email, limit: 2 });
|
||||
const existing = found.result.items.at(0);
|
||||
if (existing) {
|
||||
// **Never take one that belongs to another team.** Moving an
|
||||
// external id would move where a subscription is billed, and
|
||||
// the team losing it would go quiet rather than error.
|
||||
if (existing.externalId && existing.externalId !== input.teamId) {
|
||||
return { created: false, reason: 'address belongs to another team' as const };
|
||||
}
|
||||
if (!existing.externalId) {
|
||||
await client().customers.update({
|
||||
id: existing.id,
|
||||
customerUpdate: { externalId: input.teamId }
|
||||
});
|
||||
}
|
||||
customerId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
if (!input.email) {
|
||||
// Without an address there is nothing to look up and nothing
|
||||
// to create with, and guessing one would make a customer
|
||||
// nobody can be reached at.
|
||||
return { created: false, reason: 'no email to create a customer with' as const };
|
||||
}
|
||||
const made = await client().customers.create({
|
||||
email: input.email,
|
||||
externalId: input.teamId
|
||||
});
|
||||
customerId = made.id;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { TeamMemberTable } from './member.sql.js';
|
||||
import { TeamTable } from './team.sql.js';
|
||||
|
||||
@@ -91,7 +92,12 @@ export namespace Team {
|
||||
// idempotent.
|
||||
Database.effect(async () => {
|
||||
try {
|
||||
await Polar.ensureFree({ teamId: input.id });
|
||||
// The owner's address, so a customer can be found or made. A team
|
||||
// created by somebody with no verified address gets no customer
|
||||
// yet, which is a state `ensureFree` reports rather than guesses
|
||||
// its way out of.
|
||||
const owner = await User.fromID(ownerId);
|
||||
await Polar.ensureFree({ teamId: input.id, email: owner?.email ?? undefined });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('could not register team with the payment provider:', error);
|
||||
|
||||
Reference in New Issue
Block a user