diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts index 79af3494..c83667cb 100644 --- a/apps/api/test/fleet.test.ts +++ b/apps/api/test/fleet.test.ts @@ -57,6 +57,7 @@ function createBox(headers: Record, body: object) { } afterAll(async () => { + await sql`delete from burn_segment where session_id in (select s.id from session s join box b on b.id = s.box_id where b.user_id = any(${createdUserIds}))`; await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`; await sql`delete from box where user_id = any(${createdUserIds})`; await sql`delete from game where id = any(${createdGameIds})`; @@ -167,3 +168,41 @@ describe('Entitlement on a fleet machine', () => { expect((await may(p.owner.userId)).entitled).toBe(false); }); }); + +describe('Billing a run on the fleet', () => { + test('a live fleet run accrues against the box owner’s personal team', async () => { + const f = await fleet(1); + useFleet(f.organisationId); + const machineId = f.machines[0]!.id; + const p = await person('bill-fleet'); + const box = ((await (await createBox(p.headers, { on: 'fleet' })).json()) as any).data; + + const [game] = await Game.upsert({ + id: Identifier.ascending('game'), + steamAppId: 990002, + slug: 'fleet-bill', + name: 'Fleet Bill' + }); + createdGameIds.push(game!.id); + const run = await Session.request({ + id: Identifier.ascending('session'), + boxId: box.id, + gameId: game!.id, + linkedAccountId: p.owner.linkedAccountId + }); + const claimToken = 'fleet-bill-claim-token-0001'; + for (const state of ['starting', 'live'] as const) { + const r = await Session.transition({ id: run.id, machineId, state, claimToken }); + expect(r.outcome).toBe('moved'); + } + + const open = await sql`select team_id, ended_at from burn_segment where session_id = ${run.id}`; + expect(open.length).toBe(1); + expect(open[0]!.team_id).toBe(p.owner.teamId); + expect(open[0]!.ended_at).toBeNull(); + + await Session.transition({ id: run.id, machineId, state: 'ended', claimToken }); + const closed = await sql`select ended_at from burn_segment where session_id = ${run.id}`; + expect(closed[0]!.ended_at).not.toBeNull(); + }); +}); diff --git a/packages/core/src/billing/index.ts b/packages/core/src/billing/index.ts index ea4fda70..3076da59 100644 --- a/packages/core/src/billing/index.ts +++ b/packages/core/src/billing/index.ts @@ -46,10 +46,11 @@ export namespace Billing { /** * The team that pays for a box. * - * A box runs on a host, and the host says who owns it. Fleet hardware - * belongs to an organisation, which is not a billing subject — nothing is - * placed there yet, and when it is, what grants it is a plan rather than - * this lookup. + * On a person's own hardware, the team the host is registered under. On + * the fleet, the hardware belongs to an organisation, which is not a + * billing subject — so the run is the box owner's, and is billed to their + * personal team, the one every account has and the one a plan is bought + * on. */ export const teamForBox = fn(z.string(), async (boxId) => { const box = await Box.fromID(boxId); @@ -57,11 +58,14 @@ export namespace Billing { return null; } const machine = await Machine.fromID(box.machineId); - if (!machine?.teamId) { + const teamId = + machine?.teamId ?? + (machine?.organisationId ? ((await Team.personalFor(box.userId))?.id ?? null) : null); + if (!machine || !teamId) { return null; } return { - teamId: machine.teamId, + teamId, tier: box.tier as Burn.Tier, // Whose hardware decides the cost basis, and the machine is the only // thing that knows. A host an organisation owns is ours to pay for; diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts index a65a3135..9c5987b9 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -2,6 +2,7 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import z from 'zod'; import { Burn } from '../billing/burn.js'; +import { Billing } from '../billing/index.js'; import { BoxTable, BoxTier } from '../box/box.sql.js'; import { Box } from '../box/index.js'; import { Database } from '../db/index.js'; @@ -9,7 +10,6 @@ import { ErrorCodes, VisibleError } from '../error.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { GameTable } from '../game/game.sql.js'; -import { Machine } from '../machine/index.js'; import { SessionState, SessionTable } from './session.sql.js'; /** @@ -661,25 +661,27 @@ export namespace Session { // `live` is where it starts rather than `starting`, because what // is billed is an envelope actually held — a box that never // finished coming up held nothing. - const machine = await Machine.fromID(input.machineId); - if (machine?.teamId) { + // Who pays is `Billing.teamForBox`'s answer and nobody else's: the + // host's team on someone's own hardware, the box owner's personal + // team on the fleet. + const payer = await Billing.teamForBox(moved.boxId); + if (payer) { if (current.state !== 'live' && moved.state === 'live') { // The rate is fixed here, from what this run actually is: // the size it holds, and whose card it holds it on. Both // are settled before the run starts, which is what lets a // person be told the cost before committing to it. - const box = await Box.fromID(moved.boxId); await Burn.start({ - teamId: machine.teamId, + teamId: payer.teamId, sessionId: moved.id, - tier: (box?.tier ?? 'sm') as Burn.Tier, - hostClass: machine.organisationId ? 'fleet' : 'byo' + tier: payer.tier, + hostClass: payer.hostClass }); } else if ( ACCRUING.includes(current.state as (typeof ACCRUING)[number]) && !ACCRUING.includes(moved.state as (typeof ACCRUING)[number]) ) { - await Burn.stop({ teamId: machine.teamId, sessionId: moved.id }); + await Burn.stop({ teamId: payer.teamId, sessionId: moved.id }); } }