Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.
21 files+552−220/21 viewed
| 1767 | 1767 | "Choose what happens when a team is asked to review a pull request. Off, everyone in it is asked. On (`enabled`), g1t picks `count` people from it (1 to 10, never the pull request's author) and asks them, and the team stays shown as asked beside them: `round_robin` picks whoever this team asked least recently, `load_balance` whoever has the fewest pull requests waiting on their review. `skip_busy` leaves out anyone with `busy_at` or more waiting; `include_child_teams` also picks from its child teams' people; `excluded` lists usernames never picked; `notify_team` also tells the rest of the team. Fields left out keep their current value. Owners of the workspace and the team's maintainers. People only. Returns the team." | |
| 1768 | 1768 | } | |
| 1769 | 1769 | Op::GetUsage => { | |
| 1770 | − | "A workspace's usage over a range of days, at price, and what paid for it. `from` and `until` are UTC days, `YYYY-MM-DD`, with `until` included and at most 400 days in all; left out, the current month so far. `products` narrows it to product families (agent, sandboxes, gateway, deployments, git_storage, packages, security, search) and `projects` to repositories (\"owner/name\"). Returns `totals`: `price_micros` less `discount_micros`, `included_micros` and `credits_micros` is `charged_micros`, what is left for the workspace to pay; `pending_micros` is metered this month and charged when it closes; `cost_micros` is what it cost g1t. Then `days` (each day and product with usage), `products` (every family, with its meters: quantity, unit, amount, a `daily` amount for each day of the range, any `allowance`, the split `by_project`, and a `note` where the quantity needs one: the agent rate's meters, `agent_rate` and `agent_rate_own` (on the workspace's own model key), count weighted tokens and name the weights), `projects` (every repository with usage in the range), `models` (the agent's input, output, cache-read and cache-write tokens by model, most first), and the AI credit and other credit left now. With `group_by` (`product`, `project` or `day`), `groups` adds up the range that way. Amounts are whole millionths of a dollar. Members of the workspace only." | |
| 1770 | + | "A workspace's usage over a range of days, at price, and what paid for it. `from` and `until` are UTC days, `YYYY-MM-DD`, with `until` included and at most 400 days in all; left out, the current month so far. `products` narrows it to product families (agent, sandboxes, gateway, deployments, git_storage, packages, security, search) and `projects` to repositories (\"owner/name\"). Returns `totals`: `price_micros` less `discount_micros`, `included_micros` and `credits_micros` is `charged_micros`, what is left for the workspace to pay; `pending_micros` is metered this month and charged when it closes; `cost_micros` is what it cost g1t. Then `days` (each day and product with usage), `products` (every family, with its meters: quantity, unit, amount, a `daily` amount for each day of the range, any `allowance`, the split `by_project`, and a `note` where the quantity needs one: the agent rate's meters, `agent_rate` and `agent_rate_own` (on the workspace's own model key), count weighted tokens and name the weights), `projects` (every repository with usage in the range), `models` (the agent's input, output, cache-read and cache-write tokens by model, most first), `by_agent` and `by_person` (the agent product at price by the agent that did the work, by handle, and by who asked, by username; `g1t` is g1t's own runs on repositories, an empty key is work attributed to no one; each sums to the agent product), and the AI credit and other credit left now. With `group_by` (`product`, `project` or `day`), `groups` adds up the range that way. Amounts are whole millionths of a dollar. Members of the workspace only." | |
| 1771 | 1771 | } | |
| 1772 | 1772 | Op::GetBudget => { | |
| 1773 | 1773 | "A workspace's budget: its monthly spend limit (`amount_micros`; `automatic` is true while the owners have not set one, and it is then $200 or twice last month's spend), what was charged this month (`spent_micros`), the most the owners may set it to themselves (`max_amount_micros`), its `alerts` (percent of the limit, each emailed to the owners once a month), whether usage pauses at the limit (`pause_at_limit`), the `webhook` told of each alert, and `state`: `ok`, `warning` or `stopped`, with a `message` when work is stopped or close to it. Members of the workspace only." |
| 6365 | 6365 | "cache_write": 590000 | |
| 6366 | 6366 | } | |
| 6367 | 6367 | ], | |
| 6368 | + | "by_agent": [ | |
| 6369 | + | { | |
| 6370 | + | "key": "g1t", | |
| 6371 | + | "label": "@g1t", | |
| 6372 | + | "micros": 21400000, | |
| 6373 | + | "count": 14 | |
| 6374 | + | }, | |
| 6375 | + | { | |
| 6376 | + | "key": "mike", | |
| 6377 | + | "label": "@mike", | |
| 6378 | + | "micros": 800000, | |
| 6379 | + | "count": 6 | |
| 6380 | + | } | |
| 6381 | + | ], | |
| 6382 | + | "by_person": [ | |
| 6383 | + | { | |
| 6384 | + | "key": "chase", | |
| 6385 | + | "label": "@chase", | |
| 6386 | + | "micros": 21900000, | |
| 6387 | + | "count": 19 | |
| 6388 | + | }, | |
| 6389 | + | { | |
| 6390 | + | "key": "", | |
| 6391 | + | "label": "No one asked", | |
| 6392 | + | "micros": 300000, | |
| 6393 | + | "count": 1 | |
| 6394 | + | } | |
| 6395 | + | ], | |
| 6368 | 6396 | "included": { | |
| 6369 | 6397 | "used": 20, | |
| 6370 | 6398 | "of": 20, |
| 24 | 24 | ||
| 25 | 25 | Months and days are UTC. An idle agent costs nothing. | |
| 26 | 26 | ||
| 27 | + | Every budget below the workspace's counts an agent's chat replies and | |
| 28 | + | sessions at list price, as the ledger enters them, before any discount or | |
| 29 | + | included usage; the workspace's spend limit is on what is charged after | |
| 30 | + | those. g1t's own runs on your repositories are on the same ledger as | |
| 31 | + | **@g1t** and count toward the spend limit, not toward the agent budgets. | |
| 32 | + | [How spend is counted](/guides/usage-and-billing/#how-spend-is-counted) | |
| 33 | + | defines each figure. | |
| 34 | + | ||
| 27 | 35 | An agent on several teams with budgets is held to each of them. Its work | |
| 28 | 36 | counts towards every team it is on, and towards its own budget, at once. | |
| 29 | 37 | A team's header shows what its agents spent this month against its |
| 31 | 31 | ||
| 32 | 32 | | View | Figures | | |
| 33 | 33 | | --- | --- | | |
| 34 | − | | **Workspace** | **Spent**: usage at price over the period, every product, the same figure as [Usage](/guides/usage-and-billing/#the-usage-page). **Agents**: every agent's replies and sessions. **Charged**: what the workspace was charged this month, against its spend limit when it has one. | | |
| 34 | + | | **Workspace** | **Spent**: usage at price over the period, every product, the same figure as [Usage](/guides/usage-and-billing/#the-usage-page). **Agents**: the agent product within it, every agent's replies, sessions and runs on repositories at price, from the same ledger. **Charged**: what the workspace was charged this month after included usage, credit and any discount, the same figure as Billing, against its spend limit when it has one. [How spend is counted](/guides/usage-and-billing/#how-spend-is-counted) defines each. | | |
| 35 | 35 | | **You** | **Agents for you** over the period. **Your budget** this month, and what is spent against it. **Your chats with agents**: the replies agents wrote you. | | |
| 36 | 36 | ||
| 37 | 37 | **By day** shows the period one day at a time. Hover or focus a day for | |
| ⋯ | |||
| 43 | 43 | ||
| 44 | 44 | | Slice | Shows | | |
| 45 | 45 | | --- | --- | | |
| 46 | − | | **Agents** | Each agent's share. A session's helpers and subagents count on the agent that started it. Open one for its own Spend tab. | | |
| 47 | − | | **People** | Who asked. Routines and agent-to-agent work have no asker. Workspace view only. | | |
| 46 | + | | **Agents** | Each agent's share of the agent product, from the ledger: its replies, sessions, runs on repositories and sandbox time at price. **@g1t** is g1t's own work on your repositories. A session's helpers and subagents count on the agent that started it. Open one for its own Spend tab. In the **You** view, the agents service's count of what agents did for you. | | |
| 47 | + | | **People** | Who asked, from the ledger. Routines, agent-to-agent work and runs from before attribution have no asker. Workspace view only. | | |
| 48 | 48 | | **Channels** | Where it was asked: each channel, direct messages together, and channels you aren't in together, without their names. | | |
| 49 | 49 | | **Models** | Each model's share. | | |
| 50 | 50 | | **Kind of work** | Chat replies, sessions, routines, helping colleagues and subagents. | | |
| ⋯ | |||
| 185 | 185 | ||
| 186 | 186 | - [Effort](/guides/agents/#effort): how hard each agent works, and what each level costs. | |
| 187 | 187 | - [Agent budgets and spend](/guides/agent-budgets/): who pays for a session tree. | |
| 188 | − | - [Usage and billing](/guides/usage-and-billing/): the spend limit, caps, AI credit and rates. | |
| 188 | + | - [Usage and billing](/guides/usage-and-billing/): the spend limit, caps, AI credit and rates, and [how spend is counted](/guides/usage-and-billing/#how-spend-is-counted). | |
| 189 | 189 | - [Sessions](/guides/agent-sessions/): caps and approving more spend. | |
| 526 | 526 | workspace: its pushes to private repositories stop at 1 GB instead. On the | |
| 527 | 527 | plan they never stop. | |
| 528 | 528 | ||
| 529 | + | ## How spend is counted | |
| 530 | + | ||
| 531 | + | Everything a workspace uses is entered on one ledger, as it happens, and | |
| 532 | + | every figure on every page is read back from it. There is no second count | |
| 533 | + | to disagree with it. | |
| 534 | + | ||
| 535 | + | | Figure | What it is | Where it shows | | |
| 536 | + | | --- | --- | --- | | |
| 537 | + | | **Spent**, or usage **at price** | Every entry at its price: what was charged, plus what the plan's included usage, the trial, a pool or g1t paid of it, plus what a discount took off. The one measure of usage. | Home's **Spent** card, Spend's **Spent**, the [Usage page](#the-usage-page), the [statement](#the-statement) | | |
| 538 | + | | **Agents** | The agent product within Spent: model tokens, the [agent rate](#the-agent-rate) and agents' sandbox time, for every agent's chat replies, sessions and runs on repositories. Each entry names the agent that did the work (g1t's own runs on your repositories are **@g1t**) and the person who asked, so **by agent** and **by person** are the same money sliced two ways, and the agents add up to the product. | The top bar's spend pill, Spend's **Agents** tile and its **Agents** and **People** slices, Usage's **Agent** product | | |
| 539 | + | | **Charged** | What is left for the workspace to pay this month: usage at price, less your discount, less what included usage, the trial, a pool or g1t paid, less what [credit](#credits-from-g1t) paid. Never below zero: a workspace with a 100% discount is charged exactly nothing. Usage metered through the month and charged when it closes (storage, git operations, scans, embeddings, domains, app traffic) counts now on the same terms. | Billing's plan card, Spend's **Charged**, the top bar's **Workspace** view, and the figure your [spend limit](#your-spend-limit) is measured against | | |
| 540 | + | ||
| 541 | + | The [agent budgets](/guides/agent-budgets/) count the same entries at | |
| 542 | + | the same price, before any discount or included usage: an agent's chat | |
| 543 | + | replies and sessions against its own budget, its team's, the person's and | |
| 544 | + | the workspace's agent budget. Budgets at list price are what makes them | |
| 545 | + | predictable; the spend limit is on what is charged. | |
| 546 | + | ||
| 547 | + | Through the API, `GET /workspaces/:workspace/usage` answers with the | |
| 548 | + | products, `by_agent` and `by_person`, and `totals.charged_micros`; | |
| 549 | + | `GET /workspaces/:workspace/budget` answers with the spend limit and | |
| 550 | + | `spent_micros`, the same charged figure. | |
| 551 | + | ||
| 529 | 552 | ## Limits | |
| 530 | 553 | ||
| 531 | 554 | Every workspace has two limits: **g1t's ceiling** on usage not yet paid | |
| ⋯ | |||
| 567 | 590 | ||
| 568 | 591 | ### Your spend limit | |
| 569 | 592 | ||
| 570 | − | What the workspace may be charged in a month (UTC). At it, new work stops | |
| 571 | − | until the month turns or an owner raises it. Until owners set one, it is | |
| 593 | + | What the workspace may be charged in a month (UTC): usage at price after | |
| 594 | + | your discount, included usage, the trial, pools and credit, as | |
| 595 | + | [How spend is counted](#how-spend-is-counted) defines it, the same figure | |
| 596 | + | as the plan card's **charged**. At it, new work stops until the month | |
| 597 | + | turns or an owner raises it. Until owners set one, it is | |
| 572 | 598 | **automatic**: $200, or twice last month's spend, whichever is more, up to | |
| 573 | 599 | what is available. | |
| 574 | 600 | ||
Binary or large file; its contents are not shown.
| 9 | 9 | */ | |
| 10 | 10 | import type { AgentPolicy, AgentSpendBreakdown, Limit, PersonBudgets, SpendPeriod, UsageReport, User, WorkspaceAgent } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | − | import { type Pricing, type SpendScope, pricingOf, spanFor } from "./spend"; | |
| 12 | + | import { type Pricing, type SpendScope, agentMicros, attributionSlices, pricingOf, spanFor } from "./spend"; | |
| 13 | 13 | import { billing, workspaceAgents } from "./services.server"; | |
| 14 | 14 | ||
| 15 | 15 | const warn = (what: string) => (error: unknown) => { | |
| ⋯ | |||
| 96 | 96 | ||
| 97 | 97 | export async function loadPill(viewer: User, slug: string, mayWorkspace: boolean): Promise<PillData> { | |
| 98 | 98 | const me = viewer.username.toLowerCase(); | |
| 99 | − | const [mine, people, everyone, limit, used] = await Promise.all([ | |
| 99 | + | const [mine, people, everyone, limit, usage] = await Promise.all([ | |
| 100 | 100 | loadBreakdown(viewer, slug, "me", "month"), | |
| 101 | 101 | workspaceAgents.personBudgets(slug, viewer).then(value).catch(warn("person budgets")), | |
| 102 | 102 | mayWorkspace ? loadBreakdown(viewer, slug, "workspace", "month") : Promise.resolve(null), | |
| 103 | 103 | mayWorkspace ? billing.limit(slug, viewer).then(value).catch(warn("limit")) : Promise.resolve(null), | |
| 104 | + | // The workspace's agents figure is the agent product on billing's | |
| 105 | + | // ledger, the same ledger "charged" is read from; the agents service's | |
| 106 | + | // own count stands in only while billing has not answered. | |
| 104 | 107 | mayWorkspace ? loadUsage(viewer, slug, "month", new Date()) : Promise.resolve(null), | |
| 105 | 108 | ]); | |
| 106 | 109 | const own = people?.people.find((p) => p.username === me); | |
| 107 | 110 | const budget = own ? own.monthly_micros : (people?.default_micros ?? null); | |
| 111 | + | const attributed = attributionSlices(usage); | |
| 108 | 112 | return { | |
| 109 | 113 | month: (mine ?? everyone)?.period ?? new Date().toISOString().slice(0, 7), | |
| 110 | 114 | me: mine ? { spentMicros: mine.total_micros, budgetMicros: budget, byKind: mine.by_kind, byAgent: mine.by_agent } : null, | |
| 111 | 115 | workspace: mayWorkspace | |
| 112 | 116 | ? { | |
| 113 | − | spentMicros: used ? (used.free ? used.totals.costMicros : used.totals.priceMicros) : null, | |
| 117 | + | spentMicros: usage ? (usage.free ? usage.totals.costMicros : usage.totals.priceMicros) : null, | |
| 114 | 118 | chargedMicros: limit?.spentMicros ?? null, | |
| 115 | 119 | limitMicros: limit ? (limit.spendLimitMicros ?? limit.ceilingMicros) : null, | |
| 116 | − | agentsMicros: everyone?.total_micros ?? null, | |
| 117 | − | byAgent: everyone?.by_agent ?? [], | |
| 120 | + | agentsMicros: usage ? agentMicros(usage) : (everyone?.total_micros ?? null), | |
| 121 | + | byAgent: attributed?.agent ?? everyone?.by_agent ?? [], | |
| 118 | 122 | } | |
| 119 | 123 | : null, | |
| 120 | 124 | }; | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { AgentSession, PriceBook } from "@g1t/contracts"; | |
| 4 | + | import type { AgentSession, MeterLine, PriceBook, UsageReport } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { agentRateLabel, daySeries, markupLabel, percentLabel, pricingOf, readPeriod, readScope, receiptOf, spanFor, tokenCount } from "./spend.ts"; | |
| 6 | + | import { agentMicros, agentRateLabel, agentSpendHref, attributionSlices, daySeries, markupLabel, percentLabel, pricingOf, readPeriod, readScope, receiptOf, spanFor, tokenCount } from "./spend.ts"; | |
| 7 | 7 | ||
| 8 | 8 | const OCT_8 = new Date("2026-10-08T15:00:00Z"); | |
| 9 | 9 | ||
| ⋯ | |||
| 98 | 98 | assert.equal(percentLabel(62, 100), "62%"); | |
| 99 | 99 | assert.equal(percentLabel(62, null), ""); | |
| 100 | 100 | }); | |
| 101 | + | ||
| 102 | + | /** A usage report as billing answers it for a comped workspace in October: every line discounted in full, attributed to its agent. */ | |
| 103 | + | function octoberReport(): Pick<UsageReport, "totals" | "products" | "byAgent" | "byPerson"> { | |
| 104 | + | const meter = (key: string, product: string, micros: number): MeterLine => ({ key, label: key, product, unit: "entries", quantity: 1, micros, daily: [micros], byProject: [] }); | |
| 105 | + | const products = [ | |
| 106 | + | { key: "agent", label: "Agent", micros: 2_544_000, meters: [meter("agent_models", "agent", 1_900_000), meter("agent_rate", "agent", 400_000), meter("agent_sandbox", "agent", 244_000)] }, | |
| 107 | + | { key: "sandboxes", label: "Sandboxes", micros: 3_360_000, meters: [meter("sandbox", "sandboxes", 3_360_000)] }, | |
| 108 | + | { key: "deployments", label: "Deployments", micros: 4_480_000, meters: [meter("requests", "deployments", 4_480_000)] }, | |
| 109 | + | ]; | |
| 110 | + | return { | |
| 111 | + | totals: { priceMicros: 10_384_000, discountMicros: 10_384_000, includedMicros: 0, creditsMicros: 0, chargedMicros: 0, pendingMicros: 0, costMicros: 8_650_000 }, | |
| 112 | + | products, | |
| 113 | + | byAgent: [ | |
| 114 | + | { key: "g1t", label: "@g1t", micros: 2_520_000, count: 9 }, | |
| 115 | + | { key: "mike", label: "@mike", micros: 20_000, count: 2 }, | |
| 116 | + | { key: "margo", label: "@margo", micros: 4_000, count: 1 }, | |
| 117 | + | ], | |
| 118 | + | byPerson: [ | |
| 119 | + | { key: "chase", label: "@chase", micros: 2_540_000, count: 11 }, | |
| 120 | + | { key: "", label: "No one asked", micros: 4_000, count: 1 }, | |
| 121 | + | ], | |
| 122 | + | }; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | test("Home's Spent, Spend's tiles and the top bar's Agents reconcile on the one ledger", () => { | |
| 126 | + | const report = octoberReport(); | |
| 127 | + | // Spent: every product at price, which is the report's total. | |
| 128 | + | const spent = report.products.reduce((n, p) => n + p.micros, 0); | |
| 129 | + | assert.equal(spent, report.totals.priceMicros); | |
| 130 | + | // Agents: the agent product, part of Spent, and what the agents by handle add up to. | |
| 131 | + | assert.equal(agentMicros(report), 2_544_000); | |
| 132 | + | assert.ok(agentMicros(report) <= spent); | |
| 133 | + | const slices = attributionSlices(report); | |
| 134 | + | assert.ok(slices); | |
| 135 | + | assert.equal( | |
| 136 | + | slices.agent.reduce((n, s) => n + s.micros, 0), | |
| 137 | + | agentMicros(report), | |
| 138 | + | ); | |
| 139 | + | assert.equal( | |
| 140 | + | slices.person.reduce((n, s) => n + s.micros, 0), | |
| 141 | + | agentMicros(report), | |
| 142 | + | ); | |
| 143 | + | assert.ok(slices.agent.every((s) => s.micros <= agentMicros(report))); | |
| 144 | + | // Charged: nothing, on a 100% discount, however much was spent. | |
| 145 | + | assert.equal(report.totals.chargedMicros, 0); | |
| 146 | + | assert.equal(report.totals.priceMicros - report.totals.discountMicros - report.totals.includedMicros - report.totals.creditsMicros, 0); | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | test("an agent's slice opens its own Spend tab; work attributed to no one opens nothing", () => { | |
| 150 | + | const slices = attributionSlices(octoberReport()); | |
| 151 | + | assert.ok(slices); | |
| 152 | + | assert.equal(agentSpendHref("flagon-io", slices.agent[0]), "/flagon-io/-/agents/g1t/spend"); | |
| 153 | + | assert.equal(agentSpendHref("flagon-io", slices.person[1]), null); | |
| 154 | + | assert.deepEqual(slices.agent[1], { key: "mike", label: "@mike", micros: 20_000, count: 2 }); | |
| 155 | + | // A report from before attribution has no slices: the caller falls back. | |
| 156 | + | assert.equal(attributionSlices({ byAgent: undefined, byPerson: undefined }), null); | |
| 157 | + | assert.equal(attributionSlices(null), null); | |
| 158 | + | assert.equal(agentMicros({ products: [] }), 0); | |
| 159 | + | }); | |
| 6 | 6 | * imports, so it is tested on its own. Every number comes from a service; | |
| 7 | 7 | * nothing here invents one. | |
| 8 | 8 | */ | |
| 9 | − | import type { AgentSession, PriceBook, SpendPeriod, SpendSlice, UsageDay } from "@g1t/contracts"; | |
| 9 | + | import type { AgentSession, PriceBook, SpendPeriod, SpendSlice, UsageDay, UsageReport, UsageShare } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | /** Whose spend: your own (what agents did for you), or the whole workspace's. */ | |
| 12 | 12 | export type SpendScope = "me" | "workspace"; | |
| 13 | 13 | ||
| 14 | + | /** | |
| 15 | + | * What agents cost over the report's range, at price: the agent product | |
| 16 | + | * (model tokens, the agent rate and agents' sandbox time), every agent's | |
| 17 | + | * replies, sessions and runs on repositories together. The top bar's and | |
| 18 | + | * Spend's "Agents" figure, from the same ledger as "Spent", so it is part | |
| 19 | + | * of it: Spent = Agents + the other products. | |
| 20 | + | */ | |
| 21 | + | export function agentMicros(report: Pick<UsageReport, "products">): number { | |
| 22 | + | return report.products.find((p) => p.key === "agent")?.micros ?? 0; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The agent product by the agent that did the work and by who asked, as | |
| 27 | + | * Spend's slices, from the ledger's own attribution. Null when billing did | |
| 28 | + | * not attribute the range (a report from before attribution), so the | |
| 29 | + | * caller can fall back to the agents service's own count. | |
| 30 | + | */ | |
| 31 | + | export function attributionSlices(report: Pick<UsageReport, "byAgent" | "byPerson"> | null): { agent: SpendSlice[]; person: SpendSlice[] } | null { | |
| 32 | + | if (!report?.byAgent || !report.byPerson) return null; | |
| 33 | + | const slice = (s: UsageShare): SpendSlice => ({ key: s.key, label: s.label, micros: s.micros, count: s.count }); | |
| 34 | + | return { agent: report.byAgent.map(slice), person: report.byPerson.map(slice) }; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** Where an agent's slice opens: its own Spend tab, for a slice that names one. */ | |
| 38 | + | export function agentSpendHref(slug: string, slice: Pick<SpendSlice, "key">): string | null { | |
| 39 | + | return slice.key ? `/${slug}/-/agents/${slice.key}/spend` : null; | |
| 40 | + | } | |
| 41 | + | ||
| 14 | 42 | export const SPEND_PERIODS: { key: SpendPeriod; label: string; short: string }[] = [ | |
| 15 | 43 | { key: "month", label: "This month", short: "This month" }, | |
| 16 | 44 | { key: "last_month", label: "Last month", short: "Last month" }, |
| 32 | 32 | import { page } from "../../lib/meta"; | |
| 33 | 33 | import { workspaceAgents } from "../../lib/services.server"; | |
| 34 | 34 | import { managesBilling, requireUser, roleIn } from "../../lib/session.server"; | |
| 35 | − | import { SPEND_PERIODS, type SpendScope, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend"; | |
| 35 | + | import { SPEND_PERIODS, type SpendScope, agentMicros, agentSpendHref, attributionSlices, daySeries, periodLabel, readPeriod, readScope, spanFor, usageByDay, withoutSelf } from "../../lib/spend"; | |
| 36 | 36 | import { loadBreakdown, loadBudgets, loadPricing, loadUsage } from "../../lib/spend.server"; | |
| 37 | 37 | import { money } from "../../lib/usage"; | |
| 38 | 38 | ||
| ⋯ | |||
| 196 | 196 | {scope === "workspace" ? ( | |
| 197 | 197 | <> | |
| 198 | 198 | <Tile label={`Spent, ${label}`} value={used ? money(used.free ? used.totals.costMicros : used.totals.priceMicros) : "—"} sub={used ? (used.free ? "At cost: g1t charges nothing for now" : "Usage at price, every product") : "Usage couldn't be read"} /> | |
| 199 | − | <Tile label={`Agents, ${label}`} value={spent ? money(spent.total_micros) : "—"} sub={spent ? `${countOf(spent.by_kind)} replies and sessions` : "The agents service didn't answer"} /> | |
| 200 | 199 | <Tile | |
| 200 | + | label={`Agents, ${label}`} | |
| 201 | + | value={used ? money(agentMicros(used)) : spent ? money(spent.total_micros) : "—"} | |
| 202 | + | sub={ | |
| 203 | + | used | |
| 204 | + | ? `Every agent's replies, sessions and runs, at price${spent ? `; ${countOf(spent.by_kind)} replies and sessions` : ""}` | |
| 205 | + | : spent | |
| 206 | + | ? `${countOf(spent.by_kind)} replies and sessions, from the agents service` | |
| 207 | + | : "Usage couldn't be read" | |
| 208 | + | } | |
| 209 | + | /> | |
| 210 | + | <Tile | |
| 201 | 211 | label="Charged, this month" | |
| 202 | 212 | value={levels.limit?.spentMicros != null ? money(levels.limit.spentMicros) : "—"} | |
| 203 | 213 | sub={levels.limit ? limitLine(levels.limit.spentMicros ?? 0, levels.limit.spendLimitMicros ?? levels.limit.ceilingMicros) : "Billing couldn't be read"} | |
| ⋯ | |||
| 296 | 306 | </Section> | |
| 297 | 307 | ||
| 298 | 308 | <p className="text-xs text-faint"> | |
| 299 | − | Agent figures count every reply and session at list price, as budgets do. What the workspace is charged, after included usage, credit and any | |
| 300 | − | discount, is on{" "} | |
| 309 | + | Every figure is the ledger's, at price: Spent is every product, Agents is the agent product within it, and the agent budgets count replies and | |
| 310 | + | sessions at the same price. What the workspace is charged, after included usage, credit and any discount, is on{" "} | |
| 301 | 311 | <Link to={`/${slug}/-/billing`} className="hover:text-fg hover:underline"> | |
| 302 | 312 | Billing | |
| 303 | 313 | </Link> | |
| ⋯ | |||
| 352 | 362 | </SlicePanel> | |
| 353 | 363 | ); | |
| 354 | 364 | } | |
| 365 | + | // The workspace's agents and people come from the ledger that is | |
| 366 | + | // charged: every agent's replies, sessions and runs on repositories, by | |
| 367 | + | // the agent that did them and by who asked. Your own view, and the | |
| 368 | + | // channels, models and kinds of work, are the agents service's. | |
| 369 | + | const attributed = scope === "workspace" ? attributionSlices(used) : null; | |
| 370 | + | if (attributed && (by === "agent" || by === "person")) { | |
| 371 | + | const chosen = | |
| 372 | + | by === "agent" | |
| 373 | + | ? { | |
| 374 | + | slices: attributed.agent, | |
| 375 | + | foot: "From the ledger, at price: each agent's replies, sessions, runs on repositories and sandbox time. @g1t is g1t's own work on your repositories.", | |
| 376 | + | link: (s: SpendSlice) => agentSpendHref(slug, s), | |
| 377 | + | } | |
| 378 | + | : { slices: attributed.person, foot: "Who asked, from the ledger. Routines, agent-to-agent work and runs from before attribution have no asker.", link: undefined }; | |
| 379 | + | return ( | |
| 380 | + | <SlicePanel foot={chosen.foot}> | |
| 381 | + | <SliceList slices={chosen.slices} unit="entries" empty="Nothing spent in this period." href={chosen.link} /> | |
| 382 | + | </SlicePanel> | |
| 383 | + | ); | |
| 384 | + | } | |
| 355 | 385 | if (!spent) return <Unavailable what="The agents service" />; | |
| 356 | 386 | const pick: Record<Exclude<SliceKey, "extension" | "product">, { slices: SpendSlice[]; unit: string; foot?: ReactNode; link?: (s: SpendSlice) => string | null }> = { | |
| 357 | 387 | agent: { | |
| 284 | 284 | /// None when the workspace's own provider names its model. | |
| 285 | 285 | #[serde(default)] | |
| 286 | 286 | pub tier: Option<String>, | |
| 287 | + | /// The agent doing the work, by handle: a workspace agent's (the one | |
| 288 | + | /// whose budget pays), or `g1t` for g1t's own work on a repository. | |
| 289 | + | /// Every line the run puts on the ledger carries it, so Spend's "by | |
| 290 | + | /// agent" reads from the ledger that is charged. | |
| 291 | + | #[serde(default)] | |
| 292 | + | pub agent: Option<String>, | |
| 293 | + | /// Who asked for the work, by username; none for a routine's or | |
| 294 | + | /// another agent's. The runner, which is TypeScript, sends `askedBy`. | |
| 295 | + | #[serde(default, alias = "askedBy")] | |
| 296 | + | pub asked_by: Option<String>, | |
| 287 | 297 | } | |
| 288 | 298 | ||
| 289 | 299 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 1102 | 1112 | /// one. A larger machine's memory and disk cost more each second. | |
| 1103 | 1113 | #[serde(default)] | |
| 1104 | 1114 | pub instance: Option<String>, | |
| 1115 | + | /// The agent whose work this was, by handle (`g1t` for g1t's own runs | |
| 1116 | + | /// on a repository), so an agent's sandbox time is attributed with | |
| 1117 | + | /// the rest of its work. Absent for checks, workflows and builds. | |
| 1118 | + | #[serde(default)] | |
| 1119 | + | pub agent: Option<String>, | |
| 1120 | + | /// Who asked for the work, by username. | |
| 1121 | + | #[serde(default, alias = "asked_by")] | |
| 1122 | + | pub asked_by: Option<String>, | |
| 1105 | 1123 | } | |
| 1106 | 1124 | ||
| 1107 | 1125 | /// How much a workspace has earned g1t's trust with money, which sets how | |
| ⋯ | |||
| 1160 | 1178 | pub state: LimitState, | |
| 1161 | 1179 | /// What to tell people when work is stopped or close to it. | |
| 1162 | 1180 | pub message: Option<String>, | |
| 1163 | − | /// Charged this month, which the spend limit is measured against. | |
| 1181 | + | /// Charged this month, which the spend limit is measured against: the | |
| 1182 | + | /// month's usage at price, less the account's discount, what the | |
| 1183 | + | /// plan's included usage, the trial or a pool paid, and what credit | |
| 1184 | + | /// paid; usage metered through the month and charged when it closes | |
| 1185 | + | /// counted on the same terms. The one figure the Billing page's plan | |
| 1186 | + | /// card, Spend's "Charged" and the top bar show (`UsageTotals::charged_micros` | |
| 1187 | + | /// over the month is the same number). | |
| 1164 | 1188 | #[serde(default)] | |
| 1165 | 1189 | pub spent_micros: i64, | |
| 1166 | 1190 | /// True while the owners have not chosen a spend limit of their own, so | |
| ⋯ | |||
| 4030 | 4054 | pub cost_micros: i64, | |
| 4031 | 4055 | } | |
| 4032 | 4056 | ||
| 4057 | + | /// One agent's or one person's share of the agent product over the range, | |
| 4058 | + | /// at price, from the ledger lines attributed to them. | |
| 4059 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 4060 | + | #[serde(rename_all = "camelCase")] | |
| 4061 | + | pub struct UsageShare { | |
| 4062 | + | /// The agent's handle or the person's username; empty for lines | |
| 4063 | + | /// attributed to no one (work from before attribution, or that no | |
| 4064 | + | /// person asked for). | |
| 4065 | + | pub key: String, | |
| 4066 | + | pub label: String, | |
| 4067 | + | pub micros: i64, | |
| 4068 | + | /// Ledger lines. | |
| 4069 | + | pub count: u32, | |
| 4070 | + | } | |
| 4071 | + | ||
| 4033 | 4072 | /// One day's usage of one product, at price. | |
| 4034 | 4073 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 4035 | 4074 | #[serde(rename_all = "camelCase")] | |
| ⋯ | |||
| 4139 | 4178 | /// Agent tokens by model over the range, most first. | |
| 4140 | 4179 | #[serde(default)] | |
| 4141 | 4180 | pub models: Vec<ModelTokens>, | |
| 4181 | + | /// The agent product (model tokens, the agent rate and agents' | |
| 4182 | + | /// sandbox time) by the agent that did the work, most first. Their sum | |
| 4183 | + | /// is the agent product's total: what agents cost is what the ledger | |
| 4184 | + | /// charges for them, not a second count. | |
| 4185 | + | #[serde(default)] | |
| 4186 | + | pub by_agent: Vec<UsageShare>, | |
| 4187 | + | /// The same by who asked; work no person asked for (routines, agents | |
| 4188 | + | /// helping agents, lines from before attribution) under an empty key. | |
| 4189 | + | #[serde(default)] | |
| 4190 | + | pub by_person: Vec<UsageShare>, | |
| 4142 | 4191 | /// The plan's included usage this month, when the workspace has it. | |
| 4143 | 4192 | #[serde(default)] | |
| 4144 | 4193 | pub included: Option<Allowance>, | |
| 727 | 727 | spendLimitMicros: number | null; | |
| 728 | 728 | state: "ok" | "warning" | "stopped"; | |
| 729 | 729 | message: string | null; | |
| 730 | − | /** Charged this month: what the spend limit is measured against. */ | |
| 730 | + | /** | |
| 731 | + | * Charged this month, which the spend limit is measured against: usage at | |
| 732 | + | * price less the discount, included usage, the trial, pools and credit, | |
| 733 | + | * with usage metered through the month counted on the same terms. The | |
| 734 | + | * same number as the month's `UsageTotals.chargedMicros`. | |
| 735 | + | */ | |
| 731 | 736 | spentMicros?: number; | |
| 732 | 737 | /** True while the owners have not chosen a limit, so the automatic one applies: $200, or twice last month's spend. */ | |
| 733 | 738 | defaultSpendLimit?: boolean; | |
| ⋯ | |||
| 1125 | 1130 | selfHosted?: boolean; | |
| 1126 | 1131 | /** The machine it ran on, by label (`g1t-4core`); absent, the standard one. */ | |
| 1127 | 1132 | instance?: string | null; | |
| 1133 | + | /** The agent whose work this was, by handle (`g1t` for g1t's own runs on a repository); absent for checks, workflows and builds. */ | |
| 1134 | + | agent?: string | null; | |
| 1135 | + | /** Who asked for the work, by username. */ | |
| 1136 | + | askedBy?: string | null; | |
| 1128 | 1137 | }): Promise<Result<boolean>>; | |
| 1129 | 1138 | startRun(run: { | |
| 1130 | 1139 | workspace: string; | |
| ⋯ | |||
| 1142 | 1151 | session?: string | null; | |
| 1143 | 1152 | /** The tier g1t routed the run to: `small`, `large` or `frontier`. */ | |
| 1144 | 1153 | tier?: "small" | "large" | "frontier" | null; | |
| 1154 | + | /** The agent doing the work, by handle: a workspace agent's, or `g1t` for g1t's own work on a repository. Every line of the run carries it. */ | |
| 1155 | + | agent?: string | null; | |
| 1156 | + | /** Who asked, by username; none for a routine's or another agent's work. */ | |
| 1157 | + | askedBy?: string | null; | |
| 1145 | 1158 | }): Promise<Result<RunTicket | null>>; | |
| 1146 | 1159 | /** Usage over a range of days (`YYYY-MM-DD`, both included), at price, by product, meter, project and day. Members only. */ | |
| 1147 | 1160 | usageReport( | |
| ⋯ | |||
| 1887 | 1900 | ||
| 1888 | 1901 | export type UsageDay = { day: string; product: string; micros: number }; | |
| 1889 | 1902 | ||
| 1903 | + | /** One agent's (by handle) or one person's (by username) share of the agent product, at price; an empty key for lines attributed to no one. */ | |
| 1904 | + | export type UsageShare = { key: string; label: string; micros: number; count: number }; | |
| 1905 | + | ||
| 1890 | 1906 | export type UsageAllowance = { used: number; of: number; unit: string }; | |
| 1891 | 1907 | ||
| 1892 | 1908 | export type ProjectUsage = { project: string; micros: number; quantity: number }; | |
| ⋯ | |||
| 1925 | 1941 | projects: string[]; | |
| 1926 | 1942 | /** Agent tokens by model over the range, most first. */ | |
| 1927 | 1943 | models?: ModelTokens[]; | |
| 1944 | + | /** The agent product by the agent that did the work, most first; their sum is the agent product's total. */ | |
| 1945 | + | byAgent?: UsageShare[]; | |
| 1946 | + | /** The agent product by who asked; work no person asked for under an empty key. */ | |
| 1947 | + | byPerson?: UsageShare[]; | |
| 1928 | 1948 | /** The plan's included usage this month, in micros. */ | |
| 1929 | 1949 | included?: UsageAllowance | null; | |
| 1930 | 1950 | discountPercent?: number | null; | |
| 15 | 15 | * 4. The compute gate's reservation (`ComputeGate.admit`, kind `agent`): | |
| 16 | 16 | * the workspace's spend limit, AI credit, pauses and g1t's breaker. | |
| 17 | 17 | * 5. A billing run (`start_run`), so the work is charged as Agent tokens on | |
| 18 | − | * the workspace's bill, under the paying agent. | |
| 18 | + | * the workspace's bill, under the paying agent, attributed to it and to | |
| 19 | + | * who asked: Spend's "by agent" and "by person" read the ledger. | |
| 19 | 20 | * 6. The work itself, through the model proxy with the session's token. | |
| 20 | 21 | * 7. `finish_run` with its cost and tokens, the reservation settled at | |
| 21 | 22 | * cost, and the charge added to the paying agent's and the workspace's | |
| ⋯ | |||
| 293 | 294 | billedTo: ownModel ? "workspace" : "g1t", | |
| 294 | 295 | session: session.id, | |
| 295 | 296 | tier: named ? null : model.tier, | |
| 297 | + | // Whose work it is, on every line the run puts on the ledger: the | |
| 298 | + | // agent that pays, and who asked. Spend reads both from the ledger. | |
| 299 | + | agent: payer.handle, | |
| 300 | + | askedBy: input.askerName, | |
| 296 | 301 | }); | |
| 297 | 302 | if (!started.ok) return { ok: false, reason: "billing", message: started.error.message }; | |
| 298 | 303 | const ticket: RunTicket | null = started.value; | |
| 1 | + | -- Who an agent's usage is attributed to: the agent that did the work, by | |
| 2 | + | -- handle, and the person who asked, by username. Every reply and session | |
| 3 | + | -- step the agents service bills (a run under `<workspace>/@<handle>`), and | |
| 4 | + | -- every repository run by @g1t from the runner, carries both, so Spend's | |
| 5 | + | -- "by agent" and "by person" come from the one ledger that is charged, | |
| 6 | + | -- with no second ledger to disagree with it. | |
| 7 | + | ALTER TABLE runs ADD COLUMN agent TEXT; | |
| 8 | + | ALTER TABLE runs ADD COLUMN asked_by TEXT; | |
| 9 | + | ALTER TABLE ledger ADD COLUMN agent TEXT; | |
| 10 | + | ALTER TABLE ledger ADD COLUMN asked_by TEXT; | |
| 11 | + | ||
| 12 | + | -- What is already on the ledger, attributed once from what it says: a | |
| 13 | + | -- line billed under `<workspace>/@<handle>` is that agent's (no repository | |
| 14 | + | -- has an `@` in its name)… | |
| 15 | + | UPDATE ledger SET agent = substr(repo, instr(repo, '/@') + 2) | |
| 16 | + | WHERE kind = 'usage' AND agent IS NULL AND repo LIKE '%/@%'; | |
| 17 | + | UPDATE runs SET agent = substr(repo, instr(repo, '/@') + 2) | |
| 18 | + | WHERE agent IS NULL AND repo LIKE '%/@%'; | |
| 19 | + | -- …and every other run, and every agent's sandbox, is g1t's own agent at | |
| 20 | + | -- work on a repository: all g1t's work shows as @g1t. Who asked is not | |
| 21 | + | -- known for these; lines from here on carry it. | |
| 22 | + | UPDATE ledger SET agent = 'g1t' | |
| 23 | + | WHERE kind = 'usage' AND agent IS NULL | |
| 24 | + | AND (reference LIKE 'run%' OR (task = 'sandbox' AND compute = 'agent')); | |
| 25 | + | UPDATE runs SET agent = 'g1t' WHERE agent IS NULL; |
| 1 | + | //! Whose work a usage line is: the agent that did it, by handle, and the | |
| 2 | + | //! person who asked, by username. Both are written on the run | |
| 3 | + | //! (`start_run`) and copied onto every ledger line it makes, and on an | |
| 4 | + | //! agent's sandbox time (`record_sandbox`), so Spend's "by agent" and "by | |
| 5 | + | //! person" are read from the one ledger that is charged | |
| 6 | + | //! (`report::attribution`), never from a second count. | |
| 7 | + | ||
| 8 | + | /// A handle or username as the ledger stores it: lowercase, without a | |
| 9 | + | /// leading `@`; none when empty. | |
| 10 | + | pub(crate) fn handle(given: Option<&str>) -> Option<String> { | |
| 11 | + | let name = given?.trim().trim_start_matches('@').to_lowercase(); | |
| 12 | + | (!name.is_empty()).then_some(name) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | /// The agent a run is attributed to: the one the caller names; else the | |
| 16 | + | /// one a run billed under `<workspace>/@<handle>` names (the agents | |
| 17 | + | /// service's replies and sessions; no repository has an `@` in its name); | |
| 18 | + | /// else g1t's own agent at work on a repository, which all shows as @g1t. | |
| 19 | + | pub(crate) fn agent_of(given: Option<&str>, repo_name: &str) -> Option<String> { | |
| 20 | + | handle(given).or_else(|| repo_name.strip_prefix('@').and_then(|h| handle(Some(h)))).or_else(|| Some("g1t".to_owned())) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[cfg(test)] | |
| 24 | + | mod tests { | |
| 25 | + | use super::*; | |
| 26 | + | ||
| 27 | + | #[test] | |
| 28 | + | fn a_run_is_the_agent_named_or_the_one_it_is_billed_under_or_g1t() { | |
| 29 | + | assert_eq!(agent_of(Some("Mike"), "@mike").as_deref(), Some("mike")); | |
| 30 | + | assert_eq!(agent_of(None, "@margo").as_deref(), Some("margo")); | |
| 31 | + | assert_eq!(agent_of(Some(" "), "@margo").as_deref(), Some("margo")); | |
| 32 | + | assert_eq!(agent_of(None, "g1t").as_deref(), Some("g1t")); | |
| 33 | + | assert_eq!(agent_of(Some("@g1t"), "api").as_deref(), Some("g1t")); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[test] | |
| 37 | + | fn who_asked_is_a_username_or_no_one() { | |
| 38 | + | assert_eq!(handle(Some("@Chase")).as_deref(), Some("chase")); | |
| 39 | + | assert_eq!(handle(Some("")), None); | |
| 40 | + | assert_eq!(handle(None), None); | |
| 41 | + | } | |
| 42 | + | } |
| 1 | + | //! What a workspace is charged in a month: one definition, used by the | |
| 2 | + | //! spend limit (`limits::limit_with`, the `spent_micros` every signed-in | |
| 3 | + | //! page shows), the Usage page and the Billing page's plan card | |
| 4 | + | //! (`report::usage_report`), and so by Spend's "Charged" tile and the top | |
| 5 | + | //! bar, which read the limit. | |
| 6 | + | //! | |
| 7 | + | //! **Charged** is usage at price, less the account's discount, less what | |
| 8 | + | //! the plan's included usage, the trial, the open-source pool or g1t paid, | |
| 9 | + | //! less what credit paid; never below zero. Every ledger line carries each | |
| 10 | + | //! part (`statement::PRICE_SQL` reads the price back from them), and usage | |
| 11 | + | //! metered through the month and charged when it closes (`pending_usage`: | |
| 12 | + | //! storage, git operations, scans, embeddings, domains, app traffic) counts | |
| 13 | + | //! now on the same terms it will be entered on: at cost plus the margin, | |
| 14 | + | //! with the discount off, and nothing for a source g1t covers on a free | |
| 15 | + | //! workspace. So a workspace with a 100% discount is charged exactly | |
| 16 | + | //! nothing, however much is pending. | |
| 17 | + | ||
| 18 | + | use g1t_contracts::billing::UsageTotals; | |
| 19 | + | use serde::Deserialize; | |
| 20 | + | use worker::wasm_bindgen::JsValue; | |
| 21 | + | use worker::Result; | |
| 22 | + | ||
| 23 | + | use crate::Billing; | |
| 24 | + | use crate::credits::with_margin; | |
| 25 | + | ||
| 26 | + | /// What is left to pay: usage at price less the discount, included usage | |
| 27 | + | /// and credit. The one arithmetic for "charged". | |
| 28 | + | pub(crate) fn charged(totals: &UsageTotals) -> i64 { | |
| 29 | + | (totals.price_micros - totals.discount_micros - totals.included_micros - totals.credits_micros).max(0) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// A month-end source's usage so far this month, metered and not charged | |
| 33 | + | /// yet (`pending_usage`). | |
| 34 | + | #[derive(Clone, Debug, Default, Deserialize, PartialEq)] | |
| 35 | + | pub(crate) struct PendingLine { | |
| 36 | + | pub source: String, | |
| 37 | + | /// What it cost g1t so far. | |
| 38 | + | #[serde(default)] | |
| 39 | + | pub cost_micros: Option<f64>, | |
| 40 | + | /// What it will be charged before the account's terms: at cost plus | |
| 41 | + | /// the margin, or nothing for a source g1t covers on a free workspace | |
| 42 | + | /// (`storage::set_pending`). | |
| 43 | + | #[serde(default)] | |
| 44 | + | pub charge_micros: Option<f64>, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl PendingLine { | |
| 48 | + | pub fn cost(&self) -> i64 { | |
| 49 | + | self.cost_micros.unwrap_or(0.0).round().max(0.0) as i64 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn charge(&self) -> i64 { | |
| 53 | + | self.charge_micros.unwrap_or(0.0).round().max(0.0) as i64 | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// A pending line's parts, as the month's close will enter them: its | |
| 58 | + | /// price, what g1t covers of it, and what the discount takes off the rest. | |
| 59 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 60 | + | pub(crate) struct PendingSplit { | |
| 61 | + | pub price: i64, | |
| 62 | + | pub covered: i64, | |
| 63 | + | pub discount: i64, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl PendingSplit { | |
| 67 | + | /// What the workspace will be charged for it. | |
| 68 | + | #[cfg(test)] | |
| 69 | + | pub fn charged(&self) -> i64 { | |
| 70 | + | self.price - self.covered - self.discount | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Splits a pending line: `cost` at cost plus `margin_percent` is its | |
| 75 | + | /// price; `charge` (what it will be charged before terms) below that is | |
| 76 | + | /// covered by g1t; `percent_off` comes off what is charged. | |
| 77 | + | pub(crate) fn pending_split(cost: i64, charge: i64, margin_percent: u32, percent_off: u32) -> PendingSplit { | |
| 78 | + | let price = with_margin(cost.max(0), margin_percent); | |
| 79 | + | let base = charge.clamp(0, price); | |
| 80 | + | PendingSplit { price, covered: price - base, discount: base * i64::from(percent_off.min(100)) / 100 } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Counts a pending line into the totals: at price (and as pending), with | |
| 84 | + | /// what g1t covers as included and the discount as discount. | |
| 85 | + | pub(crate) fn add_pending(totals: &mut UsageTotals, split: &PendingSplit) { | |
| 86 | + | totals.price_micros += split.price; | |
| 87 | + | totals.pending_micros += split.price; | |
| 88 | + | totals.included_micros += split.covered; | |
| 89 | + | totals.discount_micros += split.discount; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /// The month's totals from its ledger lines and pending usage, and what | |
| 93 | + | /// credit paid, with `charged_micros` worked out. | |
| 94 | + | pub(crate) fn month_totals(price: i64, discount: i64, covered: i64, credits: i64, pending: &[PendingSplit]) -> UsageTotals { | |
| 95 | + | let mut totals = UsageTotals { | |
| 96 | + | price_micros: price, | |
| 97 | + | discount_micros: discount, | |
| 98 | + | included_micros: covered, | |
| 99 | + | credits_micros: credits.max(0), | |
| 100 | + | ..UsageTotals::default() | |
| 101 | + | }; | |
| 102 | + | for split in pending { | |
| 103 | + | add_pending(&mut totals, split); | |
| 104 | + | } | |
| 105 | + | totals.charged_micros = charged(&totals); | |
| 106 | + | totals | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | impl Billing { | |
| 110 | + | /// What the workspaces have metered this month (`YYYY-MM`) that is | |
| 111 | + | /// charged when it closes, not charged yet. | |
| 112 | + | pub(crate) async fn pending_this_month(&self, members: &[JsValue], month: &str) -> Result<Vec<PendingLine>> { | |
| 113 | + | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 114 | + | let mut args = members.to_vec(); | |
| 115 | + | args.push(month.into()); | |
| 116 | + | self.db | |
| 117 | + | .prepare(format!( | |
| 118 | + | "SELECT source, cost_micros, charge_micros FROM pending_usage | |
| 119 | + | WHERE workspace IN ({marks}) AND month = ? AND charged_at IS NULL" | |
| 120 | + | )) | |
| 121 | + | .bind(&args)? | |
| 122 | + | .all() | |
| 123 | + | .await? | |
| 124 | + | .results::<PendingLine>() | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[cfg(test)] | |
| 129 | + | mod tests { | |
| 130 | + | use super::*; | |
| 131 | + | ||
| 132 | + | const MARGIN: u32 = 20; | |
| 133 | + | ||
| 134 | + | #[test] | |
| 135 | + | fn a_full_discount_is_charged_exactly_nothing_pending_usage_included() { | |
| 136 | + | // flagon-io, October 2026: $10.29 of usage at price on the ledger, | |
| 137 | + | // every line discounted in full; and $0.0075 of storage metered so | |
| 138 | + | // far, which the limit used to count at its charge, $0.009. | |
| 139 | + | let pending = [pending_split(7_500, with_margin(7_500, MARGIN), MARGIN, 100)]; | |
| 140 | + | assert_eq!(pending[0], PendingSplit { price: 9_000, covered: 0, discount: 9_000 }); | |
| 141 | + | let totals = month_totals(10_290_000, 10_290_000, 0, 0, &pending); | |
| 142 | + | assert_eq!(totals.price_micros, 10_299_000); | |
| 143 | + | assert_eq!(totals.discount_micros, 10_299_000); | |
| 144 | + | assert_eq!(totals.charged_micros, 0); | |
| 145 | + | assert_eq!(charged(&totals), 0); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn included_usage_pays_first_and_only_the_rest_is_charged() { | |
| 150 | + | // The plan: $10 included. $12.50 of runs at price, $10 of it drawn | |
| 151 | + | // from the included usage on the lines themselves; $0.60 of storage | |
| 152 | + | // pending, which the close will charge in full (the included usage | |
| 153 | + | // is spent). | |
| 154 | + | let pending = [pending_split(500_000, with_margin(500_000, MARGIN), MARGIN, 0)]; | |
| 155 | + | assert_eq!(pending[0], PendingSplit { price: 600_000, covered: 0, discount: 0 }); | |
| 156 | + | let totals = month_totals(12_500_000, 0, 10_000_000, 0, &pending); | |
| 157 | + | assert_eq!(totals.included_micros, 10_000_000); | |
| 158 | + | assert_eq!(totals.charged_micros, 2_500_000 + 600_000); | |
| 159 | + | // Credit from g1t pays after that. | |
| 160 | + | let with_credit = month_totals(12_500_000, 0, 10_000_000, 1_000_000, &pending); | |
| 161 | + | assert_eq!(with_credit.charged_micros, 2_100_000); | |
| 162 | + | // Never below zero: more credit than usage charges nothing. | |
| 163 | + | assert_eq!(month_totals(1_000_000, 0, 0, 5_000_000, &[]).charged_micros, 0); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn pay_as_you_go_is_charged_at_price() { | |
| 168 | + | // No discount, nothing included, no credit: price is what is | |
| 169 | + | // charged, pending usage counted at its price too. | |
| 170 | + | let pending = [pending_split(1_000_000, with_margin(1_000_000, MARGIN), MARGIN, 0), pending_split(250, 300, MARGIN, 0)]; | |
| 171 | + | let totals = month_totals(4_000_000, 0, 0, 0, &pending); | |
| 172 | + | assert_eq!(totals.pending_micros, 1_200_000 + 300); | |
| 173 | + | assert_eq!(totals.charged_micros, 4_000_000 + 1_200_000 + 300); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn a_partial_discount_comes_off_pending_usage_as_the_close_will_take_it() { | |
| 178 | + | // 30% off: $0.009 of pending storage is charged $0.0063. | |
| 179 | + | let split = pending_split(7_500, 9_000, MARGIN, 30); | |
| 180 | + | assert_eq!(split, PendingSplit { price: 9_000, covered: 0, discount: 2_700 }); | |
| 181 | + | assert_eq!(split.charged(), 6_300); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[test] | |
| 185 | + | fn what_g1t_covers_on_a_free_workspace_is_included_not_charged() { | |
| 186 | + | // Security scans on a free workspace: metered at cost, charged | |
| 187 | + | // nothing (`set_pending` writes a charge of 0), shown at price. | |
| 188 | + | let split = pending_split(2_000, 0, MARGIN, 0); | |
| 189 | + | assert_eq!(split, PendingSplit { price: 2_400, covered: 2_400, discount: 0 }); | |
| 190 | + | assert_eq!(split.charged(), 0); | |
| 191 | + | let totals = month_totals(0, 0, 0, 0, &[split]); | |
| 192 | + | assert_eq!(totals.price_micros, 2_400); | |
| 193 | + | assert_eq!(totals.included_micros, 2_400); | |
| 194 | + | assert_eq!(totals.charged_micros, 0); | |
| 195 | + | // A charge above the price (an older margin) is held to the price. | |
| 196 | + | assert_eq!(pending_split(1_000, 5_000, MARGIN, 0).covered, 0); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[test] | |
| 200 | + | fn a_pending_row_reads_whole_micros_and_never_less_than_nothing() { | |
| 201 | + | let line = PendingLine { source: "storage".into(), cost_micros: Some(7_499.6), charge_micros: Some(-1.0) }; | |
| 202 | + | assert_eq!(line.cost(), 7_500); | |
| 203 | + | assert_eq!(line.charge(), 0); | |
| 204 | + | assert_eq!(PendingLine::default().cost(), 0); | |
| 205 | + | } | |
| 206 | + | } |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.