Skip to content

Commit

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.

syntaqxcommitted Parent7d9270eBrowse files
57 files+3179−3310/57 viewed
+45−13
130130 **Update payment on Stripe**, and the plan's features stop until it is
131131 paid.
132132
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.
136136
137137 ## No card, no compute
138138
529529 person. The credit appears on the statement with the day it happened,
530530 such as *Credit from g1t: accidental usage on 2026-11-12*.
531531
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+
532560 ## Invoices
533561
534562 Every charge is a real invoice from g1t, kept on Stripe's billing page
618646 the enterprise's billing address, with a line for each workspace, due in
619647 30 days and paid by card or bank transfer. If it goes overdue, the
620648 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.
628660 - **A longer audit log**: up to 400 days for every workspace the account
629661 pays for, in place of the plan's 7 or 90. See
630662 [how long it is kept](/guides/audit-log/#how-long-it-is-kept).
631663
632664 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.
635667
636668 Each change is made by g1t staff and recorded with who made it and why. To
637669 ask for one, write to [hey@flagon.io](mailto:hey@flagon.io).
694726 | Search embeddings | Private text put in the search index, once a month. |
695727 | Security scans | History scans and dependency checks, once a month. |
696728 | 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). |
698730 | Refunds | Money given back to your card. |
699731
700732 - **Covered.** Below the totals, what paid for usage before it was
+25−0
134134 animation: none;
135135 }
136136 }
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+}
+271−58
77 import type { ReactNode } from "react";
88 import { Link } from "react-router";
99
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";
1111
1212 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";
1315 import { actionLabel } from "~/lib/ledgers";
1416 import { dollarsField, usd } from "~/lib/money";
1517 import { givenParts } from "~/lib/pricing";
6769
6870 function describeTerms(terms: Terms): [string, string][] {
6971 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)}%`],
7274 [
7375 "Limit",
74− terms.kind === "comped"
76+ fullDiscount(terms)
7577 ? terms.ceilingMicros == null
7678 ? "The default monthly budget, at cost"
7779 : `${usd(terms.ceilingMicros)} a month, at cost`
9698 const before = describeTerms(review.before);
9799 const after = describeTerms(review.after);
98100 title = "Confirm the new terms";
99− danger = review.after.kind === "comped";
101+ danger = fullDiscount(review.after);
100102 body = (
101103 <>
102104 <div className="overflow-x-auto">
119121 </tbody>
120122 </table>
121123 </div>
122− {review.after.kind === "comped" && (
124+ {fullDiscount(review.after) && (
123125 <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.
126128 </p>
127129 )}
128130 </>
203205
204206 // --- Terms -------------------------------------------------------------------
205207
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+ */
212214 export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
213215 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");
215218 return (
216219 <Section
217220 id="terms"
227230 )
228231 }
229232 >
230− <form method="post" action={`${pathname}#review`} className="space-y-4">
233+ <form method="post" action={`${pathname}#review`} className="terms-form space-y-4">
231234 <input type="hidden" name="intent" value="terms" />
232235 {error && <Notice tone="error">{error.error}</Notice>}
233236 <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>
247243 ))}
244+ <Segment name="preset" value="custom" checked={preset === "custom"}>
245+ Custom
246+ </Segment>
248247 </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>
249249 </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) : "")} />
253253 </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).">
255257 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
256258 </Field>
257259 <Field label="Until" hint="Blank: no end. UTC.">
258260 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
259261 </Field>
260262 </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" />
263265 </Field>
264266 <div className="flex justify-end">
265267 <Button type="submit">Review terms</Button>
274276 /** What staff have set beyond the terms, one line each; defaults left out. */
275277 export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] {
276278 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)");
278280 else if (allowances?.plan) lines.push("The g1t plan, without its price");
279281 if (!allowances) return lines;
280282 if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
293295 * The g1t plan without its price, the account's share of g1t's pools, and
294296 * staff's overrides of what owners set: agents at once, the run and issue
295297 * 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
297299 * anyway.
298300 */
299301 export function AllowancesForm({
316318 title="Plan, pools and caps"
317319 description={
318320 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."
320322 : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set."
321323 }
322324 >
457459
458460 // --- Credit -------------------------------------------------------------------
459461
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+ */
460479 export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
461480 const values = error?.values;
462481 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";
463485 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+ >
465491 {workspaces.length === 0 ? (
466492 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
467493 ) : (
468− <form method="post" action={`${pathname}#credit`} className="space-y-4">
494+ <form method="post" action={`${pathname}#credit`} className="credit-form space-y-4">
469495 <input type="hidden" name="intent" value="credit" />
470496 {error && <Notice tone="error">{error.error}</Notice>}
471497 {single ? (
484510 </Select>
485511 </Field>
486512 )}
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 ?? ""} />
502576 </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>
503589 <div className="flex justify-end">
504590 <Button type="submit" variant="lavender">
505591 <Gift size={14} />
506− Issue credit
592+ Give credit
507593 </Button>
508594 </div>
509595 </form>
512598 );
513599 }
514600
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+
515726 // --- Reset (testing) -----------------------------------------------------------
516727
517728 /**
518729 * 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
520731 * enterprise workspaces. The workspace, its members and repositories stay.
521732 */
522733 export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
644855 </td>
645856 <td className="px-4 py-2.5">
646857 <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>
648861 {showWorkspace && entry.workspace && (
649862 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
650863 {entry.workspace}
+6−7
1010 import type { Limit, Terms, Trust } from "@g1t/contracts";
1111
1212 import { usd } from "~/lib/money";
13+import { fullDiscount, termsLabel } from "~/lib/terms";
1314 import { when } from "~/lib/time";
1415
1516 export function Field({
150151 }
151152
152153 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>;
158157 }
159158
160159 const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = {
166165 about: "Established: the limit follows their monthly spend, up to $10,000.",
167166 },
168167 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." },
171170 };
172171
173172 export function TrustBadge({ trust }: { trust: Trust }) {
+24−13
22 * The changes staff make to how a workspace or an enterprise pays: terms,
33 * moving workspaces on and off enterprises, credits, and Stripe billing
44 * 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.
77 */
88 import type { Terms } from "@g1t/contracts";
99 import { data, redirect } from "react-router";
1010
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";
1213 import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing";
1314 import type { ActionData } from "./review";
1415 import { admin, identity, priceBook } from "./services.server";
3334 const isEnterprise = subject.kind === "enterprise";
3435
3536 if (intent === "terms") {
36− const values = fields(form, "kind", "discount", "ceiling", "note", "until");
37+ const values = fields(form, "preset", "discount", "ceiling", "note", "until");
3738 if (subject.kind === "workspace" && subject.billedTo) {
3839 return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values);
3940 }
105106 }
106107
107108 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");
109110 const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug;
110111 if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) {
111112 return failed("credit", "Choose one of this enterprise's workspaces.", values);
112113 }
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);
121118 if (!result.ok) return failed("credit", result.error.message, values);
122119 return back("credit");
123120 }
124121
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+
125136 if (intent === "reset") {
126137 // A test workspace's billing wiped. Billing refuses it on a live Stripe
127138 // key, for comped workspaces and for an enterprise's.
+2−2
168168 // --- g1t's own spend (billing's budget) ---------------------------------------
169169
170170 /**
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
172172 * account's monthly budget used up. Null when neither.
173173 */
174174 export function spendBanner(caps: SpendCaps): string | null {
191191 /** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */
192192 export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } {
193193 const notes: Record<string, string> = {
194− comped: "Work on comped accounts, at cost",
194+ comped: "Work on accounts with a 100% discount, at cost",
195195 trial: "Trial credit, at cost",
196196 oss: "Checks and workflows on public repositories, at cost",
197197 given: "Free workspaces' overruns past their trial",
+95−0
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+});
+152−0
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+}
+27−12
2222 return data;
2323 }
2424
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);
2727 assert.deepEqual(result, {
2828 ok: true,
2929 value: {
30− kind: "comped",
31− discountPercent: 0,
30+ kind: "custom",
31+ discountPercent: 100,
3232 ceilingMicros: 500_000_000,
3333 note: "g1t's own",
3434 until: "2026-12-31T23:59:59Z",
3636 setAt: NOW.toISOString(),
3737 },
3838 });
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);
3942 });
4043
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);
4356 assert.ok(result.ok);
57+ assert.equal(result.value.kind, "standard");
4458 assert.equal(result.value.ceilingMicros, null);
4559 assert.equal(result.value.until, null);
4660 });
4761
4862 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);
5570 });
5671
5772 test("workspace lists take commas, spaces and lines, once each", () => {
+24−24
5959 }
6060
6161 /**
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.
6568 */
6669 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") {
7474 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." };
7981 }
8082
8183 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;
8989 }
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;
9393
9494 let until: string | null = null;
9595 if (kind !== "standard") {
+2−0
128128 maintenance_started: "Maintenance started",
129129 maintenance_completed: "Maintenance completed",
130130 maintenance_cancelled: "Maintenance cancelled",
131+ credit_revoked: "Credit revoked",
132+ credit_expired: "Credit expired",
131133 };
132134
133135 /**
+2−2
4040
4141 test("the built pages are not marked soon", () => {
4242 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"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
4747 const soon = soonItems();
48− assert.ok(soon.length >= 9);
48+ assert.ok(soon.length >= 8);
4949 for (const item of soon) {
5050 assert.ok(item.soon.summary.length >= 1 && item.soon.summary.length <= 4, item.label);
5151 assert.ok(item.soon.plans.length >= 3 && item.soon.plans.length <= 6, item.label);
+1−14
179179 label: "Credits & refunds",
180180 to: "/credits",
181181 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.",
196183 },
197184 {
198185 label: "Usage explorer",
+2−1
2727 allowances: "Plan, pools and caps saved. They apply from now on.",
2828 attach: "Workspace moved onto the enterprise.",
2929 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.",
3132 reset: "Billing reset. The workspace starts again as a new customer, and the costs analysis ran again, so every figure is fresh.",
3233 "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.",
3334 created: "Enterprise created.",
+17−0
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+});
+30−0
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+}
+1−0
2020 route("costs/bill", "routes/costs-bill.tsx"),
2121 route("abuse", "routes/abuse.tsx"),
2222 route("invoices", "routes/invoices.tsx"),
23+ route("credits", "routes/credits.tsx"),
2324 route("stripe", "routes/stripe.tsx"),
2425 route("audit", "routes/audit.tsx"),
2526 route("timezone", "routes/timezone.tsx"),
+14−4
210210 const spent = o.costMicros + subscriptions;
211211 const net = moneyIn - spent;
212212 const givenParts = [
213− ["comped", o.givenCompedMicros ?? 0],
213+ ["100% discounts", o.givenCompedMicros ?? 0],
214214 ["free use", o.givenFreeMicros ?? 0],
215215 ["trial", o.givenTrialMicros ?? 0],
216216 ["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],
218220 ].filter(([, micros]) => (micros as number) > 0) as [string, number][];
219221 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
220222 {
343345 </tbody>
344346 </table>
345347 </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>
346356 </>
347357 );
348358 }
355365 className="mt-6"
356366 id="spend"
357367 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."
359369 >
360370 <div className="grid gap-3 lg:grid-cols-2">
361371 <CapMeter
380390 {caps.comped.map((b) => (
381391 <CapMeter
382392 key={b.account}
383− label={`${b.name}, ${caps.month} (comped)`}
393+ label={`${b.name}, ${caps.month} (100% discount)`}
384394 used={b.usedMicros}
385395 cap={b.ceilingMicros}
386396 hint={
+210−0
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+}
+25−5
44 import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
55
66 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";
88 import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
99 import { type Subject, billingAction } from "~/lib/billing-actions.server";
1010 import { usd } from "~/lib/money";
1111 import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
1212 import { admin, identity } from "~/lib/services.server";
13+import { settle } from "~/lib/settle";
1314 import { requireStaff } from "~/lib/staff";
15+import { fullDiscount } from "~/lib/terms";
1416 import { legacyAccountPath } from "~/lib/workspaces";
1517
1618 export const meta: Route.MetaFunction = ({ loaderData }) => [
6971 limit: limits.get(slug) ?? null,
7072 chargedMicros: shares.get(slug)?.chargedMicros ?? 0,
7173 }));
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+ };
7382 }
7483
7584 export async function action({ request, params, context }: Route.ActionArgs) {
8291 type Member = Route.ComponentProps["loaderData"]["members"][number];
8392
8493 export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) {
85− const { detail, members, done } = loaderData;
94+ const { detail, members, credits, creditsError, done } = loaderData;
8695 const { summary } = detail;
8796 const { account, limit } = summary;
8897 const { pathname } = useLocation();
109118 <div className="mt-2 flex flex-wrap gap-1.5">
110119 <Badge tone="lavender">Enterprise</Badge>
111120 <TermsBadge terms={account.terms} />
112− {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
121+ {!fullDiscount(account.terms) && <TrustBadge trust={limit.trust} />}
113122 <StateBadge state={limit.state} />
114123 </div>
115124 </div>
152161 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
153162 <AllowancesForm
154163 allowances={account.allowances}
155− comped={account.terms.kind === "comped"}
164+ comped={fullDiscount(account.terms)}
156165 pathname={pathname}
157166 error={error("allowances")}
158167 />
160169 </div>
161170 <div className="space-y-6">
162171 <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>
163183 <AuditSection audit={detail.audit} />
164184 </div>
165185 </div>
+20−8
1010 AuditSection,
1111 BillingLinkSection,
1212 CreditForm,
13+ CreditsSection,
1314 Figure,
1415 LedgerSection,
1516 Owners,
4748 import { admin, entitlements as entitlementsOf, identity, priceBook } from "~/lib/services.server";
4849 import { settle } from "~/lib/settle";
4950 import { requireStaff } from "~/lib/staff";
51+import { fullDiscount } from "~/lib/terms";
5052 import type { Enterprise } from "~/lib/workspaces";
5153
5254 export const meta: Route.MetaFunction = ({ loaderData }) => [
8991 const figures = billedTo
9092 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
9193 : { 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([
9395 billedTo
9496 ? Promise.resolve([])
9597 : admin
104106 settle(entitlementsOf(slug)),
105107 settle(admin.overages()),
106108 settle(priceBook()),
109+ settle(admin.credits({ workspace: slug })),
107110 ]);
108111 const done = doneKey(request.url);
109112 return {
126129 entitlementsError: plan.ok ? null : plan.error,
127130 overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null,
128131 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,
129136 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
130137 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
131138 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
148155 { id: "sales", label: "Sales" },
149156 { id: "plan", label: "Plan" },
150157 { id: "billing", label: "Billing" },
158+ { id: "credits", label: "Credits" },
151159 { id: "invoices", label: "Invoices" },
152160 { id: "ledger", label: "Ledger" },
153161 { id: "audit", label: "Audit log" },
174182 entitlementsError,
175183 overage,
176184 forgiveCap,
185+ credits,
186+ creditsError,
177187 ledger,
178188 audit,
179189 done,
212222 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
213223 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
214224 <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} />}
216227 <StateBadge state={limit.state} />
217228 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
218229 </div>
337348 ) : (
338349 <>
339350 <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")} />
341352 </>
342353 )}
354+ <CreditsSection credits={credits} unavailable={creditsError} pathname={pathname} error={error("credits")} />
343355 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
344356 <div id="ledger" className="scroll-mt-20">
345357 <LedgerSection
351363
352364 <div className="space-y-6">
353365 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
354− {!billedTo && terms.kind !== "comped" && (
366+ {!billedTo && !fullDiscount(terms) && (
355367 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
356368 )}
357369 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
358370 <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")} />}
360372 <div id="audit" className="scroll-mt-20">
361373 <AuditSection
362374 audit={audit}
386398 const lines: { label: string; value: ReactNode }[] = [];
387399 if (billedTo) {
388400 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)) {
390402 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
391403 }
392404 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
393− if (!billedTo && terms.kind !== "comped") {
405+ if (!billedTo && !fullDiscount(terms)) {
394406 lines.push({
395407 label: "Owners' limit",
396408 value: limit.defaultSpendLimit ? (
502514 );
503515 }
504516
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" };
506518
507519 function gigabytes(bytes: number): string {
508520 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
+3−2
99 import { usd } from "~/lib/money";
1010 import { admin, identity } from "~/lib/services.server";
1111 import { requireStaff } from "~/lib/staff";
12+import { fullDiscount } from "~/lib/terms";
1213 import { PAGE_SIZE, type WorkspaceRow, joinWorkspaces, paginate } from "~/lib/workspaces";
1314
1415 export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }];
1516
1617 const FILTERS = {
1718 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" },
1920 enterprise: { label: "On an enterprise", test: (row: WorkspaceRow) => row.billing.billedTo != null },
2021 } as const;
2122
294295 <div className="mt-1.5 flex flex-wrap gap-1.5">
295296 {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>}
296297 <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} />}
298299 </div>
299300 </div>
300301 </div>
+47−6
11 /**
22 * The billing page's sections: the spend-spike banner (also in the app
33 * 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
55 * meant to?". Each posts to the billing route's action with an `intent`.
66 */
7−import { ArrowUpRight, CreditCard, Gauge, Landmark, ShieldCheck, Sparkles } from "lucide-react";
7+import { ArrowUpRight, CreditCard, Gauge, Gift, Landmark, ShieldCheck, Sparkles } from "lucide-react";
88 import type { ReactNode } from "react";
99 import { Form } from "react-router";
1010
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";
1212
1313 import {
1414 CAPS,
15+ CREDIT_KIND,
1516 PREPAY,
1617 type PlanStatus,
1718 alertText,
1819 alertTone,
20+ creditLine,
1921 dollars,
2022 gigabytes,
2123 requestStatus,
2224 share,
25+ shortDay,
2326 shownMeters,
2427 spendRange,
2528 wholeDollars,
258261 {status.kind === "comped" || status.kind === "enterprise" ? (
259262 <p className="mt-4 text-sm text-muted">
260263 {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."
262265 : "An enterprise pays for this workspace, on its own invoice."}
263266 </p>
264267 ) : !enabled ? (
351354 </div>
352355 <p className="mt-2 text-xs text-faint">
353356 {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."
355358 : on
356359 ? `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.`}
358361 </p>
359362 </div>
360363 );
603606
604607 // --- Prepay -----------------------------------------------------------------------
605608
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+
606647 export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
607648 return (
608649 <Card
+62−17
2020 return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
2121 }
2222
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+
2334 function monthLabel(month: string): string {
2435 const [year, number] = month.split("-").map(Number);
2536 return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
5768 navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
5869 const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
5970 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);
6074
6175 return (
6276 <section id="statement" className="mt-10 scroll-mt-20">
91105 )}
92106 </div>
93107
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+ )}
108143
109144 {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && (
110145 <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
146181 month={statement.month}
147182 kind={line.kind}
148183 count={line.count}
149− chargedMicros={line.chargedMicros}
184+ chargedMicros={atPrice && line.chargedMicros >= 0 ? (line.priceMicros ?? line.chargedMicros) : line.chargedMicros}
150185 coveredMicros={line.coveredMicros ?? 0}
151186 day={group === "day" ? g.key : null}
152187 project={group === "project" ? g.key : null}
153188 />
154189 ))}
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+ )}
155197 </ul>
156198 </div>
157199 ))}
254296 {entry.createdBy && ` · ${entry.createdBy}`}
255297 </p>
256298 </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+ )}
259304 </span>
260305 </li>
261306 ))}
+2−2
1111 /** What the card says the workspace is on, beside its spend. */
1212 const STANDING: Record<UsageGlance["kind"], string> = {
1313 beta: "Free for now",
14− comped: "Comped",
14+ comped: "100% discount",
1515 plan: "g1t plan",
1616 trial: "Trial",
1717 forge: "Free",
6565 {glance.kind === "beta"
6666 ? "At cost. Nothing is charged while g1t is being built out."
6767 : 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."
6969 : glance.kind === "forge"
7070 ? "The forge is free. Agents, workflows and deployments need the plan or the trial."
7171 : "Charged so far, from the 1st."}
+11−0
88 alertText,
99 alertTone,
1010 cardCheckResult,
11+ creditLine,
1112 dollars,
1213 needsAttention,
1314 usageGlance,
2425 wholeDollars,
2526 } from "./billing.ts";
2627
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+
2738 test("money reads as dollars", () => {
2839 assert.equal(dollars(9_500_000), "$9.50");
2940 assert.equal(dollars(-1_250_000), "−$1.25");
+32−2
33 * workspace's alerts. Pure, so it can be tested.
44 */
55
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";
77
88 /** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */
99 const MICROS_PER_DOLLAR = 1_000_000;
171171 plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined,
172172 entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined,
173173 ): 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" };
175175 if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" };
176176 if (plan?.included) return { kind: "comped", label: "Included by g1t" };
177177 const subscription = plan?.subscription;
337337 },
338338 };
339339 }
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+}
+8−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts";
4+import { pagePath, workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts";
55
66 test("everyone sees Overview, Projects and Packages; members and owners see more", () => {
77 const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key);
5252 assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null);
5353 assert.equal(workspaceRedirect("/acme", ""), null);
5454 });
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+});
+11−2
7979 "soon/insights": "-/insights",
8080 };
8181
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+
8287 /**
8388 * Where an old address of a workspace's pages is now, keeping its query;
8489 * null when it has not moved. `/<workspace>?tab=projects` and the like
8590 * open that tab.
8691 */
8792 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);
8999 const slug = parts[0];
90100 if (!slug) return null;
91− const params = new URLSearchParams(search);
92101 if (parts.length === 1 && params.has("tab")) {
93102 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
94103 if (to === undefined) return null;
+8−2
77 import {
88 Alerts,
99 CapsCard,
10+ CreditsCard,
1011 OverageCard,
1112 PlanCard,
1213 PrepayCard,
8788 if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`);
8889
8990 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([
9192 billing.account(slug, viewer),
9293 billing.statement(slug, viewer, url.searchParams.get("month"), group),
9394 billing.features(slug, viewer),
9798 billing.entitlements(slug).catch(() => null),
9899 billing.limitRequests(slug, viewer).catch(() => null),
99100 billing.prices().catch(() => null),
101+ billing.credits(slug, viewer).catch(() => null),
100102 ]);
101103 const featureStates = unwrap(features);
102104 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
114116 invoices: invoices?.ok ? invoices.value : [],
115117 entitlements,
116118 requests: requests?.ok ? requests.value : [],
119+ credits: credits?.ok && credits.value.grants.length > 0 ? credits.value : null,
117120 trialMicros,
118121 notice:
119122 url.searchParams.has("checked") && entitlements
216219 }
217220
218221 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 } =
220223 loaderData;
221224 const { status } = account;
222225 const owner = role === "owner";
271274 </>
272275 )}
273276
277+ {credits && <CreditsCard credits={credits} />}
278+
274279 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
275280 <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
276281 )}
324329 costs g1t plus {account.marginPercent}%, from the first second, after what is included.
325330 </li>
326331 <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>
327333 <li>
328334 With your own model provider, connected under{" "}
329335 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
+17−0
2424 "pull request",
2525 "model",
2626 "by",
27+ "price (USD)",
28+ "discount (USD)",
2729 "amount (USD)",
2830 "paid by included usage (USD)",
2931 "paid by trial credit (USD)",
6466 entry.number ? String(entry.number) : "",
6567 entry.model ?? "",
6668 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),
6772 // Charges positive, as on the statement.
6873 (-entry.amountMicros / MICROS_PER_DOLLAR).toFixed(6),
6974 ((entry.creditMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6),
7378 ];
7479 }
7580
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+
7693 /** A CSV cell, quoted when it must be, and never read as a formula. */
7794 function cell(value: string): string {
7895 const safe = /^[=+\-@\t\r]/.test(value) && !/^-?\d/.test(value) ? `'${value}` : value;
+2−1
44 import { page } from "../../lib/meta";
55 import { roadmapItem } from "../../lib/roadmap";
66 import { getViewer, roleIn } from "../../lib/session.server";
7+import { pagePath } from "../../lib/workspace-nav";
78 import { SoonView } from "../repo/soon";
89
910 /** 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() ?? "";
1112
1213 export function meta({ params, ...args }: Route.MetaArgs) {
1314 const item = roadmapItem(tabOf(args.location.pathname));
+17−11
191191 const days = Math.max(1, Math.ceil((Date.now() - new Date(since).getTime()) / 86_400_000));
192192 // While g1t is free nothing is charged, so usage is measured at cost
193193 // 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;
198202 const perDay = atCost ? 0 : total / days;
199203 const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null;
200204 return (
218222
219223 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
220224 {atCost ? (
225+ <Stat label="Used" value={dollars(total)} note="At cost. Nothing is charged while g1t is being built out." />
226+ ) : atPrice ? (
221227 <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)}`}
225231 />
226232 ) : (
227233 <Stat
243249 note="Making a change, reviewing, revising…"
244250 />
245251 {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.
247253 <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>
251257 </div>
252258 ) : (
253259 <div className="rounded-2xl bg-surface p-5 ring-1 ring-line">
+253−19
190190 /// workspace's last trial run that went past its trial credit.
191191 #[serde(default)]
192192 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>,
193201 }
194202
195203 fn g1t() -> String {
329337 /// price is `spent_micros` plus this.
330338 #[serde(default)]
331339 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>,
332348 /// What g1t's model provider charged, before the margin.
333349 pub cost_micros: i64,
334350 /// What runs on the workspace's own provider cost there, as the harness
15321548 }
15331549 }
15341550
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+
15351569 /// What a charge becomes under these terms.
15361570 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
15421572 }
15431573
15441574 /// 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.
15501579 pub fn discounted(&self, charge_micros: i64) -> (i64, i64) {
15511580 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")),
15551590 }
15561591 }
15571592 }
15611596 pub enum TermsKind {
15621597 /// Prices as published, limits by trust.
15631598 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).
15671601 Comped,
1568− /// A discount, a ceiling, or both.
1602+ /// A discount (up to 100%), a ceiling, or both.
15691603 Custom,
15701604 }
15711605
17431777 pub lines: Vec<StatementLine>,
17441778 /// What the group's charges come to.
17451779 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,
17461785 }
17471786
17481787 #[derive(Clone, Debug, Serialize, Deserialize)]
17601799 /// pool, or g1t itself. Not in `charged_micros`.
17611800 #[serde(default)]
17621801 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,
17631809 }
17641810
17651811 #[derive(Clone, Debug, Serialize, Deserialize)]
17691815 pub paid_micros: i64,
17701816 pub cost_micros: i64,
17711817 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>,
17721827 /// What paid for usage before it was charged, one line per source,
17731828 /// such as "Paid by g1t's open-source pool".
17741829 #[serde(default)]
21662221 pub by: String,
21672222 }
21682223
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>`.
21712278 #[derive(Debug, Serialize, Deserialize)]
21722279 pub struct AdminCreditArgs {
21732280 pub workspace: String,
21742281 pub amount_micros: i64,
21752282 pub note: String,
21762283 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,
21772396 }
21782397
21792398 /// `admin_reset_billing`: a test workspace's billing wiped, so it starts
24792698 /// margin: given, so a discounted sale is not margin lost.
24802699 #[serde(default)]
24812700 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,
24822716 /// `cost_micros` by who g1t pays: Cloudflare's bill (billed amounts,
24832717 /// after the included allowances), and model providers (the ledger's
24842718 /// cost of the tokens, which Cloudflare's bill does not show).
+148−3
128128 - **discount**: what a discount on an account's custom terms took below
129129 cost plus the margin (`ledger.discount_micros`, see
130130 [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).
132137
133138 Otherwise a workspace's day is split by those shares of its value at
134139 price, and the same shares of each of its buckets' cost are given, its
301306 follows (`Terms::discounted`, `Billing::charged`):
302307
303308 - **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,
305311 the open-source pool, and overruns g1t covers: given, and counted by why
306312 (above).
307313 - **Custom, with a discount**: the discount comes off, and what it took
316322 Every usage path goes through this: `finish_run`, settling, sandbox time,
317323 features and builds (`charge_feature`), and the month-end meters.
318324
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+
319461 ## Token usage
320462
321463 The model proxy (`services/models`) reads Anthropic's `usage` from every
346488 `0023_one_operation_mapping.sql` drops `billable_units` (see above).
347489 Migration `0036_model_costs_in_full.sql` adds `ledger.discount_micros`,
348490 `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)).
350495
351496 ## Spend caps
352497
+25−0
989989 stops work until paid. The owner's own spend limit always means stop.
990990 Test-mode payments never lower exposure or raise trust.
991991
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+
9921017 ## Agents and models
9931018
9941019 ### Defining an agent
+110−3
6868 ossMicros?: number;
6969 /** For usage: what g1t covered itself, such as a trial's last run past its credit. */
7070 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;
7175 };
7276
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+
73141 /** What lets a sandbox, and nothing else, report what its run cost. */
74142 export type RunTicket = { runId: string; token: string };
75143
83151 * open to them: a few dollars of model cost each, out of one pool, until a
84152 * date. Mirrors `Trial` in `crates/contracts/src/billing.rs`.
85153 */
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+ */
87160 export type Terms = {
88161 kind: "standard" | "comped" | "custom";
89162 discountPercent: number;
394467 key: string;
395468 label: string;
396469 /** `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+ }[];
398480 chargedMicros: number;
481+ priceMicros?: number;
482+ discountMicros?: number;
399483 }[];
400484 totals: {
401485 chargedMicros: number;
402486 paidMicros: number;
403487 costMicros: number;
404488 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;
405494 /** What paid for usage before it was charged, such as "Paid by g1t's open-source pool". */
406495 covered?: { source: "included" | "trial" | "oss_pool" | "given" | string; label: string; micros: number }[];
407496 /** Owed when the month closed but under the minimum charge: on the next invoice. */
523612 setAllowances(id: string, allowances: Allowances, note: string, by: string): Promise<Result<PayingAccount>>;
524613 createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>;
525614 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>>;
527621 /** A test workspace's billing wiped, to start again as a new customer. Only on Stripe's test key; never comped or enterprise. Logged. */
528622 resetBilling(workspace: string, confirm: string, note: string, by: string): Promise<Result<BillingReset>>;
529623 /** The workspace's Stripe billing page, to send to the customer. Logged. */
786880 account(workspace: string, viewer: Viewer): Promise<Result<BillingAccount>>;
787881 /** Newest first. Members of the workspace only. */
788882 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>>;
789885 /** A month of the ledger, grouped by `day` (default) or `project`. Members only. */
790886 statement(workspace: string, viewer: Viewer, month?: string | null, group?: "day" | "project"): Promise<Result<Statement>>;
791887 /** One statement line's entries, 50 at a time; `before` is the last id seen. */
10191115 spentMicros: number;
10201116 /** 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. */
10211117 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;
10221122 /** What g1t's model provider charged, before the margin. */
10231123 costMicros: number;
10241124 /** What runs on the workspace's own provider cost there, estimated. Not charged by g1t. */
10851185 givenPoolMicros?: number;
10861186 /** What discounts on an account's terms took below cost plus the margin: given, not margin lost. */
10871187 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;
10881195 /** costMicros by who g1t pays: Cloudflare's bill (billed, after included allowances) and model providers (tokens, not on Cloudflare's bill). */
10891196 /** What the plan's included usage paid for, at price: money in for usage, paid out of plansMicros. */
10901197 includedMicros?: number;
+20−1
440440 status: () => call("status", {}),
441441 account: (workspace, viewer) => call("account", { workspace, viewer }),
442442 ledger: (workspace, viewer) => call("ledger", { workspace, viewer }),
443+ credits: (workspace, viewer) => call("credits", { workspace, viewer }),
443444 statement: (workspace, viewer, month = null, group = "day") => call("statement", { workspace, viewer, month, group }),
444445 statementEntries: (workspace, viewer, filter) =>
445446 call("statement_entries", {
501502 setAllowances: (id, allowances, note, by) => call("admin_set_allowances", { id, allowances, note, by }),
502503 createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }),
503504 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 }),
505524 resetBilling: (workspace, confirm, note, by) => call("admin_reset_billing", { workspace, confirm, note, by }),
506525 billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
507526 stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }),
+61−0
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:%';
+19−0
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+ );
+46−38
1010 //! - **Enterprises.** One account paying for several workspaces, as GitHub
1111 //! Enterprise does: their usage and payments count together against one
1212 //! limit, on one set of terms.
13−//! - **Credits**, such as refunds.
13+//! - **Credits**: promotional, goodwill or refunds (see `grants`).
1414 //!
1515 //! Every change names who made it and is kept in `admin_actions`.
1616
1717 use g1t_contracts::billing::{
1818 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,
2121 };
2222 use g1t_contracts::time::rfc3339;
2323 use g1t_contracts::{FailureCode, Outcome, new_id};
130130 fn describe(terms: &Terms) -> String {
131131 let mut text = match terms.kind {
132132 TermsKind::Standard => "standard".to_owned(),
133− TermsKind::Comped => "comped".to_owned(),
134− TermsKind::Custom => {
133+ TermsKind::Comped | TermsKind::Custom => {
135134 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);
138137 }
139138 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)));
141141 }
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(", ") }
143143 }
144144 };
145145 if let Some(until) = &terms.until {
453453 Ok(Outcome::Ok(AccountDetail { summary: self.summary(account).await?, workspaces, ledger, audit }))
454454 }
455455
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);
457458 if a.by.trim().is_empty() {
458459 return Ok(Outcome::fail(FailureCode::Invalid, "Say who is making the change."));
459460 }
675676 Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
676677 }
677678 }
679+}
678680
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;
704694 }
695+ terms
705696 }
706697
707698 /// Allowances as the audit log reads them.
773764 assert_eq!(charged + given, base);
774765 // Over 100% is everything given, never a negative charge.
775766 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());
778774 // Every discount: charged plus given is the whole charge.
779775 for percent in 0..=100 {
780776 let (charged, given) = terms(TermsKind::Custom, percent).discounted(base);
785781 #[test]
786782 fn terms_read_plainly_in_the_audit_log() {
787783 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");
789785 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));
790798 }
791799
792800 #[test]
+7−7
3131 //! docs/BILLING_OPERATIONS.md.
3232
3333 use g1t_contracts::billing::{
34− AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, TermsKind,
34+ AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps,
3535 };
3636 use g1t_contracts::time::rfc3339;
3737 use g1t_contracts::{FailureCode, Outcome};
219219 return Ok(());
220220 }
221221 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());
223223 if paid.total() == 0 {
224224 return Ok(());
225225 }
324324 if self.caps.daily <= 0 || !breaker_applies(kind, hosted_model) {
325325 return Ok(None);
326326 }
327− let comped = account.terms.kind == TermsKind::Comped;
327+ let comped = account.terms.full_discount();
328328 if covered_by_revenue(plan, comped, account.allowances.plan, self.live()) {
329329 return Ok(None);
330330 }
362362 /// Why new work on a comped account is refused, if its budget is used
363363 /// up. None for every other account.
364364 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() {
366366 return Ok(None);
367367 }
368368 let budget = self.comped_budget(account).await?;
382382 }
383383 let comped = self
384384 .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))
386386 .all()
387387 .await?
388388 .results::<Id>()?;
469469 }
470470 let ids = self
471471 .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))
473473 .all()
474474 .await?
475475 .results::<Id>()?;
553553 /// How sudo names a bucket of g1t's own spend.
554554 pub(crate) fn bucket_title(bucket: &str) -> &'static str {
555555 match bucket {
556− "comped" => "Comped (g1t's own)",
556+ "comped" => "100% discount (g1t's own)",
557557 "trial" => "Trial pool",
558558 "oss" => "Open-source pool",
559559 "given" => "Free overruns g1t covered",
+2−2
1818 //! like any other), and records it. The ledger, invoices and statements
1919 //! stay, under the slug, for accounting.
2020
21−use g1t_contracts::billing::{CloseWorkspaceArgs, TermsKind};
21+use g1t_contracts::billing::CloseWorkspaceArgs;
2222 use g1t_contracts::time::rfc3339;
2323 use g1t_contracts::{FailureCode, Outcome, Role};
2424 use g1t_kit::now_ms;
141141 Ok(Facts {
142142 workspace: workspace.to_owned(),
143143 enterprise: account.id.starts_with("ent_").then(|| account.name.clone()),
144− comped: account.terms.kind == TermsKind::Comped,
144+ comped: account.terms.full_discount(),
145145 failed_invoices: failed,
146146 balance_micros: row.as_ref().map_or(0, |r| r.balance_micros),
147147 has_card: row.as_ref().is_some_and(|r| r.customer_id.is_some()) && self.stripe.is_some(),
+2−2
2727 //! is one D1 batch, which runs as a transaction, so two charges at once
2828 //! never take more than a budget holds.
2929
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};
3131 use g1t_contracts::time::rfc3339;
3232 use g1t_kit::now_ms;
3333 use serde::Deserialize;
285285
286286 /// The plan, from the account already read for the workspace.
287287 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() {
289289 return Ok(PlanKind::Internal);
290290 }
291291 if account.kind == g1t_contracts::billing::AccountKind::Enterprise {
+2−3
330330 /// enterprise's workspaces, or given by g1t staff.
331331 async fn included(&self, workspace: &str) -> Result<bool> {
332332 let account = self.account_of(workspace).await?;
333− Ok(account.terms.kind == g1t_contracts::billing::TermsKind::Comped
333+ Ok(account.terms.full_discount()
334334 || account.kind == g1t_contracts::billing::AccountKind::Enterprise
335335 || account.allowances.plan)
336336 }
671671 }
672672 None => a.cost_micros,
673673 };
674− let cost = cost_micros as f64 / MICROS_PER_DOLLAR as f64;
675674 // Never free: the margin applies whatever FREE_WHILE_BUILDING says,
676675 // and only the account's terms change it. The plan's included usage
677676 // pays what it can; the trial and the open-source pool never pay for
678677 // 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));
680679 let drawn = self.draw(&workspace, charge, &month, &crate::credits::Eligible::default()).await?;
681680 description.push_str(&drawn.note());
682681 self.post_usage(crate::storage::UsageLine {
+1097−0
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+}
+124−21
3535 lines
3636 }
3737
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+
3896 #[derive(Deserialize)]
3997 struct InvoiceRow {
4098 invoice_id: String,
140198 .collect();
141199 let lines = invoice_lines(&used, owed);
142200 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− }
153201 let description = match reason {
154202 "month" => format!("g1t usage for {workspace}, {period}"),
155203 _ => format!("g1t usage for {workspace}, charged as it neared its limit"),
156204 };
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(&cents).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+ }
168216 // A retry finds it finalized already; that is fine.
169217 let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await;
170218 // Charge the card now; a decline comes back as an error.
172220 let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?;
173221 let total = lines.iter().map(|l| l.amount_micros).sum::<i64>();
174222 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+ }
175230 let mut writes = vec![self
176231 .db
177232 .prepare(
357412 // Exactly what was used.
358413 assert_eq!(invoice_lines(&used, 50_000_000).len(), 2);
359414 }
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+ }
360463 }
+48−23
2626 mod margin;
2727 mod pricing;
2828 mod credits;
29+mod grants;
2930 mod overages;
3031 mod requests;
3132 mod storage;
4748 use g1t_contracts::billing::*;
4849 use g1t_contracts::time::rfc3339;
4950 use g1t_contracts::{FailureCode, Outcome, Role, new_id};
50−use g1t_contracts::billing::TermsKind;
5151 use g1t_kit::{args, now_ms, reply, rpc_method};
5252 use serde::Deserialize;
5353 use sha2::{Digest, Sha256};
123123 oss_micros: Option<i64>,
124124 #[serde(default)]
125125 given_micros: Option<i64>,
126+ #[serde(default)]
127+ credit_kind: Option<String>,
128+ #[serde(default)]
129+ discount_micros: Option<i64>,
126130 }
127131
128132 impl From<LedgerRow> for LedgerEntry {
144148 trial_micros: row.trial_micros.unwrap_or(0),
145149 oss_micros: row.oss_micros.unwrap_or(0),
146150 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),
147153 }
148154 }
149155 }
348354 ])?,
349355 ])
350356 .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 {
353361 self.db
354362 .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
355363 .bind(&[workspace.into()])?
395403 micros: Option<i64>,
396404 runs: Option<u32>,
397405 }
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+ };
412418 let slices = |key: &str, limit: u32| {
413419 format!(
414420 "SELECT {key} AS key, SUM({measure}) AS micros, COUNT(*) AS runs FROM ledger
446452 provider: Option<i64>,
447453 runs: Option<u32>,
448454 added: Option<i64>,
455+ discount: Option<i64>,
449456 }
450457 let totals = async {
451458 self.db
456463 SUM(CASE WHEN kind = 'usage' AND COALESCE(billed_to, 'g1t') = 'g1t' THEN cost_micros END) AS cost,
457464 SUM(CASE WHEN kind = 'usage' AND billed_to = 'workspace' THEN cost_micros END) AS provider,
458465 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
460468 FROM ledger WHERE workspace = ?1 AND created_at >= ?2",
461469 )
462470 .bind(&[workspace.as_str().into(), a.since.as_str().into()])?
483491 provider: None,
484492 runs: None,
485493 added: None,
494+ discount: None,
486495 });
487496 Ok(Outcome::Ok(Usage {
488497 spent_micros: totals.spent.unwrap_or_default(),
489498 // At price, what g1t's usage came to, less what was charged: the
490499 // part the included usage, the trial, a pool, or a free period paid.
491500 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())
493503 .max(0),
494504 cost_micros: totals.cost.unwrap_or_default(),
495505 provider_micros: totals.provider.unwrap_or_default(),
496506 used_micros: totals.cost.unwrap_or_default() + totals.provider.unwrap_or_default(),
497507 free: self.free,
508+ discount_micros: totals.discount.unwrap_or_default(),
509+ discount_percent: (discount_percent > 0).then_some(discount_percent),
498510 runs: totals.runs.unwrap_or_default(),
499511 added_micros: totals.added.unwrap_or_default(),
500512 by_day,
581593 &self.stripe,
582594 self.db
583595 .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.
584599 "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",
586601 )
587602 .bind(&[a.session.as_str().into(), workspace.as_str().into()])?
588603 .first::<CheckoutRow>(None)
980995 }
981996 let terms = self.terms_of(workspace).await?;
982997 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})"),
9861000 _ => String::new(),
9871001 };
9881002 Ok((charge, note, discount))
11011115 && let Err(error) = billing.reconcile(&keeper).await {
11021116 worker::console_error!("checking costs against Cloudflare failed: {error}");
11031117 }
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+ }
11041126 // Once a day: what Cloudflare charged, reconciled against what g1t
11051127 // counted and charged; prices whose day has come; margin alerts
11061128 // (margin.rs). After the keeper, so its proposals are in.
11671189 "status" => reply(&billing.status()),
11681190 "account" => reply(&billing.account(args(body)?).await?),
11691191 "ledger" => reply(&billing.ledger(args(body)?).await?),
1192+ "credits" => reply(&billing.credits(args(body)?).await?),
11701193 "usage" => reply(&billing.usage(args(body)?).await?),
11711194 "record_tokens" => reply(&billing.record_tokens(args(body)?).await?),
11721195 "token_usage" => reply(&billing.token_usage(args(body)?).await?),
12251248 "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?),
12261249 "admin_attach" => reply(&billing.admin_attach(args(body)?).await?),
12271250 "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?),
12281253 "admin_reset_billing" => reply(&billing.admin_reset_billing(&env, args(body)?).await?),
12291254 "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?),
12301255 "entitlements" => reply(&billing.entitlements(args(body)?).await?),
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.