mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(billing): bill a run on rented hardware to the box owner's team
A fleet machine belongs to an organisation, which is not something that pays, so fleet runs were neither checked nor metered. The payer is now the box owner's personal team, and the meter that starts and stops with a run asks the same function the start check does, so the team checked and the team billed cannot differ.
This commit is contained in:
@@ -57,6 +57,7 @@ function createBox(headers: Record<string, string>, 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user