feat: emit team billing events for the payment integration

The charge lives outside this repo, the way the marketplace extension cancels
Stripe subscriptions off `user.delete`. This is the trigger, and it is the
whole of what OSS owes billing.

  team.account.created    a seat exists and can be used
  team.account.disabled   it stopped, and still holds its bytes
  team.account.enabled    it resumed
  team.account.deleted    it is gone
  team.deleted            the team is gone; its accounts are not

Each carries the team uid, the affected account, and the owner's
`stripe_customer_id`. That column ships in the mysql and postgres schemas but
not sqlite, so the read is guarded and degrades to null, as `cascadeDelete`
already does.

Deleting a team emits one `team.account.disabled` per seat plus the
team event, rather than one bulk event: the accounts persist, suspended,
holding their files and their usernames. Deleting a team is not a way to
stop paying for the accounts in it.

`team.account.deleted` is captured before the row is deleted.
`jct_user_group.user_id` is ON DELETE CASCADE, so by the time a listener on
`user.delete` runs, nothing can say which team paid for the account.

`getOrgSeat` deliberately admits soft-deleted teams: their accounts still
exist, so the charge is still running.

Closes PUT-1712.
This commit is contained in:
Juan Castro
2026-09-08 16:59:28 -04:00
parent 00afea1285
commit 0c6045ed90
6 changed files with 461 additions and 32 deletions
+39 -5
View File
@@ -35,6 +35,24 @@ type GuiEvent<R = Record<string, unknown>> = {
// The entry arrives under several aliases, for handlers of any vintage.
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };
/**
* Who pays, and for which team. Deliberately no payment identity: the
* payer's row is alive at emit time, so a consumer resolves it when it acts --
* a snapshot taken here would be stale, and two events racing on it would each
* create their own customer.
*/
export type TeamBillingContext = {
team_uid: string;
owner_user_id: number;
};
/** A team event about one seat. */
export type TeamBillingEvent = TeamBillingContext & {
user_id: number;
user_uuid: string;
username: string;
};
/**
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
* events declare the payload here by declaration merging, so both the emitter
@@ -336,6 +354,23 @@ export type EventMap = {
stripe_customer_id?: string | null;
};
// ---- Team billing ---- the trigger, never the charge ----
'team.account.created': TeamBillingEvent;
/** `held_bytes` is what to bill storage on while the seat is off. */
'team.account.disabled': TeamBillingEvent & { held_bytes: number };
'team.account.enabled': TeamBillingEvent & { held_bytes: number };
/** Emitted pre-delete: the membership row cascades away with the user. */
'team.account.deleted': TeamBillingEvent;
/** Per-seat charges stop; byte charges do not, the accounts remain. */
'team.deleted': TeamBillingContext & { account_count: number };
/** A budget line was crossed. Emitted on transition only, never per request. */
'metering.credit-state': {
user_uuid: string;
state: 'near-limit' | 'exhausted';
allowance_used: number;
month_usage_allowance: number;
};
// ---- Filesystem ----
'fs.copy.node': {
source: unknown;
@@ -758,11 +793,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -0,0 +1,251 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { PuterServer } from '../../server.ts';
import { setupTestServer } from '../../testUtil.ts';
describe('team billing events', () => {
let server: PuterServer;
let service: PuterServer['services']['team'];
let owner: { id: number };
/** Every billing event emitted since the last reset, in order. */
const seen: Array<{ key: string; data: Record<string, unknown> }> = [];
const of = (key: string) => seen.filter((e) => e.key === key);
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `bil_${Math.random().toString(36).slice(2, 10)}`;
const created = (await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
})) as unknown as { id: number };
return { id: created.id, username };
};
const freeHandle = () => `bw-${Math.random().toString(36).slice(2, 10)}`;
const makeTeam = async () =>
service.createTeam(owner.id, {
name: 'Billing Co',
handle: freeHandle(),
});
/** A real provisioned seat, as every chargeable account is. */
const provision = async (team: { uid: string }) => {
const username = `seat_${Math.random().toString(36).slice(2, 10)}`;
const created = await service.provisionAccount(team.uid, owner.id, {
username,
email: `${username}@test.local`,
});
return created;
};
/** Same, for a team whose owner is not the shared one. */
const provision2 = async (team: { uid: string }, ownerId: number) => {
const username = `seat_${Math.random().toString(36).slice(2, 10)}`;
return service.provisionAccount(team.uid, ownerId, {
username,
email: `${username}@test.local`,
});
};
/** Bytes the report has to find; `size` is what `SUM(size)` reads. */
const giveFile = async (userId: number, size: number) => {
await server.clients.db.write(
'INSERT INTO fsentries (uuid, parent_uid, user_id, name, path, is_dir, size, created, accessed, modified) ' +
'VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)',
[
uuidv4(),
userId,
`f_${Math.random().toString(36).slice(2, 8)}`,
`/f_${Math.random().toString(36).slice(2, 8)}`,
server.clients.db.booleanValue(false),
size,
0,
0,
0,
],
);
};
beforeAll(async () => {
// The cap has its own suite; these tests need many teams.
server = await setupTestServer({
teams_enabled: true,
max_teams_per_user: 100,
} as never);
service = server.services.team;
owner = await makeUser();
for (const key of [
'team.account.created',
'team.account.disabled',
'team.account.enabled',
'team.account.deleted',
'team.deleted',
'team.held-bytes.report',
]) {
server.clients.event.on(
key as never,
((k: string, data: Record<string, unknown>) => {
seen.push({ key: k, data });
}) as never,
);
}
});
beforeEach(() => {
seen.length = 0;
});
afterAll(async () => {
await server?.shutdown();
});
// -- on-demand lifecycle events -----------------------------------
it('emits account-created once, naming the team and the owner', async () => {
const team = await makeTeam();
const seat = await provision(team);
const events = of('team.account.created');
expect(events).toHaveLength(1);
expect(events[0].data).toMatchObject({
team_uid: team.uid,
owner_user_id: owner.id,
user_id: seat.userId,
username: seat.username,
});
// The payment side keys the charge off this, so it must be present.
expect(typeof events[0].data.user_uuid).toBe('string');
});
it('carries the held bytes on disable, so billing need not query back', async () => {
const team = await makeTeam();
const seat = await provision(team);
await giveFile(seat.userId, 4096);
seen.length = 0;
await service.disableMember(team.uid, owner.id, seat.userId);
const events = of('team.account.disabled');
expect(events).toHaveLength(1);
expect(events[0].data.held_bytes).toBe(4096);
expect(events[0].data.user_id).toBe(seat.userId);
});
it('emits account-enabled with the bytes that stop being charged', async () => {
const team = await makeTeam();
const seat = await provision(team);
await giveFile(seat.userId, 1024);
await service.disableMember(team.uid, owner.id, seat.userId);
seen.length = 0;
await service.enableMember(team.uid, owner.id, seat.userId);
const events = of('team.account.enabled');
expect(events).toHaveLength(1);
expect(events[0].data.held_bytes).toBe(1024);
});
it('emits one disabled event per seat plus a team event on delete', async () => {
const team = await makeTeam();
const a = await provision(team);
const b = await provision(team);
seen.length = 0;
await service.deleteTeam(team.uid, owner.id);
// Per seat, because the byte charge that follows is per account.
const disabled = of('team.account.disabled');
expect(disabled).toHaveLength(2);
expect(disabled.map((e) => e.data.user_id).sort()).toEqual(
[a.userId, b.userId].sort(),
);
const deleted = of('team.deleted');
expect(deleted).toHaveLength(1);
expect(deleted[0].data).toMatchObject({
team_uid: team.uid,
account_count: 2,
});
});
it('emits account-deleted, which a post-delete listener could not', async () => {
const team = await makeTeam();
const seat = await provision(team);
seen.length = 0;
await server.services.userAccount.cascadeDelete(seat.userId);
const events = of('team.account.deleted');
expect(events).toHaveLength(1);
expect(events[0].data).toMatchObject({
team_uid: team.uid,
user_id: seat.userId,
username: seat.username,
});
});
it('leaves the membership unreadable after the delete it was captured for', async () => {
const team = await makeTeam();
const seat = await provision(team);
await server.services.userAccount.cascadeDelete(seat.userId);
// `jct_user_group.user_id` is ON DELETE CASCADE -- this is why the
// identity has to be captured before the row goes.
expect(await server.stores.team.getOrgSeat(seat.userId)).toBeNull();
});
it('says nothing about an account no team pays for', async () => {
const outsider = await makeUser();
seen.length = 0;
await server.services.userAccount.cascadeDelete(outsider.id);
expect(of('team.account.deleted')).toHaveLength(0);
});
it('does not open a second byte charge when a seat is disabled twice', async () => {
const team = await makeTeam();
const seat = await provision(team);
await service.disableMember(team.uid, owner.id, seat.userId);
seen.length = 0;
await service.disableMember(team.uid, owner.id, seat.userId);
// `held_bytes` opens a charge one `enabled` closes; two opens and one
// close leaves the payer billed for storage nobody holds.
expect(of('team.account.disabled')).toHaveLength(0);
});
it('does not close a charge that was never opened', async () => {
const team = await makeTeam();
const seat = await provision(team);
seen.length = 0;
await service.enableMember(team.uid, owner.id, seat.userId);
expect(of('team.account.enabled')).toHaveLength(0);
});
});
+118 -8
View File
@@ -26,6 +26,11 @@ import {
USERNAME_MAX_LENGTH,
USERNAME_REGEX,
} from '../../controllers/auth/AuthController.js';
import type {
EventMap,
TeamBillingContext,
TeamBillingEvent,
} from '../../clients/event/types';
import { HttpError } from '../../core/http/HttpError.js';
import { checkHandle } from '../../stores/team/TeamStore.js';
import type {
@@ -56,6 +61,61 @@ export const generateTemporaryPassword = (length = 16): string => {
export const DISABLED_BY_TEAM = 'disabled_by_team';
export class TeamService extends PuterService {
// -- Billing ---- OSS emits; prod decides (see TEAMS-BILLING-SPLIT) ----
/** The team owner pays, so the charge is keyed to its customer id. */
async #billingContext(team: TeamRow): Promise<TeamBillingContext> {
return {
team_uid: team.uid,
owner_user_id: team.owner_user_id,
};
}
/** Bytes the account holds right now, for prod to price if it wants to. */
async #heldBytes(userId: number): Promise<number> {
try {
return await this.stores.fsEntry.getHeldBytes(userId);
} catch (e) {
// A missing figure must not fail the operation that reported it.
console.warn('[team-billing] held-bytes read failed:', e);
return 0;
}
}
/** Captured pre-delete: the membership row cascades away with the user. */
async captureSeatForBilling(
userId: number,
): Promise<TeamBillingEvent | null> {
if (this.config.teams_enabled !== true) return null;
try {
const seat = await this.stores.team.getOrgSeat(userId);
if (!seat) return null;
return {
team_uid: seat.team_uid,
owner_user_id: seat.owner_user_id,
user_id: seat.user_id,
user_uuid: seat.uuid,
username: seat.username,
};
} catch (e) {
console.warn('[team-billing] seat capture failed:', e);
return null;
}
}
/** Paired with `captureSeatForBilling`, once the account is really gone. */
emitSeatDeleted(seat: TeamBillingEvent | null): void {
if (seat) this.#emitBilling('team.account.deleted', seat);
}
/** Fire-and-forget, as `user.delete` is: the charge is not our business. */
#emitBilling<K extends keyof EventMap>(name: K, payload: EventMap[K]) {
try {
this.clients.event?.emit(name, payload, {});
} catch (e) {
console.warn('[team-billing] emit failed:', name, e);
}
}
// -- Authority ---- the whole authorization model ------------------
/** 404 to a non-member so the endpoint is not an existence oracle. */
@@ -203,24 +263,20 @@ export class TeamService extends PuterService {
);
if (!owner || Number(owner.org_owned) !== 0) return false;
const rows = (await this.clients.db.read(
'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
'WHERE `group_id` = ? AND `org_owned` = 0',
[team.id],
)) as { n: number }[];
return Number(rows[0]?.n) === 1;
return (await this.stores.team.countPayers(team.id)) === 1;
}
/** Soft delete disables the accounts it created; recovery is via support. */
async deleteTeam(teamUid: string, actorUserId: number): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
const billing = await this.#billingContext(team);
let disabled = 0;
// Otherwise they keep working, unreachable through a deleted team.
let page = await this.stores.team.listMembers(teamUid, { limit: 200 });
for (;;) {
for (const member of page.items) {
if (Number(member.org_owned) !== 1) continue;
await this.#suspend(member.user_id);
await this.stores.team.appendAudit({
teamId: team.id,
userId: member.user_id,
@@ -228,6 +284,18 @@ export class TeamService extends PuterService {
action: 'disable',
reason: 'team_deleted',
});
const held = await this.#heldBytes(member.user_id);
await this.#suspend(member.user_id);
disabled++;
// Per seat, not one bulk event: the byte charge is per account.
this.#emitBilling('team.account.disabled', {
...billing,
user_id: member.user_id,
user_uuid: member.uuid,
username: member.username,
held_bytes: held,
});
}
if (!page.cursor) break;
page = await this.stores.team.listMembers(teamUid, {
@@ -243,6 +311,11 @@ export class TeamService extends PuterService {
action: 'delete_team',
});
await this.stores.team.softDelete(teamUid);
this.#emitBilling('team.deleted', {
...billing,
account_count: disabled,
});
}
/** Team owner only. Readable after deletion -- that is the point of it. */
@@ -355,6 +428,7 @@ export class TeamService extends PuterService {
temporaryPassword: string;
}> {
const team = await this.requireOwner(teamUid, actorUserId);
this.#assertUsableUsername(input.username);
if (!validator.isEmail(input.email)) {
throw new HttpError(400, 'Invalid email', {
@@ -417,6 +491,14 @@ export class TeamService extends PuterService {
});
await this.#notifyAccountCreated(user, team);
// Last: the seat is only chargeable once it exists and can be used.
this.#emitBilling('team.account.created', {
...(await this.#billingContext(team)),
user_id: user.id,
user_uuid: user.uuid,
username: user.username,
});
return {
userId: user.id,
username: user.username,
@@ -487,7 +569,14 @@ export class TeamService extends PuterService {
targetUserId: number,
): Promise<void> {
const team = await this.requireOwner(teamUid, actorUserId);
await this.requireOrgAccount(teamUid, targetUserId);
const membership = await this.requireOrgAccount(teamUid, targetUserId);
// Already off: emitting again would open a second byte charge that
// only one `enabled` will ever close.
const current = await this.stores.user.getByProperty('id', targetUserId, {
force: true,
});
if (current?.suspended) return;
// Recorded first: a failed append must not leave an unlogged suspension.
await this.stores.team.appendAudit({
@@ -496,7 +585,17 @@ export class TeamService extends PuterService {
actorUserId,
action: 'disable',
});
// Read before suspending, though a disabled account cannot change it.
const held = await this.#heldBytes(targetUserId);
await this.#suspend(targetUserId);
this.#emitBilling('team.account.disabled', {
...(await this.#billingContext(team)),
user_id: targetUserId,
user_uuid: membership.uuid,
username: membership.username,
held_bytes: held,
});
}
/** Nothing was destroyed, so the account returns as it was. */
@@ -521,6 +620,8 @@ export class TeamService extends PuterService {
legacyCode: 'conflict',
});
}
// Never disabled, so there is no charge to close.
if (!user?.suspended) return;
await this.stores.team.appendAudit({
teamId: team.id,
@@ -534,6 +635,15 @@ export class TeamService extends PuterService {
suspended_reason: null,
});
await this.stores.user.invalidateById(targetUserId);
// Closes the byte charge the disable opened, at the same figure.
this.#emitBilling('team.account.enabled', {
...(await this.#billingContext(team)),
user_id: targetUserId,
user_uuid: user.uuid,
username: user.username,
held_bytes: await this.#heldBytes(targetUserId),
});
}
/** The three columns together; `suspended` is the one that gates requests. */
@@ -60,6 +60,9 @@ export class UserAccountService extends PuterService {
console.warn('[cascade-delete-user] identifier lookup failed:', e);
}
// Same reason, one table over: the membership row cascades on delete.
const seat = await this.services.team.captureSeatForBilling(userId);
try {
await this.services.fs.removeAllForUser(userId);
} catch (e) {
@@ -95,6 +98,8 @@ export class UserAccountService extends PuterService {
} catch {
// ignore — event emission shouldn't block deletion
}
// Tells prod to stop charging the owner for this seat.
this.services.team.emitSeatDeleted(seat);
}
/**
+11 -4
View File
@@ -2489,8 +2489,7 @@ export class FSEntryStore extends PuterStore {
} = {},
): Promise<{ entries: FSEntry[]; cursor?: string }> {
const payload = decodeCursor(options.cursor) as
| { v: unknown; id: number; s?: string; o?: string }
| undefined;
{ v: unknown; id: number; s?: string; o?: string } | undefined;
const requestedSort = options.sortBy ?? null;
const requestedOrder = options.sortOrder ?? null;
@@ -2676,8 +2675,7 @@ export class FSEntryStore extends PuterStore {
const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000;
const payload = decodeCursor(options.cursor) as
| { p: string }
| undefined;
{ p: string } | undefined;
const seek = payload ? 'AND path > ?' : '';
const params: unknown[] = payload
? [userId, likePattern, maxSlashes, payload.p, limit + 1]
@@ -3048,6 +3046,15 @@ export class FSEntryStore extends PuterStore {
return Number.isFinite(affected) ? affected : 0;
}
/** Bytes held, with no allowance or quota-bonus reckoning attached. */
async getHeldBytes(userId: number): Promise<number> {
const rows = (await this.clients.db.read(
`SELECT COALESCE(SUM(size), 0) AS ${this.clients.db.quoteIdentifier('totalUsage')} FROM fsentries WHERE user_id = ?`,
[userId],
)) as { totalUsage: number }[];
return Number(rows[0]?.totalUsage ?? 0);
}
async getUserStorageAllowance(
userId: number,
): Promise<{ curr: number; max: number }> {
+37 -15
View File
@@ -77,7 +77,7 @@ export const MEMBER_PAGE_CAP = 200;
export const AUDIT_PAGE_SIZE = 50;
export const AUDIT_PAGE_CAP = 200;
/** Longest handle mysql can store — `varchar(64)` in mysql_mig_26. */
/** Longest handle mysql can store — `varchar(64)` in mysql_mig_28. */
export const HANDLE_MAX_LENGTH = 64;
export const HANDLE_MIN_LENGTH = 3;
@@ -379,6 +379,26 @@ export class TeamStore extends PuterStore {
return result.anyRowsAffected;
}
/** Seats the team has provisioned. The owner is not one of them. */
async countSeats(teamId: number): Promise<number> {
const rows = (await this.clients.db.read(
'SELECT COUNT(*) AS n FROM `jct_user_group` ' +
'WHERE `group_id` = ? AND `org_owned` = 1',
[teamId],
)) as { n: number }[];
return Number(rows[0]?.n ?? 0);
}
/** Live teams this user owns. Soft-deleted ones do not count. */
async countOwned(ownerUserId: number): Promise<number> {
const rows = (await this.clients.db.read(
'SELECT COUNT(*) AS n FROM `group` ' +
`WHERE \`owner_user_id\` = ? AND ${this.#live()}`,
[ownerUserId, TEAM_KIND],
)) as { n: number }[];
return Number(rows[0]?.n ?? 0);
}
/** How many members pay for themselves; the owner should be the only one. */
async countPayers(teamId: number): Promise<number> {
const rows = (await this.clients.db.read(
@@ -389,6 +409,22 @@ export class TeamStore extends PuterStore {
return Number(rows[0]?.n ?? 0);
}
/** Soft-deleted teams count: their seats still hold billable bytes. */
async getOrgSeat(userId: number): Promise<OrgSeatRow | null> {
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
'g.`uid` AS `team_uid`, g.`owner_user_id` ' +
'FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' +
'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1',
[userId, TEAM_KIND],
)) as unknown as OrgSeatRow[];
return rows[0] ?? null;
}
// -- Audit ---- insert-only; no update or delete path exists --------
/** Records something the team did to an account. */
@@ -476,18 +512,4 @@ export class TeamStore extends PuterStore {
return result.anyRowsAffected;
}
/** The team seat this user is, if any. Soft-deleted teams count. */
async getOrgSeat(userId: number): Promise<OrgSeatRow | null> {
const rows = (await this.clients.db.read(
'SELECT ug.`id`, ug.`user_id`, u.`uuid`, u.`username`, ' +
'g.`uid` AS `team_uid`, g.`owner_user_id` ' +
'FROM `jct_user_group` ug ' +
'JOIN `user` u ON u.`id` = ug.`user_id` ' +
'JOIN `group` g ON g.`id` = ug.`group_id` ' +
'WHERE ug.`user_id` = ? AND ug.`org_owned` = 1 ' +
'AND g.`kind` = ? ORDER BY g.`id` LIMIT 1',
[userId, TEAM_KIND],
)) as unknown as OrgSeatRow[];
return rows[0] ?? null;
}
}