Billing: credits with a kind and expiry, discounts instead of comped, and safer charging
Credits from g1t. Staff give a workspace $10, $20, $25, $50, $100 or a custom amount from sudo, as promotional, goodwill or refund credit, with a note and an optional expiry (30, 90 or 365 days, or a date; refunds never expire). Up to $100 is one step; over it, the slug is typed out. The form is CSS-only: the custom amount, a refund's details and the expiry date show only for their choice, and all show without :has(). Owners are emailed. sudo lists each workspace's credits with used, left and expiry and a Revoke unused form, and a new Credits & refunds page lists every grant (by kind, month, staff, workspace) and the last 12 months by kind. The web Billing page shows each credit in a line. Accounting. Grants are crd_ ledger lines with ledger.credit_kind and a credit_grants row (migration 0038; earlier "Credit from g1t:" lines backfilled as goodwill). Credit is spent before anything prepaid, models-scoped first, then the soonest-expiring, worked out from the ledger in order (grants.rs), so no charge path changes. Usage paid with promotional or goodwill credit is given (given_credit_*_micros), never money in. A refund comes off money in on the day it refunds, and what it pays for later is paid for. The daily run expires unused credit; revoking takes back what is left; neither takes the balance below zero; all are audited and on the statement. credit_grants has scope (all | models) and source (staff | purchase | promo_code), and CreditKind::Purchased, ready for prepaid AI. Promo codes are planned in docs/PLAN.md. Discounts. "Comped" is now a 100% discount on custom terms (migration 0039 moves flagon-io and any other comped account, and backfills the discount on their earlier zero-charged entries). Every charge records its discount, so the statement shows every line at its price with a Discount line per day and in the totals (Usage at price, Discount, Charged), the CSV has price and discount columns, and the Usage page measures at price. sudo's Terms form takes None, 25%, 50%, 100% or a custom percent with a reason and an end date; badges and Costs & margin say 100% discount / N% off. Everything comped did, a 100% discount still does. Docs: BILLING_OPERATIONS.md (credits, margin, expiry, revoke, discounts, purchased credit) and the usage-and-billing guide. Billing fixes from the pricing audit: - Billing page: agents do not run from the open-source pool - an enterprise invoice attempted again is not billed again - a refund does not clear a declined card - starting the plan twice on a saved card makes one subscription - a plan's payment page is never credited as a prepayment - a deployment charge over $100 is charged in full - Stripe failing is not a declined card, and stops nobody - an invoice holds only its own lines, never ones left pending by a failed attempt - an invoice charges what is owed rounded up to the cent, not each line cut down Also: a workspace tab that is coming (Insights) and the moved-page redirects read the request path, which a click asks for as <path>.data?_routes=...; they 404'd on client navigation. pagePath() normalises it, with a test.
| 130 | 130 | **Update payment on Stripe**, and the plan's features stop until it is | |
| 131 | 131 | paid. | |
| 132 | 132 | ||
| 133 | − | A card that says **Comped by g1t** or **Included by g1t** is on under terms | |
| 134 | − | g1t set with the workspace, such as | |
| 135 | − | [comped](#enterprises-and-custom-terms) terms, with nothing to pay or end. | |
| 133 | + | A card that says **100% discount from g1t** or **Included by g1t** is on | |
| 134 | + | under terms g1t set with the workspace, such as a | |
| 135 | + | [100% discount](#enterprises-and-custom-terms), with nothing to pay or end. | |
| 136 | 136 | ||
| 137 | 137 | ## No card, no compute | |
| 138 | 138 | ||
| ⋯ | |||
| 529 | 529 | person. The credit appears on the statement with the day it happened, | |
| 530 | 530 | such as *Credit from g1t: accidental usage on 2026-11-12*. | |
| 531 | 531 | ||
| 532 | + | ## Credits from g1t | |
| 533 | + | ||
| 534 | + | g1t sometimes adds credit to a workspace: a welcome or referral credit, an | |
| 535 | + | apology, or a refund for something that went wrong. The workspace's owners | |
| 536 | + | get an email when it does, and **Billing** shows a **Credits from g1t** | |
| 537 | + | card with each credit in a line, such as *$25.00 credit, $12.40 left, | |
| 538 | + | expires Jan 5*. | |
| 539 | + | ||
| 540 | + | | Kind | What it is | Expires | | |
| 541 | + | | --- | --- | --- | | |
| 542 | + | | Promotional | A welcome, a referral or an event. | Sometimes: the date is on the card and in the email. | | |
| 543 | + | | Goodwill | An apology, or usage past what you meant forgiven. | Sometimes, as above. | | |
| 544 | + | | Refund | Money back for something that went wrong, with what it is for. | Never. | | |
| 545 | + | ||
| 546 | + | How credit is used: | |
| 547 | + | ||
| 548 | + | 1. It is added to your balance at once, so it lowers what you owe and | |
| 549 | + | raises what you can use before work stops. | |
| 550 | + | 2. Usage is paid from credit before anything you prepaid, and from the | |
| 551 | + | credit that expires soonest first. | |
| 552 | + | 3. Given while the workspace owes for this month, it pays that first. | |
| 553 | + | 4. Credit left when it expires stops counting, and the statement shows a | |
| 554 | + | line for what expired. g1t can also withdraw credit given by mistake; | |
| 555 | + | only what is left is withdrawn, never what was already used. | |
| 556 | + | ||
| 557 | + | Credit is never paid out as money, and is never charged to a card. On the | |
| 558 | + | statement, credits, expiries and withdrawals are under **Credits from g1t**. | |
| 559 | + | ||
| 532 | 560 | ## Invoices | |
| 533 | 561 | ||
| 534 | 562 | Every charge is a real invoice from g1t, kept on Stripe's billing page | |
| ⋯ | |||
| 618 | 646 | the enterprise's billing address, with a line for each workspace, due in | |
| 619 | 647 | 30 days and paid by card or bank transfer. If it goes overdue, the | |
| 620 | 648 | workspaces' work stops until it is paid. | |
| 621 | − | - **Comped**: g1t covers the account's usage. The plan is on without being | |
| 622 | − | charged, and usage is still recorded at what it cost, so the Usage page | |
| 623 | − | stays accurate. | |
| 624 | − | - **Custom**: a discount on usage, a limit of its own, or a larger share | |
| 625 | − | of the pools, sometimes until a date. A discount comes off each usage | |
| 626 | − | charge, and the statement line says how much off (*(20% off)*); prices | |
| 627 | − | themselves stay the public ones. | |
| 649 | + | - **A discount**: a percentage off every usage charge, from a few percent | |
| 650 | + | to 100%, with a reason, sometimes until a date. Prices themselves stay | |
| 651 | + | the public ones. The statement shows every line at its price and the | |
| 652 | + | discount beside it: the totals read **Usage at price**, **Discount | |
| 653 | + | (30%)** and **Charged**, each day or project has a **Discount** line, and | |
| 654 | + | the CSV has `price (USD)` and `discount (USD)` columns. The Usage page | |
| 655 | + | shows usage at price too, with the discount and what was charged. | |
| 656 | + | - **A 100% discount**: nothing is charged, and the plan is on without | |
| 657 | + | being charged. The statement and the Usage page still show everything at | |
| 658 | + | its price, so you can see what the workspace would pay. | |
| 659 | + | - **A limit of its own**, or a larger share of the pools. | |
| 628 | 660 | - **A longer audit log**: up to 400 days for every workspace the account | |
| 629 | 661 | pays for, in place of the plan's 7 or 90. See | |
| 630 | 662 | [how long it is kept](/guides/audit-log/#how-long-it-is-kept). | |
| 631 | 663 | ||
| 632 | 664 | g1t's own workspaces and those of Flagon, Inc., the company that makes g1t, | |
| 633 | − | run comped. Their usage is recorded at cost, apart from what customers | |
| 634 | − | pay. | |
| 665 | + | have a 100% discount. Their usage is shown at price and kept apart from | |
| 666 | + | what customers pay. | |
| 635 | 667 | ||
| 636 | 668 | Each change is made by g1t staff and recorded with who made it and why. To | |
| 637 | 669 | ask for one, write to [hey@flagon.io](mailto:hey@flagon.io). | |
| ⋯ | |||
| 694 | 726 | | Search embeddings | Private text put in the search index, once a month. | | |
| 695 | 727 | | Security scans | History scans and dependency checks, once a month. | | |
| 696 | 728 | | Payments | Card payments and invoices paid. | | |
| 697 | − | | Credits from g1t | Credit g1t added, such as a goodwill credit. | | |
| 729 | + | | Credits from g1t | Credit g1t added (promotional, goodwill or a refund), and what of it expired or was withdrawn. See [Credits from g1t](#credits-from-g1t). | | |
| 698 | 730 | | Refunds | Money given back to your card. | | |
| 699 | 731 | ||
| 700 | 732 | - **Covered.** Below the totals, what paid for usage before it was | |
| 134 | 134 | animation: none; | |
| 135 | 135 | } | |
| 136 | 136 | } | |
| 137 | + | ||
| 138 | + | /* | |
| 139 | + | * The credit and terms forms (components/billing.tsx) show a field only for the | |
| 140 | + | * choice it belongs to, with CSS alone: the custom amount and the slug for | |
| 141 | + | * Custom, a refund's details for Refund, the expiry for anything else, and | |
| 142 | + | * the date for "On a date". Where `:has()` is not supported (or without | |
| 143 | + | * CSS) every field shows, and the server ignores the ones that do not apply. | |
| 144 | + | */ | |
| 145 | + | @supports selector(:has(*)) { | |
| 146 | + | .credit-form .when-custom, | |
| 147 | + | .credit-form .when-refund, | |
| 148 | + | .credit-form .when-date, | |
| 149 | + | .terms-form .when-custom { | |
| 150 | + | display: none; | |
| 151 | + | } | |
| 152 | + | .credit-form:has(input[name="preset"][value="custom"]:checked) .when-custom, | |
| 153 | + | .terms-form:has(input[name="preset"][value="custom"]:checked) .when-custom, | |
| 154 | + | .credit-form:has(input[name="kind"][value="refund"]:checked) .when-refund, | |
| 155 | + | .credit-form:has(input[name="expires"][value="date"]:checked) .when-date { | |
| 156 | + | display: block; | |
| 157 | + | } | |
| 158 | + | .credit-form:has(input[name="kind"][value="refund"]:checked) .when-not-refund { | |
| 159 | + | display: none; | |
| 160 | + | } | |
| 161 | + | } |
| 7 | 7 | import type { ReactNode } from "react"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | − | import type { AdminAction, AdminOwner, Allowances, BillingLink, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 10 | + | import type { AdminAction, AdminOwner, Allowances, BillingLink, CreditGrant, Credits, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 13 | + | import { CREDIT_KINDS, CREDIT_PRESETS, EXPIRIES, kindLabel } from "~/lib/credits"; | |
| 14 | + | import { DISCOUNT_PRESETS, fullDiscount, percentOff, termsLabel } from "~/lib/terms"; | |
| 13 | 15 | import { actionLabel } from "~/lib/ledgers"; | |
| 14 | 16 | import { dollarsField, usd } from "~/lib/money"; | |
| 15 | 17 | import { givenParts } from "~/lib/pricing"; | |
| ⋯ | |||
| 67 | 69 | ||
| 68 | 70 | function describeTerms(terms: Terms): [string, string][] { | |
| 69 | 71 | return [ | |
| 70 | − | ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"], | |
| 71 | − | ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"], | |
| 72 | + | ["Terms", termsLabel(terms)], | |
| 73 | + | ["Discount", `${percentOff(terms)}%`], | |
| 72 | 74 | [ | |
| 73 | 75 | "Limit", | |
| 74 | − | terms.kind === "comped" | |
| 76 | + | fullDiscount(terms) | |
| 75 | 77 | ? terms.ceilingMicros == null | |
| 76 | 78 | ? "The default monthly budget, at cost" | |
| 77 | 79 | : `${usd(terms.ceilingMicros)} a month, at cost` | |
| ⋯ | |||
| 96 | 98 | const before = describeTerms(review.before); | |
| 97 | 99 | const after = describeTerms(review.after); | |
| 98 | 100 | title = "Confirm the new terms"; | |
| 99 | − | danger = review.after.kind === "comped"; | |
| 101 | + | danger = fullDiscount(review.after); | |
| 100 | 102 | body = ( | |
| 101 | 103 | <> | |
| 102 | 104 | <div className="overflow-x-auto"> | |
| ⋯ | |||
| 119 | 121 | </tbody> | |
| 120 | 122 | </table> | |
| 121 | 123 | </div> | |
| 122 | − | {review.after.kind === "comped" && ( | |
| 124 | + | {fullDiscount(review.after) && ( | |
| 123 | 125 | <p className="mt-3 text-sm text-warn"> | |
| 124 | − | Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded | |
| 125 | − | at cost. | |
| 126 | + | A 100% discount: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. The workspace's | |
| 127 | + | statement still shows its usage at price, with the discount beside it. | |
| 126 | 128 | </p> | |
| 127 | 129 | )} | |
| 128 | 130 | </> | |
| ⋯ | |||
| 203 | 205 | ||
| 204 | 206 | // --- Terms ------------------------------------------------------------------- | |
| 205 | 207 | ||
| 206 | − | const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [ | |
| 207 | − | { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." }, | |
| 208 | − | { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." }, | |
| 209 | − | { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." }, | |
| 210 | − | ]; | |
| 211 | − | ||
| 208 | + | /** | |
| 209 | + | * The account's terms: a discount (0, 25, 50, 100% or a custom percent), | |
| 210 | + | * a limit, an end date and why. A 100% discount charges nothing; the | |
| 211 | + | * workspace's statement still shows its usage at price. The custom percent | |
| 212 | + | * shows only for Custom, by CSS alone (`.terms-form` in app.css). | |
| 213 | + | */ | |
| 212 | 214 | export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) { | |
| 213 | 215 | const values = error?.values; | |
| 214 | − | const kind = values?.kind ?? terms.kind; | |
| 216 | + | const current = percentOff(terms); | |
| 217 | + | const preset = values?.preset ?? ((DISCOUNT_PRESETS as readonly number[]).includes(current) ? String(current) : "custom"); | |
| 215 | 218 | return ( | |
| 216 | 219 | <Section | |
| 217 | 220 | id="terms" | |
| ⋯ | |||
| 227 | 230 | ) | |
| 228 | 231 | } | |
| 229 | 232 | > | |
| 230 | − | <form method="post" action={`${pathname}#review`} className="space-y-4"> | |
| 233 | + | <form method="post" action={`${pathname}#review`} className="terms-form space-y-4"> | |
| 231 | 234 | <input type="hidden" name="intent" value="terms" /> | |
| 232 | 235 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 233 | 236 | <fieldset> | |
| 234 | − | <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend> | |
| 235 | − | <div className="grid gap-2 sm:grid-cols-3"> | |
| 236 | − | {KINDS.map((option) => ( | |
| 237 | − | <label | |
| 238 | − | key={option.value} | |
| 239 | − | className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8" | |
| 240 | − | > | |
| 241 | − | <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required /> | |
| 242 | − | <span> | |
| 243 | − | <span className="block text-sm font-medium">{option.title}</span> | |
| 244 | − | <span className="mt-0.5 block text-xs text-muted">{option.text}</span> | |
| 245 | − | </span> | |
| 246 | − | </label> | |
| 237 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Discount</legend> | |
| 238 | + | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 239 | + | {DISCOUNT_PRESETS.map((percent) => ( | |
| 240 | + | <Segment key={percent} name="preset" value={String(percent)} checked={preset === String(percent)}> | |
| 241 | + | {percent === 0 ? "None" : `${percent}%`} | |
| 242 | + | </Segment> | |
| 247 | 243 | ))} | |
| 244 | + | <Segment name="preset" value="custom" checked={preset === "custom"}> | |
| 245 | + | Custom | |
| 246 | + | </Segment> | |
| 248 | 247 | </div> | |
| 248 | + | <p className="mt-1.5 text-xs text-faint">Off every usage charge. 100%: nothing is charged, and the statement shows usage at price with the discount beside it.</p> | |
| 249 | 249 | </fieldset> | |
| 250 | − | <div className="grid gap-4 sm:grid-cols-3"> | |
| 251 | − | <Field label="Discount %" hint="Custom only."> | |
| 252 | − | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} /> | |
| 250 | + | <div className="when-custom"> | |
| 251 | + | <Field label="Custom discount %" hint="A whole percent, 1 to 100."> | |
| 252 | + | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="30" defaultValue={values?.discount ?? (preset === "custom" ? String(current) : "")} /> | |
| 253 | 253 | </Field> | |
| 254 | − | <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. Comped: the monthly budget at cost; blank: the default ($150)."> | |
| 254 | + | </div> | |
| 255 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 256 | + | <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget for it, at cost; blank: the default ($150)."> | |
| 255 | 257 | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 256 | 258 | </Field> | |
| 257 | 259 | <Field label="Until" hint="Blank: no end. UTC."> | |
| 258 | 260 | <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} /> | |
| 259 | 261 | </Field> | |
| 260 | 262 | </div> | |
| 261 | − | <Field label="Note" hint="Required. Why, for whoever looks next."> | |
| 262 | − | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" /> | |
| 263 | + | <Field label="Reason" hint="Required. Why, for whoever looks next; at 100%, shown as the reason for the discount."> | |
| 264 | + | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. g1t's own workspace" /> | |
| 263 | 265 | </Field> | |
| 264 | 266 | <div className="flex justify-end"> | |
| 265 | 267 | <Button type="submit">Review terms</Button> | |
| ⋯ | |||
| 274 | 276 | /** What staff have set beyond the terms, one line each; defaults left out. */ | |
| 275 | 277 | export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] { | |
| 276 | 278 | const lines: string[] = []; | |
| 277 | − | if (comped) lines.push("The g1t plan, without its price (comped)"); | |
| 279 | + | if (comped) lines.push("The g1t plan, without its price (100% discount)"); | |
| 278 | 280 | else if (allowances?.plan) lines.push("The g1t plan, without its price"); | |
| 279 | 281 | if (!allowances) return lines; | |
| 280 | 282 | if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`); | |
| ⋯ | |||
| 293 | 295 | * The g1t plan without its price, the account's share of g1t's pools, and | |
| 294 | 296 | * staff's overrides of what owners set: agents at once, the run and issue | |
| 295 | 297 | * caps, the days of audit log kept (such as for an organization that pays | |
| 296 | − | * for longer), and a hold on new compute. Comped accounts have the plan | |
| 298 | + | * for longer), and a hold on new compute. Accounts on a 100% discount have the plan | |
| 297 | 299 | * anyway. | |
| 298 | 300 | */ | |
| 299 | 301 | export function AllowancesForm({ | |
| ⋯ | |||
| 316 | 318 | title="Plan, pools and caps" | |
| 317 | 319 | description={ | |
| 318 | 320 | comped | |
| 319 | − | ? "Comped: the g1t plan is on without its price whatever is set here. The pools and caps still apply." | |
| 321 | + | ? "A 100% discount: the g1t plan is on without its price whatever is set here. The pools and caps still apply." | |
| 320 | 322 | : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set." | |
| 321 | 323 | } | |
| 322 | 324 | > | |
| ⋯ | |||
| 457 | 459 | ||
| 458 | 460 | // --- Credit ------------------------------------------------------------------- | |
| 459 | 461 | ||
| 462 | + | /** A radio drawn as one button of a segmented row; still a plain radio without CSS. */ | |
| 463 | + | function Segment({ name, value, checked, children }: { name: string; value: string; checked: boolean; children: ReactNode }) { | |
| 464 | + | return ( | |
| 465 | + | <label className="flex-1 cursor-pointer border-r border-line px-3 py-1.5 text-center text-sm whitespace-nowrap text-muted transition-colors last:border-r-0 hover:bg-raised hover:text-fg has-checked:bg-merged has-checked:font-medium has-checked:text-bg has-focus-visible:outline-2 has-focus-visible:-outline-offset-2 has-focus-visible:outline-merged"> | |
| 466 | + | <input type="radio" name={name} value={value} defaultChecked={checked} className="sr-only" /> | |
| 467 | + | {children} | |
| 468 | + | </label> | |
| 469 | + | ); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /** | |
| 473 | + | * Credit for a workspace: a preset amount or a custom one, a kind, an | |
| 474 | + | * expiry, and a note. Fields that belong to one choice (the custom amount, | |
| 475 | + | * a refund's details, an expiry date) show only for it, by CSS alone | |
| 476 | + | * (`.credit-form` in app.css); without `:has()` every field shows and the | |
| 477 | + | * ones that do not apply are ignored. Over $100 the slug is typed out too. | |
| 478 | + | */ | |
| 460 | 479 | export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) { | |
| 461 | 480 | const values = error?.values; | |
| 462 | 481 | const single = workspaces.length === 1 ? workspaces[0] : null; | |
| 482 | + | const preset = values?.preset ?? "25"; | |
| 483 | + | const kind = values?.kind ?? "promotional"; | |
| 484 | + | const expires = values?.expires ?? "none"; | |
| 463 | 485 | return ( | |
| 464 | − | <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once."> | |
| 486 | + | <Section | |
| 487 | + | id="credit" | |
| 488 | + | title="Give credit" | |
| 489 | + | description="Added to the balance at once and spent before anything paid in advance. The owners are emailed." | |
| 490 | + | > | |
| 465 | 491 | {workspaces.length === 0 ? ( | |
| 466 | 492 | <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p> | |
| 467 | 493 | ) : ( | |
| 468 | − | <form method="post" action={`${pathname}#credit`} className="space-y-4"> | |
| 494 | + | <form method="post" action={`${pathname}#credit`} className="credit-form space-y-4"> | |
| 469 | 495 | <input type="hidden" name="intent" value="credit" /> | |
| 470 | 496 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 471 | 497 | {single ? ( | |
| ⋯ | |||
| 484 | 510 | </Select> | |
| 485 | 511 | </Field> | |
| 486 | 512 | )} | |
| 487 | − | <Field label="Amount $" hint="Up to $10,000 at a time."> | |
| 488 | − | <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} /> | |
| 489 | − | </Field> | |
| 490 | − | <Field label="Note" hint="Required. Shown on the workspace's statement."> | |
| 491 | − | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} /> | |
| 492 | − | </Field> | |
| 493 | − | <Field | |
| 494 | − | label="Confirm" | |
| 495 | − | hint={ | |
| 496 | − | <> | |
| 497 | − | Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it. | |
| 498 | − | </> | |
| 499 | − | } | |
| 500 | − | > | |
| 501 | − | <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" /> | |
| 513 | + | <fieldset> | |
| 514 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Amount</legend> | |
| 515 | + | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 516 | + | {CREDIT_PRESETS.map((dollars) => ( | |
| 517 | + | <Segment key={dollars} name="preset" value={String(dollars)} checked={preset === String(dollars)}> | |
| 518 | + | ${dollars} | |
| 519 | + | </Segment> | |
| 520 | + | ))} | |
| 521 | + | <Segment name="preset" value="custom" checked={preset === "custom"}> | |
| 522 | + | Custom | |
| 523 | + | </Segment> | |
| 524 | + | </div> | |
| 525 | + | </fieldset> | |
| 526 | + | <div className="when-custom space-y-4"> | |
| 527 | + | <Field label="Custom amount $" hint="Up to $10,000 at a time."> | |
| 528 | + | <Input name="amount" inputMode="decimal" placeholder="15.00" defaultValue={values?.amount ?? ""} /> | |
| 529 | + | </Field> | |
| 530 | + | </div> | |
| 531 | + | <fieldset> | |
| 532 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend> | |
| 533 | + | <div className="grid gap-2"> | |
| 534 | + | {CREDIT_KINDS.map((option) => ( | |
| 535 | + | <label | |
| 536 | + | key={option.value} | |
| 537 | + | className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg px-3 py-2.5 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8" | |
| 538 | + | > | |
| 539 | + | <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required /> | |
| 540 | + | <span> | |
| 541 | + | <span className="block text-sm font-medium">{option.title}</span> | |
| 542 | + | <span className="mt-0.5 block text-xs text-muted">{option.text}</span> | |
| 543 | + | </span> | |
| 544 | + | </label> | |
| 545 | + | ))} | |
| 546 | + | </div> | |
| 547 | + | </fieldset> | |
| 548 | + | <div className="when-refund"> | |
| 549 | + | <div className="grid gap-4 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> | |
| 550 | + | <Field label="Refund for" hint="Refunds only. On the statement and in the email."> | |
| 551 | + | <Input name="refundFor" maxLength={200} placeholder="the failed runs on Oct 2" defaultValue={values?.refundFor ?? ""} /> | |
| 552 | + | </Field> | |
| 553 | + | <Field label="Day refunded" hint="Comes off what was paid that day. Blank: today."> | |
| 554 | + | <Input type="date" name="refundDay" defaultValue={values?.refundDay ?? ""} /> | |
| 555 | + | </Field> | |
| 556 | + | </div> | |
| 557 | + | </div> | |
| 558 | + | <fieldset className="when-not-refund"> | |
| 559 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Expires</legend> | |
| 560 | + | <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg"> | |
| 561 | + | {EXPIRIES.map((option) => ( | |
| 562 | + | <Segment key={option.value} name="expires" value={option.value} checked={expires === option.value}> | |
| 563 | + | {option.label} | |
| 564 | + | </Segment> | |
| 565 | + | ))} | |
| 566 | + | </div> | |
| 567 | + | <p className="mt-1.5 text-xs text-faint">Unused credit stops counting then. A refund never expires.</p> | |
| 568 | + | </fieldset> | |
| 569 | + | <div className="when-date when-not-refund"> | |
| 570 | + | <Field label="Expires on" hint="At the end of the day, UTC."> | |
| 571 | + | <Input type="date" name="expiresOn" defaultValue={values?.expiresOn ?? ""} /> | |
| 572 | + | </Field> | |
| 573 | + | </div> | |
| 574 | + | <Field label="Note" hint="Required. On the workspace's statement and in the owners' email."> | |
| 575 | + | <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Welcome to g1t" defaultValue={values?.note ?? ""} /> | |
| 502 | 576 | </Field> | |
| 577 | + | <div className="when-custom"> | |
| 578 | + | <Field | |
| 579 | + | label="Confirm" | |
| 580 | + | hint={ | |
| 581 | + | <> | |
| 582 | + | Over $100 only: type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>}. | |
| 583 | + | </> | |
| 584 | + | } | |
| 585 | + | > | |
| 586 | + | <Input name="confirmation" placeholder={single ?? "workspace-slug"} className="font-mono" /> | |
| 587 | + | </Field> | |
| 588 | + | </div> | |
| 503 | 589 | <div className="flex justify-end"> | |
| 504 | 590 | <Button type="submit" variant="lavender"> | |
| 505 | 591 | <Gift size={14} /> | |
| 506 | − | Issue credit | |
| 592 | + | Give credit | |
| 507 | 593 | </Button> | |
| 508 | 594 | </div> | |
| 509 | 595 | </form> | |
| ⋯ | |||
| 512 | 598 | ); | |
| 513 | 599 | } | |
| 514 | 600 | ||
| 601 | + | const GRANT_STATE: Record<CreditGrant["state"], { label: string; tone: "mint" | "plain" | "warn" | "danger" }> = { | |
| 602 | + | open: { label: "Open", tone: "mint" }, | |
| 603 | + | used: { label: "Used", tone: "plain" }, | |
| 604 | + | expired: { label: "Expired", tone: "warn" }, | |
| 605 | + | revoked: { label: "Revoked", tone: "danger" }, | |
| 606 | + | }; | |
| 607 | + | ||
| 608 | + | /** | |
| 609 | + | * Credits g1t gave, newest first: amount, kind, note, who, when, used, | |
| 610 | + | * left and expiry, and on an open one a form to take back what is left. | |
| 611 | + | */ | |
| 612 | + | export function CreditList({ | |
| 613 | + | grants, | |
| 614 | + | pathname, | |
| 615 | + | error, | |
| 616 | + | showWorkspace = false, | |
| 617 | + | }: { | |
| 618 | + | grants: CreditGrant[]; | |
| 619 | + | pathname: string; | |
| 620 | + | error?: SectionError; | |
| 621 | + | showWorkspace?: boolean; | |
| 622 | + | }) { | |
| 623 | + | if (grants.length === 0) return <EmptyState title="No credits given">Credit given from sudo shows here, with what is left of it.</EmptyState>; | |
| 624 | + | return ( | |
| 625 | + | <ul className="-my-1 divide-y divide-line"> | |
| 626 | + | {grants.map((grant) => { | |
| 627 | + | const state = GRANT_STATE[grant.state]; | |
| 628 | + | const failed = error && error.values?.id === grant.id ? error.error : null; | |
| 629 | + | return ( | |
| 630 | + | <li key={grant.id} id={`grant-${grant.id}`} className="scroll-mt-20 py-3"> | |
| 631 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 632 | + | <span className="tabular font-semibold">{usd(grant.amountMicros, { cents: true })}</span> | |
| 633 | + | <Badge tone="lavender">{kindLabel(grant.kind)}</Badge> | |
| 634 | + | <Badge tone={state.tone}>{state.label}</Badge> | |
| 635 | + | {showWorkspace && ( | |
| 636 | + | <Link to={`/workspaces/${encodeURIComponent(grant.workspace)}#credits`} className="font-mono text-xs text-merged hover:underline"> | |
| 637 | + | {grant.workspace} | |
| 638 | + | </Link> | |
| 639 | + | )} | |
| 640 | + | </div> | |
| 641 | + | <p className="tabular mt-1 text-sm text-fg-soft"> | |
| 642 | + | Used {usd(grant.usedMicros, { cents: true })} · left {usd(grant.leftMicros, { cents: true })} | |
| 643 | + | {grant.expiresAt ? ( | |
| 644 | + | <> | |
| 645 | + | {" "} | |
| 646 | + | · {grant.state === "expired" ? "expired" : "expires"} <When at={grant.expiresAt} /> | |
| 647 | + | </> | |
| 648 | + | ) : grant.kind === "refund" ? ( | |
| 649 | + | " · never expires" | |
| 650 | + | ) : ( | |
| 651 | + | " · no expiry" | |
| 652 | + | )} | |
| 653 | + | </p> | |
| 654 | + | <p className="mt-0.5 text-sm break-words"> | |
| 655 | + | {grant.kind === "refund" && grant.refundFor && <span className="text-muted">For {grant.refundFor}{grant.refundDay ? ` (${grant.refundDay})` : ""}: </span>} | |
| 656 | + | {grant.note} | |
| 657 | + | </p> | |
| 658 | + | <p className="mt-0.5 flex flex-wrap items-center gap-x-1 font-mono text-xs text-faint"> | |
| 659 | + | <UserRound size={11} /> | |
| 660 | + | {grant.createdBy} · <When at={grant.createdAt} time /> | |
| 661 | + | </p> | |
| 662 | + | {grant.closedAt && grant.state === "revoked" && ( | |
| 663 | + | <p className="mt-1 text-xs text-muted"> | |
| 664 | + | {usd(grant.closedMicros ?? 0, { cents: true })} taken back by {grant.closedBy} on <When at={grant.closedAt} /> | |
| 665 | + | {grant.closedNote ? `: “${grant.closedNote}”` : ""} | |
| 666 | + | </p> | |
| 667 | + | )} | |
| 668 | + | {grant.closedAt && grant.state === "expired" && (grant.closedMicros ?? 0) > 0 && ( | |
| 669 | + | <p className="mt-1 text-xs text-muted">{usd(grant.closedMicros ?? 0, { cents: true })} unused when it expired.</p> | |
| 670 | + | )} | |
| 671 | + | {grant.state === "open" && ( | |
| 672 | + | <details className="mt-2" open={failed != null}> | |
| 673 | + | <summary className="cursor-pointer text-xs text-danger hover:underline">Revoke unused</summary> | |
| 674 | + | <form method="post" action={`${pathname}#grant-${grant.id}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 675 | + | <input type="hidden" name="intent" value="revoke-credit" /> | |
| 676 | + | <input type="hidden" name="id" value={grant.id} /> | |
| 677 | + | {showWorkspace && <input type="hidden" name="workspace" value={grant.workspace} />} | |
| 678 | + | <Field label="Why" hint={`Takes ${usd(grant.leftMicros, { cents: true })} off the balance. Kept in the audit log.`} className="flex-1"> | |
| 679 | + | <Input name="reason" required maxLength={500} placeholder="e.g. Given to the wrong workspace" defaultValue={failed ? (error?.values?.reason ?? "") : ""} /> | |
| 680 | + | </Field> | |
| 681 | + | <Button type="submit" variant="danger" className="shrink-0"> | |
| 682 | + | Revoke {usd(grant.leftMicros, { cents: true })} | |
| 683 | + | </Button> | |
| 684 | + | </form> | |
| 685 | + | {failed && ( | |
| 686 | + | <div className="mt-2"> | |
| 687 | + | <Notice tone="error">{failed}</Notice> | |
| 688 | + | </div> | |
| 689 | + | )} | |
| 690 | + | </details> | |
| 691 | + | )} | |
| 692 | + | </li> | |
| 693 | + | ); | |
| 694 | + | })} | |
| 695 | + | </ul> | |
| 696 | + | ); | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | /** A workspace's credits from g1t: what is left to spend, and each grant. */ | |
| 700 | + | export function CreditsSection({ | |
| 701 | + | credits, | |
| 702 | + | unavailable, | |
| 703 | + | pathname, | |
| 704 | + | error, | |
| 705 | + | }: { | |
| 706 | + | credits: Credits | null; | |
| 707 | + | unavailable: string | null; | |
| 708 | + | pathname: string; | |
| 709 | + | error: SectionError; | |
| 710 | + | }) { | |
| 711 | + | return ( | |
| 712 | + | <Section | |
| 713 | + | id="credits" | |
| 714 | + | title="Credits" | |
| 715 | + | description={ | |
| 716 | + | credits | |
| 717 | + | ? `${usd(credits.leftMicros, { cents: true })} left to spend. Spent before anything paid in advance, the soonest-expiring first.` | |
| 718 | + | : "Credits from g1t, with what is left of each." | |
| 719 | + | } | |
| 720 | + | > | |
| 721 | + | {unavailable ? <Notice tone="warn">Billing did not answer for credits: {unavailable}</Notice> : <CreditList grants={credits?.grants ?? []} pathname={pathname} error={error} />} | |
| 722 | + | </Section> | |
| 723 | + | ); | |
| 724 | + | } | |
| 725 | + | ||
| 515 | 726 | // --- Reset (testing) ----------------------------------------------------------- | |
| 516 | 727 | ||
| 517 | 728 | /** | |
| 518 | 729 | * Wipes a test workspace's billing so it starts again as a new customer. | |
| 519 | − | * Only while billing runs on Stripe's test key; billing refuses comped and | |
| 730 | + | * Only while billing runs on Stripe's test key; billing refuses 100% discounts and | |
| 520 | 731 | * enterprise workspaces. The workspace, its members and repositories stay. | |
| 521 | 732 | */ | |
| 522 | 733 | export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) { | |
| ⋯ | |||
| 644 | 855 | </td> | |
| 645 | 856 | <td className="px-4 py-2.5"> | |
| 646 | 857 | <div className="flex flex-wrap items-center gap-1.5"> | |
| 647 | − | <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge> | |
| 858 | + | <Badge tone={entry.creditKind ? "lavender" : entry.amountMicros > 0 ? "mint" : "plain"}> | |
| 859 | + | {entry.creditKind ? `Credit · ${kindLabel(entry.creditKind)}` : (ENTRY_KIND[entry.kind] ?? entry.kind)} | |
| 860 | + | </Badge> | |
| 648 | 861 | {showWorkspace && entry.workspace && ( | |
| 649 | 862 | <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline"> | |
| 650 | 863 | {entry.workspace} | |
| 10 | 10 | import type { Limit, Terms, Trust } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { usd } from "~/lib/money"; | |
| 13 | + | import { fullDiscount, termsLabel } from "~/lib/terms"; | |
| 13 | 14 | import { when } from "~/lib/time"; | |
| 14 | 15 | ||
| 15 | 16 | export function Field({ | |
| ⋯ | |||
| 150 | 151 | } | |
| 151 | 152 | ||
| 152 | 153 | export function TermsBadge({ terms }: { terms: Terms }) { | |
| 153 | − | if (terms.kind === "comped") return <Badge tone="mint">Comped</Badge>; | |
| 154 | − | if (terms.kind === "custom") { | |
| 155 | − | return <Badge tone="info">Custom{terms.discountPercent > 0 ? ` −${terms.discountPercent}%` : ""}</Badge>; | |
| 156 | − | } | |
| 157 | − | return <Badge>Standard</Badge>; | |
| 154 | + | if (fullDiscount(terms)) return <Badge tone="mint">100% discount</Badge>; | |
| 155 | + | if (terms.kind === "standard") return <Badge>Standard</Badge>; | |
| 156 | + | return <Badge tone="info">{termsLabel(terms)}</Badge>; | |
| 158 | 157 | } | |
| 159 | 158 | ||
| 160 | 159 | const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = { | |
| ⋯ | |||
| 166 | 165 | about: "Established: the limit follows their monthly spend, up to $10,000.", | |
| 167 | 166 | }, | |
| 168 | 167 | reviewed: { label: "Reviewed", tone: "mint", about: "Reviewed: g1t set the limit by hand, after talking to them." }, | |
| 169 | − | // Comped accounts: g1t covers their usage, with no ceiling. | |
| 170 | − | internal: { label: "Comped", tone: "lavender", about: "Comped: g1t covers their usage, with no limit." }, | |
| 168 | + | // A 100% discount: nothing is charged, so no limit on unpaid usage. | |
| 169 | + | internal: { label: "100% discount", tone: "lavender", about: "A 100% discount: nothing is charged, so there is no limit on unpaid usage." }, | |
| 171 | 170 | }; | |
| 172 | 171 | ||
| 173 | 172 | export function TrustBadge({ trust }: { trust: Trust }) { | |
| 2 | 2 | * The changes staff make to how a workspace or an enterprise pays: terms, | |
| 3 | 3 | * moving workspaces on and off enterprises, credits, and Stripe billing | |
| 4 | 4 | * links. What is acted on comes from the billing service and identity, not | |
| 5 | − | * from the form; each change shows a confirmation first, and a credit | |
| 6 | − | * needs the workspace's slug typed out. | |
| 5 | + | * from the form; each change shows a confirmation first, and a credit over | |
| 6 | + | * $100 needs the workspace's slug typed out. | |
| 7 | 7 | */ | |
| 8 | 8 | import type { Terms } from "@g1t/contracts"; | |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 11 | − | import { fields, parseAllowances, parseCredit, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms"; | |
| 11 | + | import { parseCreditForm } from "./credits"; | |
| 12 | + | import { fields, parseAllowances, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms"; | |
| 12 | 13 | import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing"; | |
| 13 | 14 | import type { ActionData } from "./review"; | |
| 14 | 15 | import { admin, identity, priceBook } from "./services.server"; | |
| ⋯ | |||
| 33 | 34 | const isEnterprise = subject.kind === "enterprise"; | |
| 34 | 35 | ||
| 35 | 36 | if (intent === "terms") { | |
| 36 | − | const values = fields(form, "kind", "discount", "ceiling", "note", "until"); | |
| 37 | + | const values = fields(form, "preset", "discount", "ceiling", "note", "until"); | |
| 37 | 38 | if (subject.kind === "workspace" && subject.billedTo) { | |
| 38 | 39 | return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values); | |
| 39 | 40 | } | |
| ⋯ | |||
| 105 | 106 | } | |
| 106 | 107 | ||
| 107 | 108 | if (intent === "credit") { | |
| 108 | − | const values = fields(form, "workspace", "amount", "note", "confirmation"); | |
| 109 | + | const values = fields(form, "workspace", "preset", "amount", "kind", "expires", "expiresOn", "refundFor", "refundDay", "note"); | |
| 109 | 110 | const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug; | |
| 110 | 111 | if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) { | |
| 111 | 112 | return failed("credit", "Choose one of this enterprise's workspaces.", values); | |
| 112 | 113 | } | |
| 113 | − | const amount = parseCredit(values.amount); | |
| 114 | − | if (!amount.ok) return failed("credit", amount.error, values); | |
| 115 | − | const note = parseNote(values.note); | |
| 116 | − | if (!note.ok) return failed("credit", note.error, values); | |
| 117 | − | if (values.confirmation !== workspace) { | |
| 118 | − | return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" }); | |
| 119 | − | } | |
| 120 | − | const result = await admin.credit(workspace, amount.value, note.value, staff.email); | |
| 114 | + | const credit = parseCreditForm(form, workspace); | |
| 115 | + | if (!credit.ok) return failed("credit", credit.error, values); | |
| 116 | + | const { amountMicros, note, ...options } = credit.value; | |
| 117 | + | const result = await admin.credit(workspace, amountMicros, note, staff.email, options); | |
| 121 | 118 | if (!result.ok) return failed("credit", result.error.message, values); | |
| 122 | 119 | return back("credit"); | |
| 123 | 120 | } | |
| 124 | 121 | ||
| 122 | + | if (intent === "revoke-credit") { | |
| 123 | + | // What is left of a grant, taken back. The grant must be one of the page's. | |
| 124 | + | const values = fields(form, "id", "reason"); | |
| 125 | + | const workspaces = subject.kind === "enterprise" ? subject.workspaces : [subject.slug]; | |
| 126 | + | const reason = parseNote(values.reason); | |
| 127 | + | if (!reason.ok) return failed("credits", reason.error, values); | |
| 128 | + | const { grants } = await admin.credits(subject.kind === "workspace" ? { workspace: subject.slug } : {}); | |
| 129 | + | const grant = grants.find((row) => row.id === values.id && workspaces.includes(row.workspace)); | |
| 130 | + | if (!grant) return failed("credits", "That credit is not this page's, or is gone.", values); | |
| 131 | + | const result = await admin.revokeCredit(grant.id, reason.value, staff.email); | |
| 132 | + | if (!result.ok) return failed("credits", result.error.message, values); | |
| 133 | + | return back("revoked"); | |
| 134 | + | } | |
| 135 | + | ||
| 125 | 136 | if (intent === "reset") { | |
| 126 | 137 | // A test workspace's billing wiped. Billing refuses it on a live Stripe | |
| 127 | 138 | // key, for comped workspaces and for an enterprise's. | |
| 168 | 168 | // --- g1t's own spend (billing's budget) --------------------------------------- | |
| 169 | 169 | ||
| 170 | 170 | /** | |
| 171 | − | * The red bar on every sudo page: the daily breaker open, or a comped | |
| 171 | + | * The red bar on every sudo page: the daily breaker open, or a 100%-discount | |
| 172 | 172 | * account's monthly budget used up. Null when neither. | |
| 173 | 173 | */ | |
| 174 | 174 | export function spendBanner(caps: SpendCaps): string | null { | |
| ⋯ | |||
| 191 | 191 | /** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */ | |
| 192 | 192 | export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } { | |
| 193 | 193 | const notes: Record<string, string> = { | |
| 194 | − | comped: "Work on comped accounts, at cost", | |
| 194 | + | comped: "Work on accounts with a 100% discount, at cost", | |
| 195 | 195 | trial: "Trial credit, at cost", | |
| 196 | 196 | oss: "Checks and workflows on public repositories, at cost", | |
| 197 | 197 | given: "Free workspaces' overruns past their trial", | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CONFIRM_OVER_MICROS, CREDIT_PRESETS, grantSummary, parseCreditAmount, parseCreditForm, parseExpiry, parseMonth, shortDay } from "./credits.ts"; | |
| 5 | + | import { usd } from "./money.ts"; | |
| 6 | + | ||
| 7 | + | const NOW = new Date("2026-10-07T12:00:00Z"); | |
| 8 | + | ||
| 9 | + | function form(values: Record<string, string>): FormData { | |
| 10 | + | const data = new FormData(); | |
| 11 | + | for (const [name, value] of Object.entries(values)) data.set(name, value); | |
| 12 | + | return data; | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | test("the presets are $10, $20, $25, $50 and $100, each one step", () => { | |
| 16 | + | assert.deepEqual([...CREDIT_PRESETS], [10, 20, 25, 50, 100]); | |
| 17 | + | for (const dollars of CREDIT_PRESETS) { | |
| 18 | + | assert.deepEqual(parseCreditAmount(String(dollars), ""), { ok: true, value: dollars * 1_000_000 }); | |
| 19 | + | assert.ok(dollars * 1_000_000 <= CONFIRM_OVER_MICROS, "no preset needs the slug typed"); | |
| 20 | + | } | |
| 21 | + | // Only the presets offered; anything else is Custom. | |
| 22 | + | assert.equal(parseCreditAmount("15", "").ok, false); | |
| 23 | + | assert.deepEqual(parseCreditAmount("custom", "$12.40"), { ok: true, value: 12_400_000 }); | |
| 24 | + | assert.equal(parseCreditAmount("custom", "").ok, false); | |
| 25 | + | assert.equal(parseCreditAmount("custom", "0").ok, false); | |
| 26 | + | assert.equal(parseCreditAmount("custom", "10000.01").ok, false); | |
| 27 | + | assert.deepEqual(parseCreditAmount("custom", "10,000"), { ok: true, value: 10_000_000_000 }); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("an expiry is never, 30, 90 or 365 days, or the end of a chosen day", () => { | |
| 31 | + | assert.deepEqual(parseExpiry("none", "", NOW), { ok: true, value: null }); | |
| 32 | + | assert.deepEqual(parseExpiry("", "", NOW), { ok: true, value: null }); | |
| 33 | + | assert.deepEqual(parseExpiry("30", "", NOW), { ok: true, value: "2026-11-06T23:59:59Z" }); | |
| 34 | + | assert.deepEqual(parseExpiry("90", "", NOW), { ok: true, value: "2027-01-05T23:59:59Z" }); | |
| 35 | + | assert.deepEqual(parseExpiry("365", "", NOW), { ok: true, value: "2027-10-07T23:59:59Z" }); | |
| 36 | + | assert.deepEqual(parseExpiry("date", "2027-01-05", NOW), { ok: true, value: "2027-01-05T23:59:59Z" }); | |
| 37 | + | assert.equal(parseExpiry("date", "", NOW).ok, false); | |
| 38 | + | assert.equal(parseExpiry("date", "2026-10-01", NOW).ok, false); | |
| 39 | + | assert.equal(parseExpiry("date", "2040-01-01", NOW).ok, false); | |
| 40 | + | assert.equal(parseExpiry("7", "", NOW).ok, false); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("a small credit is one step; over $100 the slug is typed out", () => { | |
| 44 | + | const small = parseCreditForm(form({ preset: "25", kind: "promotional", expires: "90", note: "Welcome to g1t" }), "acme", NOW); | |
| 45 | + | assert.deepEqual(small, { | |
| 46 | + | ok: true, | |
| 47 | + | value: { amountMicros: 25_000_000, kind: "promotional", expiresAt: "2027-01-05T23:59:59Z", refundFor: null, refundDay: null, note: "Welcome to g1t" }, | |
| 48 | + | }); | |
| 49 | + | // $100 is still one step. | |
| 50 | + | assert.ok(parseCreditForm(form({ preset: "100", kind: "goodwill", note: "Sorry" }), "acme", NOW).ok); | |
| 51 | + | const big = form({ preset: "custom", amount: "250", kind: "goodwill", note: "Sorry about the outage" }); | |
| 52 | + | const refused = parseCreditForm(big, "acme", NOW); | |
| 53 | + | assert.equal(refused.ok, false); | |
| 54 | + | assert.match(refused.ok ? "" : refused.error, /type the workspace's slug, acme/); | |
| 55 | + | big.set("confirmation", "acme"); | |
| 56 | + | assert.equal(parseCreditForm(big, "acme", NOW).ok, true); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("a credit needs a kind and a note", () => { | |
| 60 | + | assert.equal(parseCreditForm(form({ preset: "10", note: "x" }), "acme", NOW).ok, false); | |
| 61 | + | assert.equal(parseCreditForm(form({ preset: "10", kind: "gift", note: "x" }), "acme", NOW).ok, false); | |
| 62 | + | assert.equal(parseCreditForm(form({ preset: "10", kind: "goodwill" }), "acme", NOW).ok, false); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("a refund says what it is for, never expires, and refunds a day that was", () => { | |
| 66 | + | const refund = parseCreditForm( | |
| 67 | + | form({ preset: "20", kind: "refund", refundFor: "the failed runs on Oct 2", refundDay: "2026-10-02", expires: "30", note: "Sorry" }), | |
| 68 | + | "acme", | |
| 69 | + | NOW, | |
| 70 | + | ); | |
| 71 | + | // The expiry chosen before switching to Refund is ignored. | |
| 72 | + | assert.deepEqual(refund, { | |
| 73 | + | ok: true, | |
| 74 | + | value: { amountMicros: 20_000_000, kind: "refund", expiresAt: null, refundFor: "the failed runs on Oct 2", refundDay: "2026-10-02", note: "Sorry" }, | |
| 75 | + | }); | |
| 76 | + | assert.equal(parseCreditForm(form({ preset: "20", kind: "refund", note: "Sorry" }), "acme", NOW).ok, false); | |
| 77 | + | assert.equal(parseCreditForm(form({ preset: "20", kind: "refund", refundFor: "x", refundDay: "2026-10-09", note: "Sorry" }), "acme", NOW).ok, false); | |
| 78 | + | assert.equal(parseCreditForm(form({ preset: "20", kind: "refund", refundFor: "x", refundDay: "2026-02-30", note: "Sorry" }), "acme", NOW).ok, false); | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | test("a grant reads in a line, as the Billing page says it", () => { | |
| 82 | + | const open = { amountMicros: 25_000_000, leftMicros: 12_400_000, expiresAt: "2027-01-05T23:59:59Z", state: "open" as const }; | |
| 83 | + | assert.equal(grantSummary(open, usd, NOW), "$25.00 credit, $12.40 left, expires Jan 5, 2027"); | |
| 84 | + | assert.equal(grantSummary({ ...open, expiresAt: "2026-11-05T23:59:59Z" }, usd, NOW), "$25.00 credit, $12.40 left, expires Nov 5"); | |
| 85 | + | assert.equal(grantSummary({ ...open, expiresAt: null }, usd), "$25.00 credit, $12.40 left"); | |
| 86 | + | assert.equal(grantSummary({ ...open, state: "used", leftMicros: 0 }, usd), "$25.00 credit, all used"); | |
| 87 | + | assert.equal(grantSummary({ ...open, state: "expired", leftMicros: 0 }, usd), "$25.00 credit, expired"); | |
| 88 | + | assert.equal(shortDay("2026-11-05T23:59:59Z", NOW), "Nov 5"); | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | test("a month filter is a month", () => { | |
| 92 | + | assert.equal(parseMonth("2026-10"), "2026-10"); | |
| 93 | + | assert.equal(parseMonth("2026-13"), null); | |
| 94 | + | assert.equal(parseMonth(null), null); | |
| 95 | + | }); |
| 1 | + | /** | |
| 2 | + | * Giving a workspace credit from sudo: a preset or custom amount, a kind, | |
| 3 | + | * an optional expiry, and a note. Small credits are one step; past | |
| 4 | + | * `CONFIRM_OVER_MICROS` the workspace's slug is typed out as well. No | |
| 5 | + | * Workers or React imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import type { CreditGrant, CreditKind } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import { type Parsed, parseCredit, parseNote, text } from "./forms.ts"; | |
| 10 | + | import { MICROS_PER_DOLLAR } from "./money.ts"; | |
| 11 | + | ||
| 12 | + | /** The amounts offered as one click, in dollars. */ | |
| 13 | + | export const CREDIT_PRESETS = [10, 20, 25, 50, 100] as const; | |
| 14 | + | ||
| 15 | + | /** Over this, the slug is typed out to give it. */ | |
| 16 | + | export const CONFIRM_OVER_MICROS = 100 * MICROS_PER_DOLLAR; | |
| 17 | + | ||
| 18 | + | export const CREDIT_KINDS: { value: CreditKind; title: string; text: string }[] = [ | |
| 19 | + | { value: "promotional", title: "Promotional", text: "A welcome, a referral, an event. Given away when spent." }, | |
| 20 | + | { value: "goodwill", title: "Goodwill", text: "An apology. Given away when spent." }, | |
| 21 | + | { value: "refund", title: "Refund", text: "Money back for something that went wrong. Never expires." }, | |
| 22 | + | ]; | |
| 23 | + | ||
| 24 | + | /** How long unused credit lasts. */ | |
| 25 | + | export const EXPIRIES: { value: string; label: string }[] = [ | |
| 26 | + | { value: "none", label: "Never" }, | |
| 27 | + | { value: "30", label: "30 days" }, | |
| 28 | + | { value: "90", label: "90 days" }, | |
| 29 | + | { value: "365", label: "1 year" }, | |
| 30 | + | { value: "date", label: "On a date" }, | |
| 31 | + | ]; | |
| 32 | + | ||
| 33 | + | const MAX_REFUND_FOR = 200; | |
| 34 | + | const MAX_EXPIRY_DAYS = 5 * 366; | |
| 35 | + | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 36 | + | ||
| 37 | + | export function kindLabel(kind: CreditKind): string { | |
| 38 | + | return CREDIT_KINDS.find((k) => k.value === kind)?.title ?? kind; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | export function isCreditKind(value: string): value is CreditKind { | |
| 42 | + | return CREDIT_KINDS.some((k) => k.value === value); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | export type CreditInput = { | |
| 46 | + | amountMicros: number; | |
| 47 | + | kind: CreditKind; | |
| 48 | + | /** The last second of the day it expires, UTC; null never. */ | |
| 49 | + | expiresAt: string | null; | |
| 50 | + | refundFor: string | null; | |
| 51 | + | refundDay: string | null; | |
| 52 | + | note: string; | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | /** The last second of a day, UTC, as billing keeps times: `2027-01-05T23:59:59Z`. */ | |
| 56 | + | export function endOfDay(day: string): string { | |
| 57 | + | return `${day}T23:59:59Z`; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | function isDay(raw: string): boolean { | |
| 61 | + | if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return false; | |
| 62 | + | const date = new Date(`${raw}T00:00:00Z`); | |
| 63 | + | return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === raw; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** The amount: a preset (`preset=25`) or, with `preset=custom`, what was typed in `amount`. */ | |
| 67 | + | export function parseCreditAmount(preset: string, typed: string): Parsed<number> { | |
| 68 | + | if (preset !== "custom") { | |
| 69 | + | const dollars = Number(preset); | |
| 70 | + | if (!CREDIT_PRESETS.includes(dollars as (typeof CREDIT_PRESETS)[number])) return { ok: false, error: "Choose an amount, or Custom and type one." }; | |
| 71 | + | return { ok: true, value: dollars * MICROS_PER_DOLLAR }; | |
| 72 | + | } | |
| 73 | + | return parseCredit(typed); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** When unused credit stops counting: never, in 30, 90 or 365 days, or at the end of a chosen day (UTC). */ | |
| 77 | + | export function parseExpiry(choice: string, day: string, now = new Date()): Parsed<string | null> { | |
| 78 | + | if (choice === "" || choice === "none") return { ok: true, value: null }; | |
| 79 | + | if (choice === "date") { | |
| 80 | + | if (!isDay(day)) return { ok: false, error: "Choose the day the credit expires." }; | |
| 81 | + | const end = new Date(endOfDay(day)); | |
| 82 | + | if (end.getTime() <= now.getTime()) return { ok: false, error: "The expiry has to be in the future." }; | |
| 83 | + | if (end.getTime() > now.getTime() + MAX_EXPIRY_DAYS * DAY_MS) return { ok: false, error: "The expiry is within five years." }; | |
| 84 | + | return { ok: true, value: endOfDay(day) }; | |
| 85 | + | } | |
| 86 | + | const days = Number(choice); | |
| 87 | + | if (![30, 90, 365].includes(days)) return { ok: false, error: "Choose when the credit expires." }; | |
| 88 | + | return { ok: true, value: endOfDay(new Date(now.getTime() + days * DAY_MS).toISOString().slice(0, 10)) }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * The credit form: amount, kind, expiry (never for a refund), what a refund | |
| 93 | + | * is for and the day it refunds, the note, and the slug typed out past | |
| 94 | + | * `CONFIRM_OVER_MICROS`. | |
| 95 | + | */ | |
| 96 | + | export function parseCreditForm(form: FormData, workspace: string, now = new Date()): Parsed<CreditInput> { | |
| 97 | + | const amount = parseCreditAmount(text(form, "preset") || "custom", text(form, "amount")); | |
| 98 | + | if (!amount.ok) return amount; | |
| 99 | + | const kind = text(form, "kind"); | |
| 100 | + | if (!isCreditKind(kind)) return { ok: false, error: "Choose what kind of credit it is: promotional, goodwill or a refund." }; | |
| 101 | + | ||
| 102 | + | let refundFor: string | null = null; | |
| 103 | + | let refundDay: string | null = null; | |
| 104 | + | let expiresAt: string | null = null; | |
| 105 | + | if (kind === "refund") { | |
| 106 | + | refundFor = text(form, "refundFor").replace(/\s+/g, " "); | |
| 107 | + | if (!refundFor) return { ok: false, error: "Say what the refund is for, such as the failed runs on Oct 2." }; | |
| 108 | + | if (refundFor.length > MAX_REFUND_FOR) return { ok: false, error: `Keep what the refund is for under ${MAX_REFUND_FOR} characters.` }; | |
| 109 | + | const day = text(form, "refundDay"); | |
| 110 | + | if (day) { | |
| 111 | + | if (!isDay(day)) return { ok: false, error: "The day refunded is not a date." }; | |
| 112 | + | if (day > now.toISOString().slice(0, 10)) return { ok: false, error: "The day refunded cannot be in the future." }; | |
| 113 | + | refundDay = day; | |
| 114 | + | } | |
| 115 | + | } else { | |
| 116 | + | const expiry = parseExpiry(text(form, "expires"), text(form, "expiresOn"), now); | |
| 117 | + | if (!expiry.ok) return expiry; | |
| 118 | + | expiresAt = expiry.value; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | const note = parseNote(text(form, "note")); | |
| 122 | + | if (!note.ok) return note; | |
| 123 | + | if (amount.value > CONFIRM_OVER_MICROS && text(form, "confirmation") !== workspace) { | |
| 124 | + | return { ok: false, error: `Over $100: type the workspace's slug, ${workspace}, exactly, to give it.` }; | |
| 125 | + | } | |
| 126 | + | return { ok: true, value: { amountMicros: amount.value, kind, expiresAt, refundFor, refundDay, note: note.value } }; | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /** `$25.00 credit, $12.40 left, expires Jan 5`: a grant in a line, as the Billing page says it. */ | |
| 130 | + | export function grantSummary( | |
| 131 | + | grant: Pick<CreditGrant, "amountMicros" | "leftMicros" | "expiresAt" | "state">, | |
| 132 | + | usd: (micros: number) => string, | |
| 133 | + | now = new Date(), | |
| 134 | + | ): string { | |
| 135 | + | const parts = [`${usd(grant.amountMicros)} credit`]; | |
| 136 | + | if (grant.state === "open") parts.push(`${usd(grant.leftMicros)} left`); | |
| 137 | + | else parts.push(grant.state === "used" ? "all used" : grant.state); | |
| 138 | + | if (grant.state === "open" && grant.expiresAt) parts.push(`expires ${shortDay(grant.expiresAt, now)}`); | |
| 139 | + | return parts.join(", "); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /** `Jan 5`, or `Jan 5, 2028` outside this year, UTC. */ | |
| 143 | + | export function shortDay(at: string, now = new Date()): string { | |
| 144 | + | const date = new Date(at); | |
| 145 | + | const sameYear = date.getUTCFullYear() === now.getUTCFullYear(); | |
| 146 | + | return date.toLocaleDateString("en-US", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" }); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /** The months of the credits list: `YYYY-MM`, or null. */ | |
| 150 | + | export function parseMonth(value: string | null): string | null { | |
| 151 | + | return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : null; | |
| 152 | + | } |
| 22 | 22 | return data; | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | − | test("comped terms keep a ceiling and an end date, and name who set them", () => { | |
| 26 | − | const result = parseTerms(form({ kind: "comped", note: "g1t's own", ceiling: "500", until: "2026-12-31", discount: "40" }), "owner@g1t.sh", NOW); | |
| 25 | + | test("a 100% discount keeps its budget and an end date, and names who set it", () => { | |
| 26 | + | const result = parseTerms(form({ preset: "100", note: "g1t's own", ceiling: "500", until: "2026-12-31", discount: "40" }), "owner@g1t.sh", NOW); | |
| 27 | 27 | assert.deepEqual(result, { | |
| 28 | 28 | ok: true, | |
| 29 | 29 | value: { | |
| 30 | − | kind: "comped", | |
| 31 | − | discountPercent: 0, | |
| 30 | + | kind: "custom", | |
| 31 | + | discountPercent: 100, | |
| 32 | 32 | ceilingMicros: 500_000_000, | |
| 33 | 33 | note: "g1t's own", | |
| 34 | 34 | until: "2026-12-31T23:59:59Z", | |
| ⋯ | |||
| 36 | 36 | setAt: NOW.toISOString(), | |
| 37 | 37 | }, | |
| 38 | 38 | }); | |
| 39 | + | // A form from before discounts, saying comped, is a 100% discount. | |
| 40 | + | const old = parseTerms(form({ kind: "comped", note: "g1t's own" }), "a", NOW); | |
| 41 | + | assert.ok(old.ok && old.value.kind === "custom" && old.value.discountPercent === 100); | |
| 39 | 42 | }); | |
| 40 | 43 | ||
| 41 | − | test("standard terms clear everything but the note", () => { | |
| 42 | − | const result = parseTerms(form({ kind: "standard", note: "back to normal", ceiling: "5", until: "2027-01-01" }), "a@g1t.sh", NOW); | |
| 44 | + | test("a discount is a preset or a custom whole percent", () => { | |
| 45 | + | const custom = parseTerms(form({ preset: "custom", discount: "30", note: "Design partner" }), "a", NOW); | |
| 46 | + | assert.ok(custom.ok && custom.value.discountPercent === 30 && custom.value.kind === "custom"); | |
| 47 | + | const half = parseTerms(form({ preset: "50", discount: "30", note: "x" }), "a", NOW); | |
| 48 | + | assert.ok(half.ok && half.value.discountPercent === 50); | |
| 49 | + | // A limit alone is custom terms too. | |
| 50 | + | const limit = parseTerms(form({ preset: "0", ceiling: "250", note: "x" }), "a", NOW); | |
| 51 | + | assert.ok(limit.ok && limit.value.kind === "custom" && limit.value.discountPercent === 0); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("no discount and no limit is standard, which clears the end date", () => { | |
| 55 | + | const result = parseTerms(form({ preset: "0", note: "back to normal", until: "2027-01-01" }), "a@g1t.sh", NOW); | |
| 43 | 56 | assert.ok(result.ok); | |
| 57 | + | assert.equal(result.value.kind, "standard"); | |
| 44 | 58 | assert.equal(result.value.ceilingMicros, null); | |
| 45 | 59 | assert.equal(result.value.until, null); | |
| 46 | 60 | }); | |
| 47 | 61 | ||
| 48 | 62 | test("terms are refused without a note, with a bad discount, or ending in the past", () => { | |
| 49 | − | assert.equal(parseTerms(form({ kind: "comped", note: "" }), "a", NOW).ok, false); | |
| 50 | − | assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "101" }), "a", NOW).ok, false); | |
| 51 | − | assert.equal(parseTerms(form({ kind: "custom", note: "x" }), "a", NOW).ok, false); | |
| 52 | − | assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "20", until: "2026-10-01" }), "a", NOW).ok, false); | |
| 53 | − | assert.equal(parseTerms(form({ kind: "custom", note: "x", discount: "20", until: "2026-02-30" }), "a", NOW).ok, false); | |
| 54 | − | assert.equal(parseTerms(form({ kind: "free", note: "x" }), "a", NOW).ok, false); | |
| 63 | + | assert.equal(parseTerms(form({ preset: "100", note: "" }), "a", NOW).ok, false); | |
| 64 | + | assert.equal(parseTerms(form({ preset: "custom", note: "x", discount: "101" }), "a", NOW).ok, false); | |
| 65 | + | assert.equal(parseTerms(form({ preset: "custom", note: "x" }), "a", NOW).ok, false); | |
| 66 | + | assert.equal(parseTerms(form({ preset: "custom", note: "x", discount: "20", until: "2026-10-01" }), "a", NOW).ok, false); | |
| 67 | + | assert.equal(parseTerms(form({ preset: "custom", note: "x", discount: "20", until: "2026-02-30" }), "a", NOW).ok, false); | |
| 68 | + | assert.equal(parseTerms(form({ preset: "75", note: "x" }), "a", NOW).ok, true); | |
| 69 | + | assert.equal(parseTerms(form({ preset: "free", note: "x" }), "a", NOW).ok, false); | |
| 55 | 70 | }); | |
| 56 | 71 | ||
| 57 | 72 | test("workspace lists take commas, spaces and lines, once each", () => { | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | 61 | /** | |
| 62 | − | * Terms from the terms form. Standard clears everything else; a ceiling | |
| 63 | − | * applies to comped and custom; a discount to custom only. An end date is | |
| 64 | − | * a day, and the terms last to its end, UTC. | |
| 62 | + | * Terms from the terms form: a discount (a preset, 0, 25, 50 or 100%, or | |
| 63 | + | * a custom whole percent), a limit, an end date and why. No discount and no | |
| 64 | + | * limit is standard, and clears everything else; anything else is custom | |
| 65 | + | * terms. A 100% discount charges nothing (what was "comped"), and its limit | |
| 66 | + | * is g1t's monthly budget for it, at cost. An end date is a day, and the | |
| 67 | + | * terms last to its end, UTC. | |
| 65 | 68 | */ | |
| 66 | 69 | export function parseTerms(form: FormData, by: string, now = new Date()): Parsed<Terms> { | |
| 67 | − | const kind = text(form, "kind"); | |
| 68 | − | if (kind !== "standard" && kind !== "comped" && kind !== "custom") return { ok: false, error: "Choose standard, comped or custom terms." }; | |
| 69 | − | const note = parseNote(text(form, "note")); | |
| 70 | − | if (!note.ok) return note; | |
| 71 | − | ||
| 72 | − | let discountPercent = 0; | |
| 73 | − | if (kind === "custom") { | |
| 70 | + | // A form from before discounts said `kind=comped`. | |
| 71 | + | const preset = text(form, "kind") === "comped" ? "100" : text(form, "preset") || "0"; | |
| 72 | + | let discountPercent: number; | |
| 73 | + | if (preset === "custom") { | |
| 74 | 74 | const raw = text(form, "discount"); | |
| 75 | − | if (raw !== "") { | |
| 76 | − | if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." }; | |
| 77 | − | discountPercent = Number(raw); | |
| 78 | − | } | |
| 75 | + | if (!/^\d{1,3}$/.test(raw) || Number(raw) > 100) return { ok: false, error: "The discount is a whole percent from 0 to 100." }; | |
| 76 | + | discountPercent = Number(raw); | |
| 77 | + | } else if (/^\d{1,3}$/.test(preset) && Number(preset) <= 100) { | |
| 78 | + | discountPercent = Number(preset); | |
| 79 | + | } else { | |
| 80 | + | return { ok: false, error: "Choose a discount: 0, 25, 50 or 100%, or Custom." }; | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | 83 | let ceilingMicros: number | null = null; | |
| 82 | − | if (kind !== "standard") { | |
| 83 | − | const raw = text(form, "ceiling"); | |
| 84 | − | if (raw !== "") { | |
| 85 | − | const micros = parseDollars(raw); | |
| 86 | − | if (micros == null) return { ok: false, error: "The ceiling is a dollar amount, such as 250 or 1,000.00." }; | |
| 87 | − | ceilingMicros = micros; | |
| 88 | − | } | |
| 84 | + | const rawCeiling = text(form, "ceiling"); | |
| 85 | + | if (rawCeiling !== "") { | |
| 86 | + | const micros = parseDollars(rawCeiling); | |
| 87 | + | if (micros == null) return { ok: false, error: "The limit is a dollar amount, such as 250 or 1,000.00." }; | |
| 88 | + | ceilingMicros = micros; | |
| 89 | 89 | } | |
| 90 | − | if (kind === "custom" && discountPercent === 0 && ceilingMicros == null) { | |
| 91 | − | return { ok: false, error: "Custom terms need a discount, a ceiling, or both." }; | |
| 92 | − | } | |
| 90 | + | const kind: Terms["kind"] = discountPercent > 0 || ceilingMicros != null ? "custom" : "standard"; | |
| 91 | + | const note = parseNote(text(form, "note")); | |
| 92 | + | if (!note.ok) return note; | |
| 93 | 93 | ||
| 94 | 94 | let until: string | null = null; | |
| 95 | 95 | if (kind !== "standard") { |
| 128 | 128 | maintenance_started: "Maintenance started", | |
| 129 | 129 | maintenance_completed: "Maintenance completed", | |
| 130 | 130 | maintenance_cancelled: "Maintenance cancelled", | |
| 131 | + | credit_revoked: "Credit revoked", | |
| 132 | + | credit_expired: "Credit expired", | |
| 131 | 133 | }; | |
| 132 | 134 | ||
| 133 | 135 | /** |
| 40 | 40 | ||
| 41 | 41 | test("the built pages are not marked soon", () => { | |
| 42 | 42 | const built = navItems().filter((item) => !item.soon).map((item) => item.to); | |
| 43 | − | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]); | |
| 43 | + | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]); | |
| 44 | 44 | }); | |
| 45 | 45 | ||
| 46 | 46 | test("every soon page says what it will do, why, and what it will have", () => { | |
| 47 | 47 | const soon = soonItems(); | |
| 48 | − | assert.ok(soon.length >= 9); | |
| 48 | + | assert.ok(soon.length >= 8); | |
| 49 | 49 | for (const item of soon) { | |
| 50 | 50 | assert.ok(item.soon.summary.length >= 1 && item.soon.summary.length <= 4, item.label); | |
| 51 | 51 | assert.ok(item.soon.plans.length >= 3 && item.soon.plans.length <= 6, item.label); |
| 179 | 179 | label: "Credits & refunds", | |
| 180 | 180 | to: "/credits", | |
| 181 | 181 | icon: "credits", | |
| 182 | − | about: "Every credit and refund staff have issued, and why.", | |
| 183 | − | soon: { | |
| 184 | − | summary: [ | |
| 185 | − | "Every credit and refund staff have given, across all customers: how much, to whom, by whom and why. Goodwill is a cost, and finance needs to see what it adds up to each month.", | |
| 186 | − | "It is also where refunds to a card will live. Today a credit goes to a workspace's balance; giving money back to the card it came from goes through Stripe, and should be done from here, recorded, with the same typed confirmation as a credit.", | |
| 187 | − | ], | |
| 188 | − | plans: [ | |
| 189 | − | "Every credit, filterable by staff member, workspace and month, with totals", | |
| 190 | − | "Refund a payment to the card it came from, in full or in part", | |
| 191 | − | "Per-role limits: support can credit up to a set amount; more needs finance", | |
| 192 | − | "Reasons as a short list (outage, billing error, goodwill, trial) so they can be counted", | |
| 193 | − | ], | |
| 194 | − | meanwhile: { text: "Issue a credit from the workspace's page, under Billing.", to: "/workspaces", link: "Workspaces" }, | |
| 195 | − | }, | |
| 182 | + | about: "Every credit staff have given (promotional, goodwill, refunds), what was used, and each month by kind.", | |
| 196 | 183 | }, | |
| 197 | 184 | { | |
| 198 | 185 | label: "Usage explorer", |
| 27 | 27 | allowances: "Plan, pools and caps saved. They apply from now on.", | |
| 28 | 28 | attach: "Workspace moved onto the enterprise.", | |
| 29 | 29 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 30 | − | credit: "Credit issued.", | |
| 30 | + | credit: "Credit given. It is on the balance and the statement, and the owners were emailed.", | |
| 31 | + | revoked: "What was left of the credit was taken back. It is on the statement and in the audit log.", | |
| 31 | 32 | reset: "Billing reset. The workspace starts again as a new customer, and the costs analysis ran again, so every figure is fresh.", | |
| 32 | 33 | "reset-stale": "Billing reset. The workspace starts again as a new customer; the costs analysis did not finish, so press Run the analysis now on Costs & margin.", | |
| 33 | 34 | created: "Enterprise created.", |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { fullDiscount, percentOff, termsLabel } from "./terms.ts"; | |
| 5 | + | ||
| 6 | + | test("terms read as a discount; comped from before discounts is 100%", () => { | |
| 7 | + | assert.equal(percentOff({ kind: "comped", discountPercent: 0 }), 100); | |
| 8 | + | assert.equal(percentOff({ kind: "custom", discountPercent: 30 }), 30); | |
| 9 | + | assert.equal(percentOff({ kind: "standard", discountPercent: 30 }), 0); | |
| 10 | + | assert.ok(fullDiscount({ kind: "custom", discountPercent: 100 })); | |
| 11 | + | assert.ok(!fullDiscount({ kind: "custom", discountPercent: 99 })); | |
| 12 | + | assert.equal(termsLabel({ kind: "custom", discountPercent: 100 }), "100% discount"); | |
| 13 | + | assert.equal(termsLabel({ kind: "comped", discountPercent: 0 }), "100% discount"); | |
| 14 | + | assert.equal(termsLabel({ kind: "custom", discountPercent: 30 }), "30% off"); | |
| 15 | + | assert.equal(termsLabel({ kind: "custom", discountPercent: 0 }), "Custom limit"); | |
| 16 | + | assert.equal(termsLabel({ kind: "standard", discountPercent: 0 }), "Standard"); | |
| 17 | + | }); |
| 1 | + | /** | |
| 2 | + | * An account's terms as sudo names them: a discount from 0 to 100%, and a | |
| 3 | + | * 100% discount is what used to be "comped" (billing reads a leftover | |
| 4 | + | * `comped` row as 100%). No Workers or React imports, so it can be tested | |
| 5 | + | * under Node. | |
| 6 | + | */ | |
| 7 | + | import type { Terms } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | /** The discount presets on the terms form, in percent. */ | |
| 10 | + | export const DISCOUNT_PRESETS = [0, 25, 50, 100] as const; | |
| 11 | + | ||
| 12 | + | /** The discount in percent, 0 to 100. */ | |
| 13 | + | export function percentOff(terms: Pick<Terms, "kind" | "discountPercent">): number { | |
| 14 | + | if (terms.kind === "comped") return 100; | |
| 15 | + | if (terms.kind === "custom") return Math.min(100, Math.max(0, terms.discountPercent)); | |
| 16 | + | return 0; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** A 100% discount: nothing charged, usage shown at its price; g1t's own spend on it held to a monthly budget. */ | |
| 20 | + | export function fullDiscount(terms: Pick<Terms, "kind" | "discountPercent">): boolean { | |
| 21 | + | return percentOff(terms) >= 100; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** `100% discount`, `30% off`, `Custom limit` or `Standard`. */ | |
| 25 | + | export function termsLabel(terms: Pick<Terms, "kind" | "discountPercent">): string { | |
| 26 | + | const percent = percentOff(terms); | |
| 27 | + | if (percent >= 100) return "100% discount"; | |
| 28 | + | if (percent > 0) return `${percent}% off`; | |
| 29 | + | return terms.kind === "standard" ? "Standard" : "Custom limit"; | |
| 30 | + | } |
| 20 | 20 | route("costs/bill", "routes/costs-bill.tsx"), | |
| 21 | 21 | route("abuse", "routes/abuse.tsx"), | |
| 22 | 22 | route("invoices", "routes/invoices.tsx"), | |
| 23 | + | route("credits", "routes/credits.tsx"), | |
| 23 | 24 | route("stripe", "routes/stripe.tsx"), | |
| 24 | 25 | route("audit", "routes/audit.tsx"), | |
| 25 | 26 | route("timezone", "routes/timezone.tsx"), |
| 210 | 210 | const spent = o.costMicros + subscriptions; | |
| 211 | 211 | const net = moneyIn - spent; | |
| 212 | 212 | const givenParts = [ | |
| 213 | − | ["comped", o.givenCompedMicros ?? 0], | |
| 213 | + | ["100% discounts", o.givenCompedMicros ?? 0], | |
| 214 | 214 | ["free use", o.givenFreeMicros ?? 0], | |
| 215 | 215 | ["trial", o.givenTrialMicros ?? 0], | |
| 216 | 216 | ["open-source pool", o.givenPoolMicros ?? 0], | |
| 217 | − | ["discounts", o.givenDiscountMicros ?? 0], | |
| 217 | + | ["partial discounts", o.givenDiscountMicros ?? 0], | |
| 218 | + | ["promotional credit", o.givenCreditPromotionalMicros ?? 0], | |
| 219 | + | ["goodwill credit", o.givenCreditGoodwillMicros ?? 0], | |
| 218 | 220 | ].filter(([, micros]) => (micros as number) > 0) as [string, number][]; | |
| 219 | 221 | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ | |
| 220 | 222 | { | |
| ⋯ | |||
| 343 | 345 | </tbody> | |
| 344 | 346 | </table> | |
| 345 | 347 | </div> | |
| 348 | + | <p className="mt-2 text-xs text-faint"> | |
| 349 | + | Credits from g1t, {report.since} to {report.until}: {usd(o.creditsGivenMicros ?? 0, { cents: true })} given, {usd(o.creditsUsedMicros ?? 0, { cents: true })}{" "} | |
| 350 | + | spent on usage. What promotional and goodwill credit paid for is given away above, never money in; refunds took{" "} | |
| 351 | + | {usd(o.creditsRefundedMicros ?? 0, { cents: true })} off money in on the days they refund.{" "} | |
| 352 | + | <Link to="/credits" className="underline underline-offset-2"> | |
| 353 | + | Every credit | |
| 354 | + | </Link> | |
| 355 | + | </p> | |
| 346 | 356 | </> | |
| 347 | 357 | ); | |
| 348 | 358 | } | |
| ⋯ | |||
| 355 | 365 | className="mt-6" | |
| 356 | 366 | id="spend" | |
| 357 | 367 | title="g1t's own spend" | |
| 358 | − | description="What g1t pays for itself, at cost: comped accounts, the trial and open-source pools, free workspaces' overruns, and anything charged without real money behind it. Two caps hold it: each comped account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for." | |
| 368 | + | description="What g1t pays for itself, at cost: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and anything charged without real money behind it. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for." | |
| 359 | 369 | > | |
| 360 | 370 | <div className="grid gap-3 lg:grid-cols-2"> | |
| 361 | 371 | <CapMeter | |
| ⋯ | |||
| 380 | 390 | {caps.comped.map((b) => ( | |
| 381 | 391 | <CapMeter | |
| 382 | 392 | key={b.account} | |
| 383 | − | label={`${b.name}, ${caps.month} (comped)`} | |
| 393 | + | label={`${b.name}, ${caps.month} (100% discount)`} | |
| 384 | 394 | used={b.usedMicros} | |
| 385 | 395 | cap={b.ceilingMicros} | |
| 386 | 396 | hint={ | |
| 1 | + | import { Link, data, redirect, useLocation } from "react-router"; | |
| 2 | + | ||
| 3 | + | import type { CreditKind, CreditMonth } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/credits"; | |
| 6 | + | import { CreditList } from "~/components/billing"; | |
| 7 | + | import { Button, Input, Notice, PageHeader, Section, Select, Stat } from "~/components/ui"; | |
| 8 | + | import { monthLong } from "~/lib/chart"; | |
| 9 | + | import { CREDIT_KINDS, isCreditKind, kindLabel, parseMonth } from "~/lib/credits"; | |
| 10 | + | import { fields, parseNote, parseSlug } from "~/lib/forms"; | |
| 11 | + | import { parseBy } from "~/lib/ledgers"; | |
| 12 | + | import { usd } from "~/lib/money"; | |
| 13 | + | import { DONE, doneKey } from "~/lib/review"; | |
| 14 | + | import { admin } from "~/lib/services.server"; | |
| 15 | + | import { settle } from "~/lib/settle"; | |
| 16 | + | import { requireStaff } from "~/lib/staff"; | |
| 17 | + | ||
| 18 | + | export const meta: Route.MetaFunction = () => [{ title: "Credits & refunds · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 19 | + | ||
| 20 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 21 | + | requireStaff(context); | |
| 22 | + | const url = new URL(request.url); | |
| 23 | + | const kindParam = url.searchParams.get("kind") ?? ""; | |
| 24 | + | const kind: CreditKind | null = isCreditKind(kindParam) ? kindParam : null; | |
| 25 | + | const month = parseMonth(url.searchParams.get("month")); | |
| 26 | + | const by = parseBy(url.searchParams.get("by")); | |
| 27 | + | const slug = parseSlug(url.searchParams.get("workspace") ?? ""); | |
| 28 | + | const workspace = slug.ok ? slug.value : null; | |
| 29 | + | const result = await settle(admin.credits({ kind, month, by, workspace })); | |
| 30 | + | const done = doneKey(request.url); | |
| 31 | + | return { | |
| 32 | + | kind, | |
| 33 | + | month, | |
| 34 | + | by, | |
| 35 | + | workspace, | |
| 36 | + | credits: result.ok ? result.value : null, | |
| 37 | + | error: result.ok ? null : result.error, | |
| 38 | + | done: done ? DONE[done] : null, | |
| 39 | + | }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 43 | + | const staff = requireStaff(context); | |
| 44 | + | const form = await request.formData(); | |
| 45 | + | const values = fields(form, "id", "reason", "workspace"); | |
| 46 | + | const fail = (error: string) => data({ error, section: "credits", values }, { status: 422 }); | |
| 47 | + | if (values.id === "" || form.get("intent") !== "revoke-credit") return fail("Unknown action."); | |
| 48 | + | const reason = parseNote(values.reason); | |
| 49 | + | if (!reason.ok) return fail(reason.error); | |
| 50 | + | const result = await admin.revokeCredit(values.id, reason.value, staff.email); | |
| 51 | + | if (!result.ok) return fail(result.error.message); | |
| 52 | + | const back = new URL(request.url); | |
| 53 | + | back.searchParams.set("done", "revoked"); | |
| 54 | + | throw redirect(`${back.pathname}${back.search}#top`); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** Each month's kinds, with a total line when there is more than one. */ | |
| 58 | + | function byMonth(months: CreditMonth[]): { month: string; rows: CreditMonth[]; total: CreditMonth }[] { | |
| 59 | + | const out: { month: string; rows: CreditMonth[]; total: CreditMonth }[] = []; | |
| 60 | + | for (const row of months) { | |
| 61 | + | let group = out.find((g) => g.month === row.month); | |
| 62 | + | if (!group) { | |
| 63 | + | group = { month: row.month, rows: [], total: { ...row, kind: row.kind, givenMicros: 0, grants: 0, usedMicros: 0, expiredMicros: 0, revokedMicros: 0 } }; | |
| 64 | + | out.push(group); | |
| 65 | + | } | |
| 66 | + | group.rows.push(row); | |
| 67 | + | group.total.givenMicros += row.givenMicros; | |
| 68 | + | group.total.grants += row.grants; | |
| 69 | + | group.total.usedMicros += row.usedMicros; | |
| 70 | + | group.total.expiredMicros += row.expiredMicros; | |
| 71 | + | group.total.revokedMicros += row.revokedMicros; | |
| 72 | + | } | |
| 73 | + | return out; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | export default function Credits({ loaderData, actionData }: Route.ComponentProps) { | |
| 77 | + | const { kind, month, by, workspace, credits, error, done } = loaderData; | |
| 78 | + | const { pathname, search } = useLocation(); | |
| 79 | + | const failed = actionData && "error" in actionData ? { error: actionData.error, values: actionData.values } : null; | |
| 80 | + | const filtered = kind != null || month != null || by != null || workspace != null; | |
| 81 | + | const thisMonth = new Date().toISOString().slice(0, 7); | |
| 82 | + | const now = byMonth(credits?.months ?? []).find((g) => g.month === thisMonth)?.total; | |
| 83 | + | const open = (credits?.grants ?? []).filter((grant) => grant.state === "open"); | |
| 84 | + | ||
| 85 | + | return ( | |
| 86 | + | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 87 | + | <PageHeader | |
| 88 | + | title="Credits & refunds" | |
| 89 | + | description="Every credit staff have given: promotional and goodwill credit is given away when spent; a refund gives back money already paid. Give credit from a workspace's page, under Billing." | |
| 90 | + | /> | |
| 91 | + | {done && ( | |
| 92 | + | <div className="mt-6"> | |
| 93 | + | <Notice tone="ok">{done}</Notice> | |
| 94 | + | </div> | |
| 95 | + | )} | |
| 96 | + | {error ? ( | |
| 97 | + | <div className="mt-6"> | |
| 98 | + | <Notice tone="warn">Billing did not answer for credits: {error}</Notice> | |
| 99 | + | </div> | |
| 100 | + | ) : ( | |
| 101 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 102 | + | <Stat label="Given this month" value={usd(now?.givenMicros ?? 0, { cents: true })} hint={`${now?.grants ?? 0} credit${now?.grants === 1 ? "" : "s"}`} /> | |
| 103 | + | <Stat label="Spent this month" value={usd(now?.usedMicros ?? 0, { cents: true })} hint="On usage, every kind" /> | |
| 104 | + | <Stat label="Taken back this month" value={usd((now?.expiredMicros ?? 0) + (now?.revokedMicros ?? 0), { cents: true })} hint="Expired or revoked unused" /> | |
| 105 | + | <Stat | |
| 106 | + | label="Left to spend" | |
| 107 | + | value={usd(open.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })} | |
| 108 | + | hint={`${open.length} open credit${open.length === 1 ? "" : "s"}${filtered ? ", filtered" : ""}`} | |
| 109 | + | /> | |
| 110 | + | </div> | |
| 111 | + | )} | |
| 112 | + | ||
| 113 | + | {credits && credits.months.length > 0 && ( | |
| 114 | + | <Section className="mt-6" title="By month" description="The last 12 months by kind. Spent is what credit paid for that month, whenever it was given."> | |
| 115 | + | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 116 | + | <table className="w-full min-w-[34rem] text-sm"> | |
| 117 | + | <thead> | |
| 118 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 119 | + | <th className="px-4 py-2 font-medium sm:pl-5">Month</th> | |
| 120 | + | <th className="px-4 py-2 font-medium">Kind</th> | |
| 121 | + | <th className="px-4 py-2 text-right font-medium">Given</th> | |
| 122 | + | <th className="px-4 py-2 text-right font-medium">Spent</th> | |
| 123 | + | <th className="px-4 py-2 text-right font-medium">Expired</th> | |
| 124 | + | <th className="px-4 py-2 text-right font-medium sm:pr-5">Revoked</th> | |
| 125 | + | </tr> | |
| 126 | + | </thead> | |
| 127 | + | <tbody> | |
| 128 | + | {byMonth(credits.months).map((group) => ( | |
| 129 | + | <MonthRows key={group.month} group={group} /> | |
| 130 | + | ))} | |
| 131 | + | </tbody> | |
| 132 | + | </table> | |
| 133 | + | </div> | |
| 134 | + | </Section> | |
| 135 | + | )} | |
| 136 | + | ||
| 137 | + | <form method="get" action="/credits" className="mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> | |
| 138 | + | <Select name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="sm:w-40"> | |
| 139 | + | <option value="">Any kind</option> | |
| 140 | + | {CREDIT_KINDS.map((option) => ( | |
| 141 | + | <option key={option.value} value={option.value}> | |
| 142 | + | {option.title} | |
| 143 | + | </option> | |
| 144 | + | ))} | |
| 145 | + | </Select> | |
| 146 | + | <Input type="month" name="month" defaultValue={month ?? ""} aria-label="Month given" className="sm:w-44" /> | |
| 147 | + | <Select name="by" defaultValue={by ?? ""} aria-label="Given by" className="sm:w-56"> | |
| 148 | + | <option value="">Anyone</option> | |
| 149 | + | {(credits?.staff ?? []).map((email) => ( | |
| 150 | + | <option key={email} value={email}> | |
| 151 | + | {email} | |
| 152 | + | </option> | |
| 153 | + | ))} | |
| 154 | + | </Select> | |
| 155 | + | <Input name="workspace" defaultValue={workspace ?? ""} placeholder="Workspace" aria-label="Workspace" className="font-mono sm:w-44" /> | |
| 156 | + | <div className="flex items-center gap-2"> | |
| 157 | + | <Button type="submit" variant="quiet" className="py-2"> | |
| 158 | + | Apply | |
| 159 | + | </Button> | |
| 160 | + | {filtered && ( | |
| 161 | + | <Link to="/credits" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 162 | + | Clear | |
| 163 | + | </Link> | |
| 164 | + | )} | |
| 165 | + | </div> | |
| 166 | + | </form> | |
| 167 | + | ||
| 168 | + | {credits && ( | |
| 169 | + | <Section | |
| 170 | + | className="mt-4" | |
| 171 | + | title={filtered ? "Credits that match" : "Every credit"} | |
| 172 | + | description={`Newest first${credits.grants.length >= 200 ? ", the newest 200" : ""}. Revoking takes back what is left; what was spent stays spent.`} | |
| 173 | + | > | |
| 174 | + | <CreditList grants={credits.grants} pathname={`${pathname}${search}`} error={failed} showWorkspace /> | |
| 175 | + | </Section> | |
| 176 | + | )} | |
| 177 | + | </main> | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | function MonthRows({ group }: { group: { month: string; rows: CreditMonth[]; total: CreditMonth } }) { | |
| 182 | + | const cell = "tabular px-4 py-2 text-right"; | |
| 183 | + | return ( | |
| 184 | + | <> | |
| 185 | + | {group.rows.map((row, index) => ( | |
| 186 | + | <tr key={row.kind} className={index === 0 ? "border-t border-line" : ""}> | |
| 187 | + | <td className="px-4 py-2 sm:pl-5">{index === 0 ? monthLong(group.month) : ""}</td> | |
| 188 | + | <td className="px-4 py-2 text-fg-soft"> | |
| 189 | + | {kindLabel(row.kind)} | |
| 190 | + | <span className="text-faint"> · {row.grants}</span> | |
| 191 | + | </td> | |
| 192 | + | <td className={cell}>{usd(row.givenMicros, { cents: true })}</td> | |
| 193 | + | <td className={cell}>{usd(row.usedMicros, { cents: true })}</td> | |
| 194 | + | <td className={`${cell} text-muted`}>{usd(row.expiredMicros, { cents: true })}</td> | |
| 195 | + | <td className={`${cell} text-muted sm:pr-5`}>{usd(row.revokedMicros, { cents: true })}</td> | |
| 196 | + | </tr> | |
| 197 | + | ))} | |
| 198 | + | {group.rows.length > 1 && ( | |
| 199 | + | <tr className="text-muted"> | |
| 200 | + | <td className="px-4 py-2 sm:pl-5" /> | |
| 201 | + | <td className="px-4 py-2 text-xs">All kinds</td> | |
| 202 | + | <td className={cell}>{usd(group.total.givenMicros, { cents: true })}</td> | |
| 203 | + | <td className={cell}>{usd(group.total.usedMicros, { cents: true })}</td> | |
| 204 | + | <td className={cell}>{usd(group.total.expiredMicros, { cents: true })}</td> | |
| 205 | + | <td className={`${cell} sm:pr-5`}>{usd(group.total.revokedMicros, { cents: true })}</td> | |
| 206 | + | </tr> | |
| 207 | + | )} | |
| 208 | + | </> | |
| 209 | + | ); | |
| 210 | + | } |
| 4 | 4 | import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/enterprise"; | |
| 7 | − | import { AllowancesForm, AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing"; | |
| 7 | + | import { AllowancesForm, AuditSection, CreditForm, CreditList, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing"; | |
| 8 | 8 | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui"; | |
| 9 | 9 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 10 | 10 | import { usd } from "~/lib/money"; | |
| 11 | 11 | import { type ActionData, type SectionError, doneMessage } from "~/lib/review"; | |
| 12 | 12 | import { admin, identity } from "~/lib/services.server"; | |
| 13 | + | import { settle } from "~/lib/settle"; | |
| 13 | 14 | import { requireStaff } from "~/lib/staff"; | |
| 15 | + | import { fullDiscount } from "~/lib/terms"; | |
| 14 | 16 | import { legacyAccountPath } from "~/lib/workspaces"; | |
| 15 | 17 | ||
| 16 | 18 | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| ⋯ | |||
| 69 | 71 | limit: limits.get(slug) ?? null, | |
| 70 | 72 | chargedMicros: shares.get(slug)?.chargedMicros ?? 0, | |
| 71 | 73 | })); | |
| 72 | − | return { detail, members, done: doneMessage(request.url) }; | |
| 74 | + | const credits = await settle(admin.credits()); | |
| 75 | + | return { | |
| 76 | + | detail, | |
| 77 | + | members, | |
| 78 | + | credits: credits.ok ? credits.value.grants.filter((grant) => slugs.includes(grant.workspace)) : [], | |
| 79 | + | creditsError: credits.ok ? null : credits.error, | |
| 80 | + | done: doneMessage(request.url), | |
| 81 | + | }; | |
| 73 | 82 | } | |
| 74 | 83 | ||
| 75 | 84 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 82 | 91 | type Member = Route.ComponentProps["loaderData"]["members"][number]; | |
| 83 | 92 | ||
| 84 | 93 | export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) { | |
| 85 | − | const { detail, members, done } = loaderData; | |
| 94 | + | const { detail, members, credits, creditsError, done } = loaderData; | |
| 86 | 95 | const { summary } = detail; | |
| 87 | 96 | const { account, limit } = summary; | |
| 88 | 97 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 109 | 118 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 110 | 119 | <Badge tone="lavender">Enterprise</Badge> | |
| 111 | 120 | <TermsBadge terms={account.terms} /> | |
| 112 | − | {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 121 | + | {!fullDiscount(account.terms) && <TrustBadge trust={limit.trust} />} | |
| 113 | 122 | <StateBadge state={limit.state} /> | |
| 114 | 123 | </div> | |
| 115 | 124 | </div> | |
| ⋯ | |||
| 152 | 161 | <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} /> | |
| 153 | 162 | <AllowancesForm | |
| 154 | 163 | allowances={account.allowances} | |
| 155 | − | comped={account.terms.kind === "comped"} | |
| 164 | + | comped={fullDiscount(account.terms)} | |
| 156 | 165 | pathname={pathname} | |
| 157 | 166 | error={error("allowances")} | |
| 158 | 167 | /> | |
| ⋯ | |||
| 160 | 169 | </div> | |
| 161 | 170 | <div className="space-y-6"> | |
| 162 | 171 | <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} /> | |
| 172 | + | <Section | |
| 173 | + | id="credits" | |
| 174 | + | title="Credits" | |
| 175 | + | description={`${usd(credits.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })} left to spend across its workspaces.`} | |
| 176 | + | > | |
| 177 | + | {creditsError ? ( | |
| 178 | + | <Notice tone="warn">Billing did not answer for credits: {creditsError}</Notice> | |
| 179 | + | ) : ( | |
| 180 | + | <CreditList grants={credits} pathname={pathname} error={error("credits")} showWorkspace /> | |
| 181 | + | )} | |
| 182 | + | </Section> | |
| 163 | 183 | <AuditSection audit={detail.audit} /> | |
| 164 | 184 | </div> | |
| 165 | 185 | </div> | |
| 10 | 10 | AuditSection, | |
| 11 | 11 | BillingLinkSection, | |
| 12 | 12 | CreditForm, | |
| 13 | + | CreditsSection, | |
| 13 | 14 | Figure, | |
| 14 | 15 | LedgerSection, | |
| 15 | 16 | Owners, | |
| ⋯ | |||
| 47 | 48 | import { admin, entitlements as entitlementsOf, identity, priceBook } from "~/lib/services.server"; | |
| 48 | 49 | import { settle } from "~/lib/settle"; | |
| 49 | 50 | import { requireStaff } from "~/lib/staff"; | |
| 51 | + | import { fullDiscount } from "~/lib/terms"; | |
| 50 | 52 | import type { Enterprise } from "~/lib/workspaces"; | |
| 51 | 53 | ||
| 52 | 54 | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| ⋯ | |||
| 89 | 91 | const figures = billedTo | |
| 90 | 92 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 91 | 93 | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| 92 | − | const [enterprises, sales, invoices, plan, overages, book] = await Promise.all([ | |
| 94 | + | const [enterprises, sales, invoices, plan, overages, book, credits] = await Promise.all([ | |
| 93 | 95 | billedTo | |
| 94 | 96 | ? Promise.resolve([]) | |
| 95 | 97 | : admin | |
| ⋯ | |||
| 104 | 106 | settle(entitlementsOf(slug)), | |
| 105 | 107 | settle(admin.overages()), | |
| 106 | 108 | settle(priceBook()), | |
| 109 | + | settle(admin.credits({ workspace: slug })), | |
| 107 | 110 | ]); | |
| 108 | 111 | const done = doneKey(request.url); | |
| 109 | 112 | return { | |
| ⋯ | |||
| 126 | 129 | entitlementsError: plan.ok ? null : plan.error, | |
| 127 | 130 | overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null, | |
| 128 | 131 | forgiveCap: (book.ok && book.value.free?.overageForgiveCostMicros) || 50_000_000, | |
| 132 | + | credits: credits.ok | |
| 133 | + | ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) } | |
| 134 | + | : null, | |
| 135 | + | creditsError: credits.ok ? null : credits.error, | |
| 129 | 136 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 130 | 137 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 131 | 138 | done: done && !SALES_DONE.has(done) ? DONE[done] : null, | |
| ⋯ | |||
| 148 | 155 | { id: "sales", label: "Sales" }, | |
| 149 | 156 | { id: "plan", label: "Plan" }, | |
| 150 | 157 | { id: "billing", label: "Billing" }, | |
| 158 | + | { id: "credits", label: "Credits" }, | |
| 151 | 159 | { id: "invoices", label: "Invoices" }, | |
| 152 | 160 | { id: "ledger", label: "Ledger" }, | |
| 153 | 161 | { id: "audit", label: "Audit log" }, | |
| ⋯ | |||
| 174 | 182 | entitlementsError, | |
| 175 | 183 | overage, | |
| 176 | 184 | forgiveCap, | |
| 185 | + | credits, | |
| 186 | + | creditsError, | |
| 177 | 187 | ledger, | |
| 178 | 188 | audit, | |
| 179 | 189 | done, | |
| ⋯ | |||
| 212 | 222 | {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>} | |
| 213 | 223 | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 214 | 224 | <TermsBadge terms={terms} /> | |
| 215 | − | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 225 | + | {terms.kind !== "standard" && terms.note && <span className="self-center text-xs text-muted">({terms.note})</span>} | |
| 226 | + | {!fullDiscount(terms) && <TrustBadge trust={limit.trust} />} | |
| 216 | 227 | <StateBadge state={limit.state} /> | |
| 217 | 228 | {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />} | |
| 218 | 229 | </div> | |
| ⋯ | |||
| 337 | 348 | ) : ( | |
| 338 | 349 | <> | |
| 339 | 350 | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 340 | − | <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} /> | |
| 351 | + | <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} /> | |
| 341 | 352 | </> | |
| 342 | 353 | )} | |
| 354 | + | <CreditsSection credits={credits} unavailable={creditsError} pathname={pathname} error={error("credits")} /> | |
| 343 | 355 | <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} /> | |
| 344 | 356 | <div id="ledger" className="scroll-mt-20"> | |
| 345 | 357 | <LedgerSection | |
| ⋯ | |||
| 351 | 363 | ||
| 352 | 364 | <div className="space-y-6"> | |
| 353 | 365 | <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} /> | |
| 354 | − | {!billedTo && terms.kind !== "comped" && ( | |
| 366 | + | {!billedTo && !fullDiscount(terms) && ( | |
| 355 | 367 | <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} /> | |
| 356 | 368 | )} | |
| 357 | 369 | <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} /> | |
| 358 | 370 | <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} /> | |
| 359 | − | {!billedTo && terms.kind !== "comped" && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />} | |
| 371 | + | {!billedTo && !fullDiscount(terms) && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />} | |
| 360 | 372 | <div id="audit" className="scroll-mt-20"> | |
| 361 | 373 | <AuditSection | |
| 362 | 374 | audit={audit} | |
| ⋯ | |||
| 386 | 398 | const lines: { label: string; value: ReactNode }[] = []; | |
| 387 | 399 | if (billedTo) { | |
| 388 | 400 | lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> }); | |
| 389 | − | } else if (terms.kind !== "comped") { | |
| 401 | + | } else if (!fullDiscount(terms)) { | |
| 390 | 402 | lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> }); | |
| 391 | 403 | } | |
| 392 | 404 | if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) }); | |
| 393 | − | if (!billedTo && terms.kind !== "comped") { | |
| 405 | + | if (!billedTo && !fullDiscount(terms)) { | |
| 394 | 406 | lines.push({ | |
| 395 | 407 | label: "Owners' limit", | |
| 396 | 408 | value: limit.defaultSpendLimit ? ( | |
| ⋯ | |||
| 502 | 514 | ); | |
| 503 | 515 | } | |
| 504 | 516 | ||
| 505 | − | const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (comped)", enterprise: "Enterprise" }; | |
| 517 | + | const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (100% discount)", enterprise: "Enterprise" }; | |
| 506 | 518 | ||
| 507 | 519 | function gigabytes(bytes: number): string { | |
| 508 | 520 | return `${Math.round((bytes / 1e9) * 100) / 100} GB`; | |
| 9 | 9 | import { usd } from "~/lib/money"; | |
| 10 | 10 | import { admin, identity } from "~/lib/services.server"; | |
| 11 | 11 | import { requireStaff } from "~/lib/staff"; | |
| 12 | + | import { fullDiscount } from "~/lib/terms"; | |
| 12 | 13 | import { PAGE_SIZE, type WorkspaceRow, joinWorkspaces, paginate } from "~/lib/workspaces"; | |
| 13 | 14 | ||
| 14 | 15 | export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | 16 | ||
| 16 | 17 | const FILTERS = { | |
| 17 | 18 | attention: { label: "Stopped or warning", test: (row: WorkspaceRow) => row.billing.limit != null && row.billing.limit.state !== "ok" }, | |
| 18 | − | terms: { label: "Comped or custom", test: (row: WorkspaceRow) => row.billing.terms.kind !== "standard" }, | |
| 19 | + | terms: { label: "A discount or custom terms", test: (row: WorkspaceRow) => row.billing.terms.kind !== "standard" }, | |
| 19 | 20 | enterprise: { label: "On an enterprise", test: (row: WorkspaceRow) => row.billing.billedTo != null }, | |
| 20 | 21 | } as const; | |
| 21 | 22 | ||
| ⋯ | |||
| 294 | 295 | <div className="mt-1.5 flex flex-wrap gap-1.5"> | |
| 295 | 296 | {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>} | |
| 296 | 297 | <TermsBadge terms={billing.terms} /> | |
| 297 | − | {billing.limit && billing.terms.kind !== "comped" && <TrustBadge trust={billing.limit.trust} />} | |
| 298 | + | {billing.limit && !fullDiscount(billing.terms) && <TrustBadge trust={billing.limit.trust} />} | |
| 298 | 299 | </div> | |
| 299 | 300 | </div> | |
| 300 | 301 | </div> | |
| 1 | 1 | /** | |
| 2 | 2 | * The billing page's sections: the spend-spike banner (also in the app | |
| 3 | 3 | * shell), alerts, the g1t plan, the trial, the spend limit and its range, | |
| 4 | − | * Raise my limit, Prepay, the run and issue caps, and "Spent more than you | |
| 4 | + | * Raise my limit, credits from g1t, Prepay, the run and issue caps, and "Spent more than you | |
| 5 | 5 | * meant to?". Each posts to the billing route's action with an `intent`. | |
| 6 | 6 | */ | |
| 7 | − | import { ArrowUpRight, CreditCard, Gauge, Landmark, ShieldCheck, Sparkles } from "lucide-react"; | |
| 7 | + | import { ArrowUpRight, CreditCard, Gauge, Gift, Landmark, ShieldCheck, Sparkles } from "lucide-react"; | |
| 8 | 8 | import type { ReactNode } from "react"; | |
| 9 | 9 | import { Form } from "react-router"; | |
| 10 | 10 | ||
| 11 | − | import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts"; | |
| 11 | + | import type { Credits, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | 13 | import { | |
| 14 | 14 | CAPS, | |
| 15 | + | CREDIT_KIND, | |
| 15 | 16 | PREPAY, | |
| 16 | 17 | type PlanStatus, | |
| 17 | 18 | alertText, | |
| 18 | 19 | alertTone, | |
| 20 | + | creditLine, | |
| 19 | 21 | dollars, | |
| 20 | 22 | gigabytes, | |
| 21 | 23 | requestStatus, | |
| 22 | 24 | share, | |
| 25 | + | shortDay, | |
| 23 | 26 | shownMeters, | |
| 24 | 27 | spendRange, | |
| 25 | 28 | wholeDollars, | |
| ⋯ | |||
| 258 | 261 | {status.kind === "comped" || status.kind === "enterprise" ? ( | |
| 259 | 262 | <p className="mt-4 text-sm text-muted"> | |
| 260 | 263 | {status.kind === "comped" | |
| 261 | − | ? "g1t covers this workspace's plan. Its usage is still recorded at what it costs, and shown as given." | |
| 264 | + | ? "This workspace has a 100% discount from g1t: the plan and its usage are shown at their price, and nothing is charged." | |
| 262 | 265 | : "An enterprise pays for this workspace, on its own invoice."} | |
| 263 | 266 | </p> | |
| 264 | 267 | ) : !enabled ? ( | |
| ⋯ | |||
| 351 | 354 | </div> | |
| 352 | 355 | <p className="mt-2 text-xs text-faint"> | |
| 353 | 356 | {comped | |
| 354 | − | ? "What this workspace's usage would cost. g1t covers it." | |
| 357 | + | ? "What this workspace's usage comes to at price. Its 100% discount takes all of it off." | |
| 355 | 358 | : on | |
| 356 | 359 | ? `Drawn from the included usage first, then charged up to your spend limit. Projects, previews and repositories are never charged, and the first ${freeStorage} of private storage and ${freeGit} git operations a month are free. App traffic, custom domains, storage and git operations are counted through the month and charged when it closes.` | |
| 357 | − | : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial or the open-source pool, which pay part of this total: Usage shows what was charged.`} | |
| 360 | + | : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial, and checks, workflows and the merge queue on public repositories from the open-source pool; together they pay part of this total: Usage shows what was charged.`} | |
| 358 | 361 | </p> | |
| 359 | 362 | </div> | |
| 360 | 363 | ); | |
| ⋯ | |||
| 603 | 606 | ||
| 604 | 607 | // --- Prepay ----------------------------------------------------------------------- | |
| 605 | 608 | ||
| 609 | + | /** | |
| 610 | + | * Credit g1t gave the workspace: what is left, each grant in a line with | |
| 611 | + | * its expiry, and what each was for. Spent before anything prepaid. | |
| 612 | + | */ | |
| 613 | + | export function CreditsCard({ credits }: { credits: Credits }) { | |
| 614 | + | const open = credits.grants.filter((grant) => grant.state === "open"); | |
| 615 | + | const past = credits.grants.filter((grant) => grant.state !== "open"); | |
| 616 | + | return ( | |
| 617 | + | <Card | |
| 618 | + | id="credits" | |
| 619 | + | icon={<Gift size={16} />} | |
| 620 | + | title="Credits from g1t" | |
| 621 | + | about="Credit g1t gave this workspace. It pays for usage before anything prepaid, the soonest-expiring first. Unused credit stops counting when it expires." | |
| 622 | + | aside={ | |
| 623 | + | <p className="shrink-0 sm:text-right"> | |
| 624 | + | <span className="block text-xs text-muted">Credit left</span> | |
| 625 | + | <span className="text-xl font-semibold tabular-nums">{dollars(credits.leftMicros)}</span> | |
| 626 | + | </p> | |
| 627 | + | } | |
| 628 | + | > | |
| 629 | + | <ul className="mt-4 divide-y divide-line rounded-lg border border-line"> | |
| 630 | + | {[...open, ...past].map((grant) => ( | |
| 631 | + | <li key={grant.id} className={`px-3.5 py-2.5 text-sm ${grant.state === "open" ? "" : "text-muted"}`}> | |
| 632 | + | <p className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 633 | + | <span className={`tabular-nums ${grant.state === "open" ? "font-medium" : ""}`}>{creditLine(grant)}</span> | |
| 634 | + | <span className="rounded-full border border-line px-2 py-px text-xs text-muted">{CREDIT_KIND[grant.kind]}</span> | |
| 635 | + | </p> | |
| 636 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 637 | + | {grant.kind === "refund" && grant.refundFor ? `For ${grant.refundFor}. ` : ""} | |
| 638 | + | {grant.note} · given {shortDay(grant.createdAt)} | |
| 639 | + | </p> | |
| 640 | + | </li> | |
| 641 | + | ))} | |
| 642 | + | </ul> | |
| 643 | + | </Card> | |
| 644 | + | ); | |
| 645 | + | } | |
| 646 | + | ||
| 606 | 647 | export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) { | |
| 607 | 648 | return ( | |
| 608 | 649 | <Card | |
| 20 | 20 | return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros); | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | + | /** A usage entry at its price: what was charged, what paid for it first, and what the discount took off. */ | |
| 24 | + | export function entryPrice(entry: Pick<LedgerEntry, "amountMicros" | "creditMicros" | "trialMicros" | "ossMicros" | "givenMicros" | "discountMicros">): number { | |
| 25 | + | if ((entry.discountMicros ?? 0) === 0) return -entry.amountMicros; | |
| 26 | + | return -entry.amountMicros + (entry.creditMicros ?? 0) + (entry.trialMicros ?? 0) + (entry.ossMicros ?? 0) + (entry.givenMicros ?? 0) + (entry.discountMicros ?? 0); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** `Discount (100%)`, or `Discount` when the percentage is not known (a discount since ended). */ | |
| 30 | + | export function discountName(percent: number | null | undefined): string { | |
| 31 | + | return percent ? `Discount (${percent}%)` : "Discount"; | |
| 32 | + | } | |
| 33 | + | ||
| 23 | 34 | function monthLabel(month: string): string { | |
| 24 | 35 | const [year, number] = month.split("-").map(Number); | |
| 25 | 36 | return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", { | |
| ⋯ | |||
| 57 | 68 | navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true }); | |
| 58 | 69 | const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months]; | |
| 59 | 70 | const { totals } = statement; | |
| 71 | + | // With a discount, every line at its price, and the discount beside it. | |
| 72 | + | const atPrice = (totals.discountMicros ?? 0) > 0; | |
| 73 | + | const discountLabel = discountName(totals.discountPercent); | |
| 60 | 74 | ||
| 61 | 75 | return ( | |
| 62 | 76 | <section id="statement" className="mt-10 scroll-mt-20"> | |
| ⋯ | |||
| 91 | 105 | )} | |
| 92 | 106 | </div> | |
| 93 | 107 | ||
| 94 | − | <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm"> | |
| 95 | − | <div className="px-4 py-3"> | |
| 96 | − | <dt className="text-xs text-faint">Charged</dt> | |
| 97 | − | <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd> | |
| 98 | − | </div> | |
| 99 | − | <div className="px-4 py-3"> | |
| 100 | − | <dt className="text-xs text-faint">Paid and credited</dt> | |
| 101 | − | <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd> | |
| 102 | − | </div> | |
| 103 | − | <div className="px-4 py-3"> | |
| 104 | − | <dt className="text-xs text-faint">Entries</dt> | |
| 105 | − | <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd> | |
| 106 | − | </div> | |
| 107 | − | </dl> | |
| 108 | + | {atPrice ? ( | |
| 109 | + | <dl className="mt-3 grid grid-cols-2 divide-line rounded-xl border border-line bg-surface text-sm sm:grid-cols-4 sm:divide-x"> | |
| 110 | + | <div className="px-4 py-3"> | |
| 111 | + | <dt className="text-xs text-faint">Usage at price</dt> | |
| 112 | + | <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.priceMicros ?? 0)}</dd> | |
| 113 | + | </div> | |
| 114 | + | <div className="px-4 py-3"> | |
| 115 | + | <dt className="text-xs text-faint">{discountLabel}</dt> | |
| 116 | + | <dd className="mt-0.5 font-mono tabular-nums text-accent">{charge(-(totals.discountMicros ?? 0))}</dd> | |
| 117 | + | </div> | |
| 118 | + | <div className="px-4 py-3"> | |
| 119 | + | <dt className="text-xs text-faint">Charged</dt> | |
| 120 | + | <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd> | |
| 121 | + | </div> | |
| 122 | + | <div className="px-4 py-3"> | |
| 123 | + | <dt className="text-xs text-faint">Paid and credited</dt> | |
| 124 | + | <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd> | |
| 125 | + | </div> | |
| 126 | + | </dl> | |
| 127 | + | ) : ( | |
| 128 | + | <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm"> | |
| 129 | + | <div className="px-4 py-3"> | |
| 130 | + | <dt className="text-xs text-faint">Charged</dt> | |
| 131 | + | <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd> | |
| 132 | + | </div> | |
| 133 | + | <div className="px-4 py-3"> | |
| 134 | + | <dt className="text-xs text-faint">Paid and credited</dt> | |
| 135 | + | <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd> | |
| 136 | + | </div> | |
| 137 | + | <div className="px-4 py-3"> | |
| 138 | + | <dt className="text-xs text-faint">Entries</dt> | |
| 139 | + | <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd> | |
| 140 | + | </div> | |
| 141 | + | </dl> | |
| 142 | + | )} | |
| 108 | 143 | ||
| 109 | 144 | {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && ( | |
| 110 | 145 | <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| ⋯ | |||
| 146 | 181 | month={statement.month} | |
| 147 | 182 | kind={line.kind} | |
| 148 | 183 | count={line.count} | |
| 149 | − | chargedMicros={line.chargedMicros} | |
| 184 | + | chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros} | |
| 150 | 185 | coveredMicros={line.coveredMicros ?? 0} | |
| 151 | 186 | day={group === "day" ? g.key : null} | |
| 152 | 187 | project={group === "project" ? g.key : null} | |
| 153 | 188 | /> | |
| 154 | 189 | ))} | |
| 190 | + | {(g.discountMicros ?? 0) > 0 && ( | |
| 191 | + | <li className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 192 | + | <span className="w-3.5 shrink-0" /> | |
| 193 | + | <span className="grow truncate text-muted">{discountLabel}</span> | |
| 194 | + | <span className="w-24 shrink-0 text-right font-mono tabular-nums text-accent">{charge(-(g.discountMicros ?? 0))}</span> | |
| 195 | + | </li> | |
| 196 | + | )} | |
| 155 | 197 | </ul> | |
| 156 | 198 | </div> | |
| 157 | 199 | ))} | |
| ⋯ | |||
| 254 | 296 | {entry.createdBy && ` · ${entry.createdBy}`} | |
| 255 | 297 | </p> | |
| 256 | 298 | </div> | |
| 257 | − | <span className="shrink-0 font-mono text-xs tabular-nums text-muted"> | |
| 258 | − | {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(-entry.amountMicros)} | |
| 299 | + | <span className="shrink-0 text-right font-mono text-xs tabular-nums text-muted"> | |
| 300 | + | {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(entryPrice(entry))} | |
| 301 | + | {(entry.discountMicros ?? 0) > 0 && ( | |
| 302 | + | <span className="block text-[0.6875rem] text-accent">{charge(-(entry.discountMicros ?? 0))} discount</span> | |
| 303 | + | )} | |
| 259 | 304 | </span> | |
| 260 | 305 | </li> | |
| 261 | 306 | ))} | |
| 11 | 11 | /** What the card says the workspace is on, beside its spend. */ | |
| 12 | 12 | const STANDING: Record<UsageGlance["kind"], string> = { | |
| 13 | 13 | beta: "Free for now", | |
| 14 | − | comped: "Comped", | |
| 14 | + | comped: "100% discount", | |
| 15 | 15 | plan: "g1t plan", | |
| 16 | 16 | trial: "Trial", | |
| 17 | 17 | forge: "Free", | |
| ⋯ | |||
| 65 | 65 | {glance.kind === "beta" | |
| 66 | 66 | ? "At cost. Nothing is charged while g1t is being built out." | |
| 67 | 67 | : glance.kind === "comped" | |
| 68 | − | ? "Recorded at what it costs. Nothing is charged to this workspace." | |
| 68 | + | ? "At price, with a 100% discount: nothing is charged to this workspace." | |
| 69 | 69 | : glance.kind === "forge" | |
| 70 | 70 | ? "The forge is free. Agents, workflows and deployments need the plan or the trial." | |
| 71 | 71 | : "Charged so far, from the 1st."} | |
| 8 | 8 | alertText, | |
| 9 | 9 | alertTone, | |
| 10 | 10 | cardCheckResult, | |
| 11 | + | creditLine, | |
| 11 | 12 | dollars, | |
| 12 | 13 | needsAttention, | |
| 13 | 14 | usageGlance, | |
| ⋯ | |||
| 24 | 25 | wholeDollars, | |
| 25 | 26 | } from "./billing.ts"; | |
| 26 | 27 | ||
| 28 | + | test("a credit from g1t reads in a line, with what is left and when it expires", () => { | |
| 29 | + | const now = new Date("2026-10-07T12:00:00Z"); | |
| 30 | + | const grant = { amountMicros: 25_000_000, leftMicros: 12_400_000, expiresAt: "2027-01-05T23:59:59Z", state: "open" as const }; | |
| 31 | + | assert.equal(creditLine(grant, now), "$25.00 credit, $12.40 left, expires Jan 5, 2027"); | |
| 32 | + | assert.equal(creditLine({ ...grant, expiresAt: "2026-12-05T23:59:59Z" }, now), "$25.00 credit, $12.40 left, expires Dec 5"); | |
| 33 | + | assert.equal(creditLine({ ...grant, expiresAt: null }, now), "$25.00 credit, $12.40 left"); | |
| 34 | + | assert.equal(creditLine({ ...grant, state: "used", leftMicros: 0 }, now), "$25.00 credit, all used"); | |
| 35 | + | assert.equal(creditLine({ ...grant, state: "revoked", leftMicros: 0 }, now), "$25.00 credit, withdrawn"); | |
| 36 | + | }); | |
| 37 | + | ||
| 27 | 38 | test("money reads as dollars", () => { | |
| 28 | 39 | assert.equal(dollars(9_500_000), "$9.50"); | |
| 29 | 40 | assert.equal(dollars(-1_250_000), "−$1.25"); | |
| 3 | 3 | * workspace's alerts. Pure, so it can be tested. | |
| 4 | 4 | */ | |
| 5 | 5 | ||
| 6 | − | import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts"; | |
| 6 | + | import type { CreditGrant, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | /** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */ | |
| 9 | 9 | const MICROS_PER_DOLLAR = 1_000_000; | |
| ⋯ | |||
| 171 | 171 | plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined, | |
| 172 | 172 | entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined, | |
| 173 | 173 | ): PlanStatus { | |
| 174 | − | if (entitlements?.plan === "internal") return { kind: "comped", label: "Comped by g1t" }; | |
| 174 | + | if (entitlements?.plan === "internal") return { kind: "comped", label: "100% discount from g1t" }; | |
| 175 | 175 | if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" }; | |
| 176 | 176 | if (plan?.included) return { kind: "comped", label: "Included by g1t" }; | |
| 177 | 177 | const subscription = plan?.subscription; | |
| ⋯ | |||
| 337 | 337 | }, | |
| 338 | 338 | }; | |
| 339 | 339 | } | |
| 340 | + | ||
| 341 | + | /** What a credit from g1t is for, as the Billing page names it. */ | |
| 342 | + | export const CREDIT_KIND: Record<CreditGrant["kind"], string> = { | |
| 343 | + | promotional: "Promotional", | |
| 344 | + | goodwill: "Goodwill", | |
| 345 | + | refund: "Refund", | |
| 346 | + | purchased: "Purchased", | |
| 347 | + | }; | |
| 348 | + | ||
| 349 | + | /** `Jan 5`, or `Jan 5, 2028` outside this year (UTC). */ | |
| 350 | + | export function shortDay(at: string, now = new Date()): string { | |
| 351 | + | const date = new Date(at); | |
| 352 | + | const sameYear = date.getUTCFullYear() === now.getUTCFullYear(); | |
| 353 | + | return date.toLocaleDateString("en-US", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" }); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | /** | |
| 357 | + | * A credit from g1t in a line: `$25.00 credit, $12.40 left, expires Jan 5`; | |
| 358 | + | * once it is spent, expired or withdrawn, says so. | |
| 359 | + | */ | |
| 360 | + | export function creditLine(grant: Pick<CreditGrant, "amountMicros" | "leftMicros" | "expiresAt" | "state">, now = new Date()): string { | |
| 361 | + | const parts = [`${dollars(grant.amountMicros)} credit`]; | |
| 362 | + | if (grant.state === "open") { | |
| 363 | + | parts.push(`${dollars(grant.leftMicros)} left`); | |
| 364 | + | if (grant.expiresAt) parts.push(`expires ${shortDay(grant.expiresAt, now)}`); | |
| 365 | + | } else { | |
| 366 | + | parts.push(grant.state === "used" ? "all used" : grant.state === "expired" ? "expired" : "withdrawn"); | |
| 367 | + | } | |
| 368 | + | return parts.join(", "); | |
| 369 | + | } | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts"; | |
| 4 | + | import { pagePath, workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("everyone sees Overview, Projects and Packages; members and owners see more", () => { | |
| 7 | 7 | const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key); | |
| ⋯ | |||
| 52 | 52 | assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null); | |
| 53 | 53 | assert.equal(workspaceRedirect("/acme", ""), null); | |
| 54 | 54 | }); | |
| 55 | + | ||
| 56 | + | test("a click's data request is for the same page as a full load", () => { | |
| 57 | + | assert.equal(pagePath("/acme/-/insights.data"), "/acme/-/insights"); | |
| 58 | + | assert.equal(pagePath("/acme/-/insights/"), "/acme/-/insights"); | |
| 59 | + | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=routes%2Fworkspace%2Fmoved-members"), "/acme/-/people"); | |
| 60 | + | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=x&q=ada"), "/acme/-/people?q=ada"); | |
| 61 | + | }); | |
| 79 | 79 | "soon/insights": "-/insights", | |
| 80 | 80 | }; | |
| 81 | 81 | ||
| 82 | + | /** The page a request is for: a click's data request ends in `.data`. */ | |
| 83 | + | export function pagePath(pathname: string): string { | |
| 84 | + | return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/"; | |
| 85 | + | } | |
| 86 | + | ||
| 82 | 87 | /** | |
| 83 | 88 | * Where an old address of a workspace's pages is now, keeping its query; | |
| 84 | 89 | * null when it has not moved. `/<workspace>?tab=projects` and the like | |
| 85 | 90 | * open that tab. | |
| 86 | 91 | */ | |
| 87 | 92 | export function workspaceRedirect(pathname: string, search = ""): string | null { | |
| 88 | − | const parts = pathname.split("/").filter(Boolean); | |
| 93 | + | // A click asks for the page's data at `<path>.data?_routes=…`; the page | |
| 94 | + | // is the same one a full load asks for. | |
| 95 | + | const params = new URLSearchParams(search); | |
| 96 | + | params.delete("_routes"); | |
| 97 | + | search = params.toString(); | |
| 98 | + | const parts = pagePath(pathname).split("/").filter(Boolean); | |
| 89 | 99 | const slug = parts[0]; | |
| 90 | 100 | if (!slug) return null; | |
| 91 | − | const params = new URLSearchParams(search); | |
| 92 | 101 | if (parts.length === 1 && params.has("tab")) { | |
| 93 | 102 | const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()]; | |
| 94 | 103 | if (to === undefined) return null; |
| 7 | 7 | import { | |
| 8 | 8 | Alerts, | |
| 9 | 9 | CapsCard, | |
| 10 | + | CreditsCard, | |
| 10 | 11 | OverageCard, | |
| 11 | 12 | PlanCard, | |
| 12 | 13 | PrepayCard, | |
| ⋯ | |||
| 87 | 88 | if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`); | |
| 88 | 89 | ||
| 89 | 90 | const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day"; | |
| 90 | − | const [account, statement, features, meters, limit, invoices, entitlements, requests, book] = await Promise.all([ | |
| 91 | + | const [account, statement, features, meters, limit, invoices, entitlements, requests, book, credits] = await Promise.all([ | |
| 91 | 92 | billing.account(slug, viewer), | |
| 92 | 93 | billing.statement(slug, viewer, url.searchParams.get("month"), group), | |
| 93 | 94 | billing.features(slug, viewer), | |
| ⋯ | |||
| 97 | 98 | billing.entitlements(slug).catch(() => null), | |
| 98 | 99 | billing.limitRequests(slug, viewer).catch(() => null), | |
| 99 | 100 | billing.prices().catch(() => null), | |
| 101 | + | billing.credits(slug, viewer).catch(() => null), | |
| 100 | 102 | ]); | |
| 101 | 103 | const featureStates = unwrap(features); | |
| 102 | 104 | const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS; | |
| ⋯ | |||
| 114 | 116 | invoices: invoices?.ok ? invoices.value : [], | |
| 115 | 117 | entitlements, | |
| 116 | 118 | requests: requests?.ok ? requests.value : [], | |
| 119 | + | credits: credits?.ok && credits.value.grants.length > 0 ? credits.value : null, | |
| 117 | 120 | trialMicros, | |
| 118 | 121 | notice: | |
| 119 | 122 | url.searchParams.has("checked") && entitlements | |
| ⋯ | |||
| 216 | 219 | } | |
| 217 | 220 | ||
| 218 | 221 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| 219 | − | const { slug, role, account, statement, group, plan, securityPlan, meters, limit, invoices, entitlements, requests, trialMicros, notice, problem } = | |
| 222 | + | const { slug, role, account, statement, group, plan, securityPlan, meters, limit, invoices, entitlements, requests, credits, trialMicros, notice, problem } = | |
| 220 | 223 | loaderData; | |
| 221 | 224 | const { status } = account; | |
| 222 | 225 | const owner = role === "owner"; | |
| ⋯ | |||
| 271 | 274 | </> | |
| 272 | 275 | )} | |
| 273 | 276 | ||
| 277 | + | {credits && <CreditsCard credits={credits} />} | |
| 278 | + | ||
| 274 | 279 | {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && ( | |
| 275 | 280 | <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} /> | |
| 276 | 281 | )} | |
| ⋯ | |||
| 324 | 329 | costs g1t plus {account.marginPercent}%, from the first second, after what is included. | |
| 325 | 330 | </li> | |
| 326 | 331 | <li>What paid first is on each statement line: the plan's included usage, the trial, the open-source pool, or g1t.</li> | |
| 332 | + | <li>Credit from g1t comes off what you owe, before anything prepaid, the soonest-expiring first.</li> | |
| 327 | 333 | <li> | |
| 328 | 334 | With your own model provider, connected under{" "} | |
| 329 | 335 | <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline"> | |
| 24 | 24 | "pull request", | |
| 25 | 25 | "model", | |
| 26 | 26 | "by", | |
| 27 | + | "price (USD)", | |
| 28 | + | "discount (USD)", | |
| 27 | 29 | "amount (USD)", | |
| 28 | 30 | "paid by included usage (USD)", | |
| 29 | 31 | "paid by trial credit (USD)", | |
| ⋯ | |||
| 64 | 66 | entry.number ? String(entry.number) : "", | |
| 65 | 67 | entry.model ?? "", | |
| 66 | 68 | entry.createdBy ?? "", | |
| 69 | + | // Usage at its price (empty for money in), then the discount off it. | |
| 70 | + | entry.kind === "usage" ? (usagePrice(entry) / MICROS_PER_DOLLAR).toFixed(6) : "", | |
| 71 | + | ((entry.discountMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6), | |
| 67 | 72 | // Charges positive, as on the statement. | |
| 68 | 73 | (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6), | |
| 69 | 74 | ((entry.creditMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6), | |
| ⋯ | |||
| 73 | 78 | ]; | |
| 74 | 79 | } | |
| 75 | 80 | ||
| 81 | + | /** A usage entry at its price: charged, what paid for it first, and the discount. */ | |
| 82 | + | function usagePrice(entry: LedgerEntry): number { | |
| 83 | + | return ( | |
| 84 | + | -entry.amountMicros + | |
| 85 | + | (entry.creditMicros ?? 0) + | |
| 86 | + | (entry.trialMicros ?? 0) + | |
| 87 | + | (entry.ossMicros ?? 0) + | |
| 88 | + | (entry.givenMicros ?? 0) + | |
| 89 | + | (entry.discountMicros ?? 0) | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | ||
| 76 | 93 | /** A CSV cell, quoted when it must be, and never read as a formula. */ | |
| 77 | 94 | function cell(value: string): string { | |
| 78 | 95 | const safe = /^[=+\-@\t\r]/.test(value) && !/^-?\d/.test(value) ? `'${value}` : value; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | 5 | import { roadmapItem } from "../../lib/roadmap"; | |
| 6 | 6 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 7 | + | import { pagePath } from "../../lib/workspace-nav"; | |
| 7 | 8 | import { SoonView } from "../repo/soon"; | |
| 8 | 9 | ||
| 9 | 10 | /** The tab is the last part of the address: `-/insights`. */ | |
| 10 | − | const tabOf = (pathname: string) => pathname.replace(/\/+$/, "").split("/").pop() ?? ""; | |
| 11 | + | const tabOf = (pathname: string) => pagePath(pathname).split("/").pop() ?? ""; | |
| 11 | 12 | ||
| 12 | 13 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 13 | 14 | const item = roadmapItem(tabOf(args.location.pathname)); |
| 191 | 191 | const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000)); | |
| 192 | 192 | // While g1t is free nothing is charged, so usage is measured at cost | |
| 193 | 193 | // and credit is not drawn down. | |
| 194 | − | // Free or comped, nothing is charged: what the runs used, at cost, is | |
| 195 | − | // what there is to show. | |
| 196 | − | const atCost = usage.free || comped; | |
| 197 | − | const total = atCost ? usage.usedMicros : usage.spentMicros; | |
| 194 | + | // With a discount (100% for g1t's own), usage is shown at its price, with | |
| 195 | + | // the discount beside it, so a workspace that pays nothing still sees | |
| 196 | + | // what it would pay. | |
| 197 | + | const atCost = usage.free; | |
| 198 | + | const discount = usage.discountMicros ?? 0; | |
| 199 | + | const atPrice = !atCost && (usage.discountPercent ?? 0) > 0; | |
| 200 | + | const price = usage.spentMicros + (usage.coveredMicros ?? 0) + discount; | |
| 201 | + | const total = atCost ? usage.usedMicros : atPrice ? price : usage.spentMicros; | |
| 198 | 202 | const perDay = atCost ? 0 : total / days; | |
| 199 | 203 | const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null; | |
| 200 | 204 | return ( | |
| ⋯ | |||
| 218 | 222 | ||
| 219 | 223 | <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | |
| 220 | 224 | {atCost ? ( | |
| 225 | + | <Stat label="Used" value={dollars(total)} note="At cost. Nothing is charged while g1t is being built out." /> | |
| 226 | + | ) : atPrice ? ( | |
| 221 | 227 | <Stat | |
| 222 | − | label="Used" | |
| 223 | − | value={dollars(total)} | |
| 224 | − | note={comped ? "At cost. The workspace is comped: nothing is charged." : "At cost. Nothing is charged while g1t is being built out."} | |
| 228 | + | label="Usage at price" | |
| 229 | + | value={dollars(price)} | |
| 230 | + | note={`Discount (${usage.discountPercent}%) −${dollars(discount)}; charged ${dollars(usage.spentMicros)}`} | |
| 225 | 231 | /> | |
| 226 | 232 | ) : ( | |
| 227 | 233 | <Stat | |
| ⋯ | |||
| 243 | 249 | note="Making a change, reviewing, revising…" | |
| 244 | 250 | /> | |
| 245 | 251 | {comped ? ( | |
| 246 | − | // Nothing is charged to a comped workspace: no credit to run down. | |
| 252 | + | // A 100% discount charges nothing: no credit to run down. | |
| 247 | 253 | <div className="rounded-2xl bg-surface p-5 ring-1 ring-line"> | |
| 248 | − | <p className="text-sm text-muted">Credit</p> | |
| 249 | − | <p className="mt-2 text-3xl font-semibold tracking-tight text-accent">Comped</p> | |
| 250 | − | <p className="mt-1 text-xs text-faint">Recorded at what it costs; nothing is charged to this workspace.</p> | |
| 254 | + | <p className="text-sm text-muted">Discount</p> | |
| 255 | + | <p className="mt-2 text-3xl font-semibold tracking-tight text-accent">100%</p> | |
| 256 | + | <p className="mt-1 text-xs text-faint">Usage is shown at its price; g1t takes all of it off, so nothing is charged.</p> | |
| 251 | 257 | </div> | |
| 252 | 258 | ) : ( | |
| 253 | 259 | <div className="rounded-2xl bg-surface p-5 ring-1 ring-line"> | |
| 190 | 190 | /// workspace's last trial run that went past its trial credit. | |
| 191 | 191 | #[serde(default)] | |
| 192 | 192 | pub given_micros: i64, | |
| 193 | + | /// For usage: what the account's discount took off its price. The | |
| 194 | + | /// price is `-amount_micros` plus this and what paid for it. | |
| 195 | + | #[serde(default)] | |
| 196 | + | pub discount_micros: i64, | |
| 197 | + | /// For a credit from g1t, and for what of one expired or was revoked: | |
| 198 | + | /// its kind. | |
| 199 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 200 | + | pub credit_kind: Option<CreditKind>, | |
| 193 | 201 | } | |
| 194 | 202 | ||
| 195 | 203 | fn g1t() -> String { | |
| ⋯ | |||
| 329 | 337 | /// price is `spent_micros` plus this. | |
| 330 | 338 | #[serde(default)] | |
| 331 | 339 | pub covered_micros: i64, | |
| 340 | + | /// What the account's discount took off the price. Usage at price is | |
| 341 | + | /// `spent_micros` plus `covered_micros` plus this. | |
| 342 | + | #[serde(default)] | |
| 343 | + | pub discount_micros: i64, | |
| 344 | + | /// The account's discount now, in percent; absent without one. With | |
| 345 | + | /// one, the slices measure usage at price. | |
| 346 | + | #[serde(default)] | |
| 347 | + | pub discount_percent: Option<u32>, | |
| 332 | 348 | /// What g1t's model provider charged, before the margin. | |
| 333 | 349 | pub cost_micros: i64, | |
| 334 | 350 | /// What runs on the workspace's own provider cost there, as the harness | |
| ⋯ | |||
| 1532 | 1548 | } | |
| 1533 | 1549 | } | |
| 1534 | 1550 | ||
| 1551 | + | /// The discount in percent, 0 to 100. Terms from before discounts | |
| 1552 | + | /// replaced "comped" read as 100%. | |
| 1553 | + | pub fn percent_off(&self) -> u32 { | |
| 1554 | + | match self.kind { | |
| 1555 | + | TermsKind::Comped => 100, | |
| 1556 | + | TermsKind::Custom => self.discount_percent.min(100), | |
| 1557 | + | TermsKind::Standard => 0, | |
| 1558 | + | } | |
| 1559 | + | } | |
| 1560 | + | ||
| 1561 | + | /// A 100% discount: nothing is charged, usage is recorded at its price | |
| 1562 | + | /// and discounted in full. g1t's own workspaces and partners. Paid | |
| 1563 | + | /// features are on without a plan, and g1t's own spend on it is held to | |
| 1564 | + | /// a monthly budget (the terms' ceiling, at cost). | |
| 1565 | + | pub fn full_discount(&self) -> bool { | |
| 1566 | + | self.percent_off() >= 100 | |
| 1567 | + | } | |
| 1568 | + | ||
| 1535 | 1569 | /// What a charge becomes under these terms. | |
| 1536 | 1570 | pub fn apply(&self, charge_micros: i64) -> i64 { | |
| 1537 | − | match self.kind { | |
| 1538 | − | TermsKind::Comped => 0, | |
| 1539 | − | TermsKind::Custom => charge_micros * i64::from(100 - self.discount_percent.min(100)) / 100, | |
| 1540 | − | TermsKind::Standard => charge_micros, | |
| 1541 | − | } | |
| 1571 | + | charge_micros * i64::from(100 - self.percent_off()) / 100 | |
| 1542 | 1572 | } | |
| 1543 | 1573 | ||
| 1544 | 1574 | /// What a charge at cost plus the margin becomes under these terms, and | |
| 1545 | − | /// how much of it g1t gives away by a discount: a sold charge is never | |
| 1546 | − | /// below its cost plus the margin unless the difference is counted as | |
| 1547 | − | /// given (`ledger.discount_micros`), never lost. Comped terms give it | |
| 1548 | − | /// all, and are counted as comped elsewhere, so their given part is 0 | |
| 1549 | − | /// here. | |
| 1575 | + | /// what the discount took off it (`ledger.discount_micros`), so the | |
| 1576 | + | /// statement shows the usage at its price and the discount beside it, | |
| 1577 | + | /// and a discount below cost plus the margin is counted as given, never | |
| 1578 | + | /// lost. A 100% discount takes it all. | |
| 1550 | 1579 | pub fn discounted(&self, charge_micros: i64) -> (i64, i64) { | |
| 1551 | 1580 | let charged = self.apply(charge_micros); | |
| 1552 | − | match self.kind { | |
| 1553 | − | TermsKind::Custom => (charged, (charge_micros - charged).max(0)), | |
| 1554 | − | TermsKind::Comped | TermsKind::Standard => (charged, 0), | |
| 1581 | + | (charged, (charge_micros - charged).max(0)) | |
| 1582 | + | } | |
| 1583 | + | ||
| 1584 | + | /// How the statement and sudo name the terms: `100% discount`, `30% off`. | |
| 1585 | + | pub fn discount_label(&self) -> Option<String> { | |
| 1586 | + | match self.percent_off() { | |
| 1587 | + | 0 => None, | |
| 1588 | + | 100 => Some("100% discount".to_owned()), | |
| 1589 | + | percent => Some(format!("{percent}% off")), | |
| 1555 | 1590 | } | |
| 1556 | 1591 | } | |
| 1557 | 1592 | } | |
| ⋯ | |||
| 1561 | 1596 | pub enum TermsKind { | |
| 1562 | 1597 | /// Prices as published, limits by trust. | |
| 1563 | 1598 | Standard, | |
| 1564 | − | /// Nothing charged; usage still recorded with its cost. Paid features | |
| 1565 | − | /// are on without a plan. For g1t's own workspaces, partners, and the | |
| 1566 | − | /// like. | |
| 1599 | + | /// Before discounts: what a 100% discount is now. Read as one | |
| 1600 | + | /// (`Terms::percent_off`); billing never writes it (migration 0039). | |
| 1567 | 1601 | Comped, | |
| 1568 | − | /// A discount, a ceiling, or both. | |
| 1602 | + | /// A discount (up to 100%), a ceiling, or both. | |
| 1569 | 1603 | Custom, | |
| 1570 | 1604 | } | |
| 1571 | 1605 | ||
| ⋯ | |||
| 1743 | 1777 | pub lines: Vec<StatementLine>, | |
| 1744 | 1778 | /// What the group's charges come to. | |
| 1745 | 1779 | pub charged_micros: i64, | |
| 1780 | + | /// Its usage at price, and what the discount took off it. | |
| 1781 | + | #[serde(default)] | |
| 1782 | + | pub price_micros: i64, | |
| 1783 | + | #[serde(default)] | |
| 1784 | + | pub discount_micros: i64, | |
| 1746 | 1785 | } | |
| 1747 | 1786 | ||
| 1748 | 1787 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 1760 | 1799 | /// pool, or g1t itself. Not in `charged_micros`. | |
| 1761 | 1800 | #[serde(default)] | |
| 1762 | 1801 | pub covered_micros: i64, | |
| 1802 | + | /// Usage at its price: charged, plus what paid for it and what the | |
| 1803 | + | /// discount took off. Zero for money in. | |
| 1804 | + | #[serde(default)] | |
| 1805 | + | pub price_micros: i64, | |
| 1806 | + | /// What the account's discount took off the line's price. | |
| 1807 | + | #[serde(default)] | |
| 1808 | + | pub discount_micros: i64, | |
| 1763 | 1809 | } | |
| 1764 | 1810 | ||
| 1765 | 1811 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 1769 | 1815 | pub paid_micros: i64, | |
| 1770 | 1816 | pub cost_micros: i64, | |
| 1771 | 1817 | pub entries: u32, | |
| 1818 | + | /// Usage at price, and what the discount took off it: charged is the | |
| 1819 | + | /// price less the discount and what paid for it. | |
| 1820 | + | #[serde(default)] | |
| 1821 | + | pub price_micros: i64, | |
| 1822 | + | #[serde(default)] | |
| 1823 | + | pub discount_micros: i64, | |
| 1824 | + | /// The account's discount now, in percent; absent without one. | |
| 1825 | + | #[serde(default)] | |
| 1826 | + | pub discount_percent: Option<u32>, | |
| 1772 | 1827 | /// What paid for usage before it was charged, one line per source, | |
| 1773 | 1828 | /// such as "Paid by g1t's open-source pool". | |
| 1774 | 1829 | #[serde(default)] | |
| ⋯ | |||
| 2166 | 2221 | pub by: String, | |
| 2167 | 2222 | } | |
| 2168 | 2223 | ||
| 2169 | − | /// `admin_credit`: money g1t gives a workspace, such as a refund or a | |
| 2170 | − | /// goodwill credit. Returns `Outcome<LedgerEntry>`. | |
| 2224 | + | /// Why g1t gave a workspace credit. | |
| 2225 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] | |
| 2226 | + | #[serde(rename_all = "snake_case")] | |
| 2227 | + | pub enum CreditKind { | |
| 2228 | + | /// Marketing: a welcome, a referral, an event. Given away when spent. | |
| 2229 | + | Promotional, | |
| 2230 | + | /// An apology, or accidental usage forgiven. Given away when spent. | |
| 2231 | + | #[default] | |
| 2232 | + | Goodwill, | |
| 2233 | + | /// Money back for something that went wrong. Not given away: it gives | |
| 2234 | + | /// back money already paid, so it comes off what was paid on the day | |
| 2235 | + | /// it refunds, and what it pays for later is paid for. | |
| 2236 | + | Refund, | |
| 2237 | + | /// Bought by the workspace (prepaid AI): money paid in up front, owed | |
| 2238 | + | /// as usage until spent. What it pays for is paid for, never given. | |
| 2239 | + | /// Staff never give it; its ledger line is a payment, not `crd…`. | |
| 2240 | + | Purchased, | |
| 2241 | + | } | |
| 2242 | + | ||
| 2243 | + | impl CreditKind { | |
| 2244 | + | pub fn as_str(self) -> &'static str { | |
| 2245 | + | match self { | |
| 2246 | + | CreditKind::Promotional => "promotional", | |
| 2247 | + | CreditKind::Goodwill => "goodwill", | |
| 2248 | + | CreditKind::Refund => "refund", | |
| 2249 | + | CreditKind::Purchased => "purchased", | |
| 2250 | + | } | |
| 2251 | + | } | |
| 2252 | + | ||
| 2253 | + | pub fn parse(text: &str) -> Option<CreditKind> { | |
| 2254 | + | match text { | |
| 2255 | + | "promotional" => Some(CreditKind::Promotional), | |
| 2256 | + | "goodwill" => Some(CreditKind::Goodwill), | |
| 2257 | + | "refund" => Some(CreditKind::Refund), | |
| 2258 | + | "purchased" => Some(CreditKind::Purchased), | |
| 2259 | + | _ => None, | |
| 2260 | + | } | |
| 2261 | + | } | |
| 2262 | + | ||
| 2263 | + | /// As people read it: `Promotional`. | |
| 2264 | + | pub fn label(self) -> &'static str { | |
| 2265 | + | match self { | |
| 2266 | + | CreditKind::Promotional => "Promotional", | |
| 2267 | + | CreditKind::Goodwill => "Goodwill", | |
| 2268 | + | CreditKind::Refund => "Refund", | |
| 2269 | + | CreditKind::Purchased => "Purchased", | |
| 2270 | + | } | |
| 2271 | + | } | |
| 2272 | + | } | |
| 2273 | + | ||
| 2274 | + | /// `admin_credit`: credit g1t gives a workspace: promotional, goodwill or a | |
| 2275 | + | /// refund, with a note, and optionally an expiry. It is spent before | |
| 2276 | + | /// anything paid in advance, the soonest-expiring first. The workspace's | |
| 2277 | + | /// owners are emailed. Returns `Outcome<LedgerEntry>`. | |
| 2171 | 2278 | #[derive(Debug, Serialize, Deserialize)] | |
| 2172 | 2279 | pub struct AdminCreditArgs { | |
| 2173 | 2280 | pub workspace: String, | |
| 2174 | 2281 | pub amount_micros: i64, | |
| 2175 | 2282 | pub note: String, | |
| 2176 | 2283 | pub by: String, | |
| 2284 | + | #[serde(default)] | |
| 2285 | + | pub kind: CreditKind, | |
| 2286 | + | /// RFC 3339; unused credit stops counting then. Never for a refund. | |
| 2287 | + | #[serde(default)] | |
| 2288 | + | pub expires_at: Option<String>, | |
| 2289 | + | /// A refund: what it refunds, in a line, and the day of it | |
| 2290 | + | /// (`YYYY-MM-DD`; today if absent). | |
| 2291 | + | #[serde(default)] | |
| 2292 | + | pub refund_for: Option<String>, | |
| 2293 | + | #[serde(default)] | |
| 2294 | + | pub refund_day: Option<String>, | |
| 2295 | + | } | |
| 2296 | + | ||
| 2297 | + | /// One credit g1t gave, with what of it was used: spent on usage, the | |
| 2298 | + | /// soonest-expiring grant first, before anything paid in advance. | |
| 2299 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 2300 | + | #[serde(rename_all = "camelCase")] | |
| 2301 | + | pub struct CreditGrant { | |
| 2302 | + | /// `crd_…`, the grant's ledger reference. | |
| 2303 | + | pub id: String, | |
| 2304 | + | pub workspace: String, | |
| 2305 | + | pub kind: CreditKind, | |
| 2306 | + | pub amount_micros: i64, | |
| 2307 | + | pub used_micros: i64, | |
| 2308 | + | /// What can still be spent: nothing once it expired or was revoked. | |
| 2309 | + | pub left_micros: i64, | |
| 2310 | + | pub note: String, | |
| 2311 | + | #[serde(default)] | |
| 2312 | + | pub refund_for: Option<String>, | |
| 2313 | + | #[serde(default)] | |
| 2314 | + | pub refund_day: Option<String>, | |
| 2315 | + | pub expires_at: Option<String>, | |
| 2316 | + | pub created_by: String, | |
| 2317 | + | pub created_at: String, | |
| 2318 | + | /// `open`, `used`, `expired` or `revoked`. | |
| 2319 | + | pub state: String, | |
| 2320 | + | #[serde(default)] | |
| 2321 | + | pub closed_at: Option<String>, | |
| 2322 | + | #[serde(default)] | |
| 2323 | + | pub closed_note: Option<String>, | |
| 2324 | + | #[serde(default)] | |
| 2325 | + | pub closed_by: Option<String>, | |
| 2326 | + | /// What expiring or revoking took off the balance. | |
| 2327 | + | #[serde(default)] | |
| 2328 | + | pub closed_micros: i64, | |
| 2329 | + | /// What it pays for: `all` usage, or `models` only (agent runs' model | |
| 2330 | + | /// cost), which is spent first. | |
| 2331 | + | #[serde(default)] | |
| 2332 | + | pub scope: String, | |
| 2333 | + | /// Where it came from: `staff`, `purchase` or `promo_code`. | |
| 2334 | + | #[serde(default)] | |
| 2335 | + | pub source: String, | |
| 2336 | + | } | |
| 2337 | + | ||
| 2338 | + | /// `credits` (`Outcome<Credits>`, `AccountArgs`): a workspace's credits from | |
| 2339 | + | /// g1t, newest first, for its members. | |
| 2340 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 2341 | + | #[serde(rename_all = "camelCase")] | |
| 2342 | + | pub struct Credits { | |
| 2343 | + | pub grants: Vec<CreditGrant>, | |
| 2344 | + | /// What is left to spend, in all. | |
| 2345 | + | pub left_micros: i64, | |
| 2346 | + | } | |
| 2347 | + | ||
| 2348 | + | /// `admin_credits`: every credit g1t gave, newest first, filtered. Returns | |
| 2349 | + | /// `AdminCredits`. | |
| 2350 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 2351 | + | pub struct AdminCreditsArgs { | |
| 2352 | + | #[serde(default)] | |
| 2353 | + | pub workspace: Option<String>, | |
| 2354 | + | #[serde(default)] | |
| 2355 | + | pub kind: Option<CreditKind>, | |
| 2356 | + | /// `YYYY-MM`: given that month. | |
| 2357 | + | #[serde(default)] | |
| 2358 | + | pub month: Option<String>, | |
| 2359 | + | /// Given by this member of staff. | |
| 2360 | + | #[serde(default)] | |
| 2361 | + | pub by: Option<String>, | |
| 2362 | + | } | |
| 2363 | + | ||
| 2364 | + | /// One month's credits of one kind: given, used on usage that month, and | |
| 2365 | + | /// taken back unused (expired or revoked). | |
| 2366 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 2367 | + | #[serde(rename_all = "camelCase")] | |
| 2368 | + | pub struct CreditMonth { | |
| 2369 | + | pub month: String, | |
| 2370 | + | pub kind: CreditKind, | |
| 2371 | + | pub given_micros: i64, | |
| 2372 | + | pub grants: u32, | |
| 2373 | + | pub used_micros: i64, | |
| 2374 | + | pub expired_micros: i64, | |
| 2375 | + | pub revoked_micros: i64, | |
| 2376 | + | } | |
| 2377 | + | ||
| 2378 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 2379 | + | #[serde(rename_all = "camelCase")] | |
| 2380 | + | pub struct AdminCredits { | |
| 2381 | + | /// At most 200. | |
| 2382 | + | pub grants: Vec<CreditGrant>, | |
| 2383 | + | /// The last 12 months, newest first, whatever the month filter. | |
| 2384 | + | pub months: Vec<CreditMonth>, | |
| 2385 | + | /// Who has given credit, for the filter. | |
| 2386 | + | pub staff: Vec<String>, | |
| 2387 | + | } | |
| 2388 | + | ||
| 2389 | + | /// `admin_revoke_credit`: what is left of a grant, taken off the balance, | |
| 2390 | + | /// with why. Returns `Outcome<CreditGrant>`. | |
| 2391 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 2392 | + | pub struct AdminRevokeCreditArgs { | |
| 2393 | + | pub id: String, | |
| 2394 | + | pub note: String, | |
| 2395 | + | pub by: String, | |
| 2177 | 2396 | } | |
| 2178 | 2397 | ||
| 2179 | 2398 | /// `admin_reset_billing`: a test workspace's billing wiped, so it starts | |
| ⋯ | |||
| 2479 | 2698 | /// margin: given, so a discounted sale is not margin lost. | |
| 2480 | 2699 | #[serde(default)] | |
| 2481 | 2700 | pub given_discount_micros: i64, | |
| 2701 | + | /// Credits from g1t spent on usage, by kind: given, so usage paid for | |
| 2702 | + | /// with them is never money in. Refunds are not here: they come off | |
| 2703 | + | /// money in on the day they refund. | |
| 2704 | + | #[serde(default)] | |
| 2705 | + | pub given_credit_promotional_micros: i64, | |
| 2706 | + | #[serde(default)] | |
| 2707 | + | pub given_credit_goodwill_micros: i64, | |
| 2708 | + | /// Credits over the range: given (every kind), spent on usage, and | |
| 2709 | + | /// refunds' money given back. | |
| 2710 | + | #[serde(default)] | |
| 2711 | + | pub credits_given_micros: i64, | |
| 2712 | + | #[serde(default)] | |
| 2713 | + | pub credits_used_micros: i64, | |
| 2714 | + | #[serde(default)] | |
| 2715 | + | pub credits_refunded_micros: i64, | |
| 2482 | 2716 | /// `cost_micros` by who g1t pays: Cloudflare's bill (billed amounts, | |
| 2483 | 2717 | /// after the included allowances), and model providers (the ledger's | |
| 2484 | 2718 | /// cost of the tokens, which Cloudflare's bill does not show). | |
| 128 | 128 | - **discount**: what a discount on an account's custom terms took below | |
| 129 | 129 | cost plus the margin (`ledger.discount_micros`, see | |
| 130 | 130 | [Margin floor](#margin-floor)). The usage is valued at its price, so a | |
| 131 | − | discounted sale never reads as margin lost. | |
| 131 | + | discounted sale never reads as margin lost; | |
| 132 | + | - **promotional credit** and **goodwill credit**: what credit staff gave | |
| 133 | + | paid for, when it is spent (`given_credit_promotional_micros`, | |
| 134 | + | `given_credit_goodwill_micros`, migration 0038). That usage's charge is | |
| 135 | + | taken out of cash, so it is never money in. A refund is not here: see | |
| 136 | + | [Credits from g1t](#credits-from-g1t). | |
| 132 | 137 | ||
| 133 | 138 | Otherwise a workspace's day is split by those shares of its value at | |
| 134 | 139 | price, and the same shares of each of its buckets' cost are given, its | |
| ⋯ | |||
| 301 | 306 | follows (`Terms::discounted`, `Billing::charged`): | |
| 302 | 307 | ||
| 303 | 308 | - **Standard**: charged in full. | |
| 304 | − | - **Comped**, `FREE_WHILE_BUILDING`, the plan's included usage, the trial, | |
| 309 | + | - **A 100% discount** (what was "comped"; see [Discounts](#discounts)), | |
| 310 | + | `FREE_WHILE_BUILDING`, the plan's included usage, the trial, | |
| 305 | 311 | the open-source pool, and overruns g1t covers: given, and counted by why | |
| 306 | 312 | (above). | |
| 307 | 313 | - **Custom, with a discount**: the discount comes off, and what it took | |
| ⋯ | |||
| 316 | 322 | Every usage path goes through this: `finish_run`, settling, sandbox time, | |
| 317 | 323 | features and builds (`charge_feature`), and the month-end meters. | |
| 318 | 324 | ||
| 325 | + | ## Discounts | |
| 326 | + | ||
| 327 | + | An account's terms are standard, or custom: a **discount** from 1 to 100%, | |
| 328 | + | a limit of its own, or both, with a reason (the terms' note) and an | |
| 329 | + | optional end date. What used to be "comped" is a **100% discount** | |
| 330 | + | (`Terms::full_discount`; migration `0039_discounts_not_comped.sql` moved | |
| 331 | + | every `comped` row to `custom` at 100%, and code reads a leftover `comped` | |
| 332 | + | row as 100%). In SQL, `sales::FULL_DISCOUNT_SQL`. | |
| 333 | + | ||
| 334 | + | - **Charging.** Every charge records what the discount took off it | |
| 335 | + | (`ledger.discount_micros`), 100% included: the entry is charged nothing | |
| 336 | + | and the discount is its whole price. Migration 0039 backfilled the | |
| 337 | + | discount on a 100%-discounted workspace's earlier entries that were | |
| 338 | + | charged nothing and paid by nothing, at cost plus 20%. | |
| 339 | + | - **What a 100% discount still does as "comped" did.** The plan is on | |
| 340 | + | without its price (`PlanKind::Internal`), trust is `internal` (no limit | |
| 341 | + | on unpaid usage), nothing is invoiced or closed, and g1t's own spend on | |
| 342 | + | it is held to the monthly budget (the terms' limit, at cost; see | |
| 343 | + | [Spend caps](#spend-caps)). | |
| 344 | + | - **The statement and Usage.** The customer sees every usage line at its | |
| 345 | + | price (`StatementLine.price_micros`: charged, plus what paid for it, plus | |
| 346 | + | the discount), the discount per day or project and in the totals | |
| 347 | + | (`StatementTotals.price_micros`, `discount_micros`, `discount_percent`), | |
| 348 | + | and the CSV has price and discount columns. The Usage page measures at | |
| 349 | + | price for a discounted account (`Usage.discount_micros`, | |
| 350 | + | `discount_percent`). | |
| 351 | + | - **Margin.** A 100% discount's usage is given away as before, in the | |
| 352 | + | bucket still named `comped` (`given_comped_micros`); sudo calls it | |
| 353 | + | **100% discounts**. A partial discount's part below cost plus the margin | |
| 354 | + | is `given_discount_micros` (**partial discounts**). Both are kept apart | |
| 355 | + | from margin on what was sold. | |
| 356 | + | - **sudo.** The workspace's **Terms** form takes a discount (None, 25%, | |
| 357 | + | 50%, 100%, or Custom, a whole percent; the custom field shows by CSS | |
| 358 | + | alone), a limit, an end date and the reason. Badges and filters say | |
| 359 | + | *100% discount* or *N% off*. Each change is audited (`terms`), such as | |
| 360 | + | `standard → 100% discount, monthly budget $150.00: g1t's own`. | |
| 361 | + | ||
| 362 | + | ## Credits from g1t | |
| 363 | + | ||
| 364 | + | Staff give a workspace credit from sudo; the code is | |
| 365 | + | `services/billing/src/grants.rs`, the tables `credit_grants` and | |
| 366 | + | `ledger.credit_kind` (migration `0038_staff_credits.sql`). | |
| 367 | + | ||
| 368 | + | ### Giving credit | |
| 369 | + | ||
| 370 | + | sudo → the workspace (or an enterprise, choosing one of its workspaces) → | |
| 371 | + | **Give credit**: | |
| 372 | + | ||
| 373 | + | 1. **Amount**: $10, $20, $25, $50, $100, or **Custom** (up to $10,000). | |
| 374 | + | Up to $100 it is one step; over $100, type the workspace's slug as well. | |
| 375 | + | 2. **Kind**: promotional (a welcome, a referral, an event), goodwill (an | |
| 376 | + | apology), or refund (money back for something that went wrong: say what | |
| 377 | + | it refunds and, optionally, the day). | |
| 378 | + | 3. **Expires**: never, 30, 90 or 365 days, or the end of a chosen day | |
| 379 | + | (UTC). A refund never expires. | |
| 380 | + | 4. **Note**: required. It is on the statement and in the owners' email. | |
| 381 | + | ||
| 382 | + | The form needs no JavaScript: the fields for one choice (the custom amount, | |
| 383 | + | a refund's details, the expiry date) show by CSS alone, and all show where | |
| 384 | + | `:has()` is not supported. `admin_credit` checks everything again. | |
| 385 | + | ||
| 386 | + | A grant is a `crd_…` ledger line (kind `top_up`, so never a payment) with | |
| 387 | + | `credit_kind`, and a `credit_grants` row. The balance rises at once. The | |
| 388 | + | owners are emailed through identity's `notify_owners` (the same path as | |
| 389 | + | limit notices). It is audited as `credit`. The Overages queue's one-click | |
| 390 | + | goodwill credit is a grant too, of kind goodwill. | |
| 391 | + | ||
| 392 | + | The inbox is not told: its items are threads on a repository, built from | |
| 393 | + | events, and a credit is a workspace's. That needs a workspace-level inbox | |
| 394 | + | thread first. | |
| 395 | + | ||
| 396 | + | ### How it is spent | |
| 397 | + | ||
| 398 | + | Credit is spent before anything prepaid, the soonest-expiring grant first | |
| 399 | + | (never-expiring last, then the oldest). Given while the workspace owes, it | |
| 400 | + | pays what is owed first, the most recent usage first. What each grant paid | |
| 401 | + | for is never stored: `grants::replay` works it out from the ledger in order, | |
| 402 | + | so the charge paths do not know about credit and the answer is always what | |
| 403 | + | the ledger says. A charge that comes down (a settled run) gives back to | |
| 404 | + | the grant that paid last, while it can still be spent. | |
| 405 | + | ||
| 406 | + | ### Expiry and revoking | |
| 407 | + | ||
| 408 | + | - **Expiry.** The daily run (`expire_credits`, before the reconciliation) | |
| 409 | + | closes grants past `expires_at` and enters what was left as a negative | |
| 410 | + | `crd…_expired` line; audited as `credit_expired`. A grant past its expiry | |
| 411 | + | pays for nothing even before the run. | |
| 412 | + | - **Revoke.** sudo → the workspace's **Credits** (or **Credits & refunds**) | |
| 413 | + | → **Revoke unused**, with why (`admin_revoke_credit`): what is left, as a | |
| 414 | + | `crd…_revoked` line, audited as `credit_revoked`. What was spent stays | |
| 415 | + | spent. | |
| 416 | + | ||
| 417 | + | Neither takes the balance below zero: at most the balance, if a refunded | |
| 418 | + | payment left less there than the credit. | |
| 419 | + | ||
| 420 | + | ### How margin treats them | |
| 421 | + | ||
| 422 | + | | Kind | When spent | On the day it was given | | |
| 423 | + | | --- | --- | --- | | |
| 424 | + | | Promotional | The usage is valued at its price, its charge comes out of cash and is given (`given_credit_promotional_micros`) | Nothing | | |
| 425 | + | | Goodwill | The same, as `given_credit_goodwill_micros` | Nothing | | |
| 426 | + | | Refund | Paid for: cash, as any usage | Its amount (less what was revoked) comes off cash on the day it refunds, shared over that day's paid usage | | |
| 427 | + | ||
| 428 | + | A refund gives back money already collected, so counting it as given would | |
| 429 | + | make it look like a budget g1t chose to spend. Taking it off cash for the | |
| 430 | + | day it refunds says that day's sale was worth less, and counting what it | |
| 431 | + | later pays for as cash keeps money in equal to what was collected. The | |
| 432 | + | refund's day is clamped to the last 30 days, the days the reconciliation | |
| 433 | + | recomputes; an older one lands on the oldest. Refunds never expire, so | |
| 434 | + | cash taken back is never stranded. | |
| 435 | + | ||
| 436 | + | What credit paid of a month-end meter (storage, git, scans) is its own | |
| 437 | + | row on the day it was charged, since those meters are reconciled from | |
| 438 | + | snapshots. Sudo's Costs & margin lists promotional and goodwill credit | |
| 439 | + | under **Given away**, and below the statement the range's credits given, | |
| 440 | + | spent, and refunded. **Credits & refunds** (`/credits`, `admin_credits`) | |
| 441 | + | lists every grant (by kind, month, staff and workspace) and the last 12 | |
| 442 | + | months by kind: given, spent, expired, revoked. | |
| 443 | + | ||
| 444 | + | ### Purchased and scoped credit (for prepaid AI) | |
| 445 | + | ||
| 446 | + | `credit_grants` also has `scope` (`all`, or `models`: an agent run's model | |
| 447 | + | usage only, `grants::is_model_usage`) and `source` (`staff`, `purchase`, | |
| 448 | + | `promo_code`), and `CreditKind::Purchased`. Spending takes credit scoped | |
| 449 | + | to models first, then the soonest-expiring. Purchased credit is money paid | |
| 450 | + | in: its ledger line is a payment (not `crd…`), and the usage it pays for | |
| 451 | + | stays money in (revenue as it is spent, a liability until then), never | |
| 452 | + | given. Staff cannot give it (`admin_credit` refuses the kind). | |
| 453 | + | Nothing writes purchased grants yet: prepaying for AI builds on this. | |
| 454 | + | ||
| 455 | + | ### Earlier credits | |
| 456 | + | ||
| 457 | + | Migration 0038 makes every earlier `Credit from g1t:` line a goodwill | |
| 458 | + | grant with no expiry: the old form asked for "a refund or goodwill" with | |
| 459 | + | no way to tell them apart, and goodwill never reads as money in. | |
| 460 | + | ||
| 319 | 461 | ## Token usage | |
| 320 | 462 | ||
| 321 | 463 | The model proxy (`services/models`) reads Anthropic's `usage` from every | |
| ⋯ | |||
| 346 | 488 | `0023_one_operation_mapping.sql` drops `billable_units` (see above). | |
| 347 | 489 | Migration `0036_model_costs_in_full.sql` adds `ledger.discount_micros`, | |
| 348 | 490 | `margin_days.given_discount_micros`, `runs.gateway_note` and the | |
| 349 | − | `ai_gateway_requests` → `models` mapping. | |
| 491 | + | `ai_gateway_requests` → `models` mapping. Migration | |
| 492 | + | `0038_staff_credits.sql` adds `credit_grants`, `ledger.credit_kind` and | |
| 493 | + | `margin_days.given_credit_{promotional,goodwill}_micros`, and backfills | |
| 494 | + | earlier credits (see [Credits from g1t](#credits-from-g1t)). | |
| 350 | 495 | ||
| 351 | 496 | ## Spend caps | |
| 352 | 497 | ||
| 989 | 989 | stops work until paid. The owner's own spend limit always means stop. | |
| 990 | 990 | Test-mode payments never lower exposure or raise trust. | |
| 991 | 991 | ||
| 992 | + | ### Promo codes (planned) | |
| 993 | + | ||
| 994 | + | Staff credits (promotional, goodwill, refund; `services/billing/src/grants.rs`, | |
| 995 | + | docs/BILLING_OPERATIONS.md) are given one workspace at a time. Promo codes | |
| 996 | + | give promotional credit in bulk, on redemption, through the same grants: | |
| 997 | + | ||
| 998 | + | - **Codes.** sudo → Credits & refunds → **New code**: the code (or one | |
| 999 | + | made up), the credit ($ amount), max redemptions, when the code stops | |
| 1000 | + | working, how long each redeemed credit lasts (an expiry, as any grant), | |
| 1001 | + | and a note. Table `promo_codes` (code, amount, max, redeemed, ends_at, | |
| 1002 | + | credit_days, note, created_by, disabled_at) and `promo_redemptions` | |
| 1003 | + | (code, workspace, grant id, by, at), unique on (code, workspace). | |
| 1004 | + | - **Redeeming.** An owner types it on the workspace's Billing page | |
| 1005 | + | (`redeem_code`, owners only). One D1 batch checks the code is open, under | |
| 1006 | + | its max and not redeemed by the workspace, counts the redemption and | |
| 1007 | + | makes the grant (`grant_credit`, kind promotional, the code as its note), | |
| 1008 | + | so two redemptions at once never pass the max. Wrong or used-up codes say | |
| 1009 | + | so without telling which codes exist; redemptions are rate-limited per | |
| 1010 | + | owner. | |
| 1011 | + | - **Seeing them.** Each code's redemptions, credit given and spent come | |
| 1012 | + | from the grants it made, so margin needs nothing new: spent promo credit | |
| 1013 | + | is already given away, by kind. | |
| 1014 | + | - **Not yet built** because it adds an owner-facing form, abuse limits and | |
| 1015 | + | a second sudo form; giving credit by workspace covers launch. | |
| 1016 | + | ||
| 992 | 1017 | ## Agents and models | |
| 993 | 1018 | ||
| 994 | 1019 | ### Defining an agent |
| 68 | 68 | ossMicros?: number; | |
| 69 | 69 | /** For usage: what g1t covered itself, such as a trial's last run past its credit. */ | |
| 70 | 70 | givenMicros?: number; | |
| 71 | + | /** For usage: what the account's discount took off its price. */ | |
| 72 | + | discountMicros?: number; | |
| 73 | + | /** For a credit from g1t, and for what of one expired or was revoked: its kind. */ | |
| 74 | + | creditKind?: CreditKind; | |
| 71 | 75 | }; | |
| 72 | 76 | ||
| 77 | + | /** | |
| 78 | + | * Why g1t gave a workspace credit. Promotional (a welcome, a referral) and | |
| 79 | + | * goodwill (an apology) are given away when spent; a refund gives back | |
| 80 | + | * money already paid, and never expires. | |
| 81 | + | */ | |
| 82 | + | export type CreditKind = "promotional" | "goodwill" | "refund" | "purchased"; | |
| 83 | + | ||
| 84 | + | /** One credit g1t gave, with what of it was used: spent before anything paid in advance, the soonest-expiring first. */ | |
| 85 | + | export type CreditGrant = { | |
| 86 | + | /** `crd_…`, the grant's ledger reference. */ | |
| 87 | + | id: string; | |
| 88 | + | workspace: string; | |
| 89 | + | kind: CreditKind; | |
| 90 | + | amountMicros: number; | |
| 91 | + | usedMicros: number; | |
| 92 | + | /** What can still be spent: nothing once it expired or was revoked. */ | |
| 93 | + | leftMicros: number; | |
| 94 | + | note: string; | |
| 95 | + | /** A refund: what it refunds, and the day of it. */ | |
| 96 | + | refundFor?: string | null; | |
| 97 | + | refundDay?: string | null; | |
| 98 | + | /** RFC 3339; null never expires. */ | |
| 99 | + | expiresAt: string | null; | |
| 100 | + | createdBy: string; | |
| 101 | + | createdAt: string; | |
| 102 | + | state: "open" | "used" | "expired" | "revoked"; | |
| 103 | + | closedAt?: string | null; | |
| 104 | + | closedNote?: string | null; | |
| 105 | + | closedBy?: string | null; | |
| 106 | + | /** What expiring or revoking took off the balance. */ | |
| 107 | + | closedMicros?: number; | |
| 108 | + | /** What it pays for: all usage, or models only (spent first). */ | |
| 109 | + | scope?: "all" | "models"; | |
| 110 | + | /** Where it came from. */ | |
| 111 | + | source?: "staff" | "purchase" | "promo_code"; | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | /** A workspace's credits from g1t, newest first. */ | |
| 115 | + | export type Credits = { grants: CreditGrant[]; leftMicros: number }; | |
| 116 | + | ||
| 117 | + | /** One month's credits of one kind: given, spent that month, and taken back unused. */ | |
| 118 | + | export type CreditMonth = { | |
| 119 | + | month: string; | |
| 120 | + | kind: CreditKind; | |
| 121 | + | givenMicros: number; | |
| 122 | + | grants: number; | |
| 123 | + | usedMicros: number; | |
| 124 | + | expiredMicros: number; | |
| 125 | + | revokedMicros: number; | |
| 126 | + | }; | |
| 127 | + | ||
| 128 | + | /** Every credit g1t gave (at most 200, filtered), the last 12 months by kind, and who gave them. */ | |
| 129 | + | export type AdminCredits = { grants: CreditGrant[]; months: CreditMonth[]; staff: string[] }; | |
| 130 | + | ||
| 131 | + | /** What a credit from sudo is, past its amount and note. */ | |
| 132 | + | export type CreditOptions = { | |
| 133 | + | kind: CreditKind; | |
| 134 | + | /** RFC 3339; never for a refund. */ | |
| 135 | + | expiresAt?: string | null; | |
| 136 | + | /** A refund: what it is for, and the day refunded (`YYYY-MM-DD`). */ | |
| 137 | + | refundFor?: string | null; | |
| 138 | + | refundDay?: string | null; | |
| 139 | + | }; | |
| 140 | + | ||
| 73 | 141 | /** What lets a sandbox, and nothing else, report what its run cost. */ | |
| 74 | 142 | export type RunTicket = { runId: string; token: string }; | |
| 75 | 143 | ||
| ⋯ | |||
| 83 | 151 | * open to them: a few dollars of model cost each, out of one pool, until a | |
| 84 | 152 | * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`. | |
| 85 | 153 | */ | |
| 86 | − | /** How an account is charged. Standard unless g1t set otherwise in sudo. */ | |
| 154 | + | /** | |
| 155 | + | * How an account is charged. Standard unless g1t set otherwise in sudo: | |
| 156 | + | * custom terms are a discount (0 to 100%), a ceiling, or both. A 100% | |
| 157 | + | * discount charges nothing and shows the usage at its price. `comped` is | |
| 158 | + | * from before discounts and reads as 100%; billing no longer writes it. | |
| 159 | + | */ | |
| 87 | 160 | export type Terms = { | |
| 88 | 161 | kind: "standard" | "comped" | "custom"; | |
| 89 | 162 | discountPercent: number; | |
| ⋯ | |||
| 394 | 467 | key: string; | |
| 395 | 468 | label: string; | |
| 396 | 469 | /** `coveredMicros`: what the plan's included usage, the trial, the open-source pool or g1t paid, not in `chargedMicros`. */ | |
| 397 | − | lines: { kind: string; count: number; chargedMicros: number; costMicros: number; coveredMicros?: number }[]; | |
| 470 | + | /** `priceMicros`: usage at its price; `discountMicros`: what the account's discount took off it. */ | |
| 471 | + | lines: { | |
| 472 | + | kind: string; | |
| 473 | + | count: number; | |
| 474 | + | chargedMicros: number; | |
| 475 | + | costMicros: number; | |
| 476 | + | coveredMicros?: number; | |
| 477 | + | priceMicros?: number; | |
| 478 | + | discountMicros?: number; | |
| 479 | + | }[]; | |
| 398 | 480 | chargedMicros: number; | |
| 481 | + | priceMicros?: number; | |
| 482 | + | discountMicros?: number; | |
| 399 | 483 | }[]; | |
| 400 | 484 | totals: { | |
| 401 | 485 | chargedMicros: number; | |
| 402 | 486 | paidMicros: number; | |
| 403 | 487 | costMicros: number; | |
| 404 | 488 | entries: number; | |
| 489 | + | /** Usage at price, and what the discount took off: charged is the price less the discount and what paid for it. */ | |
| 490 | + | priceMicros?: number; | |
| 491 | + | discountMicros?: number; | |
| 492 | + | /** The account's discount now, in percent; absent without one. */ | |
| 493 | + | discountPercent?: number | null; | |
| 405 | 494 | /** What paid for usage before it was charged, such as "Paid by g1t's open-source pool". */ | |
| 406 | 495 | covered?: { source: "included" | "trial" | "oss_pool" | "given" | string; label: string; micros: number }[]; | |
| 407 | 496 | /** Owed when the month closed but under the minimum charge: on the next invoice. */ | |
| ⋯ | |||
| 523 | 612 | setAllowances(id: string, allowances: Allowances, note: string, by: string): Promise<Result<PayingAccount>>; | |
| 524 | 613 | createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>; | |
| 525 | 614 | attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>; | |
| 526 | − | credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>; | |
| 615 | + | /** Credit for a workspace: promotional, goodwill or a refund; the owners are emailed. */ | |
| 616 | + | credit(workspace: string, amountMicros: number, note: string, by: string, options?: CreditOptions): Promise<Result<LedgerEntry>>; | |
| 617 | + | /** Every credit g1t gave, filtered, with each month's totals by kind. */ | |
| 618 | + | credits(filter?: { workspace?: string | null; kind?: CreditKind | null; month?: string | null; by?: string | null }): Promise<AdminCredits>; | |
| 619 | + | /** What is left of a credit, taken back, with why. */ | |
| 620 | + | revokeCredit(id: string, note: string, by: string): Promise<Result<CreditGrant>>; | |
| 527 | 621 | /** A test workspace's billing wiped, to start again as a new customer. Only on Stripe's test key; never comped or enterprise. Logged. */ | |
| 528 | 622 | resetBilling(workspace: string, confirm: string, note: string, by: string): Promise<Result<BillingReset>>; | |
| 529 | 623 | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| ⋯ | |||
| 786 | 880 | account(workspace: string, viewer: Viewer): Promise<Result<BillingAccount>>; | |
| 787 | 881 | /** Newest first. Members of the workspace only. */ | |
| 788 | 882 | ledger(workspace: string, viewer: Viewer): Promise<Result<LedgerEntry[]>>; | |
| 883 | + | /** Credits from g1t, newest first, with what is left of each. Members only. */ | |
| 884 | + | credits(workspace: string, viewer: Viewer): Promise<Result<Credits>>; | |
| 789 | 885 | /** A month of the ledger, grouped by `day` (default) or `project`. Members only. */ | |
| 790 | 886 | statement(workspace: string, viewer: Viewer, month?: string | null, group?: "day" | "project"): Promise<Result<Statement>>; | |
| 791 | 887 | /** One statement line's entries, 50 at a time; `before` is the last id seen. */ | |
| ⋯ | |||
| 1019 | 1115 | spentMicros: number; | |
| 1020 | 1116 | /** What g1t's usage came to at price, less what was charged: the plan's included usage, the trial, a pool or a free period paid it. Usage at price is `spentMicros` plus this. */ | |
| 1021 | 1117 | coveredMicros?: number; | |
| 1118 | + | /** What the account's discount took off the price; usage at price is spent + covered + this. */ | |
| 1119 | + | discountMicros?: number; | |
| 1120 | + | /** The account's discount now, in percent; with one, the slices are at price. */ | |
| 1121 | + | discountPercent?: number | null; | |
| 1022 | 1122 | /** What g1t's model provider charged, before the margin. */ | |
| 1023 | 1123 | costMicros: number; | |
| 1024 | 1124 | /** What runs on the workspace's own provider cost there, estimated. Not charged by g1t. */ | |
| ⋯ | |||
| 1085 | 1185 | givenPoolMicros?: number; | |
| 1086 | 1186 | /** What discounts on an account's terms took below cost plus the margin: given, not margin lost. */ | |
| 1087 | 1187 | givenDiscountMicros?: number; | |
| 1188 | + | /** Credits from g1t spent on usage, by kind: given, never money in. Refunds come off money in instead. */ | |
| 1189 | + | givenCreditPromotionalMicros?: number; | |
| 1190 | + | givenCreditGoodwillMicros?: number; | |
| 1191 | + | /** Credits over the range: given (every kind), spent on usage, and refunds' money given back. */ | |
| 1192 | + | creditsGivenMicros?: number; | |
| 1193 | + | creditsUsedMicros?: number; | |
| 1194 | + | creditsRefundedMicros?: number; | |
| 1088 | 1195 | /** costMicros by who g1t pays: Cloudflare's bill (billed, after included allowances) and model providers (tokens, not on Cloudflare's bill). */ | |
| 1089 | 1196 | /** What the plan's included usage paid for, at price: money in for usage, paid out of plansMicros. */ | |
| 1090 | 1197 | includedMicros?: number; | |
| 440 | 440 | status: () => call("status", {}), | |
| 441 | 441 | account: (workspace, viewer) => call("account", { workspace, viewer }), | |
| 442 | 442 | ledger: (workspace, viewer) => call("ledger", { workspace, viewer }), | |
| 443 | + | credits: (workspace, viewer) => call("credits", { workspace, viewer }), | |
| 443 | 444 | statement: (workspace, viewer, month = null, group = "day") => call("statement", { workspace, viewer, month, group }), | |
| 444 | 445 | statementEntries: (workspace, viewer, filter) => | |
| 445 | 446 | call("statement_entries", { | |
| ⋯ | |||
| 501 | 502 | setAllowances: (id, allowances, note, by) => call("admin_set_allowances", { id, allowances, note, by }), | |
| 502 | 503 | createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }), | |
| 503 | 504 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 504 | − | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), | |
| 505 | + | credit: (workspace, amountMicros, note, by, options = { kind: "goodwill" }) => | |
| 506 | + | call("admin_credit", { | |
| 507 | + | workspace, | |
| 508 | + | amount_micros: amountMicros, | |
| 509 | + | note, | |
| 510 | + | by, | |
| 511 | + | kind: options.kind, | |
| 512 | + | expires_at: options.expiresAt ?? null, | |
| 513 | + | refund_for: options.refundFor ?? null, | |
| 514 | + | refund_day: options.refundDay ?? null, | |
| 515 | + | }), | |
| 516 | + | credits: (filter = {}) => | |
| 517 | + | call("admin_credits", { | |
| 518 | + | workspace: filter.workspace ?? null, | |
| 519 | + | kind: filter.kind ?? null, | |
| 520 | + | month: filter.month ?? null, | |
| 521 | + | by: filter.by ?? null, | |
| 522 | + | }), | |
| 523 | + | revokeCredit: (id, note, by) => call("admin_revoke_credit", { id, note, by }), | |
| 505 | 524 | resetBilling: (workspace, confirm, note, by) => call("admin_reset_billing", { workspace, confirm, note, by }), | |
| 506 | 525 | billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }), | |
| 507 | 526 | stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }), | |
| 1 | + | -- Credits g1t staff give a workspace from sudo: promotional, goodwill or a | |
| 2 | + | -- refund, each with a note, who gave it, and an optional expiry. | |
| 3 | + | -- | |
| 4 | + | -- A grant is a ledger entry (kind top_up, reference `crd…`, so it is never | |
| 5 | + | -- a payment) with `ledger.credit_kind` set; what expires or is revoked | |
| 6 | + | -- unused is another, negative, with the same kind and the reference | |
| 7 | + | -- `<grant>_expired` or `<grant>_revoked`. How much of a grant was used is | |
| 8 | + | -- not stored: it is worked out from the ledger in order (grants.rs), the | |
| 9 | + | -- soonest-expiring grant first, so it is always what the ledger says. | |
| 10 | + | ALTER TABLE ledger ADD COLUMN credit_kind TEXT; | |
| 11 | + | ||
| 12 | + | CREATE TABLE IF NOT EXISTS credit_grants ( | |
| 13 | + | -- `crd_…`: the grant's ledger reference. | |
| 14 | + | id TEXT PRIMARY KEY, | |
| 15 | + | workspace TEXT NOT NULL, | |
| 16 | + | -- promotional, goodwill or refund (staff); purchased (paid for, by the | |
| 17 | + | -- workspace: cash, not given). | |
| 18 | + | kind TEXT NOT NULL, | |
| 19 | + | -- What it pays for: all usage, or models only (agent runs' model cost). | |
| 20 | + | -- Scoped credit is spent before credit for everything. | |
| 21 | + | scope TEXT NOT NULL DEFAULT 'all', | |
| 22 | + | -- Where it came from: staff (sudo), purchase, or promo_code. | |
| 23 | + | source TEXT NOT NULL DEFAULT 'staff', | |
| 24 | + | amount_micros INTEGER NOT NULL, | |
| 25 | + | note TEXT NOT NULL, | |
| 26 | + | -- A refund: what it refunds, and the day whose money it gives back. | |
| 27 | + | refund_for TEXT, | |
| 28 | + | refund_day TEXT, | |
| 29 | + | -- RFC 3339; null never expires. | |
| 30 | + | expires_at TEXT, | |
| 31 | + | created_by TEXT NOT NULL, | |
| 32 | + | created_at TEXT NOT NULL, | |
| 33 | + | -- Expired or revoked: when, why, and what was taken off the balance. | |
| 34 | + | closed_at TEXT, | |
| 35 | + | closed_reason TEXT, | |
| 36 | + | closed_note TEXT, | |
| 37 | + | closed_by TEXT, | |
| 38 | + | closed_micros INTEGER NOT NULL DEFAULT 0 | |
| 39 | + | ); | |
| 40 | + | CREATE INDEX IF NOT EXISTS credit_grants_by_workspace ON credit_grants (workspace, created_at); | |
| 41 | + | CREATE INDEX IF NOT EXISTS credit_grants_open ON credit_grants (closed_at, expires_at); | |
| 42 | + | CREATE INDEX IF NOT EXISTS credit_grants_by_month ON credit_grants (created_at); | |
| 43 | + | ||
| 44 | + | -- Spent credit, by kind: given away (promotional, goodwill). A refund's | |
| 45 | + | -- use is not given: it gives back money already paid, and takes it off | |
| 46 | + | -- cash on the day it refunds instead. | |
| 47 | + | ALTER TABLE margin_days ADD COLUMN given_credit_promotional_micros INTEGER NOT NULL DEFAULT 0; | |
| 48 | + | ALTER TABLE margin_days ADD COLUMN given_credit_goodwill_micros INTEGER NOT NULL DEFAULT 0; | |
| 49 | + | ||
| 50 | + | -- Credits from g1t until now were all "a refund or goodwill" by the form's | |
| 51 | + | -- word, with no way to tell which: goodwill, which counts as given and so | |
| 52 | + | -- never as money in. None expire. | |
| 53 | + | UPDATE ledger SET credit_kind = 'goodwill' | |
| 54 | + | WHERE kind = 'top_up' AND reference LIKE 'crd%' AND amount_micros > 0 AND description LIKE 'Credit from g1t:%' | |
| 55 | + | AND credit_kind IS NULL; | |
| 56 | + | INSERT OR IGNORE INTO credit_grants (id, workspace, kind, amount_micros, note, created_by, created_at) | |
| 57 | + | SELECT reference, workspace, 'goodwill', amount_micros, | |
| 58 | + | TRIM(substr(description, length('Credit from g1t:') + 1)), | |
| 59 | + | COALESCE(created_by, 'g1t'), created_at | |
| 60 | + | FROM ledger | |
| 61 | + | WHERE kind = 'top_up' AND reference LIKE 'crd%' AND amount_micros > 0 AND description LIKE 'Credit from g1t:%'; |
| 1 | + | -- "Comped" becomes what it always was: a 100% discount on custom terms, | |
| 2 | + | -- with its note as the reason and its ceiling as the monthly budget at | |
| 3 | + | -- cost. Code reads a leftover `comped` row as 100% too. | |
| 4 | + | UPDATE billing_accounts SET terms_kind = 'custom', discount_percent = 100 WHERE terms_kind = 'comped'; | |
| 5 | + | ||
| 6 | + | -- What a 100%-discounted workspace's usage was worth, on the entries from | |
| 7 | + | -- before discounts recorded it: charged nothing and paid by nothing, at | |
| 8 | + | -- cost plus the margin (MARGIN_PERCENT, 20), rounded up as `margin_on` | |
| 9 | + | -- does. So its statement shows every line at its price and the discount | |
| 10 | + | -- beside it. The reconciliation counts these workspaces' usage as given | |
| 11 | + | -- from their cost, not from this column, so margins do not move. | |
| 12 | + | UPDATE ledger SET discount_micros = (cost_micros * 120 + 99) / 100 | |
| 13 | + | WHERE kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' AND amount_micros = 0 AND COALESCE(cost_micros, 0) > 0 | |
| 14 | + | AND discount_micros = 0 AND credit_micros = 0 AND trial_micros = 0 AND oss_micros = 0 AND given_micros = 0 | |
| 15 | + | AND workspace IN ( | |
| 16 | + | SELECT substr(id, 4) FROM billing_accounts WHERE kind = 'workspace' AND terms_kind = 'custom' AND discount_percent >= 100 | |
| 17 | + | UNION SELECT m.workspace FROM account_members m JOIN billing_accounts b ON b.id = m.account_id | |
| 18 | + | WHERE b.terms_kind = 'custom' AND b.discount_percent >= 100 | |
| 19 | + | ); |
| 10 | 10 | //! - **Enterprises.** One account paying for several workspaces, as GitHub | |
| 11 | 11 | //! Enterprise does: their usage and payments count together against one | |
| 12 | 12 | //! limit, on one set of terms. | |
| 13 | − | //! - **Credits**, such as refunds. | |
| 13 | + | //! - **Credits**: promotional, goodwill or refunds (see `grants`). | |
| 14 | 14 | //! | |
| 15 | 15 | //! Every change names who made it and is kept in `admin_actions`. | |
| 16 | 16 | ||
| 17 | 17 | use g1t_contracts::billing::{ | |
| 18 | 18 | AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs, | |
| 19 | − | AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount, | |
| 20 | − | EntryKind, LedgerEntry, Terms, TermsKind, WorkspaceFigures, | |
| 19 | + | AdminCreateEnterpriseArgs, AdminSetAllowancesArgs, AdminSetTermsArgs, Allowances, BillingAccount, LedgerEntry, Terms, | |
| 20 | + | TermsKind, WorkspaceFigures, | |
| 21 | 21 | }; | |
| 22 | 22 | use g1t_contracts::time::rfc3339; | |
| 23 | 23 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| ⋯ | |||
| 130 | 130 | fn describe(terms: &Terms) -> String { | |
| 131 | 131 | let mut text = match terms.kind { | |
| 132 | 132 | TermsKind::Standard => "standard".to_owned(), | |
| 133 | − | TermsKind::Comped => "comped".to_owned(), | |
| 134 | − | TermsKind::Custom => { | |
| 133 | + | TermsKind::Comped | TermsKind::Custom => { | |
| 135 | 134 | let mut parts = vec![]; | |
| 136 | − | if terms.discount_percent > 0 { | |
| 137 | − | parts.push(format!("{}% off", terms.discount_percent)); | |
| 135 | + | if let Some(label) = terms.discount_label() { | |
| 136 | + | parts.push(label); | |
| 138 | 137 | } | |
| 139 | 138 | if let Some(ceiling) = terms.ceiling_micros { | |
| 140 | − | parts.push(format!("ceiling {}", crate::features::dollars(ceiling))); | |
| 139 | + | let what = if terms.full_discount() { "monthly budget" } else { "ceiling" }; | |
| 140 | + | parts.push(format!("{what} {}", crate::features::dollars(ceiling))); | |
| 141 | 141 | } | |
| 142 | − | format!("custom ({})", if parts.is_empty() { "no changes".to_owned() } else { parts.join(", ") }) | |
| 142 | + | if parts.is_empty() { "custom (no changes)".to_owned() } else { parts.join(", ") } | |
| 143 | 143 | } | |
| 144 | 144 | }; | |
| 145 | 145 | if let Some(until) = &terms.until { | |
| ⋯ | |||
| 453 | 453 | Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit })) | |
| 454 | 454 | } | |
| 455 | 455 | ||
| 456 | − | pub(crate) async fn admin_set_terms(&self, a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> { | |
| 456 | + | pub(crate) async fn admin_set_terms(&self, mut a: AdminSetTermsArgs) -> Result<Outcome<BillingAccount>> { | |
| 457 | + | a.terms = normalized(a.terms); | |
| 457 | 458 | if a.by.trim().is_empty() { | |
| 458 | 459 | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change.")); | |
| 459 | 460 | } | |
| ⋯ | |||
| 675 | 676 | Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))), | |
| 676 | 677 | } | |
| 677 | 678 | } | |
| 679 | + | } | |
| 678 | 680 | ||
| 679 | − | pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> { | |
| 680 | − | let workspace = a.workspace.trim().to_lowercase(); | |
| 681 | − | if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() { | |
| 682 | − | return Ok(Outcome::fail(FailureCode::Invalid, "A credit needs a workspace, a note and who gave it.")); | |
| 683 | − | } | |
| 684 | − | if a.amount_micros <= 0 || a.amount_micros > 10_000 * g1t_contracts::billing::MICROS_PER_DOLLAR { | |
| 685 | − | return Ok(Outcome::fail(FailureCode::Invalid, "A credit is more than $0 and at most $10,000.")); | |
| 686 | − | } | |
| 687 | − | let reference = new_id("crd", now_ms()); | |
| 688 | − | let description = format!("Credit from g1t: {}", a.note.trim()); | |
| 689 | − | self.enter(&workspace, EntryKind::TopUp, a.amount_micros, &description, &reference, None, None, Some(&a.by), None) | |
| 690 | − | .await?; | |
| 691 | − | let account = self.account_of(&workspace).await?; | |
| 692 | − | self.audit(&account.id, "credit", &format!("{} to {workspace}: {}", crate::features::dollars(a.amount_micros), a.note.trim()), &a.by) | |
| 693 | − | .await?; | |
| 694 | − | let row = self | |
| 695 | − | .db | |
| 696 | − | .prepare("SELECT * FROM ledger WHERE reference = ?") | |
| 697 | − | .bind(&[reference.as_str().into()])? | |
| 698 | − | .first::<LedgerRow>(None) | |
| 699 | − | .await?; | |
| 700 | − | Ok(match row { | |
| 701 | − | Some(row) => Outcome::Ok(LedgerEntry::from(row)), | |
| 702 | − | None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."), | |
| 703 | − | }) | |
| 681 | + | /// Terms as billing keeps them: "comped" is a 100% discount, and custom | |
| 682 | + | /// terms with nothing in them are standard. | |
| 683 | + | fn normalized(mut terms: Terms) -> Terms { | |
| 684 | + | if terms.kind == TermsKind::Comped { | |
| 685 | + | terms.kind = TermsKind::Custom; | |
| 686 | + | terms.discount_percent = 100; | |
| 687 | + | } | |
| 688 | + | if terms.kind == TermsKind::Custom && terms.discount_percent == 0 && terms.ceiling_micros.is_none() { | |
| 689 | + | terms.kind = TermsKind::Standard; | |
| 690 | + | } | |
| 691 | + | if terms.kind == TermsKind::Standard { | |
| 692 | + | terms.discount_percent = 0; | |
| 693 | + | terms.ceiling_micros = None; | |
| 704 | 694 | } | |
| 695 | + | terms | |
| 705 | 696 | } | |
| 706 | 697 | ||
| 707 | 698 | /// Allowances as the audit log reads them. | |
| ⋯ | |||
| 773 | 764 | assert_eq!(charged + given, base); | |
| 774 | 765 | // Over 100% is everything given, never a negative charge. | |
| 775 | 766 | assert_eq!(terms(TermsKind::Custom, 250).discounted(base), (0, base)); | |
| 776 | − | // Comped is counted as comped by the reconciliation, not here. | |
| 777 | − | assert_eq!(terms(TermsKind::Comped, 0).discounted(base), (0, 0)); | |
| 767 | + | // A 100% discount (and "comped", from before discounts) charges | |
| 768 | + | // nothing and records the whole price as the discount, so the | |
| 769 | + | // statement shows what the workspace would pay. | |
| 770 | + | assert_eq!(terms(TermsKind::Custom, 100).discounted(base), (0, base)); | |
| 771 | + | assert_eq!(terms(TermsKind::Comped, 0).discounted(base), (0, base)); | |
| 772 | + | assert!(terms(TermsKind::Comped, 0).full_discount() && terms(TermsKind::Custom, 100).full_discount()); | |
| 773 | + | assert!(!terms(TermsKind::Custom, 99).full_discount() && !Terms::standard().full_discount()); | |
| 778 | 774 | // Every discount: charged plus given is the whole charge. | |
| 779 | 775 | for percent in 0..=100 { | |
| 780 | 776 | let (charged, given) = terms(TermsKind::Custom, percent).discounted(base); | |
| ⋯ | |||
| 785 | 781 | #[test] | |
| 786 | 782 | fn terms_read_plainly_in_the_audit_log() { | |
| 787 | 783 | let custom = Terms { ceiling_micros: Some(50_000_000), note: "Design partner".into(), ..terms(TermsKind::Custom, 20) }; | |
| 788 | − | assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner"); | |
| 784 | + | assert_eq!(describe(&custom), "20% off, ceiling $50.00: Design partner"); | |
| 789 | 785 | assert_eq!(describe(&Terms::standard()), "standard"); | |
| 786 | + | let flagon = Terms { ceiling_micros: Some(150_000_000), note: "g1t's own".into(), ..terms(TermsKind::Custom, 100) }; | |
| 787 | + | assert_eq!(describe(&flagon), "100% discount, monthly budget $150.00: g1t's own"); | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | #[test] | |
| 791 | + | fn comped_terms_are_kept_as_a_100_percent_discount() { | |
| 792 | + | let comped = normalized(Terms { note: "Partner".into(), ..terms(TermsKind::Comped, 0) }); | |
| 793 | + | assert_eq!((comped.kind, comped.discount_percent), (TermsKind::Custom, 100)); | |
| 794 | + | // Nothing in custom terms is standard, and standard keeps nothing. | |
| 795 | + | assert_eq!(normalized(terms(TermsKind::Custom, 0)).kind, TermsKind::Standard); | |
| 796 | + | let standard = normalized(Terms { ceiling_micros: Some(1), ..terms(TermsKind::Standard, 30) }); | |
| 797 | + | assert_eq!((standard.discount_percent, standard.ceiling_micros), (0, None)); | |
| 790 | 798 | } | |
| 791 | 799 | ||
| 792 | 800 | #[test] | |
| 31 | 31 | //! docs/BILLING_OPERATIONS.md. | |
| 32 | 32 | ||
| 33 | 33 | use g1t_contracts::billing::{ | |
| 34 | − | AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, TermsKind, | |
| 34 | + | AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, | |
| 35 | 35 | }; | |
| 36 | 36 | use g1t_contracts::time::rfc3339; | |
| 37 | 37 | use g1t_contracts::{FailureCode, Outcome}; | |
| ⋯ | |||
| 219 | 219 | return Ok(()); | |
| 220 | 220 | } | |
| 221 | 221 | let account = self.account_of(workspace).await?; | |
| 222 | − | let paid = share(cost, charged, drawn, account.terms.kind == TermsKind::Comped, self.live()); | |
| 222 | + | let paid = share(cost, charged, drawn, account.terms.full_discount(), self.live()); | |
| 223 | 223 | if paid.total() == 0 { | |
| 224 | 224 | return Ok(()); | |
| 225 | 225 | } | |
| ⋯ | |||
| 324 | 324 | if self.caps.daily <= 0 || !breaker_applies(kind, hosted_model) { | |
| 325 | 325 | return Ok(None); | |
| 326 | 326 | } | |
| 327 | − | let comped = account.terms.kind == TermsKind::Comped; | |
| 327 | + | let comped = account.terms.full_discount(); | |
| 328 | 328 | if covered_by_revenue(plan, comped, account.allowances.plan, self.live()) { | |
| 329 | 329 | return Ok(None); | |
| 330 | 330 | } | |
| ⋯ | |||
| 362 | 362 | /// Why new work on a comped account is refused, if its budget is used | |
| 363 | 363 | /// up. None for every other account. | |
| 364 | 364 | pub(crate) async fn comped_stop(&self, account: &BillingAccount) -> Result<Option<String>> { | |
| 365 | − | if account.terms.kind != TermsKind::Comped { | |
| 365 | + | if !account.terms.full_discount() { | |
| 366 | 366 | return Ok(None); | |
| 367 | 367 | } | |
| 368 | 368 | let budget = self.comped_budget(account).await?; | |
| ⋯ | |||
| 382 | 382 | } | |
| 383 | 383 | let comped = self | |
| 384 | 384 | .db | |
| 385 | − | .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped'") | |
| 385 | + | .prepare(format!("SELECT id FROM billing_accounts WHERE {}", crate::sales::FULL_DISCOUNT_SQL)) | |
| 386 | 386 | .all() | |
| 387 | 387 | .await? | |
| 388 | 388 | .results::<Id>()?; | |
| ⋯ | |||
| 469 | 469 | } | |
| 470 | 470 | let ids = self | |
| 471 | 471 | .db | |
| 472 | − | .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped' ORDER BY id") | |
| 472 | + | .prepare(format!("SELECT id FROM billing_accounts WHERE {} ORDER BY id", crate::sales::FULL_DISCOUNT_SQL)) | |
| 473 | 473 | .all() | |
| 474 | 474 | .await? | |
| 475 | 475 | .results::<Id>()?; | |
| ⋯ | |||
| 553 | 553 | /// How sudo names a bucket of g1t's own spend. | |
| 554 | 554 | pub(crate) fn bucket_title(bucket: &str) -> &'static str { | |
| 555 | 555 | match bucket { | |
| 556 | − | "comped" => "Comped (g1t's own)", | |
| 556 | + | "comped" => "100% discount (g1t's own)", | |
| 557 | 557 | "trial" => "Trial pool", | |
| 558 | 558 | "oss" => "Open-source pool", | |
| 559 | 559 | "given" => "Free overruns g1t covered", | |
| 18 | 18 | //! like any other), and records it. The ledger, invoices and statements | |
| 19 | 19 | //! stay, under the slug, for accounting. | |
| 20 | 20 | ||
| 21 | − | use g1t_contracts::billing::{CloseWorkspaceArgs, TermsKind}; | |
| 21 | + | use g1t_contracts::billing::CloseWorkspaceArgs; | |
| 22 | 22 | use g1t_contracts::time::rfc3339; | |
| 23 | 23 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 24 | 24 | use g1t_kit::now_ms; | |
| ⋯ | |||
| 141 | 141 | Ok(Facts { | |
| 142 | 142 | workspace: workspace.to_owned(), | |
| 143 | 143 | enterprise: account.id.starts_with("ent_").then(|| account.name.clone()), | |
| 144 | − | comped: account.terms.kind == TermsKind::Comped, | |
| 144 | + | comped: account.terms.full_discount(), | |
| 145 | 145 | failed_invoices: failed, | |
| 146 | 146 | balance_micros: row.as_ref().map_or(0, |r| r.balance_micros), | |
| 147 | 147 | has_card: row.as_ref().is_some_and(|r| r.customer_id.is_some()) && self.stripe.is_some(), | |
| 27 | 27 | //! is one D1 batch, which runs as a transaction, so two charges at once | |
| 28 | 28 | //! never take more than a budget holds. | |
| 29 | 29 | ||
| 30 | − | use g1t_contracts::billing::{BillingAccount, ComputeKind, Feature, PlanKind, Pools, TermsKind, Trial, TrialArgs}; | |
| 30 | + | use g1t_contracts::billing::{BillingAccount, ComputeKind, Feature, PlanKind, Pools, Trial, TrialArgs}; | |
| 31 | 31 | use g1t_contracts::time::rfc3339; | |
| 32 | 32 | use g1t_kit::now_ms; | |
| 33 | 33 | use serde::Deserialize; | |
| ⋯ | |||
| 285 | 285 | ||
| 286 | 286 | /// The plan, from the account already read for the workspace. | |
| 287 | 287 | pub(crate) async fn plan_kind_for(&self, workspace: &str, account: &BillingAccount) -> Result<PlanKind> { | |
| 288 | − | if account.terms.kind == TermsKind::Comped { | |
| 288 | + | if account.terms.full_discount() { | |
| 289 | 289 | return Ok(PlanKind::Internal); | |
| 290 | 290 | } | |
| 291 | 291 | if account.kind == g1t_contracts::billing::AccountKind::Enterprise { | |
| 330 | 330 | /// enterprise's workspaces, or given by g1t staff. | |
| 331 | 331 | async fn included(&self, workspace: &str) -> Result<bool> { | |
| 332 | 332 | let account = self.account_of(workspace).await?; | |
| 333 | − | Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped | |
| 333 | + | Ok(account.terms.full_discount() | |
| 334 | 334 | || account.kind == g1t_contracts::billing::AccountKind::Enterprise | |
| 335 | 335 | || account.allowances.plan) | |
| 336 | 336 | } | |
| ⋯ | |||
| 671 | 671 | } | |
| 672 | 672 | None => a.cost_micros, | |
| 673 | 673 | }; | |
| 674 | − | let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64; | |
| 675 | 674 | // Never free: the margin applies whatever FREE_WHILE_BUILDING says, | |
| 676 | 675 | // and only the account's terms change it. The plan's included usage | |
| 677 | 676 | // pays what it can; the trial and the open-source pool never pay for | |
| 678 | 677 | // deployments. | |
| 679 | − | let (charge, discount) = self.terms_of(&workspace).await?.discounted(crate::charge_micros(cost, self.margin_percent)); | |
| 678 | + | let (charge, discount) = self.terms_of(&workspace).await?.discounted(crate::margin_on(cost_micros, self.margin_percent)); | |
| 680 | 679 | let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?; | |
| 681 | 680 | description.push_str(&drawn.note()); | |
| 682 | 681 | self.post_usage(crate::storage::UsageLine { | |
| 1 | + | //! Credit g1t staff give a workspace from sudo: promotional, goodwill, or a | |
| 2 | + | //! refund. | |
| 3 | + | //! | |
| 4 | + | //! A grant goes on the ledger as a `crd…` top-up, so it is never a payment, | |
| 5 | + | //! with `credit_kind` set, and in `credit_grants` with its note, who gave | |
| 6 | + | //! it, and an optional expiry. It raises the balance at once. | |
| 7 | + | //! | |
| 8 | + | //! **Spending.** Credit is spent before anything paid in advance, the | |
| 9 | + | //! soonest-expiring grant first (never-expiring last, then oldest). Given | |
| 10 | + | //! while the workspace owes, it pays what is owed first: the most recent | |
| 11 | + | //! usage not yet paid for. What each grant paid for is never stored: it is | |
| 12 | + | //! worked out from the ledger in order (`replay`), so it is always what the | |
| 13 | + | //! ledger says, and the many places that charge usage need not know about | |
| 14 | + | //! credit at all. | |
| 15 | + | //! | |
| 16 | + | //! **Expiry and revoking.** Unused credit past its expiry stops counting: | |
| 17 | + | //! the daily run enters what is left as a negative `crd…_expired` line. | |
| 18 | + | //! Staff can revoke what is left of a grant, with why (`crd…_revoked`). | |
| 19 | + | //! Neither ever takes the balance below what was paid in: at most the | |
| 20 | + | //! balance, if a refund of a payment brought it lower than the credit left. | |
| 21 | + | //! | |
| 22 | + | //! **Margin.** Usage paid for with promotional or goodwill credit is given | |
| 23 | + | //! away, never money in (`margin::usage_rows`). A refund gives back money | |
| 24 | + | //! already paid: it comes off money in on the day it refunds (at most 30 | |
| 25 | + | //! days back, the days the reconciliation still recomputes), and what it | |
| 26 | + | //! pays for later is paid for. Refunds never expire. | |
| 27 | + | ||
| 28 | + | use std::collections::BTreeMap; | |
| 29 | + | ||
| 30 | + | use g1t_contracts::billing::{ | |
| 31 | + | AdminCreditArgs, AdminCredits, AdminCreditsArgs, AdminRevokeCreditArgs, CreditGrant, CreditKind, CreditMonth, Credits, EntryKind, | |
| 32 | + | LedgerEntry, MICROS_PER_DOLLAR, | |
| 33 | + | }; | |
| 34 | + | use g1t_contracts::time::{parse_rfc3339, rfc3339}; | |
| 35 | + | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 36 | + | use g1t_kit::now_ms; | |
| 37 | + | use serde::Deserialize; | |
| 38 | + | use worker::Result; | |
| 39 | + | ||
| 40 | + | use crate::features::cents; | |
| 41 | + | use crate::{Billing, LedgerRow, optional}; | |
| 42 | + | ||
| 43 | + | /// The most one credit can be, against a slipped finger. | |
| 44 | + | pub(crate) const MAX_CREDIT_MICROS: i64 = 10_000 * MICROS_PER_DOLLAR; | |
| 45 | + | /// The furthest an expiry can be: five years. | |
| 46 | + | const MAX_EXPIRY_DAYS: u64 = 5 * 366; | |
| 47 | + | /// How far back a refund can take money off: the days the daily | |
| 48 | + | /// reconciliation recomputes. An older day's refund lands on the oldest. | |
| 49 | + | pub(crate) const REFUND_DAYS_BACK: u64 = 30; | |
| 50 | + | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 51 | + | ||
| 52 | + | // --------------------------------------------------------------------- | |
| 53 | + | // The arithmetic, apart from the database so it can be tested. | |
| 54 | + | // --------------------------------------------------------------------- | |
| 55 | + | ||
| 56 | + | /// A grant, as the replay needs it. | |
| 57 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 58 | + | pub(crate) struct Grant { | |
| 59 | + | pub id: String, | |
| 60 | + | pub kind: CreditKind, | |
| 61 | + | pub expires_at: Option<String>, | |
| 62 | + | pub created_at: String, | |
| 63 | + | pub closed_at: Option<String>, | |
| 64 | + | /// Pays only for model usage (agent runs), not everything. | |
| 65 | + | pub models_only: bool, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The tasks that are not a model's work: sandbox and runner time, | |
| 69 | + | /// deployments, and the month-end meters. | |
| 70 | + | const NOT_MODELS: [&str; 8] = ["sandbox", "self_hosted", "deployments", "security", "context", "storage", "git", "cache"]; | |
| 71 | + | ||
| 72 | + | /// Whether a usage line is model usage (an agent run's model cost), which | |
| 73 | + | /// credit scoped to models can pay for. | |
| 74 | + | pub(crate) fn is_model_usage(task: Option<&str>) -> bool { | |
| 75 | + | task.is_some_and(|task| !NOT_MODELS.contains(&task)) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// A ledger line, oldest first. | |
| 79 | + | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] | |
| 80 | + | pub(crate) struct Line { | |
| 81 | + | pub reference: String, | |
| 82 | + | pub kind: String, | |
| 83 | + | pub amount_micros: i64, | |
| 84 | + | pub created_at: String, | |
| 85 | + | #[serde(default)] | |
| 86 | + | pub task: Option<String>, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// What a grant paid of one usage line: less than nothing when a charge | |
| 90 | + | /// came down and gave some back. | |
| 91 | + | #[derive(Clone, Debug, PartialEq)] | |
| 92 | + | pub(crate) struct Draw { | |
| 93 | + | pub grant: String, | |
| 94 | + | pub kind: CreditKind, | |
| 95 | + | /// The usage line it paid for, or the grant itself for what was owed | |
| 96 | + | /// from before the lines read. | |
| 97 | + | pub reference: String, | |
| 98 | + | pub task: Option<String>, | |
| 99 | + | /// When the line it paid for was entered. | |
| 100 | + | pub at: String, | |
| 101 | + | pub micros: i64, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// What the ledger says each grant paid for. | |
| 105 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 106 | + | pub(crate) struct Replay { | |
| 107 | + | pub draws: Vec<Draw>, | |
| 108 | + | /// Each grant's left, at the end; absent for a grant not on the ledger. | |
| 109 | + | pub left: BTreeMap<String, i64>, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | impl Replay { | |
| 113 | + | pub fn used(&self, grant: &str) -> i64 { | |
| 114 | + | self.draws.iter().filter(|d| d.grant == grant).map(|d| d.micros).sum() | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Whether a grant can pay for something entered at `at`: on the ledger, | |
| 119 | + | /// not closed, not expired. | |
| 120 | + | fn open_at(grant: &Grant, at: &str) -> bool { | |
| 121 | + | grant.closed_at.as_deref().is_none_or(|closed| closed > at) && grant.expires_at.as_deref().is_none_or(|expires| expires > at) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Works out what each grant paid for, from the ledger in order: every | |
| 125 | + | /// charge from the open grants, the soonest-expiring first; a grant given | |
| 126 | + | /// while the balance was below zero pays what was owed, the most recent | |
| 127 | + | /// usage first; a charge that came down gives back to the grants that paid | |
| 128 | + | /// last. `opening` is the balance before the first line. | |
| 129 | + | pub(crate) fn replay(opening: i64, lines: &[Line], grants: &[Grant]) -> Replay { | |
| 130 | + | let mut out = Replay::default(); | |
| 131 | + | let mut balance = opening; | |
| 132 | + | // Usage lines with what is still unpaid by credit, for a grant that | |
| 133 | + | // pays what was owed. | |
| 134 | + | let mut usage: Vec<(usize, i64)> = vec![]; | |
| 135 | + | for (index, line) in lines.iter().enumerate() { | |
| 136 | + | let grant = grants.iter().find(|g| g.id == line.reference); | |
| 137 | + | if let Some(grant) = grant.filter(|_| line.amount_micros > 0) { | |
| 138 | + | let mut left = line.amount_micros; | |
| 139 | + | let mut owed = (-balance).max(0).min(left); | |
| 140 | + | for (i, unpaid) in usage.iter_mut().rev() { | |
| 141 | + | if owed == 0 { | |
| 142 | + | break; | |
| 143 | + | } | |
| 144 | + | let take = (*unpaid).min(owed); | |
| 145 | + | if take > 0 { | |
| 146 | + | let paid = &lines[*i]; | |
| 147 | + | out.draws.push(Draw { | |
| 148 | + | grant: grant.id.clone(), | |
| 149 | + | kind: grant.kind, | |
| 150 | + | reference: paid.reference.clone(), | |
| 151 | + | task: paid.task.clone(), | |
| 152 | + | at: paid.created_at.clone(), | |
| 153 | + | micros: take, | |
| 154 | + | }); | |
| 155 | + | *unpaid -= take; | |
| 156 | + | owed -= take; | |
| 157 | + | left -= take; | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | if owed > 0 { | |
| 161 | + | // Owed from before the lines read: on the grant's own day. | |
| 162 | + | out.draws.push(Draw { | |
| 163 | + | grant: grant.id.clone(), | |
| 164 | + | kind: grant.kind, | |
| 165 | + | reference: grant.id.clone(), | |
| 166 | + | task: None, | |
| 167 | + | at: line.created_at.clone(), | |
| 168 | + | micros: owed, | |
| 169 | + | }); | |
| 170 | + | left -= owed; | |
| 171 | + | } | |
| 172 | + | out.left.insert(grant.id.clone(), left); | |
| 173 | + | } else if line.kind == "usage" && line.amount_micros < 0 { | |
| 174 | + | let charge = -line.amount_micros; | |
| 175 | + | let mut open: Vec<&Grant> = grants | |
| 176 | + | .iter() | |
| 177 | + | .filter(|g| out.left.get(&g.id).is_some_and(|left| *left > 0) && open_at(g, &line.created_at)) | |
| 178 | + | .filter(|g| !g.models_only || is_model_usage(line.task.as_deref())) | |
| 179 | + | .collect(); | |
| 180 | + | open.sort_by(|a, b| spend_order(a, b)); | |
| 181 | + | let mut due = charge; | |
| 182 | + | for grant in open { | |
| 183 | + | if due == 0 { | |
| 184 | + | break; | |
| 185 | + | } | |
| 186 | + | let left = out.left.get_mut(&grant.id).expect("an open grant has a left"); | |
| 187 | + | let take = (*left).min(due); | |
| 188 | + | *left -= take; | |
| 189 | + | due -= take; | |
| 190 | + | out.draws.push(Draw { | |
| 191 | + | grant: grant.id.clone(), | |
| 192 | + | kind: grant.kind, | |
| 193 | + | reference: line.reference.clone(), | |
| 194 | + | task: line.task.clone(), | |
| 195 | + | at: line.created_at.clone(), | |
| 196 | + | micros: take, | |
| 197 | + | }); | |
| 198 | + | } | |
| 199 | + | usage.push((index, due)); | |
| 200 | + | } else if line.kind == "usage" && line.amount_micros > 0 { | |
| 201 | + | // A charge that came down: back to the grants that paid last, | |
| 202 | + | // while they can still be spent. | |
| 203 | + | let mut back = line.amount_micros; | |
| 204 | + | let mut returned: Vec<Draw> = vec![]; | |
| 205 | + | for draw in out.draws.iter().rev() { | |
| 206 | + | if back == 0 { | |
| 207 | + | break; | |
| 208 | + | } | |
| 209 | + | let Some(grant) = grants.iter().find(|g| g.id == draw.grant) else { continue }; | |
| 210 | + | let paid: i64 = out.draws.iter().chain(returned.iter()).filter(|d| d.grant == grant.id).map(|d| d.micros).sum(); | |
| 211 | + | if draw.micros <= 0 || paid <= 0 || !open_at(grant, &line.created_at) { | |
| 212 | + | continue; | |
| 213 | + | } | |
| 214 | + | let give = draw.micros.min(paid).min(back); | |
| 215 | + | back -= give; | |
| 216 | + | returned.push(Draw { | |
| 217 | + | grant: grant.id.clone(), | |
| 218 | + | kind: grant.kind, | |
| 219 | + | reference: line.reference.clone(), | |
| 220 | + | task: line.task.clone(), | |
| 221 | + | at: line.created_at.clone(), | |
| 222 | + | micros: -give, | |
| 223 | + | }); | |
| 224 | + | } | |
| 225 | + | for draw in returned { | |
| 226 | + | *out.left.entry(draw.grant.clone()).or_insert(0) -= draw.micros; | |
| 227 | + | out.draws.push(draw); | |
| 228 | + | } | |
| 229 | + | } else if let Some(id) = closed_grant(&line.reference) { | |
| 230 | + | // What expired or was revoked: nothing more to spend. | |
| 231 | + | if let Some(left) = out.left.get_mut(id) { | |
| 232 | + | *left = 0; | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | balance += line.amount_micros; | |
| 236 | + | } | |
| 237 | + | // Closed or expired by now: nothing left to spend, whatever the ledger | |
| 238 | + | // has not caught up with yet. | |
| 239 | + | out | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// Credit scoped to models before credit for everything; then the | |
| 243 | + | /// soonest-expiring first, never-expiring last; then the oldest. | |
| 244 | + | fn spend_order(a: &Grant, b: &Grant) -> std::cmp::Ordering { | |
| 245 | + | b.models_only | |
| 246 | + | .cmp(&a.models_only) | |
| 247 | + | .then_with(|| match (&a.expires_at, &b.expires_at) { | |
| 248 | + | (Some(x), Some(y)) => x.cmp(y), | |
| 249 | + | (Some(_), None) => std::cmp::Ordering::Less, | |
| 250 | + | (None, Some(_)) => std::cmp::Ordering::Greater, | |
| 251 | + | (None, None) => std::cmp::Ordering::Equal, | |
| 252 | + | }) | |
| 253 | + | .then_with(|| a.created_at.cmp(&b.created_at)) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// The grant a `<grant>_expired` or `<grant>_revoked` line closes. | |
| 257 | + | pub(crate) fn closed_grant(reference: &str) -> Option<&str> { | |
| 258 | + | reference.strip_suffix("_expired").or_else(|| reference.strip_suffix("_revoked")) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// What expiring or revoking takes off the balance: what is left of the | |
| 262 | + | /// grant, and never the balance below zero (a refunded payment can leave | |
| 263 | + | /// less there than the credit). | |
| 264 | + | pub(crate) fn take_back(left: i64, balance: i64) -> i64 { | |
| 265 | + | left.max(0).min(balance.max(0)) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// `open`, `used`, `expired` or `revoked`, and what can still be spent. | |
| 269 | + | pub(crate) fn state(left: i64, expires_at: Option<&str>, closed_reason: Option<&str>, now: &str) -> (&'static str, i64) { | |
| 270 | + | match closed_reason { | |
| 271 | + | Some("revoked") => ("revoked", 0), | |
| 272 | + | Some(_) => ("expired", 0), | |
| 273 | + | None if expires_at.is_some_and(|at| at <= now) => ("expired", 0), | |
| 274 | + | None if left <= 0 => ("used", 0), | |
| 275 | + | None => ("open", left), | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | /// The key a usage line is reconciled under, as `margin::usage_rows` reads | |
| 280 | + | /// it: builds apart from a deployment's requests. | |
| 281 | + | pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String { | |
| 282 | + | match task { | |
| 283 | + | Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(), | |
| 284 | + | Some(task) => task.to_owned(), | |
| 285 | + | None => "other".to_owned(), | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | /// The day a refund takes money off: the day it refunds, but no further | |
| 290 | + | /// back than the reconciliation recomputes from the day it was given. | |
| 291 | + | pub(crate) fn refund_cash_day(refund_day: Option<&str>, granted_at: &str) -> String { | |
| 292 | + | let granted = &granted_at[..10]; | |
| 293 | + | let oldest = rfc3339(parse_rfc3339(&format!("{granted}T00:00:00Z")).unwrap_or(0).saturating_sub(REFUND_DAYS_BACK * DAY_MS))[..10].to_owned(); | |
| 294 | + | match refund_day { | |
| 295 | + | Some(day) if day.len() == 10 && day <= granted => day.max(oldest.as_str()).to_owned(), | |
| 296 | + | _ => granted.to_owned(), | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// A `YYYY-MM-DD` that is a real day. | |
| 301 | + | fn is_day(day: &str) -> bool { | |
| 302 | + | day.len() == 10 && parse_rfc3339(&format!("{day}T00:00:00Z")).is_some_and(|ms| rfc3339(ms).starts_with(day)) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// What is wrong with a credit as asked for, if anything. | |
| 306 | + | pub(crate) fn invalid(a: &AdminCreditArgs, now_ms: u64) -> Option<&'static str> { | |
| 307 | + | if a.workspace.trim().is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() { | |
| 308 | + | return Some("A credit needs a workspace, a note and who gave it."); | |
| 309 | + | } | |
| 310 | + | if a.note.trim().chars().count() > 500 { | |
| 311 | + | return Some("Keep the note under 500 characters."); | |
| 312 | + | } | |
| 313 | + | if a.kind == CreditKind::Purchased { | |
| 314 | + | return Some("Staff give promotional, goodwill or refund credit; purchased credit is bought by the workspace."); | |
| 315 | + | } | |
| 316 | + | if a.amount_micros <= 0 || a.amount_micros > MAX_CREDIT_MICROS { | |
| 317 | + | return Some("A credit is more than $0 and at most $10,000."); | |
| 318 | + | } | |
| 319 | + | if let Some(expires) = &a.expires_at { | |
| 320 | + | if a.kind == CreditKind::Refund { | |
| 321 | + | return Some("A refund never expires: it is money the workspace already paid."); | |
| 322 | + | } | |
| 323 | + | match parse_rfc3339(expires) { | |
| 324 | + | Some(at) if at > now_ms && at <= now_ms + MAX_EXPIRY_DAYS * DAY_MS => {} | |
| 325 | + | Some(at) if at <= now_ms => return Some("The expiry has to be in the future."), | |
| 326 | + | _ => return Some("The expiry is a date within five years."), | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | if a.kind == CreditKind::Refund { | |
| 330 | + | if a.refund_for.as_deref().is_none_or(|f| f.trim().is_empty()) { | |
| 331 | + | return Some("Say what the refund is for, such as the failed runs on Oct 2."); | |
| 332 | + | } | |
| 333 | + | if a.refund_for.as_deref().is_some_and(|f| f.trim().chars().count() > 200) { | |
| 334 | + | return Some("Keep what the refund is for under 200 characters."); | |
| 335 | + | } | |
| 336 | + | if let Some(day) = &a.refund_day | |
| 337 | + | && (!is_day(day) || day.as_str() > &rfc3339(now_ms)[..10]) | |
| 338 | + | { | |
| 339 | + | return Some("The day refunded is a date, today or before."); | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | None | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// The line on the statement: `Credit from g1t (promotional): Welcome to g1t`. | |
| 346 | + | pub(crate) fn describe_grant(kind: CreditKind, note: &str, refund_for: Option<&str>, expires_at: Option<&str>) -> String { | |
| 347 | + | let what = match (kind, refund_for) { | |
| 348 | + | (CreditKind::Refund, Some(what)) => format!("refund for {}", what.trim()), | |
| 349 | + | (kind, _) => kind.as_str().to_owned(), | |
| 350 | + | }; | |
| 351 | + | let until = expires_at.map(|at| format!(", until {}", &at[..at.len().min(10)])).unwrap_or_default(); | |
| 352 | + | format!("Credit from g1t ({what}{until}): {}", note.trim()) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// A month's credits by kind, from the grants (given, taken back) and what | |
| 356 | + | /// they paid for. | |
| 357 | + | pub(crate) fn fold_months(grants: &[GrantRow], draws: &[Draw]) -> Vec<CreditMonth> { | |
| 358 | + | fn at<'a>(months: &'a mut BTreeMap<(String, CreditKind), CreditMonth>, month: &str, kind: CreditKind) -> &'a mut CreditMonth { | |
| 359 | + | months.entry((month.to_owned(), kind)).or_insert_with(|| CreditMonth { month: month.to_owned(), kind, ..CreditMonth::default() }) | |
| 360 | + | } | |
| 361 | + | let mut months: BTreeMap<(String, CreditKind), CreditMonth> = BTreeMap::new(); | |
| 362 | + | for grant in grants { | |
| 363 | + | let kind = grant.kind(); | |
| 364 | + | let m = at(&mut months, &grant.created_at[..7], kind); | |
| 365 | + | m.given_micros += grant.amount_micros; | |
| 366 | + | m.grants += 1; | |
| 367 | + | if let Some(closed) = &grant.closed_at { | |
| 368 | + | let m = at(&mut months, &closed[..7], kind); | |
| 369 | + | if grant.closed_reason.as_deref() == Some("revoked") { | |
| 370 | + | m.revoked_micros += grant.closed_micros; | |
| 371 | + | } else { | |
| 372 | + | m.expired_micros += grant.closed_micros; | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | for draw in draws { | |
| 377 | + | at(&mut months, &draw.at[..7], draw.kind).used_micros += draw.micros; | |
| 378 | + | } | |
| 379 | + | let mut out: Vec<CreditMonth> = months.into_values().collect(); | |
| 380 | + | out.sort_by(|a, b| b.month.cmp(&a.month).then(a.kind.cmp(&b.kind))); | |
| 381 | + | out | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // --------------------------------------------------------------------- | |
| 385 | + | // The database. | |
| 386 | + | // --------------------------------------------------------------------- | |
| 387 | + | ||
| 388 | + | #[derive(Clone, Debug, Default, Deserialize)] | |
| 389 | + | pub(crate) struct GrantRow { | |
| 390 | + | pub id: String, | |
| 391 | + | pub workspace: String, | |
| 392 | + | pub kind: String, | |
| 393 | + | pub amount_micros: i64, | |
| 394 | + | pub note: String, | |
| 395 | + | pub refund_for: Option<String>, | |
| 396 | + | pub refund_day: Option<String>, | |
| 397 | + | pub expires_at: Option<String>, | |
| 398 | + | pub created_by: String, | |
| 399 | + | pub created_at: String, | |
| 400 | + | pub closed_at: Option<String>, | |
| 401 | + | pub closed_reason: Option<String>, | |
| 402 | + | pub closed_note: Option<String>, | |
| 403 | + | pub closed_by: Option<String>, | |
| 404 | + | #[serde(default)] | |
| 405 | + | pub closed_micros: i64, | |
| 406 | + | /// `all` or `models`. | |
| 407 | + | #[serde(default)] | |
| 408 | + | pub scope: Option<String>, | |
| 409 | + | /// `staff`, `purchase` or `promo_code`. | |
| 410 | + | #[serde(default)] | |
| 411 | + | pub source: Option<String>, | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | impl GrantRow { | |
| 415 | + | pub fn kind(&self) -> CreditKind { | |
| 416 | + | CreditKind::parse(&self.kind).unwrap_or_default() | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | fn facts(&self) -> Grant { | |
| 420 | + | Grant { | |
| 421 | + | id: self.id.clone(), | |
| 422 | + | kind: self.kind(), | |
| 423 | + | expires_at: self.expires_at.clone(), | |
| 424 | + | created_at: self.created_at.clone(), | |
| 425 | + | closed_at: self.closed_at.clone(), | |
| 426 | + | models_only: self.scope.as_deref() == Some("models"), | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | fn view(&self, replay: &Replay, now: &str) -> CreditGrant { | |
| 431 | + | let used = replay.used(&self.id); | |
| 432 | + | let left = replay.left.get(&self.id).copied().unwrap_or(0); | |
| 433 | + | let (state, left) = state(left, self.expires_at.as_deref(), self.closed_reason.as_deref(), now); | |
| 434 | + | CreditGrant { | |
| 435 | + | id: self.id.clone(), | |
| 436 | + | workspace: self.workspace.clone(), | |
| 437 | + | kind: self.kind(), | |
| 438 | + | amount_micros: self.amount_micros, | |
| 439 | + | used_micros: used, | |
| 440 | + | left_micros: left, | |
| 441 | + | note: self.note.clone(), | |
| 442 | + | refund_for: self.refund_for.clone(), | |
| 443 | + | refund_day: self.refund_day.clone(), | |
| 444 | + | expires_at: self.expires_at.clone(), | |
| 445 | + | created_by: self.created_by.clone(), | |
| 446 | + | created_at: self.created_at.clone(), | |
| 447 | + | state: state.to_owned(), | |
| 448 | + | closed_at: self.closed_at.clone(), | |
| 449 | + | closed_note: self.closed_note.clone(), | |
| 450 | + | closed_by: self.closed_by.clone(), | |
| 451 | + | closed_micros: self.closed_micros, | |
| 452 | + | scope: self.scope.clone().unwrap_or_else(|| "all".to_owned()), | |
| 453 | + | source: self.source.clone().unwrap_or_else(|| "staff".to_owned()), | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | /// A refund's money given back, on the day it comes off. | |
| 459 | + | #[derive(Clone, Debug, PartialEq)] | |
| 460 | + | pub(crate) struct Refunded { | |
| 461 | + | pub workspace: String, | |
| 462 | + | pub day: String, | |
| 463 | + | pub micros: i64, | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | impl Billing { | |
| 467 | + | async fn grants_of(&self, workspace: &str) -> Result<Vec<GrantRow>> { | |
| 468 | + | self.db | |
| 469 | + | .prepare("SELECT * FROM credit_grants WHERE workspace = ? ORDER BY created_at DESC") | |
| 470 | + | .bind(&[workspace.into()])? | |
| 471 | + | .all() | |
| 472 | + | .await? | |
| 473 | + | .results::<GrantRow>() | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | /// The workspace's ledger from the month before its first grant, and | |
| 477 | + | /// the balance before it, replayed against its grants. | |
| 478 | + | async fn replay_of(&self, workspace: &str, grants: &[GrantRow]) -> Result<(Replay, i64)> { | |
| 479 | + | let Some(first) = grants.iter().map(|g| g.created_at.as_str()).min() else { | |
| 480 | + | return Ok((Replay::default(), 0)); | |
| 481 | + | }; | |
| 482 | + | let since = format!("{}-01", crate::limits::previous_month(&first[..7])); | |
| 483 | + | let lines = self | |
| 484 | + | .db | |
| 485 | + | .prepare( | |
| 486 | + | "SELECT reference, kind, amount_micros, created_at, task FROM ledger | |
| 487 | + | WHERE workspace = ? AND created_at >= ? ORDER BY created_at, id", | |
| 488 | + | ) | |
| 489 | + | .bind(&[workspace.into(), since.into()])? | |
| 490 | + | .all() | |
| 491 | + | .await? | |
| 492 | + | .results::<Line>()?; | |
| 493 | + | let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros); | |
| 494 | + | let opening = balance - lines.iter().map(|l| l.amount_micros).sum::<i64>(); | |
| 495 | + | let facts: Vec<Grant> = grants.iter().map(GrantRow::facts).collect(); | |
| 496 | + | Ok((replay(opening, &lines, &facts), balance)) | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | /// `credits`: a workspace's credits from g1t, for its members. | |
| 500 | + | pub(crate) async fn credits(&self, a: g1t_contracts::billing::AccountArgs) -> Result<Outcome<Credits>> { | |
| 501 | + | let workspace = a.workspace.to_lowercase(); | |
| 502 | + | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 503 | + | return Ok(crate::members_only()); | |
| 504 | + | } | |
| 505 | + | Ok(Outcome::Ok(self.credits_of(&workspace).await?)) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | pub(crate) async fn credits_of(&self, workspace: &str) -> Result<Credits> { | |
| 509 | + | let grants = self.grants_of(workspace).await?; | |
| 510 | + | let (replay, _) = self.replay_of(workspace, &grants).await?; | |
| 511 | + | let now = rfc3339(now_ms()); | |
| 512 | + | let grants: Vec<CreditGrant> = grants.iter().map(|g| g.view(&replay, &now)).collect(); | |
| 513 | + | Ok(Credits { left_micros: grants.iter().map(|g| g.left_micros).sum(), grants }) | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | /// Enters a grant: the ledger line and its `credit_grants` row. Returns | |
| 517 | + | /// the grant's reference. `reference` names it, for a grant made | |
| 518 | + | /// elsewhere (the Overages queue's goodwill). | |
| 519 | + | #[allow(clippy::too_many_arguments)] | |
| 520 | + | pub(crate) async fn grant_credit( | |
| 521 | + | &self, | |
| 522 | + | workspace: &str, | |
| 523 | + | kind: CreditKind, | |
| 524 | + | amount: i64, | |
| 525 | + | note: &str, | |
| 526 | + | by: &str, | |
| 527 | + | expires_at: Option<&str>, | |
| 528 | + | refund_for: Option<&str>, | |
| 529 | + | refund_day: Option<&str>, | |
| 530 | + | reference: Option<String>, | |
| 531 | + | description: Option<String>, | |
| 532 | + | ) -> Result<String> { | |
| 533 | + | let now = now_ms(); | |
| 534 | + | let reference = reference.unwrap_or_else(|| new_id("crd", now)); | |
| 535 | + | let description = description.unwrap_or_else(|| describe_grant(kind, note, refund_for, expires_at)); | |
| 536 | + | let refund_day = (kind == CreditKind::Refund).then(|| refund_day.map_or_else(|| rfc3339(now)[..10].to_owned(), str::to_owned)); | |
| 537 | + | self.db | |
| 538 | + | .prepare( | |
| 539 | + | "INSERT INTO credit_grants (id, workspace, kind, amount_micros, note, refund_for, refund_day, expires_at, created_by, created_at) | |
| 540 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 541 | + | ) | |
| 542 | + | .bind(&[ | |
| 543 | + | reference.as_str().into(), | |
| 544 | + | workspace.into(), | |
| 545 | + | kind.as_str().into(), | |
| 546 | + | (amount as f64).into(), | |
| 547 | + | note.trim().into(), | |
| 548 | + | optional(refund_for.map(str::trim)), | |
| 549 | + | optional(refund_day.as_deref()), | |
| 550 | + | optional(expires_at), | |
| 551 | + | by.into(), | |
| 552 | + | rfc3339(now).into(), | |
| 553 | + | ])? | |
| 554 | + | .run() | |
| 555 | + | .await?; | |
| 556 | + | self.enter(workspace, EntryKind::TopUp, amount, &description, &reference, None, None, Some(by), None).await?; | |
| 557 | + | self.mark_credit(&reference, kind).await?; | |
| 558 | + | Ok(reference) | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | async fn mark_credit(&self, reference: &str, kind: CreditKind) -> Result<()> { | |
| 562 | + | self.db | |
| 563 | + | .prepare("UPDATE ledger SET credit_kind = ? WHERE reference = ?") | |
| 564 | + | .bind(&[kind.as_str().into(), reference.into()])? | |
| 565 | + | .run() | |
| 566 | + | .await?; | |
| 567 | + | Ok(()) | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | /// `admin_credit`: credit for a workspace, from sudo. | |
| 571 | + | pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> { | |
| 572 | + | if let Some(why) = invalid(&a, now_ms()) { | |
| 573 | + | return Ok(Outcome::fail(FailureCode::Invalid, why)); | |
| 574 | + | } | |
| 575 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 576 | + | let reference = self | |
| 577 | + | .grant_credit( | |
| 578 | + | &workspace, | |
| 579 | + | a.kind, | |
| 580 | + | a.amount_micros, | |
| 581 | + | a.note.trim(), | |
| 582 | + | a.by.trim(), | |
| 583 | + | a.expires_at.as_deref(), | |
| 584 | + | a.refund_for.as_deref(), | |
| 585 | + | a.refund_day.as_deref(), | |
| 586 | + | None, | |
| 587 | + | None, | |
| 588 | + | ) | |
| 589 | + | .await?; | |
| 590 | + | let account = self.account_of(&workspace).await?; | |
| 591 | + | let until = a.expires_at.as_deref().map(|at| format!(", until {}", &at[..10])).unwrap_or_default(); | |
| 592 | + | let refund = a.refund_for.as_deref().filter(|_| a.kind == CreditKind::Refund).map(|f| format!(" for {}", f.trim())).unwrap_or_default(); | |
| 593 | + | self.audit( | |
| 594 | + | &account.id, | |
| 595 | + | "credit", | |
| 596 | + | &format!("{} {} credit to {workspace}{refund}{until}: {}", cents(a.amount_micros), a.kind.as_str(), a.note.trim()), | |
| 597 | + | a.by.trim(), | |
| 598 | + | ) | |
| 599 | + | .await?; | |
| 600 | + | self.tell_owners_of_credit(&workspace, a.kind, a.amount_micros, a.note.trim(), a.refund_for.as_deref(), a.expires_at.as_deref()).await; | |
| 601 | + | let row = self | |
| 602 | + | .db | |
| 603 | + | .prepare("SELECT * FROM ledger WHERE reference = ?") | |
| 604 | + | .bind(&[reference.as_str().into()])? | |
| 605 | + | .first::<LedgerRow>(None) | |
| 606 | + | .await?; | |
| 607 | + | Ok(match row { | |
| 608 | + | Some(row) => Outcome::Ok(LedgerEntry::from(row)), | |
| 609 | + | None => Outcome::fail(FailureCode::NotFound, "The credit was not saved."), | |
| 610 | + | }) | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | /// Emails the workspace's owners that credit was given. Never fails the | |
| 614 | + | /// grant. | |
| 615 | + | pub(crate) async fn tell_owners_of_credit( | |
| 616 | + | &self, | |
| 617 | + | workspace: &str, | |
| 618 | + | kind: CreditKind, | |
| 619 | + | amount: i64, | |
| 620 | + | note: &str, | |
| 621 | + | refund_for: Option<&str>, | |
| 622 | + | expires_at: Option<&str>, | |
| 623 | + | ) { | |
| 624 | + | let Some(identity) = &self.identity else { return }; | |
| 625 | + | let (subject, intro) = credit_notice(workspace, kind, amount, note, refund_for, expires_at); | |
| 626 | + | crate::limits::notify_with( | |
| 627 | + | identity, | |
| 628 | + | workspace, | |
| 629 | + | &subject, | |
| 630 | + | &intro, | |
| 631 | + | "Open billing", | |
| 632 | + | &format!("https://g1t.sh/{workspace}/-/billing#credits"), | |
| 633 | + | "You get this because you own this workspace on g1t. Credits are explained at https://docs.g1t.sh/guides/usage-and-billing/#credits-from-g1t", | |
| 634 | + | ) | |
| 635 | + | .await; | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | /// Takes what is left of a grant off the balance: expired, or revoked | |
| 639 | + | /// by staff. Returns what it took. | |
| 640 | + | async fn close_grant(&self, grant: &GrantRow, reason: &str, note: Option<&str>, by: &str) -> Result<i64> { | |
| 641 | + | let grants = self.grants_of(&grant.workspace).await?; | |
| 642 | + | let (replay, balance) = self.replay_of(&grant.workspace, &grants).await?; | |
| 643 | + | let left = replay.left.get(&grant.id).copied().unwrap_or(0); | |
| 644 | + | let take = take_back(left, balance); | |
| 645 | + | let now = rfc3339(now_ms()); | |
| 646 | + | // Closed first, so a second close finds it closed and takes nothing. | |
| 647 | + | let claimed = self | |
| 648 | + | .db | |
| 649 | + | .prepare( | |
| 650 | + | "UPDATE credit_grants SET closed_at = ?1, closed_reason = ?2, closed_note = ?3, closed_by = ?4, closed_micros = ?5 | |
| 651 | + | WHERE id = ?6 AND closed_at IS NULL RETURNING id", | |
| 652 | + | ) | |
| 653 | + | .bind(&[now.as_str().into(), reason.into(), optional(note), by.into(), (take as f64).into(), grant.id.as_str().into()])? | |
| 654 | + | .first::<crate::Touched>(None) | |
| 655 | + | .await?; | |
| 656 | + | if claimed.is_none() || take == 0 { | |
| 657 | + | return Ok(0); | |
| 658 | + | } | |
| 659 | + | let reference = format!("{}_{reason}", grant.id); | |
| 660 | + | let verb = if reason == "revoked" { "withdrawn" } else { "expired" }; | |
| 661 | + | let description = format!( | |
| 662 | + | "Credit from g1t {verb}: {} unused of the {} given on {}", | |
| 663 | + | cents(take), | |
| 664 | + | cents(grant.amount_micros), | |
| 665 | + | &grant.created_at[..10] | |
| 666 | + | ); | |
| 667 | + | self.enter(&grant.workspace, EntryKind::TopUp, -take, &description, &reference, None, None, Some(by), None).await?; | |
| 668 | + | self.mark_credit(&reference, grant.kind()).await?; | |
| 669 | + | Ok(take) | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | async fn grant(&self, id: &str) -> Result<Option<GrantRow>> { | |
| 673 | + | self.db.prepare("SELECT * FROM credit_grants WHERE id = ?").bind(&[id.into()])?.first::<GrantRow>(None).await | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | /// `admin_revoke_credit`: what is left of a grant, taken back. | |
| 677 | + | pub(crate) async fn admin_revoke_credit(&self, a: AdminRevokeCreditArgs) -> Result<Outcome<CreditGrant>> { | |
| 678 | + | let note = a.note.trim(); | |
| 679 | + | if note.is_empty() || a.by.trim().is_empty() { | |
| 680 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Say why, and who is revoking it.")); | |
| 681 | + | } | |
| 682 | + | let Some(grant) = self.grant(a.id.trim()).await? else { | |
| 683 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such credit.")); | |
| 684 | + | }; | |
| 685 | + | if grant.closed_at.is_some() { | |
| 686 | + | return Ok(Outcome::fail(FailureCode::Conflict, "This credit is closed already.")); | |
| 687 | + | } | |
| 688 | + | let taken = self.close_grant(&grant, "revoked", Some(note), a.by.trim()).await?; | |
| 689 | + | let account = self.account_of(&grant.workspace).await?; | |
| 690 | + | self.audit( | |
| 691 | + | &account.id, | |
| 692 | + | "credit_revoked", | |
| 693 | + | &format!("{} unused of {}'s {} {} credit from {}: {note}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]), | |
| 694 | + | a.by.trim(), | |
| 695 | + | ) | |
| 696 | + | .await?; | |
| 697 | + | let credits = self.credits_of(&grant.workspace).await?; | |
| 698 | + | Ok(match credits.grants.into_iter().find(|g| g.id == grant.id) { | |
| 699 | + | Some(grant) => Outcome::Ok(grant), | |
| 700 | + | None => Outcome::fail(FailureCode::NotFound, "The credit was not found again."), | |
| 701 | + | }) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | /// The daily run: grants past their expiry, closed, and what was left | |
| 705 | + | /// of each taken off the balance. | |
| 706 | + | pub(crate) async fn expire_credits(&self) -> Result<u32> { | |
| 707 | + | let now = rfc3339(now_ms()); | |
| 708 | + | let due = self | |
| 709 | + | .db | |
| 710 | + | .prepare("SELECT * FROM credit_grants WHERE closed_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ? LIMIT 200") | |
| 711 | + | .bind(&[now.as_str().into()])? | |
| 712 | + | .all() | |
| 713 | + | .await? | |
| 714 | + | .results::<GrantRow>()?; | |
| 715 | + | let mut closed = 0; | |
| 716 | + | for grant in due { | |
| 717 | + | let taken = self.close_grant(&grant, "expired", None, "g1t").await?; | |
| 718 | + | let account = self.account_of(&grant.workspace).await?; | |
| 719 | + | self.audit( | |
| 720 | + | &account.id, | |
| 721 | + | "credit_expired", | |
| 722 | + | &format!("{} unused of {}'s {} {} credit from {}", cents(taken), grant.workspace, cents(grant.amount_micros), grant.kind, &grant.created_at[..10]), | |
| 723 | + | "g1t", | |
| 724 | + | ) | |
| 725 | + | .await?; | |
| 726 | + | closed += 1; | |
| 727 | + | } | |
| 728 | + | Ok(closed) | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | /// `admin_credits`: every grant, filtered, with each month's totals. | |
| 732 | + | pub(crate) async fn admin_credits(&self, a: AdminCreditsArgs) -> Result<AdminCredits> { | |
| 733 | + | // The last 12 months, and anything older still open. | |
| 734 | + | let mut first = rfc3339(now_ms())[..7].to_owned(); | |
| 735 | + | for _ in 0..11 { | |
| 736 | + | first = crate::limits::previous_month(&first); | |
| 737 | + | } | |
| 738 | + | #[derive(Deserialize)] | |
| 739 | + | struct Workspace { | |
| 740 | + | workspace: String, | |
| 741 | + | } | |
| 742 | + | let workspaces = self | |
| 743 | + | .db | |
| 744 | + | .prepare("SELECT DISTINCT workspace FROM credit_grants WHERE created_at >= ? OR closed_at >= ? OR closed_at IS NULL") | |
| 745 | + | .bind(&[format!("{first}-01").into(), format!("{first}-01").into()])? | |
| 746 | + | .all() | |
| 747 | + | .await? | |
| 748 | + | .results::<Workspace>()?; | |
| 749 | + | let now = rfc3339(now_ms()); | |
| 750 | + | let mut rows: Vec<GrantRow> = vec![]; | |
| 751 | + | let mut views: Vec<CreditGrant> = vec![]; | |
| 752 | + | let mut draws: Vec<Draw> = vec![]; | |
| 753 | + | for Workspace { workspace } in workspaces { | |
| 754 | + | let grants = self.grants_of(&workspace).await?; | |
| 755 | + | let (replay, _) = self.replay_of(&workspace, &grants).await?; | |
| 756 | + | views.extend(grants.iter().map(|g| g.view(&replay, &now))); | |
| 757 | + | draws.extend(replay.draws); | |
| 758 | + | rows.extend(grants); | |
| 759 | + | } | |
| 760 | + | views.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | |
| 761 | + | let months = fold_months(&rows, &draws).into_iter().filter(|m| m.month >= first).collect(); | |
| 762 | + | let mut staff: Vec<String> = views.iter().map(|g| g.created_by.clone()).collect(); | |
| 763 | + | staff.sort(); | |
| 764 | + | staff.dedup(); | |
| 765 | + | let workspace = a.workspace.as_deref().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()); | |
| 766 | + | let grants = views | |
| 767 | + | .into_iter() | |
| 768 | + | .filter(|g| workspace.as_deref().is_none_or(|w| g.workspace == w)) | |
| 769 | + | .filter(|g| a.kind.is_none_or(|k| g.kind == k)) | |
| 770 | + | .filter(|g| a.month.as_deref().is_none_or(|m| g.created_at.starts_with(m))) | |
| 771 | + | .filter(|g| a.by.as_deref().is_none_or(|by| g.created_by == by)) | |
| 772 | + | .take(200) | |
| 773 | + | .collect(); | |
| 774 | + | Ok(AdminCredits { grants, months, staff }) | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | /// What credits paid for between two days (`YYYY-MM-DD`), and refunds' | |
| 778 | + | /// money given back on those days, for the reconciliation. | |
| 779 | + | pub(crate) async fn credit_effects(&self, since: &str, until: &str) -> Result<(Vec<(String, Draw)>, Vec<Refunded>)> { | |
| 780 | + | let end = format!("{until}T23:59:59.999Z"); | |
| 781 | + | let grants = self | |
| 782 | + | .db | |
| 783 | + | .prepare("SELECT * FROM credit_grants WHERE created_at <= ?1 AND (closed_at IS NULL OR closed_at >= ?2)") | |
| 784 | + | .bind(&[end.as_str().into(), day_start_before(since).into()])? | |
| 785 | + | .all() | |
| 786 | + | .await? | |
| 787 | + | .results::<GrantRow>()?; | |
| 788 | + | let mut workspaces: Vec<String> = grants.iter().map(|g| g.workspace.clone()).collect(); | |
| 789 | + | workspaces.sort(); | |
| 790 | + | workspaces.dedup(); | |
| 791 | + | let mut draws = vec![]; | |
| 792 | + | for workspace in workspaces { | |
| 793 | + | let all = self.grants_of(&workspace).await?; | |
| 794 | + | let (replay, _) = self.replay_of(&workspace, &all).await?; | |
| 795 | + | draws.extend( | |
| 796 | + | replay | |
| 797 | + | .draws | |
| 798 | + | .into_iter() | |
| 799 | + | .filter(|d| d.at.as_str() >= since && d.at.as_str() <= end.as_str()) | |
| 800 | + | .map(|d| (workspace.clone(), d)), | |
| 801 | + | ); | |
| 802 | + | } | |
| 803 | + | let refunds = grants | |
| 804 | + | .iter() | |
| 805 | + | .filter(|g| g.kind() == CreditKind::Refund) | |
| 806 | + | .map(|g| Refunded { | |
| 807 | + | workspace: g.workspace.clone(), | |
| 808 | + | day: refund_cash_day(g.refund_day.as_deref(), &g.created_at), | |
| 809 | + | micros: (g.amount_micros - g.closed_micros).max(0), | |
| 810 | + | }) | |
| 811 | + | .filter(|r| r.micros > 0 && r.day.as_str() >= since && r.day.as_str() <= until) | |
| 812 | + | .collect(); | |
| 813 | + | Ok((draws, refunds)) | |
| 814 | + | } | |
| 815 | + | } | |
| 816 | + | ||
| 817 | + | /// The instant a reconciliation's first day starts, less the refund window: | |
| 818 | + | /// a grant closed before it paid for nothing in the days and refunds none. | |
| 819 | + | fn day_start_before(since: &str) -> String { | |
| 820 | + | let ms = parse_rfc3339(&format!("{since}T00:00:00Z")).unwrap_or(0); | |
| 821 | + | rfc3339(ms.saturating_sub(REFUND_DAYS_BACK * DAY_MS)) | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | /// The owners' email: subject and first paragraph. | |
| 825 | + | pub(crate) fn credit_notice( | |
| 826 | + | workspace: &str, | |
| 827 | + | kind: CreditKind, | |
| 828 | + | amount: i64, | |
| 829 | + | note: &str, | |
| 830 | + | refund_for: Option<&str>, | |
| 831 | + | expires_at: Option<&str>, | |
| 832 | + | ) -> (String, String) { | |
| 833 | + | let amount = cents(amount); | |
| 834 | + | let subject = match kind { | |
| 835 | + | CreditKind::Refund => format!("g1t: a {amount} refund for {workspace}, as credit"), | |
| 836 | + | _ => format!("g1t: {amount} of credit for {workspace}"), | |
| 837 | + | }; | |
| 838 | + | let what = match (kind, refund_for) { | |
| 839 | + | (CreditKind::Refund, Some(what)) => format!("g1t refunded {amount} to {workspace} as credit, for {}.", what.trim()), | |
| 840 | + | _ => format!("g1t added {amount} of credit to {workspace}."), | |
| 841 | + | }; | |
| 842 | + | let until = match expires_at { | |
| 843 | + | Some(at) => format!(" Unused credit expires on {}.", &at[..at.len().min(10)]), | |
| 844 | + | None => String::new(), | |
| 845 | + | }; | |
| 846 | + | let intro = format!( | |
| 847 | + | "{what} {}{}It pays for usage before anything paid in advance, and you can see what is left on the Billing page.{until}", | |
| 848 | + | note.trim(), | |
| 849 | + | if note.trim().ends_with(['.', '!', '?']) { " " } else { ". " }, | |
| 850 | + | ); | |
| 851 | + | (subject, intro) | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | #[cfg(test)] | |
| 855 | + | mod tests { | |
| 856 | + | use super::*; | |
| 857 | + | ||
| 858 | + | fn grant(id: &str, kind: CreditKind, expires: Option<&str>, created: &str) -> Grant { | |
| 859 | + | Grant { id: id.into(), kind, expires_at: expires.map(Into::into), created_at: created.into(), closed_at: None, models_only: false } | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | fn line(reference: &str, kind: &str, amount: i64, at: &str) -> Line { | |
| 863 | + | Line { reference: reference.into(), kind: kind.into(), amount_micros: amount, created_at: at.into(), task: Some("implement".into()) } | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | #[test] | |
| 867 | + | fn credit_pays_for_usage_before_anything_paid_in_advance() { | |
| 868 | + | // $50 paid in advance, then $25 of credit, then $10 of usage: the | |
| 869 | + | // credit pays for all of it. | |
| 870 | + | let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-02T00:00:00Z")]; | |
| 871 | + | let lines = [ | |
| 872 | + | line("cs_paid", "top_up", 50_000_000, "2026-10-01T00:00:00Z"), | |
| 873 | + | line("crd_a", "top_up", 25_000_000, "2026-10-02T00:00:00Z"), | |
| 874 | + | line("run_1", "usage", -10_000_000, "2026-10-03T00:00:00Z"), | |
| 875 | + | ]; | |
| 876 | + | let r = replay(0, &lines, &grants); | |
| 877 | + | assert_eq!(r.used("crd_a"), 10_000_000); | |
| 878 | + | assert_eq!(r.left["crd_a"], 15_000_000); | |
| 879 | + | assert_eq!(r.draws.len(), 1); | |
| 880 | + | assert_eq!(r.draws[0].reference, "run_1"); | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | #[test] | |
| 884 | + | fn the_soonest_expiring_credit_is_spent_first() { | |
| 885 | + | let grants = [ | |
| 886 | + | grant("crd_never", CreditKind::Goodwill, None, "2026-10-01T00:00:00Z"), | |
| 887 | + | grant("crd_late", CreditKind::Promotional, Some("2027-01-01T00:00:00Z"), "2026-10-01T00:00:01Z"), | |
| 888 | + | grant("crd_soon", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:02Z"), | |
| 889 | + | ]; | |
| 890 | + | let lines = [ | |
| 891 | + | line("crd_never", "top_up", 5_000_000, "2026-10-01T00:00:00Z"), | |
| 892 | + | line("crd_late", "top_up", 5_000_000, "2026-10-01T00:00:01Z"), | |
| 893 | + | line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"), | |
| 894 | + | line("run_1", "usage", -7_000_000, "2026-10-05T00:00:00Z"), | |
| 895 | + | line("run_2", "usage", -6_000_000, "2026-10-06T00:00:00Z"), | |
| 896 | + | ]; | |
| 897 | + | let r = replay(0, &lines, &grants); | |
| 898 | + | assert_eq!((r.used("crd_soon"), r.used("crd_late"), r.used("crd_never")), (5_000_000, 5_000_000, 3_000_000)); | |
| 899 | + | assert_eq!(r.left["crd_never"], 2_000_000); | |
| 900 | + | // Past its expiry, a grant pays for nothing more. | |
| 901 | + | let lines = [ | |
| 902 | + | line("crd_soon", "top_up", 5_000_000, "2026-10-01T00:00:02Z"), | |
| 903 | + | line("run_late", "usage", -1_000_000, "2026-11-02T00:00:00Z"), | |
| 904 | + | ]; | |
| 905 | + | let r = replay(0, &lines, &grants); | |
| 906 | + | assert_eq!(r.used("crd_soon"), 0); | |
| 907 | + | assert_eq!(r.left["crd_soon"], 5_000_000); | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | #[test] | |
| 911 | + | fn credit_for_models_pays_only_for_models_and_is_spent_first() { | |
| 912 | + | let models = Grant { models_only: true, ..grant("pi_ai", CreditKind::Purchased, Some("2027-10-01T00:00:00Z"), "2026-10-01T00:00:01Z") }; | |
| 913 | + | let grants = [grant("crd_all", CreditKind::Promotional, Some("2026-11-01T00:00:00Z"), "2026-10-01T00:00:00Z"), models]; | |
| 914 | + | let mut sandbox = line("run_1/sandbox", "usage", -2_000_000, "2026-10-02T00:00:00Z"); | |
| 915 | + | sandbox.task = Some("sandbox".into()); | |
| 916 | + | let lines = [ | |
| 917 | + | line("crd_all", "top_up", 5_000_000, "2026-10-01T00:00:00Z"), | |
| 918 | + | line("pi_ai", "top_up", 10_000_000, "2026-10-01T00:00:01Z"), | |
| 919 | + | line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"), | |
| 920 | + | sandbox, | |
| 921 | + | ]; | |
| 922 | + | let r = replay(0, &lines, &grants); | |
| 923 | + | // The run's model cost from the models credit, though the other | |
| 924 | + | // expires sooner; the sandbox time only from credit for everything. | |
| 925 | + | assert_eq!((r.used("pi_ai"), r.used("crd_all")), (3_000_000, 2_000_000)); | |
| 926 | + | assert!(is_model_usage(Some("implement")) && !is_model_usage(Some("sandbox")) && !is_model_usage(None)); | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | #[test] | |
| 930 | + | fn credit_given_while_owing_pays_the_most_recent_usage_first() { | |
| 931 | + | let grants = [grant("crd_a", CreditKind::Goodwill, None, "2026-10-10T00:00:00Z")]; | |
| 932 | + | let lines = [ | |
| 933 | + | line("run_1", "usage", -20_000_000, "2026-10-02T00:00:00Z"), | |
| 934 | + | line("run_2", "usage", -10_000_000, "2026-10-05T00:00:00Z"), | |
| 935 | + | line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z"), | |
| 936 | + | ]; | |
| 937 | + | let r = replay(0, &lines, &grants); | |
| 938 | + | let paid: Vec<(&str, i64)> = r.draws.iter().map(|d| (d.reference.as_str(), d.micros)).collect(); | |
| 939 | + | assert_eq!(paid, [("run_2", 10_000_000), ("run_1", 15_000_000)]); | |
| 940 | + | assert_eq!(r.left["crd_a"], 0); | |
| 941 | + | // Owed from before the lines read: on the grant's day. | |
| 942 | + | let r = replay(-4_000_000, &[line("crd_a", "top_up", 25_000_000, "2026-10-10T00:00:00Z")], &grants); | |
| 943 | + | assert_eq!(r.draws[0].reference, "crd_a"); | |
| 944 | + | assert_eq!(r.draws[0].micros, 4_000_000); | |
| 945 | + | assert_eq!(r.left["crd_a"], 21_000_000); | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | #[test] | |
| 949 | + | fn a_charge_that_comes_down_gives_back_to_the_credit_that_paid() { | |
| 950 | + | let grants = [grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z")]; | |
| 951 | + | let lines = [ | |
| 952 | + | line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"), | |
| 953 | + | line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"), | |
| 954 | + | line("run_1/settled", "usage", 1_000_000, "2026-10-02T01:00:00Z"), | |
| 955 | + | ]; | |
| 956 | + | let r = replay(0, &lines, &grants); | |
| 957 | + | assert_eq!(r.used("crd_a"), 2_000_000); | |
| 958 | + | assert_eq!(r.left["crd_a"], 8_000_000); | |
| 959 | + | assert_eq!(r.draws.last().unwrap().micros, -1_000_000); | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | #[test] | |
| 963 | + | fn revoked_or_expired_credit_pays_for_nothing_more() { | |
| 964 | + | let mut revoked = grant("crd_a", CreditKind::Promotional, None, "2026-10-01T00:00:00Z"); | |
| 965 | + | revoked.closed_at = Some("2026-10-03T00:00:00Z".into()); | |
| 966 | + | let lines = [ | |
| 967 | + | line("crd_a", "top_up", 10_000_000, "2026-10-01T00:00:00Z"), | |
| 968 | + | line("run_1", "usage", -3_000_000, "2026-10-02T00:00:00Z"), | |
| 969 | + | line("crd_a_revoked", "top_up", -7_000_000, "2026-10-03T00:00:00Z"), | |
| 970 | + | line("run_2", "usage", -3_000_000, "2026-10-04T00:00:00Z"), | |
| 971 | + | ]; | |
| 972 | + | let r = replay(0, &lines, &[revoked]); | |
| 973 | + | assert_eq!(r.used("crd_a"), 3_000_000); | |
| 974 | + | assert_eq!(r.left["crd_a"], 0); | |
| 975 | + | assert_eq!(closed_grant("crd_a_revoked"), Some("crd_a")); | |
| 976 | + | assert_eq!(closed_grant("crd_a_expired"), Some("crd_a")); | |
| 977 | + | assert_eq!(closed_grant("run_1"), None); | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | #[test] | |
| 981 | + | fn taking_credit_back_never_takes_the_balance_below_zero() { | |
| 982 | + | assert_eq!(take_back(12_400_000, 50_000_000), 12_400_000); | |
| 983 | + | // A refunded payment left less on the balance than the credit. | |
| 984 | + | assert_eq!(take_back(12_400_000, 5_000_000), 5_000_000); | |
| 985 | + | assert_eq!(take_back(12_400_000, -1), 0); | |
| 986 | + | assert_eq!(take_back(-5, 10), 0); | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | #[test] | |
| 990 | + | fn a_grant_says_where_it_stands() { | |
| 991 | + | let now = "2026-10-07T12:00:00Z"; | |
| 992 | + | assert_eq!(state(12_400_000, Some("2027-01-05T23:59:59Z"), None, now), ("open", 12_400_000)); | |
| 993 | + | assert_eq!(state(0, None, None, now), ("used", 0)); | |
| 994 | + | assert_eq!(state(5, Some("2026-10-01T00:00:00Z"), None, now), ("expired", 0)); | |
| 995 | + | assert_eq!(state(5, None, Some("revoked"), now), ("revoked", 0)); | |
| 996 | + | assert_eq!(state(0, Some("2026-10-01T00:00:00Z"), Some("expired"), now), ("expired", 0)); | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | #[test] | |
| 1000 | + | fn a_credit_needs_a_kind_a_note_and_a_sensible_amount() { | |
| 1001 | + | let now = parse_rfc3339("2026-10-07T12:00:00Z").unwrap(); | |
| 1002 | + | let ok = AdminCreditArgs { | |
| 1003 | + | workspace: "acme".into(), | |
| 1004 | + | amount_micros: 25_000_000, | |
| 1005 | + | note: "Welcome to g1t".into(), | |
| 1006 | + | by: "chase@g1t.sh".into(), | |
| 1007 | + | kind: CreditKind::Promotional, | |
| 1008 | + | expires_at: Some("2027-01-05T23:59:59Z".into()), | |
| 1009 | + | refund_for: None, | |
| 1010 | + | refund_day: None, | |
| 1011 | + | }; | |
| 1012 | + | assert_eq!(invalid(&ok, now), None); | |
| 1013 | + | assert!(invalid(&AdminCreditArgs { note: " ".into(), ..clone(&ok) }, now).is_some()); | |
| 1014 | + | assert!(invalid(&AdminCreditArgs { amount_micros: 0, ..clone(&ok) }, now).is_some()); | |
| 1015 | + | assert!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS + 1, ..clone(&ok) }, now).is_some()); | |
| 1016 | + | assert_eq!(invalid(&AdminCreditArgs { amount_micros: MAX_CREDIT_MICROS, ..clone(&ok) }, now), None); | |
| 1017 | + | assert!(invalid(&AdminCreditArgs { expires_at: Some("2026-10-01T00:00:00Z".into()), ..clone(&ok) }, now).unwrap().contains("future")); | |
| 1018 | + | assert!(invalid(&AdminCreditArgs { expires_at: Some("2040-01-01T00:00:00Z".into()), ..clone(&ok) }, now).is_some()); | |
| 1019 | + | // A refund says what for, never expires, and refunds a day that was. | |
| 1020 | + | let refund = AdminCreditArgs { kind: CreditKind::Refund, expires_at: None, refund_for: Some("the failed runs on Oct 2".into()), refund_day: Some("2026-10-02".into()), ..clone(&ok) }; | |
| 1021 | + | assert_eq!(invalid(&refund, now), None); | |
| 1022 | + | assert!(invalid(&AdminCreditArgs { expires_at: Some("2027-01-05T23:59:59Z".into()), ..clone(&refund) }, now).unwrap().contains("never expires")); | |
| 1023 | + | assert!(invalid(&AdminCreditArgs { refund_for: None, ..clone(&refund) }, now).is_some()); | |
| 1024 | + | assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-10-09".into()), ..clone(&refund) }, now).is_some()); | |
| 1025 | + | assert!(invalid(&AdminCreditArgs { refund_day: Some("2026-02-30".into()), ..clone(&refund) }, now).is_some()); | |
| 1026 | + | } | |
| 1027 | + | ||
| 1028 | + | fn clone(a: &AdminCreditArgs) -> AdminCreditArgs { | |
| 1029 | + | AdminCreditArgs { | |
| 1030 | + | workspace: a.workspace.clone(), | |
| 1031 | + | amount_micros: a.amount_micros, | |
| 1032 | + | note: a.note.clone(), | |
| 1033 | + | by: a.by.clone(), | |
| 1034 | + | kind: a.kind, | |
| 1035 | + | expires_at: a.expires_at.clone(), | |
| 1036 | + | refund_for: a.refund_for.clone(), | |
| 1037 | + | refund_day: a.refund_day.clone(), | |
| 1038 | + | } | |
| 1039 | + | } | |
| 1040 | + | ||
| 1041 | + | #[test] | |
| 1042 | + | fn a_refund_takes_money_off_the_day_it_refunds_within_the_window() { | |
| 1043 | + | assert_eq!(refund_cash_day(Some("2026-10-02"), "2026-10-07T12:00:00Z"), "2026-10-02"); | |
| 1044 | + | // Further back than the reconciliation recomputes: its oldest day. | |
| 1045 | + | assert_eq!(refund_cash_day(Some("2026-08-01"), "2026-10-07T12:00:00Z"), "2026-09-07"); | |
| 1046 | + | // None, or a day after it was given: the day it was given. | |
| 1047 | + | assert_eq!(refund_cash_day(None, "2026-10-07T12:00:00Z"), "2026-10-07"); | |
| 1048 | + | assert_eq!(refund_cash_day(Some("2026-10-09"), "2026-10-07T12:00:00Z"), "2026-10-07"); | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | #[test] | |
| 1052 | + | fn usage_is_keyed_as_the_reconciliation_keys_it() { | |
| 1053 | + | assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds"); | |
| 1054 | + | assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments"); | |
| 1055 | + | assert_eq!(usage_key(Some("implement"), "run_1"), "implement"); | |
| 1056 | + | assert_eq!(usage_key(None, "crd_a"), "other"); | |
| 1057 | + | } | |
| 1058 | + | ||
| 1059 | + | #[test] | |
| 1060 | + | fn the_statement_and_the_email_say_what_the_credit_is() { | |
| 1061 | + | assert_eq!(describe_grant(CreditKind::Promotional, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z")), "Credit from g1t (promotional, until 2027-01-05): Welcome to g1t"); | |
| 1062 | + | assert_eq!( | |
| 1063 | + | describe_grant(CreditKind::Refund, "Sorry about that", Some("the failed runs on Oct 2"), None), | |
| 1064 | + | "Credit from g1t (refund for the failed runs on Oct 2): Sorry about that" | |
| 1065 | + | ); | |
| 1066 | + | let (subject, intro) = credit_notice("acme", CreditKind::Promotional, 25_000_000, "Welcome to g1t", None, Some("2027-01-05T23:59:59Z")); | |
| 1067 | + | assert_eq!(subject, "g1t: $25.00 of credit for acme"); | |
| 1068 | + | assert!(intro.starts_with("g1t added $25.00 of credit to acme. Welcome to g1t. It pays for usage")); | |
| 1069 | + | assert!(intro.ends_with("Unused credit expires on 2027-01-05.")); | |
| 1070 | + | let (subject, intro) = credit_notice("acme", CreditKind::Refund, 12_000_000, "Sorry.", Some("the outage on Oct 2"), None); | |
| 1071 | + | assert_eq!(subject, "g1t: a $12.00 refund for acme, as credit"); | |
| 1072 | + | assert!(intro.starts_with("g1t refunded $12.00 to acme as credit, for the outage on Oct 2. Sorry. It pays")); | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | #[test] | |
| 1076 | + | fn months_add_up_given_used_and_taken_back_by_kind() { | |
| 1077 | + | let promo = GrantRow { | |
| 1078 | + | id: "crd_a".into(), | |
| 1079 | + | workspace: "acme".into(), | |
| 1080 | + | kind: "promotional".into(), | |
| 1081 | + | amount_micros: 25_000_000, | |
| 1082 | + | created_at: "2026-09-20T00:00:00Z".into(), | |
| 1083 | + | closed_at: Some("2026-10-20T00:00:00Z".into()), | |
| 1084 | + | closed_reason: Some("expired".into()), | |
| 1085 | + | closed_micros: 5_000_000, | |
| 1086 | + | ..GrantRow::default() | |
| 1087 | + | }; | |
| 1088 | + | let draws = [ | |
| 1089 | + | Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r1".into(), task: None, at: "2026-09-25T00:00:00Z".into(), micros: 15_000_000 }, | |
| 1090 | + | Draw { grant: "crd_a".into(), kind: CreditKind::Promotional, reference: "r2".into(), task: None, at: "2026-10-02T00:00:00Z".into(), micros: 5_000_000 }, | |
| 1091 | + | ]; | |
| 1092 | + | let months = fold_months(&[promo], &draws); | |
| 1093 | + | assert_eq!(months.len(), 2); | |
| 1094 | + | assert_eq!((months[0].month.as_str(), months[0].used_micros, months[0].expired_micros, months[0].given_micros), ("2026-10", 5_000_000, 5_000_000, 0)); | |
| 1095 | + | assert_eq!((months[1].month.as_str(), months[1].used_micros, months[1].given_micros, months[1].grants), ("2026-09", 15_000_000, 25_000_000, 1)); | |
| 1096 | + | } | |
| 1097 | + | } |
| 35 | 35 | lines | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | /// Each line in whole cents, for the card processor. Their sum is what is | |
| 39 | + | /// owed rounded up to the next cent, never down: cutting each line to a | |
| 40 | + | /// cent on its own would charge up to a cent less per line than is owed (and | |
| 41 | + | /// a credit line a cent less of a credit), and leave the rest stranded under | |
| 42 | + | /// the minimum charge. The cent each needs is given to the lines with the | |
| 43 | + | /// largest fractions first. | |
| 44 | + | pub(crate) fn line_cents(lines: &[InvoiceItem]) -> Vec<i64> { | |
| 45 | + | const MICROS_PER_CENT: i64 = 10_000; | |
| 46 | + | let total: i64 = lines.iter().map(|l| l.amount_micros).sum(); | |
| 47 | + | let total_cents = total.div_euclid(MICROS_PER_CENT) + i64::from(total.rem_euclid(MICROS_PER_CENT) > 0); | |
| 48 | + | let mut cents: Vec<i64> = lines.iter().map(|l| l.amount_micros.div_euclid(MICROS_PER_CENT)).collect(); | |
| 49 | + | let mut short = total_cents - cents.iter().sum::<i64>(); | |
| 50 | + | let mut by_fraction: Vec<usize> = (0..lines.len()).collect(); | |
| 51 | + | by_fraction.sort_by_key(|&i| std::cmp::Reverse(lines[i].amount_micros.rem_euclid(MICROS_PER_CENT))); | |
| 52 | + | for i in by_fraction { | |
| 53 | + | if short <= 0 || lines[i].amount_micros.rem_euclid(MICROS_PER_CENT) == 0 { | |
| 54 | + | break; | |
| 55 | + | } | |
| 56 | + | cents[i] += 1; | |
| 57 | + | short -= 1; | |
| 58 | + | } | |
| 59 | + | cents | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Whether paying an invoice failed because the card said no (Stripe's | |
| 63 | + | /// 402, a `card_error`), rather than because Stripe could not be reached, | |
| 64 | + | /// was busy or failed itself. Only a decline stops a workspace's work. | |
| 65 | + | pub(crate) fn is_decline(error: &str) -> bool { | |
| 66 | + | error.contains("answered 402") || error.contains("\"card_error\"") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// A workspace invoice's draft: charged to the card, and holding only the | |
| 70 | + | /// lines put on it, never whatever is pending on the customer. | |
| 71 | + | fn draft_fields(customer: &str, workspace: &str, reason: &str, period: &str, description: String) -> Vec<(&'static str, String)> { | |
| 72 | + | vec![ | |
| 73 | + | ("customer", customer.to_owned()), | |
| 74 | + | ("collection_method", "charge_automatically".to_owned()), | |
| 75 | + | ("auto_advance", "false".to_owned()), | |
| 76 | + | ("pending_invoice_items_behavior", "exclude".to_owned()), | |
| 77 | + | ("description", description), | |
| 78 | + | ("metadata[g1t_workspace]", workspace.to_owned()), | |
| 79 | + | ("metadata[reason]", reason.to_owned()), | |
| 80 | + | ("metadata[period]", period.to_owned()), | |
| 81 | + | ] | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// One line, on the draft `invoice`. | |
| 85 | + | fn item_fields(customer: &str, invoice: &str, workspace: &str, description: &str, cents: i64) -> Vec<(&'static str, String)> { | |
| 86 | + | vec![ | |
| 87 | + | ("customer", customer.to_owned()), | |
| 88 | + | ("invoice", invoice.to_owned()), | |
| 89 | + | ("amount", cents.to_string()), | |
| 90 | + | ("currency", "usd".to_owned()), | |
| 91 | + | ("description", description.to_owned()), | |
| 92 | + | ("metadata[workspace]", workspace.to_owned()), | |
| 93 | + | ] | |
| 94 | + | } | |
| 95 | + | ||
| 38 | 96 | #[derive(Deserialize)] | |
| 39 | 97 | struct InvoiceRow { | |
| 40 | 98 | invoice_id: String, | |
| ⋯ | |||
| 140 | 198 | .collect(); | |
| 141 | 199 | let lines = invoice_lines(&used, owed); | |
| 142 | 200 | let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000); | |
| 143 | − | for (position, line) in lines.iter().enumerate() { | |
| 144 | − | let fields = [ | |
| 145 | − | ("customer", customer.clone()), | |
| 146 | − | ("amount", (line.amount_micros / 10_000).to_string()), | |
| 147 | − | ("currency", "usd".to_owned()), | |
| 148 | − | ("description", line.description.clone()), | |
| 149 | − | ("metadata[workspace]", workspace.to_owned()), | |
| 150 | − | ]; | |
| 151 | − | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?; | |
| 152 | − | } | |
| 153 | 201 | let description = match reason { | |
| 154 | 202 | "month" => format!("g1t usage for {workspace}, {period}"), | |
| 155 | 203 | _ => format!("g1t usage for {workspace}, charged as it neared its limit"), | |
| 156 | 204 | }; | |
| 157 | − | let fields = [ | |
| 158 | − | ("customer", customer.clone()), | |
| 159 | − | ("collection_method", "charge_automatically".to_owned()), | |
| 160 | − | ("auto_advance", "false".to_owned()), | |
| 161 | − | ("pending_invoice_items_behavior", "include".to_owned()), | |
| 162 | − | ("description", description), | |
| 163 | − | ("metadata[g1t_workspace]", workspace.to_owned()), | |
| 164 | − | ("metadata[reason]", reason.to_owned()), | |
| 165 | − | ("metadata[period]", period.to_owned()), | |
| 166 | − | ]; | |
| 167 | − | let draft: StripeInvoice = stripe.post_idempotent("/invoices", &fields, &key).await?; | |
| 205 | + | // The draft first, then its lines on it: lines left pending on the | |
| 206 | + | // customer by an attempt that failed half way would otherwise be | |
| 207 | + | // swept into the next invoice (this one's retry with a different | |
| 208 | + | // total, or the plan's renewal) on top of their own new lines. | |
| 209 | + | let draft: StripeInvoice = | |
| 210 | + | stripe.post_idempotent("/invoices", &draft_fields(&customer, workspace, reason, period, description), &key).await?; | |
| 211 | + | let cents = line_cents(&lines); | |
| 212 | + | for (position, (line, cents)) in lines.iter().zip(¢s).enumerate() { | |
| 213 | + | let fields = item_fields(&customer, &draft.id, workspace, &line.description, *cents); | |
| 214 | + | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?; | |
| 215 | + | } | |
| 168 | 216 | // A retry finds it finalized already; that is fine. | |
| 169 | 217 | let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await; | |
| 170 | 218 | // Charge the card now; a decline comes back as an error. | |
| ⋯ | |||
| 172 | 220 | let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?; | |
| 173 | 221 | let total = lines.iter().map(|l| l.amount_micros).sum::<i64>(); | |
| 174 | 222 | let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" }; | |
| 223 | + | // Only the card saying no is a decline, which stops work until it | |
| 224 | + | // is paid. Stripe failing to answer, or answering busy, is g1t's | |
| 225 | + | // problem and never stops a payer: the invoice stays as it is, and | |
| 226 | + | // the next attempt (the same total finds the same invoice) pays it. | |
| 227 | + | if status == "failed" && !paid.as_ref().err().is_some_and(|error| is_decline(&error.to_string())) { | |
| 228 | + | return Ok(Err("The card processor did not finish the payment; it is tried again.".into())); | |
| 229 | + | } | |
| 175 | 230 | let mut writes = vec![self | |
| 176 | 231 | .db | |
| 177 | 232 | .prepare( | |
| ⋯ | |||
| 357 | 412 | // Exactly what was used. | |
| 358 | 413 | assert_eq!(invoice_lines(&used, 50_000_000).len(), 2); | |
| 359 | 414 | } | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn an_invoice_holds_only_its_own_lines() { | |
| 418 | + | let draft = draft_fields("cus_1", "acme", "month", "2026-09", "g1t usage".to_owned()); | |
| 419 | + | assert!(draft.contains(&("pending_invoice_items_behavior", "exclude".to_owned()))); | |
| 420 | + | let item = item_fields("cus_1", "in_1", "acme", "Sandbox time", 1_234); | |
| 421 | + | assert!(item.contains(&("invoice", "in_1".to_owned()))); | |
| 422 | + | assert!(item.contains(&("amount", "1234".to_owned()))); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn only_the_card_saying_no_is_a_decline() { | |
| 427 | + | let declined = r#"the card processor answered 402: {"error": {"code": "card_declined", "type": "card_error"}}"#; | |
| 428 | + | assert!(is_decline(declined)); | |
| 429 | + | assert!(is_decline(r#"the card processor answered 400: {"error": {"type": "card_error"}}"#)); | |
| 430 | + | // Stripe down, busy or failing, or the network: tried again, nobody stopped. | |
| 431 | + | assert!(!is_decline(r#"the card processor answered 500: {"error": {"type": "api_error"}}"#)); | |
| 432 | + | assert!(!is_decline(r#"the card processor answered 429: {"error": {"type": "rate_limit_error"}}"#)); | |
| 433 | + | assert!(!is_decline("Network connection lost.")); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | fn items(micros: &[i64]) -> Vec<InvoiceItem> { | |
| 437 | + | micros.iter().map(|&amount_micros| InvoiceItem { description: String::new(), amount_micros }).collect() | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[test] | |
| 441 | + | fn the_card_is_charged_what_is_owed_rounded_up_to_the_cent_never_down() { | |
| 442 | + | // $1.234567 + $2.345678 = $3.580245 owed: 359 cents, where cutting | |
| 443 | + | // each line would have charged 357. | |
| 444 | + | let cents = line_cents(&items(&[1_234_567, 2_345_678])); | |
| 445 | + | assert_eq!(cents.iter().sum::<i64>(), 359); | |
| 446 | + | assert_eq!(cents, vec![124, 235]); | |
| 447 | + | // Whole cents stay as they are. | |
| 448 | + | assert_eq!(line_cents(&items(&[40_000_000, 10_000_000])), vec![4_000, 1_000]); | |
| 449 | + | // A credit line keeps its full credit; the total still rounds up. | |
| 450 | + | // $50.004 used, $10.0025 paid in advance: $40.0015 owed, 4,001 cents. | |
| 451 | + | let cents = line_cents(&items(&[50_004_000, -10_002_500])); | |
| 452 | + | assert_eq!(cents.iter().sum::<i64>(), 4_001); | |
| 453 | + | // Lines under a cent each add up to the cents they make together. | |
| 454 | + | let cents = line_cents(&items(&[4_000, 4_000, 4_000])); | |
| 455 | + | assert_eq!(cents.iter().sum::<i64>(), 2); | |
| 456 | + | // Every invoice the close makes: never less than owed, never a cent more. | |
| 457 | + | for (used, owed) in [(vec![("a".to_owned(), 7_777_777), ("b".to_owned(), 3)], 6_000_001), (vec![("a".to_owned(), 5_000_001)], 5_000_001)] { | |
| 458 | + | let lines = invoice_lines(&used, owed); | |
| 459 | + | let charged = line_cents(&lines).iter().sum::<i64>() * 10_000; | |
| 460 | + | assert!(charged >= owed && charged - owed < 10_000, "{charged} for {owed}"); | |
| 461 | + | } | |
| 462 | + | } | |
| 360 | 463 | } | |
| 26 | 26 | mod margin; | |
| 27 | 27 | mod pricing; | |
| 28 | 28 | mod credits; | |
| 29 | + | mod grants; | |
| 29 | 30 | mod overages; | |
| 30 | 31 | mod requests; | |
| 31 | 32 | mod storage; | |
| ⋯ | |||
| 47 | 48 | use g1t_contracts::billing::*; | |
| 48 | 49 | use g1t_contracts::time::rfc3339; | |
| 49 | 50 | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 50 | − | use g1t_contracts::billing::TermsKind; | |
| 51 | 51 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 52 | 52 | use serde::Deserialize; | |
| 53 | 53 | use sha2::{Digest, Sha256}; | |
| ⋯ | |||
| 123 | 123 | oss_micros: Option<i64>, | |
| 124 | 124 | #[serde(default)] | |
| 125 | 125 | given_micros: Option<i64>, | |
| 126 | + | #[serde(default)] | |
| 127 | + | credit_kind: Option<String>, | |
| 128 | + | #[serde(default)] | |
| 129 | + | discount_micros: Option<i64>, | |
| 126 | 130 | } | |
| 127 | 131 | ||
| 128 | 132 | impl From<LedgerRow> for LedgerEntry { | |
| ⋯ | |||
| 144 | 148 | trial_micros: row.trial_micros.unwrap_or(0), | |
| 145 | 149 | oss_micros: row.oss_micros.unwrap_or(0), | |
| 146 | 150 | given_micros: row.given_micros.unwrap_or(0), | |
| 151 | + | credit_kind: row.credit_kind.as_deref().and_then(CreditKind::parse), | |
| 152 | + | discount_micros: row.discount_micros.unwrap_or(0), | |
| 147 | 153 | } | |
| 148 | 154 | } | |
| 149 | 155 | } | |
| ⋯ | |||
| 348 | 354 | ])?, | |
| 349 | 355 | ]) | |
| 350 | 356 | .await?; | |
| 351 | − | // Money in clears a card declined at the limit. | |
| 352 | − | if kind == "top_up" { | |
| 357 | + | // Money in clears a card declined at the limit. A refund or a lost | |
| 358 | + | // dispute is a top-up of less than nothing: money out, which never | |
| 359 | + | // clears it. | |
| 360 | + | if kind == "top_up" && amount_micros > 0 { | |
| 353 | 361 | self.db | |
| 354 | 362 | .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?") | |
| 355 | 363 | .bind(&[workspace.into()])? | |
| ⋯ | |||
| 395 | 403 | micros: Option<i64>, | |
| 396 | 404 | runs: Option<u32>, | |
| 397 | 405 | } | |
| 398 | − | // While nothing is charged (g1t is free, or the workspace is | |
| 399 | − | // comped), what was used is what there is to show. | |
| 400 | − | #[derive(serde::Deserialize)] | |
| 401 | − | struct Comped { | |
| 402 | − | n: i64, | |
| 403 | − | } | |
| 404 | − | let comped = self | |
| 405 | − | .db | |
| 406 | − | .prepare("SELECT COUNT(*) AS n FROM billing_accounts WHERE kind = 'workspace' AND terms_kind = 'comped' AND substr(id, 4) = ?1") | |
| 407 | − | .bind(&[workspace.as_str().into()])? | |
| 408 | − | .first::<Comped>(None) | |
| 409 | − | .await? | |
| 410 | − | .is_some_and(|row| row.n > 0); | |
| 411 | − | let measure = if self.free || comped { "COALESCE(cost_micros, 0)" } else { "-amount_micros" }; | |
| 406 | + | // While g1t is free, what was used at cost is what there is to show. | |
| 407 | + | // With a discount, usage at its price, so a 100% discount still | |
| 408 | + | // shows what the workspace would pay. | |
| 409 | + | let discount_percent = self.terms_of(&workspace).await?.percent_off(); | |
| 410 | + | let measure = if self.free { | |
| 411 | + | "COALESCE(cost_micros, 0)" | |
| 412 | + | } else if discount_percent > 0 { | |
| 413 | + | "(-amount_micros + COALESCE(credit_micros, 0) + COALESCE(trial_micros, 0) + COALESCE(oss_micros, 0) | |
| 414 | + | + COALESCE(given_micros, 0) + COALESCE(discount_micros, 0))" | |
| 415 | + | } else { | |
| 416 | + | "-amount_micros" | |
| 417 | + | }; | |
| 412 | 418 | let slices = |key: &str, limit: u32| { | |
| 413 | 419 | format!( | |
| 414 | 420 | "SELECT {key} AS key, SUM({measure}) AS micros, COUNT(*) AS runs FROM ledger | |
| ⋯ | |||
| 446 | 452 | provider: Option<i64>, | |
| 447 | 453 | runs: Option<u32>, | |
| 448 | 454 | added: Option<i64>, | |
| 455 | + | discount: Option<i64>, | |
| 449 | 456 | } | |
| 450 | 457 | let totals = async { | |
| 451 | 458 | self.db | |
| ⋯ | |||
| 456 | 463 | SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost, | |
| 457 | 464 | SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider, | |
| 458 | 465 | SUM(CASE WHEN kind = 'usage' THEN 1 ELSE 0 END) AS runs, | |
| 459 | − | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added | |
| 466 | + | SUM(CASE WHEN kind = 'top_up' THEN amount_micros END) AS added, | |
| 467 | + | SUM(CASE WHEN kind = 'usage' THEN discount_micros END) AS discount | |
| 460 | 468 | FROM ledger WHERE workspace = ?1 AND created_at >= ?2", | |
| 461 | 469 | ) | |
| 462 | 470 | .bind(&[workspace.as_str().into(), a.since.as_str().into()])? | |
| ⋯ | |||
| 483 | 491 | provider: None, | |
| 484 | 492 | runs: None, | |
| 485 | 493 | added: None, | |
| 494 | + | discount: None, | |
| 486 | 495 | }); | |
| 487 | 496 | Ok(Outcome::Ok(Usage { | |
| 488 | 497 | spent_micros: totals.spent.unwrap_or_default(), | |
| 489 | 498 | // At price, what g1t's usage came to, less what was charged: the | |
| 490 | 499 | // part the included usage, the trial, a pool, or a free period paid. | |
| 491 | 500 | covered_micros: (crate::credits::with_margin(totals.g1t_cost.unwrap_or_default(), self.margin_percent) | |
| 492 | − | - totals.spent.unwrap_or_default()) | |
| 501 | + | - totals.spent.unwrap_or_default() | |
| 502 | + | - totals.discount.unwrap_or_default()) | |
| 493 | 503 | .max(0), | |
| 494 | 504 | cost_micros: totals.cost.unwrap_or_default(), | |
| 495 | 505 | provider_micros: totals.provider.unwrap_or_default(), | |
| 496 | 506 | used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(), | |
| 497 | 507 | free: self.free, | |
| 508 | + | discount_micros: totals.discount.unwrap_or_default(), | |
| 509 | + | discount_percent: (discount_percent > 0).then_some(discount_percent), | |
| 498 | 510 | runs: totals.runs.unwrap_or_default(), | |
| 499 | 511 | added_micros: totals.added.unwrap_or_default(), | |
| 500 | 512 | by_day, | |
| ⋯ | |||
| 581 | 593 | &self.stripe, | |
| 582 | 594 | self.db | |
| 583 | 595 | .prepare( | |
| 596 | + | // A prepayment only: a plan's or a card check's page is | |
| 597 | + | // settled where it was started, never credited as money | |
| 598 | + | // paid in advance. | |
| 584 | 599 | "SELECT workspace, created_by FROM checkouts | |
| 585 | − | WHERE id = ? AND workspace = ? AND status = 'open'", | |
| 600 | + | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NULL", | |
| 586 | 601 | ) | |
| 587 | 602 | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 588 | 603 | .first::<CheckoutRow>(None) | |
| ⋯ | |||
| 980 | 995 | } | |
| 981 | 996 | let terms = self.terms_of(workspace).await?; | |
| 982 | 997 | let (charge, discount) = terms.discounted(base); | |
| 983 | − | let note = match terms.kind { | |
| 984 | − | TermsKind::Comped => " (comped)".to_owned(), | |
| 985 | − | TermsKind::Custom if terms.discount_percent > 0 && base > 0 => format!(" ({}% off)", terms.discount_percent), | |
| 998 | + | let note = match terms.discount_label() { | |
| 999 | + | Some(label) if base > 0 => format!(" ({label})"), | |
| 986 | 1000 | _ => String::new(), | |
| 987 | 1001 | }; | |
| 988 | 1002 | Ok((charge, note, discount)) | |
| ⋯ | |||
| 1101 | 1115 | && let Err(error) = billing.reconcile(&keeper).await { | |
| 1102 | 1116 | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 1103 | 1117 | } | |
| 1118 | + | // Once a day: credit from g1t past its expiry stops counting | |
| 1119 | + | // (grants.rs), before the day is reconciled. | |
| 1120 | + | if event.cron() == keeper::DAILY { | |
| 1121 | + | match billing.expire_credits().await { | |
| 1122 | + | Ok(closed) => worker::console_log!("credits expired: {closed}"), | |
| 1123 | + | Err(error) => worker::console_error!("expiring credits failed: {error}"), | |
| 1124 | + | } | |
| 1125 | + | } | |
| 1104 | 1126 | // Once a day: what Cloudflare charged, reconciled against what g1t | |
| 1105 | 1127 | // counted and charged; prices whose day has come; margin alerts | |
| 1106 | 1128 | // (margin.rs). After the keeper, so its proposals are in. | |
| ⋯ | |||
| 1167 | 1189 | "status" => reply(&billing.status()), | |
| 1168 | 1190 | "account" => reply(&billing.account(args(body)?).await?), | |
| 1169 | 1191 | "ledger" => reply(&billing.ledger(args(body)?).await?), | |
| 1192 | + | "credits" => reply(&billing.credits(args(body)?).await?), | |
| 1170 | 1193 | "usage" => reply(&billing.usage(args(body)?).await?), | |
| 1171 | 1194 | "record_tokens" => reply(&billing.record_tokens(args(body)?).await?), | |
| 1172 | 1195 | "token_usage" => reply(&billing.token_usage(args(body)?).await?), | |
| ⋯ | |||
| 1225 | 1248 | "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?), | |
| 1226 | 1249 | "admin_attach" => reply(&billing.admin_attach(args(body)?).await?), | |
| 1227 | 1250 | "admin_credit" => reply(&billing.admin_credit(args(body)?).await?), | |
| 1251 | + | "admin_credits" => reply(&billing.admin_credits(args(body)?).await?), | |
| 1252 | + | "admin_revoke_credit" => reply(&billing.admin_revoke_credit(args(body)?).await?), | |
| 1228 | 1253 | "admin_reset_billing" => reply(&billing.admin_reset_billing(&env, args(body)?).await?), | |
| 1229 | 1254 | "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?), | |
| 1230 | 1255 | "entitlements" => reply(&billing.entitlements(args(body)?).await?), | |
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.
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.