Two limits, real invoices, trust that grows by itself, sales signals
Every workspace now has two limits. Its owners' monthly spend limit protects them from a surprise: automatic by default ($200, or twice last month's spend, so a steady $900-a-month customer gets $1,800 without asking), fixed, or none. g1t's ceiling on what is unpaid protects g1t: $3 new, twice what has cleared once paid (to $1,000), and Established after three steady months, following spend to $10,000 by itself. Only payments cleared for seven days count; prepaid cards, disputes, credits and test mode never raise it. Every charge is a real Stripe invoice, charged to the card: monthly, and mid-month as a workspace nears its ceiling. Itemised by kind of usage, with credit paid in advance and earlier arrears as lines, so the total is exactly what is owed; PDFs and receipts on Stripe, listed on Billing. For sudo: signals of who to reach out to (at limit, declined, near the ceiling, high spend, growing, Established, first payment), sales records with stages, owners, next steps and notes, an overview with six months of figures and usage by kind, and six-month figures per account. The app's top bars are 64px.
| 143 | 143 | ||
| 144 | 144 | ## Usage limits | |
| 145 | 145 | ||
| 146 | − | Everything a workspace uses costs g1t money at Cloudflare or a model | |
| 147 | − | provider before the workspace pays for it. So, as Fly and Cloudflare do | |
| 148 | − | with new accounts, every workspace has a limit on usage not yet paid for. | |
| 149 | − | When it is reached, the workspace's work stops until it pays, or the | |
| 150 | − | month turns: | |
| 146 | + | Every workspace has two limits. One protects you from a surprise bill; | |
| 147 | + | the other protects g1t from usage that is never paid for. Both are on | |
| 148 | + | **Settings → Billing → Limits**. | |
| 151 | 149 | ||
| 152 | − | - **No new sandboxes.** Assigning an agent, planning, asking for a | |
| 153 | − | review and workflow jobs are refused with `402 payment_required` and the | |
| 154 | − | reason; acceptance checks and the merge queue wait. Runs already under | |
| 155 | − | way finish. | |
| 156 | − | - **No new builds**, and **deployed apps pause**: they answer with a page | |
| 157 | − | saying so (`402`) and run nothing. Once the workspace is under its limit | |
| 158 | − | again, g1t rebuilds each one from the commit it was serving, by itself. | |
| 150 | + | ### Your monthly spend limit | |
| 159 | 151 | ||
| 160 | − | What counts is this month's usage (UTC), each item at what it cost g1t or | |
| 161 | − | what it is charged, whichever is more, less what was paid this month, plus | |
| 162 | − | any charges left unpaid from earlier months: a new month is not a fresh | |
| 163 | − | allowance. Even | |
| 164 | − | usage that is free to you, such as the free minutes, counts at its cost: | |
| 165 | − | the limit is about what g1t has spent on a workspace's behalf. | |
| 152 | + | What the workspace may spend in a month (UTC). At it, work stops until the | |
| 153 | + | month turns or an owner raises it. Owners choose one of: | |
| 166 | 154 | ||
| 167 | − | | Workspace | Limit | | |
| 155 | + | - **Automatic** (the default): $200, or twice last month's spend, | |
| 156 | + | whichever is more. It keeps up as you grow: a workspace that spent $900 | |
| 157 | + | last month can spend $1,800 this month without anyone changing a thing. | |
| 158 | + | - **Fixed**: an amount you set. | |
| 159 | + | - **None**: work never stops for spend. | |
| 160 | + | ||
| 161 | + | You are emailed at 50%, 80% and 100% of it. | |
| 162 | + | ||
| 163 | + | ### What g1t lets go unpaid | |
| 164 | + | ||
| 165 | + | Usage is charged after it runs, so at any moment some of it is not yet | |
| 166 | + | paid for. g1t lets that reach a ceiling that grows with your history, the | |
| 167 | + | way Cloudflare and Fly do: | |
| 168 | + | ||
| 169 | + | | | Ceiling on what is unpaid | | |
| 168 | 170 | | --- | --- | | |
| 169 | 171 | | **New**: has not paid g1t yet | $3: the free allowances and a little more | | |
| 170 | − | | **Paid**: has paid g1t | twice what it has paid, from $25 up to $1,000 | | |
| 171 | − | | **Reviewed** | what g1t set for it, after talking with you | | |
| 172 | − | | **Comped** | none: g1t covers it | | |
| 172 | + | | **Paid** | twice what you have paid, from $25 up to $1,000 | | |
| 173 | + | | **Established**: three steady months | three times your monthly spend, up to $10,000, by itself | | |
| 174 | + | | **Reviewed** | what g1t set with you; contact us | | |
| 173 | 175 | ||
| 174 | − | The limit is there to stop accounts that will never pay, not to slow down | |
| 175 | − | ones that do. So: | |
| 176 | + | With a card on file, **g1t charges it as you near the ceiling** (80%): | |
| 177 | + | an invoice for what you owe, paid at once, after which the ceiling is | |
| 178 | + | yours again. So a workspace that pays keeps going, however much it uses; | |
| 179 | + | the ceiling only stops one that does not. | |
| 176 | 180 | ||
| 177 | − | - **With a card on file, work does not stop.** As a workspace nears its | |
| 178 | − | limit (80%), g1t charges its card for what it owes. That payment lowers | |
| 179 | − | what is owed and raises the limit, since the limit grows with what a | |
| 180 | − | workspace has paid. A workspace that pays as it goes keeps going. | |
| 181 | − | - **A declined card stops work** until it is paid, with a message saying | |
| 182 | − | so, and the pull requests that were waiting say **Needs you**. Paying | |
| 183 | − | under Billing with another card clears it at once. | |
| 184 | − | - **Your own spend limit means stop.** An owner can set a lower monthly | |
| 185 | − | limit under **Settings → Billing → Usage limit**. At that one, g1t stops | |
| 186 | − | work and does not charge the card past it. | |
| 181 | + | How the ceiling grows: | |
| 182 | + | ||
| 183 | + | - A payment counts once it has **cleared for 7 days**, the time in which | |
| 184 | + | most bad cards are caught. Payments with prepaid cards pay, but do not | |
| 185 | + | raise the ceiling, nor do credits g1t gives or payments in test mode. | |
| 186 | + | - **Established** comes by itself after three months in a row of real spend | |
| 187 | + | ($20 or more each), every monthly invoice paid, nothing declined in 90 | |
| 188 | + | days and nothing ever disputed. From then the ceiling follows your | |
| 189 | + | spend. | |
| 190 | + | - Past $10,000, or for terms of your own, **contact us**: we set it with | |
| 191 | + | you, often with an enterprise account and invoices. | |
| 192 | + | ||
| 193 | + | What counts as unpaid is each item at what it cost g1t or what it is | |
| 194 | + | charged, whichever is more, less what was paid this month, plus anything | |
| 195 | + | left unpaid from earlier months: a new month is not a fresh allowance. | |
| 196 | + | ||
| 197 | + | A **declined card** stops work until it is paid, as does a payment | |
| 198 | + | disputed with the card's bank. Paying under Billing with another card | |
| 199 | + | clears it at once. | |
| 200 | + | ||
| 201 | + | ## Invoices | |
| 202 | + | ||
| 203 | + | Every charge is a real invoice from g1t, kept on Stripe's billing page | |
| 204 | + | with its PDF and emailed as a receipt: | |
| 205 | + | ||
| 206 | + | - **When each month closes**, an invoice for what the workspace owes, | |
| 207 | + | itemised: agents on g1t's models, runs on your own model provider, | |
| 208 | + | sandbox time, and deployments past the plan. Credit you paid in advance | |
| 209 | + | is taken off as *Paid in advance*; anything left unpaid from before is | |
| 210 | + | added. | |
| 211 | + | - **When the workspace nears its ceiling** mid-month, the same, sooner. | |
| 187 | 212 | ||
| 188 | − | Payments in test mode are not money: they neither lower what is owed nor | |
| 189 | − | raise the limit, and automatic charges only happen with live payments. | |
| 190 | − | Credits g1t gives, such as refunds, lower what is owed but do not raise | |
| 191 | − | the limit. To go past $1,000, write to support. | |
| 213 | + | Each is charged to the card on file. The Billing page lists them, with | |
| 214 | + | links to view each on Stripe and download its PDF. | |
| 192 | 215 | ||
| 193 | 216 | ## Enterprises and custom terms | |
| 194 | 217 | ||
| 223 | 246 | ||
| 224 | 247 | With a card on file: | |
| 225 | 248 | ||
| 226 | − | - **Near the usage limit** (80%), g1t charges it for what the workspace | |
| 227 | − | owes, at least $5, so work does not stop. | |
| 228 | − | - **When each month closes**, g1t charges it for what the workspace owed | |
| 229 | − | at the end of the month, and the statement shows the payment as *Usage | |
| 230 | − | for 2026-10, charged to the card on file when the month closed*. | |
| 249 | + | - **Near the ceiling** on what is unpaid (80%), g1t sends an | |
| 250 | + | [invoice](#invoices) for what the workspace owes and charges it, so work | |
| 251 | + | does not stop. | |
| 252 | + | - **When each month closes**, the month's [invoice](#invoices) is charged | |
| 253 | + | to it. | |
| 231 | 254 | - **If it is declined**, work stops until the workspace pays, and the | |
| 232 | 255 | Billing page and the API say why. Replace the card or add credit to pay. | |
| 233 | 256 |
| 394 | 394 | className="scroll-mt-28 rounded-xl border border-line" | |
| 395 | 395 | > | |
| 396 | 396 | <header | |
| 397 | − | className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 397 | + | className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 398 | 398 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 399 | 399 | }`} | |
| 400 | 400 | > | |
| 707 | 707 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 708 | 708 | return ( | |
| 709 | 709 | <div> | |
| 710 | − | <div className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 710 | + | <div className="sticky top-16 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 711 | 711 | <span className="text-sm text-muted"> | |
| 712 | 712 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 713 | 713 | </span> |
| 598 | 598 | return ( | |
| 599 | 599 | <div className="flex h-full flex-col"> | |
| 600 | 600 | {/* The same height and rule as the top bar, so the two read as one line. */} | |
| 601 | − | <div className="flex h-14 shrink-0 items-center gap-1.5 border-b border-line px-3"> | |
| 602 | − | <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised"> | |
| 603 | − | <Mark className="size-5" /> | |
| 601 | + | <div className="flex h-16 shrink-0 items-center gap-2 border-b border-line px-3"> | |
| 602 | + | <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-2 hover:bg-raised"> | |
| 603 | + | <Mark className="size-6" /> | |
| 604 | 604 | </Link> | |
| 605 | 605 | <span className="shrink-0 text-line-strong" aria-hidden="true"> | |
| 606 | 606 | / | |
| 611 | 611 | <button | |
| 612 | 612 | type="button" | |
| 613 | 613 | onClick={onFind} | |
| 614 | − | className="flex h-8 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong" | |
| 614 | + | className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong" | |
| 615 | 615 | > | |
| 616 | 616 | <Search size={14} /> | |
| 617 | 617 | <span className="grow text-left">Find…</span> | |
| 1046 | 1046 | )} | |
| 1047 | 1047 | ||
| 1048 | 1048 | <div className="flex min-h-screen min-w-0 flex-col lg:pl-64"> | |
| 1049 | − | <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6"> | |
| 1049 | + | <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6"> | |
| 1050 | 1050 | <button | |
| 1051 | 1051 | type="button" | |
| 1052 | 1052 | aria-label="Open menu" | |
| 1059 | 1059 | <div className="ml-auto flex items-center gap-1.5"> | |
| 1060 | 1060 | <a | |
| 1061 | 1061 | href="https://docs.g1t.sh/" | |
| 1062 | − | className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block" | |
| 1062 | + | className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block" | |
| 1063 | 1063 | > | |
| 1064 | 1064 | Docs | |
| 1065 | 1065 | </a> | |
| 1066 | 1066 | <DropdownMenu> | |
| 1067 | 1067 | <DropdownMenuTrigger | |
| 1068 | 1068 | aria-label="Create" | |
| 1069 | − | className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white" | |
| 1069 | + | className="flex h-9 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg outline-none transition-colors hover:bg-white" | |
| 1070 | 1070 | > | |
| 1071 | 1071 | <Plus size={14} /> | |
| 1072 | 1072 | New |
| 8 | 8 | type Feature, | |
| 9 | 9 | type FeatureState, | |
| 10 | 10 | type Limit, | |
| 11 | + | type WorkspaceInvoice, | |
| 11 | 12 | } from "@g1t/contracts"; | |
| 12 | 13 | ||
| 13 | 14 | import type { Route } from "./+types/billing"; | |
| 47 | 48 | await billing.confirm(slug, viewer, session); | |
| 48 | 49 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 49 | 50 | } | |
| 50 | − | const [account, ledger, features, deployUsage, limit] = await Promise.all([ | |
| 51 | + | const [account, ledger, features, deployUsage, limit, invoices] = await Promise.all([ | |
| 51 | 52 | billing.account(slug, viewer), | |
| 52 | 53 | billing.ledger(slug, viewer), | |
| 53 | 54 | billing.features(slug, viewer), | |
| 54 | 55 | deployments.usage(slug, viewer), | |
| 55 | 56 | billing.limit(slug, viewer), | |
| 57 | + | billing.invoices(slug, viewer).catch(() => null), | |
| 56 | 58 | ]); | |
| 57 | 59 | return { | |
| 58 | 60 | slug, | |
| 62 | 64 | features: unwrap(features), | |
| 63 | 65 | deployUsage: deployUsage.ok ? deployUsage.value : null, | |
| 64 | 66 | limit: limit.ok ? limit.value : null, | |
| 67 | + | invoices: invoices?.ok ? invoices.value : [], | |
| 65 | 68 | added: url.searchParams.has("added"), | |
| 66 | 69 | subscribed: url.searchParams.has("subscribed"), | |
| 67 | 70 | }; | |
| 79 | 82 | if (!started.ok) return { error: started.error.message }; | |
| 80 | 83 | throw redirect(started.value.url); | |
| 81 | 84 | } | |
| 82 | − | if (intent === "spend-limit" || intent === "no-spend-limit") { | |
| 85 | + | if (intent === "spend-limit") { | |
| 86 | + | // automatic, fixed (with an amount), or none. | |
| 87 | + | const mode = String(form.get("mode") ?? "automatic"); | |
| 83 | 88 | const amount = Number(form.get("limit")); | |
| 84 | − | if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) { | |
| 85 | − | return { error: "A spend limit is a dollar amount." }; | |
| 89 | + | if (mode === "fixed" && !(Number.isFinite(amount) && amount >= 1)) { | |
| 90 | + | return { error: "A spend limit is a dollar amount, $1 or more." }; | |
| 86 | 91 | } | |
| 87 | 92 | const set = await billing.setSpendLimit( | |
| 88 | 93 | user, | |
| 89 | 94 | params.owner, | |
| 90 | − | intent === "spend-limit" ? Math.round(amount * MICROS_PER_DOLLAR) : null, | |
| 95 | + | mode === "fixed" ? Math.round(amount * MICROS_PER_DOLLAR) : null, | |
| 96 | + | mode === "none", | |
| 91 | 97 | ); | |
| 92 | 98 | return set.ok ? null : { error: set.error.message }; | |
| 93 | 99 | } | |
| 120 | 126 | } | |
| 121 | 127 | ||
| 122 | 128 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| 123 | − | const { slug, role, account, ledger, features, deployUsage, limit, added, subscribed } = loaderData; | |
| 129 | + | const { slug, role, account, ledger, features, deployUsage, limit, invoices, added, subscribed } = loaderData; | |
| 124 | 130 | const { status } = account; | |
| 125 | 131 | const paying = useNavigation().state === "submitting"; | |
| 126 | 132 | const empty = account.balanceMicros <= 0; | |
| 191 | 197 | </section> | |
| 192 | 198 | )} | |
| 193 | 199 | ||
| 200 | + | {invoices.length > 0 && <InvoiceList invoices={invoices} />} | |
| 201 | + | ||
| 194 | 202 | <h2 className="font-medium">Plans</h2> | |
| 195 | 203 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 196 | 204 | Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of | |
| 491 | 499 | } | |
| 492 | 500 | ||
| 493 | 501 | const TRUST: Record<Limit["trust"], { label: string; detail: string }> = { | |
| 494 | − | new: { | |
| 495 | − | label: "New", | |
| 496 | − | detail: "No payment to g1t yet, so the limit is small: the free allowances and a little more. It grows once the workspace pays.", | |
| 497 | − | }, | |
| 498 | − | paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." }, | |
| 502 | + | new: { label: "New", detail: "No payment to g1t yet." }, | |
| 503 | + | paid: { label: "Paid", detail: "Grows with every payment." }, | |
| 504 | + | established: { label: "Established", detail: "Follows your monthly spend." }, | |
| 499 | 505 | reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." }, | |
| 500 | 506 | internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." }, | |
| 501 | 507 | }; | |
| 502 | 508 | ||
| 509 | + | /** One meter: how much of a limit is used. */ | |
| 510 | + | function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) { | |
| 511 | + | const share = of ? Math.min(1, used / Math.max(of, 1)) : 0; | |
| 512 | + | const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent"; | |
| 513 | + | return ( | |
| 514 | + | <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> | |
| 515 | + | <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} /> | |
| 516 | + | </div> | |
| 517 | + | ); | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" { | |
| 521 | + | if (of == null) return "ok"; | |
| 522 | + | if (used >= of) return "stopped"; | |
| 523 | + | return used * 5 >= of * 4 ? "warning" : "ok"; | |
| 524 | + | } | |
| 525 | + | ||
| 503 | 526 | /** | |
| 504 | − | * How far this month's unpaid usage has gone, and where work stops: g1t's | |
| 505 | − | * ceiling for the workspace, or the owners' own spend limit if lower. | |
| 527 | + | * The workspace's two limits, side by side: the owners' monthly spend | |
| 528 | + | * limit, which protects them from a surprise; and what g1t lets go unpaid, | |
| 529 | + | * which grows with what they pay and is charged to the card as it nears. | |
| 506 | 530 | */ | |
| 507 | 531 | function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) { | |
| 508 | − | const ceiling = limit.ceilingMicros; | |
| 509 | − | const share = ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0; | |
| 510 | 532 | const tone = | |
| 511 | 533 | limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"; | |
| 512 | − | const bar = limit.state === "stopped" ? "bg-danger" : limit.state === "warning" ? "bg-warn" : "bg-accent"; | |
| 513 | 534 | const trust = TRUST[limit.trust]; | |
| 535 | + | const spent = limit.spentMicros ?? 0; | |
| 536 | + | const spendLimit = limit.spendLimitMicros; | |
| 537 | + | const available = limit.availableMicros ?? limit.ceilingMicros; | |
| 538 | + | const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed"; | |
| 539 | + | if (limit.trust === "internal") { | |
| 540 | + | return ( | |
| 541 | + | <section className="mb-10 rounded-xl border border-line bg-surface p-5"> | |
| 542 | + | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 543 | + | <h2 className="font-medium">Limits</h2> | |
| 544 | + | <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span> | |
| 545 | + | </div> | |
| 546 | + | <p className="mt-1 text-sm text-muted">{trust.detail}</p> | |
| 547 | + | </section> | |
| 548 | + | ); | |
| 549 | + | } | |
| 514 | 550 | return ( | |
| 515 | 551 | <section className={`mb-10 rounded-xl border p-5 ${tone}`}> | |
| 516 | 552 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 517 | − | <h2 className="font-medium">Usage limit</h2> | |
| 553 | + | <h2 className="font-medium">Limits</h2> | |
| 518 | 554 | <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span> | |
| 519 | 555 | </div> | |
| 520 | − | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 521 | − | What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. With a card on | |
| 522 | − | file, g1t charges it as the workspace nears the limit, so its work does not stop. Without one, at the limit new | |
| 523 | − | sandboxes and builds stop and apps pause until it pays or the month turns. Work already running finishes. | |
| 524 | − | </p> | |
| 525 | − | <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight"> | |
| 526 | − | {dollars(limit.exposureMicros)} | |
| 527 | − | <span className="text-base font-normal text-muted"> {ceiling == null ? "· no limit" : `of ${dollars(ceiling)}`}</span> | |
| 528 | − | </p> | |
| 529 | − | {ceiling != null && ( | |
| 530 | − | <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> | |
| 531 | − | <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} /> | |
| 532 | − | </div> | |
| 533 | − | )} | |
| 534 | 556 | {limit.account.startsWith("ent_") && ( | |
| 535 | − | <p className="mt-3 text-sm text-muted"> | |
| 557 | + | <p className="mt-1 text-sm text-muted"> | |
| 536 | 558 | Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are | |
| 537 | 559 | for all of its workspaces together. | |
| 538 | 560 | </p> | |
| 539 | 561 | )} | |
| 540 | − | {limit.message && <p className="mt-3 text-sm">{limit.message}</p>} | |
| 541 | − | <p className="mt-3 text-xs text-faint"> | |
| 542 | − | {trust.detail} | |
| 543 | − | {limit.trustCeilingMicros != null && limit.spendLimitMicros != null && ` g1t's limit is ${dollars(limit.trustCeilingMicros)}.`} | |
| 544 | − | </p> | |
| 545 | − | {owner && limit.trust !== "internal" && ( | |
| 546 | − | <Form method="post" className="mt-4 flex flex-wrap items-end gap-2"> | |
| 547 | − | <label className="text-sm"> | |
| 548 | − | <span className="block text-xs text-muted">Your own monthly spend limit</span> | |
| 549 | − | <span className="mt-1 flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent"> | |
| 550 | − | <span className="text-muted">$</span> | |
| 551 | − | <input | |
| 552 | − | name="limit" | |
| 553 | − | type="number" | |
| 554 | − | min={0} | |
| 555 | − | step={1} | |
| 556 | − | defaultValue={limit.spendLimitMicros != null ? limit.spendLimitMicros / MICROS_PER_DOLLAR : ""} | |
| 557 | − | placeholder="None" | |
| 558 | − | className="w-24 bg-transparent px-1 py-1.5 tabular-nums outline-none" | |
| 559 | − | /> | |
| 562 | + | ||
| 563 | + | <div className="mt-5 grid gap-6 sm:grid-cols-2"> | |
| 564 | + | <div> | |
| 565 | + | <p className="text-xs text-muted">Spent this month</p> | |
| 566 | + | <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight"> | |
| 567 | + | {dollars(spent)} | |
| 568 | + | <span className="text-base font-normal text-muted"> | |
| 569 | + | {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`} | |
| 570 | + | </span> | |
| 571 | + | </p> | |
| 572 | + | {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />} | |
| 573 | + | <p className="mt-2 text-xs text-faint"> | |
| 574 | + | {mode === "automatic" | |
| 575 | + | ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow." | |
| 576 | + | : mode === "fixed" | |
| 577 | + | ? "A spend limit you set. At it, work stops until the month turns." | |
| 578 | + | : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."} | |
| 579 | + | </p> | |
| 580 | + | </div> | |
| 581 | + | <div> | |
| 582 | + | <p className="text-xs text-muted">Not yet paid</p> | |
| 583 | + | <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight"> | |
| 584 | + | {dollars(limit.exposureMicros)} | |
| 585 | + | <span className="text-base font-normal text-muted"> | |
| 586 | + | {available == null ? "" : ` of ${dollars(available)} available`} | |
| 560 | 587 | </span> | |
| 561 | − | </label> | |
| 562 | − | <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}> | |
| 563 | − | Set | |
| 564 | − | </Button> | |
| 565 | − | {limit.spendLimitMicros != null && ( | |
| 566 | − | <Button variant="quiet" type="submit" name="intent" value="no-spend-limit" disabled={busy}> | |
| 567 | − | Remove | |
| 568 | − | </Button> | |
| 588 | + | </p> | |
| 589 | + | {available != null && ( | |
| 590 | + | <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} /> | |
| 569 | 591 | )} | |
| 592 | + | <p className="mt-2 text-xs text-faint"> | |
| 593 | + | With a card on file, g1t charges it as this nears what is available, so work keeps going. | |
| 594 | + | {limit.growth ? ` ${limit.growth}` : ""} | |
| 595 | + | </p> | |
| 596 | + | </div> | |
| 597 | + | </div> | |
| 598 | + | ||
| 599 | + | {limit.message && <p className="mt-4 text-sm">{limit.message}</p>} | |
| 600 | + | ||
| 601 | + | {owner && ( | |
| 602 | + | <Form method="post" className="mt-5 border-t border-line pt-4"> | |
| 603 | + | <fieldset> | |
| 604 | + | <legend className="text-xs text-muted">Your monthly spend limit</legend> | |
| 605 | + | <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm"> | |
| 606 | + | <label className="flex items-center gap-2"> | |
| 607 | + | <input type="radio" name="mode" value="automatic" defaultChecked={mode === "automatic"} className="accent-accent" /> | |
| 608 | + | Automatic | |
| 609 | + | </label> | |
| 610 | + | <label className="flex items-center gap-2"> | |
| 611 | + | <input type="radio" name="mode" value="fixed" defaultChecked={mode === "fixed"} className="accent-accent" /> | |
| 612 | + | Fixed at | |
| 613 | + | <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent"> | |
| 614 | + | <span className="text-muted">$</span> | |
| 615 | + | <input | |
| 616 | + | name="limit" | |
| 617 | + | type="number" | |
| 618 | + | min={1} | |
| 619 | + | step={1} | |
| 620 | + | defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""} | |
| 621 | + | placeholder="500" | |
| 622 | + | className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none" | |
| 623 | + | /> | |
| 624 | + | </span> | |
| 625 | + | </label> | |
| 626 | + | <label className="flex items-center gap-2"> | |
| 627 | + | <input type="radio" name="mode" value="none" defaultChecked={mode === "none"} className="accent-accent" /> | |
| 628 | + | None | |
| 629 | + | </label> | |
| 630 | + | <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}> | |
| 631 | + | Save | |
| 632 | + | </Button> | |
| 633 | + | </div> | |
| 634 | + | </fieldset> | |
| 570 | 635 | <ErrorText>{error}</ErrorText> | |
| 636 | + | <p className="mt-3 text-xs text-faint"> | |
| 637 | + | Need more than {available != null ? dollars(available) : "this"} available?{" "} | |
| 638 | + | <a href="mailto:billing@g1t.sh" className="text-fg hover:underline"> | |
| 639 | + | Contact us | |
| 640 | + | </a>{" "} | |
| 641 | + | and we will set terms that fit. | |
| 642 | + | </p> | |
| 571 | 643 | </Form> | |
| 572 | 644 | )} | |
| 573 | 645 | </section> | |
| 574 | 646 | ); | |
| 575 | 647 | } | |
| 648 | + | ||
| 649 | + | /** The workspace's invoices from g1t, each kept on Stripe with its PDF. */ | |
| 650 | + | function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) { | |
| 651 | + | return ( | |
| 652 | + | <section className="mb-10"> | |
| 653 | + | <h2 className="font-medium">Invoices</h2> | |
| 654 | + | <p className="mt-1 text-sm text-muted"> | |
| 655 | + | One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also | |
| 656 | + | on Stripe's billing page. | |
| 657 | + | </p> | |
| 658 | + | <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 659 | + | {invoices.map((invoice) => ( | |
| 660 | + | <li key={invoice.invoiceId} className="px-4 py-3 text-sm"> | |
| 661 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 662 | + | <span className="font-medium"> | |
| 663 | + | {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`} | |
| 664 | + | </span> | |
| 665 | + | <span | |
| 666 | + | className={`rounded-full border px-2 py-0.5 text-xs ${ | |
| 667 | + | invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger" | |
| 668 | + | }`} | |
| 669 | + | > | |
| 670 | + | {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status} | |
| 671 | + | </span> | |
| 672 | + | <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span> | |
| 673 | + | {invoice.hostedUrl && ( | |
| 674 | + | <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg"> | |
| 675 | + | View | |
| 676 | + | </a> | |
| 677 | + | )} | |
| 678 | + | {invoice.pdfUrl && ( | |
| 679 | + | <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg"> | |
| 680 | + | ||
| 681 | + | </a> | |
| 682 | + | )} | |
| 683 | + | </div> | |
| 684 | + | <ul className="mt-1.5 space-y-0.5 text-xs text-faint"> | |
| 685 | + | {invoice.lines.map((line) => ( | |
| 686 | + | <li key={line.description} className="flex justify-between gap-4"> | |
| 687 | + | <span>{line.description}</span> | |
| 688 | + | <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span> | |
| 689 | + | </li> | |
| 690 | + | ))} | |
| 691 | + | </ul> | |
| 692 | + | </li> | |
| 693 | + | ))} | |
| 694 | + | </ul> | |
| 695 | + | </section> | |
| 696 | + | ); | |
| 697 | + | } |
| 402 | 402 | New, | |
| 403 | 403 | /// Has paid g1t real money: the ceiling grows with what it has paid. | |
| 404 | 404 | Paid, | |
| 405 | + | /// Has paid steadily for months, with nothing disputed or declined: | |
| 406 | + | /// the ceiling follows its monthly spend, up to $10,000, by itself. | |
| 407 | + | Established, | |
| 405 | 408 | /// A ceiling g1t set by hand, after talking to the workspace. | |
| 406 | 409 | Reviewed, | |
| 407 | 410 | /// g1t's own workspaces: no ceiling. | |
| 446 | 449 | pub state: LimitState, | |
| 447 | 450 | /// What to tell people when work is stopped or close to it. | |
| 448 | 451 | pub message: Option<String>, | |
| 452 | + | /// Charged this month, which the spend limit is measured against. | |
| 453 | + | #[serde(default)] | |
| 454 | + | pub spent_micros: i64, | |
| 455 | + | /// True while the owners have not chosen a spend limit of their own, so | |
| 456 | + | /// the automatic one applies: $200, or twice last month's spend. | |
| 457 | + | #[serde(default)] | |
| 458 | + | pub default_spend_limit: bool, | |
| 459 | + | /// The most the owners may set their own limit to: g1t's ceiling. To | |
| 460 | + | /// go past it, they contact g1t. | |
| 461 | + | #[serde(default)] | |
| 462 | + | pub available_micros: Option<i64>, | |
| 463 | + | /// How the ceiling grows from here, in a sentence. | |
| 464 | + | #[serde(default)] | |
| 465 | + | pub growth: Option<String>, | |
| 449 | 466 | } | |
| 450 | 467 | ||
| 451 | 468 | /// `limit`: a workspace's limit, for its members. Returns `Outcome<Limit>`. | |
| 483 | 500 | pub struct SetSpendLimitArgs { | |
| 484 | 501 | pub actor: User, | |
| 485 | 502 | pub workspace: String, | |
| 503 | + | /// A monthly limit, at most what is available; None goes back to the | |
| 504 | + | /// default. | |
| 486 | 505 | pub spend_limit_micros: Option<i64>, | |
| 506 | + | /// Use everything available, with no limit of their own. | |
| 507 | + | #[serde(default)] | |
| 508 | + | pub use_full_limit: bool, | |
| 487 | 509 | } | |
| 488 | 510 | ||
| 489 | 511 | /// One metered unit: what it costs g1t, and what it is sold at. The price | |
| 717 | 739 | pub amount_micros: i64, | |
| 718 | 740 | } | |
| 719 | 741 | ||
| 742 | + | /// A workspace's invoice from g1t: one per month, and one each time it is | |
| 743 | + | /// charged near its limit. Itemised, charged to the card on file, and kept | |
| 744 | + | /// in Stripe's billing page with its PDF. | |
| 745 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 746 | + | #[serde(rename_all = "camelCase")] | |
| 747 | + | pub struct WorkspaceInvoice { | |
| 748 | + | pub invoice_id: String, | |
| 749 | + | pub workspace: String, | |
| 750 | + | /// `month` (2026-10) or `threshold`. | |
| 751 | + | pub reason: String, | |
| 752 | + | pub period: String, | |
| 753 | + | pub amount_micros: i64, | |
| 754 | + | /// `paid`, `open`, `failed` or `void`. | |
| 755 | + | pub status: String, | |
| 756 | + | pub hosted_url: Option<String>, | |
| 757 | + | pub pdf_url: Option<String>, | |
| 758 | + | pub lines: Vec<InvoiceItem>, | |
| 759 | + | pub created_at: String, | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 763 | + | #[serde(rename_all = "camelCase")] | |
| 764 | + | pub struct InvoiceItem { | |
| 765 | + | pub description: String, | |
| 766 | + | pub amount_micros: i64, | |
| 767 | + | } | |
| 768 | + | ||
| 769 | + | /// `invoices`: a workspace's invoices from g1t, newest first. Members | |
| 770 | + | /// only. Returns `Outcome<Vec<WorkspaceInvoice>>`. | |
| 771 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 772 | + | pub struct InvoicesArgs { | |
| 773 | + | pub workspace: String, | |
| 774 | + | pub viewer: Viewer, | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | /// `admin_workspace_invoices`: the same, for staff. Returns | |
| 778 | + | /// `Vec<WorkspaceInvoice>`. | |
| 779 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 780 | + | pub struct AdminWorkspaceInvoicesArgs { | |
| 781 | + | pub workspace: String, | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | // --- Sales (sudo.g1t.sh) ------------------------------------------------------ | |
| 785 | + | // | |
| 786 | + | // What staff need to know to reach out: who is growing, who is close to | |
| 787 | + | // their limit, who was declined, who has become a steady customer. And what | |
| 788 | + | // was done about it: a stage, an owner on g1t's side, a next step, notes. | |
| 789 | + | ||
| 790 | + | /// Why a workspace is worth a look. | |
| 791 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 792 | + | #[serde(rename_all = "snake_case")] | |
| 793 | + | pub enum SignalKind { | |
| 794 | + | /// At its limit, or its own spend limit: work is stopped. | |
| 795 | + | AtLimit, | |
| 796 | + | /// Past 80% of what is available to it: about to need more. | |
| 797 | + | NearCeiling, | |
| 798 | + | /// Its card was declined or a payment disputed. | |
| 799 | + | Declined, | |
| 800 | + | /// This month is well ahead of last month. | |
| 801 | + | Growing, | |
| 802 | + | /// Became Established: the ceiling now follows its spend. | |
| 803 | + | Established, | |
| 804 | + | /// Paid g1t for the first time. | |
| 805 | + | FirstPayment, | |
| 806 | + | /// Spending enough that custom terms or an enterprise may suit it. | |
| 807 | + | HighSpend, | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 811 | + | #[serde(rename_all = "camelCase")] | |
| 812 | + | pub struct Signal { | |
| 813 | + | pub workspace: String, | |
| 814 | + | pub kind: SignalKind, | |
| 815 | + | /// One sentence, with the figures. | |
| 816 | + | pub detail: String, | |
| 817 | + | /// The figure that matters, such as this month's spend. | |
| 818 | + | pub value_micros: i64, | |
| 819 | + | /// Its sales stage, if staff gave it one. | |
| 820 | + | pub stage: Option<String>, | |
| 821 | + | pub owner: Option<String>, | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | /// `admin_signals`: every workspace worth reaching out to, most urgent | |
| 825 | + | /// first. Returns `Vec<Signal>`. | |
| 826 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 827 | + | pub struct AdminSignalsArgs {} | |
| 828 | + | ||
| 829 | + | /// What staff are doing about a workspace. | |
| 830 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 831 | + | #[serde(rename_all = "camelCase")] | |
| 832 | + | pub struct SalesRecord { | |
| 833 | + | pub workspace: String, | |
| 834 | + | /// `none`, `lead`, `contacted`, `negotiating`, `won`, `lost` or `churn_risk`. | |
| 835 | + | pub stage: String, | |
| 836 | + | /// The staff member looking after it. | |
| 837 | + | pub owner: Option<String>, | |
| 838 | + | pub next_step: Option<String>, | |
| 839 | + | /// RFC 3339 date. | |
| 840 | + | pub next_at: Option<String>, | |
| 841 | + | pub notes: Vec<SalesNote>, | |
| 842 | + | pub updated_at: Option<String>, | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 846 | + | #[serde(rename_all = "camelCase")] | |
| 847 | + | pub struct SalesNote { | |
| 848 | + | pub id: String, | |
| 849 | + | pub text: String, | |
| 850 | + | pub by: String, | |
| 851 | + | pub created_at: String, | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | /// `admin_sales`: a workspace's sales record. Returns `SalesRecord`. | |
| 855 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 856 | + | pub struct AdminSalesArgs { | |
| 857 | + | pub workspace: String, | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | /// `admin_set_sales`: its stage, owner and next step. Returns `Outcome<SalesRecord>`. | |
| 861 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 862 | + | pub struct AdminSetSalesArgs { | |
| 863 | + | pub workspace: String, | |
| 864 | + | pub stage: String, | |
| 865 | + | #[serde(default)] | |
| 866 | + | pub owner: Option<String>, | |
| 867 | + | #[serde(default)] | |
| 868 | + | pub next_step: Option<String>, | |
| 869 | + | #[serde(default)] | |
| 870 | + | pub next_at: Option<String>, | |
| 871 | + | pub by: String, | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | /// `admin_add_note`. Returns `Outcome<SalesRecord>`. | |
| 875 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 876 | + | pub struct AdminAddNoteArgs { | |
| 877 | + | pub workspace: String, | |
| 878 | + | pub text: String, | |
| 879 | + | pub by: String, | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | /// `admin_overview`: the business at a glance. Returns `Overview`. | |
| 883 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 884 | + | pub struct AdminOverviewArgs {} | |
| 885 | + | ||
| 886 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 887 | + | #[serde(rename_all = "camelCase")] | |
| 888 | + | pub struct Overview { | |
| 889 | + | /// YYYY-MM. | |
| 890 | + | pub month: String, | |
| 891 | + | /// The last six months, oldest first, all workspaces together. | |
| 892 | + | pub months: Vec<MonthFigures>, | |
| 893 | + | /// This month by kind of usage: models, sandbox, deployments, plans. | |
| 894 | + | pub by_kind: Vec<KindFigures>, | |
| 895 | + | pub paying_workspaces: u32, | |
| 896 | + | pub stopped: u32, | |
| 897 | + | pub near_ceiling: u32, | |
| 898 | + | pub declined: u32, | |
| 899 | + | /// Sent and not yet paid, workspaces and enterprises. | |
| 900 | + | pub open_invoices_micros: i64, | |
| 901 | + | /// Follow-ups due today or earlier. | |
| 902 | + | pub follow_ups_due: u32, | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 906 | + | #[serde(rename_all = "camelCase")] | |
| 907 | + | pub struct KindFigures { | |
| 908 | + | pub kind: String, | |
| 909 | + | pub charged_micros: i64, | |
| 910 | + | pub cost_micros: i64, | |
| 911 | + | } | |
| 912 | + | ||
| 720 | 913 | // --- Staff (sudo.g1t.sh) ------------------------------------------------------ | |
| 721 | 914 | // | |
| 722 | 915 | // Called only by the sudo app, which only g1t staff can reach (behind | |
| 750 | 943 | /// any, so staff can see what one member of an enterprise used. | |
| 751 | 944 | #[serde(default)] | |
| 752 | 945 | pub by_workspace: Vec<WorkspaceFigures>, | |
| 946 | + | /// The last six months, oldest first, for trends. | |
| 947 | + | #[serde(default)] | |
| 948 | + | pub months: Vec<MonthFigures>, | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | /// One month of an account's billing. | |
| 952 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 953 | + | #[serde(rename_all = "camelCase")] | |
| 954 | + | pub struct MonthFigures { | |
| 955 | + | /// YYYY-MM. | |
| 956 | + | pub month: String, | |
| 957 | + | pub charged_micros: i64, | |
| 958 | + | pub cost_micros: i64, | |
| 959 | + | pub paid_micros: i64, | |
| 753 | 960 | } | |
| 754 | 961 | ||
| 755 | 962 | /// One workspace's share of an [`AccountSummary`]. |
| 112 | 112 | paidMicros: number; | |
| 113 | 113 | /** The same figures for each of the account's workspaces that has any. */ | |
| 114 | 114 | byWorkspace: WorkspaceFigures[]; | |
| 115 | + | /** The last six months, oldest first. */ | |
| 116 | + | months?: MonthFigures[]; | |
| 115 | 117 | }; | |
| 116 | 118 | ||
| 117 | 119 | /** One workspace's share of an `AccountSummary`. */ | |
| 136 | 138 | createdAt: string; | |
| 137 | 139 | }; | |
| 138 | 140 | ||
| 141 | + | /** A workspace's invoice: monthly, or when charged near its limit. Itemised, in Stripe's billing page. */ | |
| 142 | + | export type WorkspaceInvoice = { | |
| 143 | + | invoiceId: string; | |
| 144 | + | workspace: string; | |
| 145 | + | reason: "month" | "threshold" | string; | |
| 146 | + | period: string; | |
| 147 | + | amountMicros: number; | |
| 148 | + | status: "paid" | "open" | "failed" | "void" | string; | |
| 149 | + | hostedUrl: string | null; | |
| 150 | + | pdfUrl: string | null; | |
| 151 | + | lines: { description: string; amountMicros: number }[]; | |
| 152 | + | createdAt: string; | |
| 153 | + | }; | |
| 154 | + | ||
| 155 | + | export type MonthFigures = { month: string; chargedMicros: number; costMicros: number; paidMicros: number }; | |
| 156 | + | ||
| 157 | + | export type SignalKind = "at_limit" | "near_ceiling" | "declined" | "growing" | "established" | "first_payment" | "high_spend"; | |
| 158 | + | ||
| 159 | + | /** Why a workspace is worth reaching out to. */ | |
| 160 | + | export type Signal = { | |
| 161 | + | workspace: string; | |
| 162 | + | kind: SignalKind; | |
| 163 | + | detail: string; | |
| 164 | + | valueMicros: number; | |
| 165 | + | stage: string | null; | |
| 166 | + | owner: string | null; | |
| 167 | + | }; | |
| 168 | + | ||
| 169 | + | export type SalesStage = "none" | "lead" | "contacted" | "negotiating" | "won" | "lost" | "churn_risk"; | |
| 170 | + | ||
| 171 | + | export type SalesRecord = { | |
| 172 | + | workspace: string; | |
| 173 | + | stage: SalesStage | string; | |
| 174 | + | owner: string | null; | |
| 175 | + | nextStep: string | null; | |
| 176 | + | nextAt: string | null; | |
| 177 | + | notes: { id: string; text: string; by: string; createdAt: string }[]; | |
| 178 | + | updatedAt: string | null; | |
| 179 | + | }; | |
| 180 | + | ||
| 181 | + | export type Overview = { | |
| 182 | + | month: string; | |
| 183 | + | months: MonthFigures[]; | |
| 184 | + | byKind: { kind: string; chargedMicros: number; costMicros: number }[]; | |
| 185 | + | payingWorkspaces: number; | |
| 186 | + | stopped: number; | |
| 187 | + | nearCeiling: number; | |
| 188 | + | declined: number; | |
| 189 | + | openInvoicesMicros: number; | |
| 190 | + | followUpsDue: number; | |
| 191 | + | }; | |
| 192 | + | ||
| 139 | 193 | /** A customer's Stripe billing page, for staff to send them. */ | |
| 140 | 194 | export type BillingLink = { | |
| 141 | 195 | /** One-time and short-lived, signed in already. */ | |
| 173 | 227 | invoiceEnterprise(id: string, by: string): Promise<Result<EnterpriseInvoice>>; | |
| 174 | 228 | /** Exactly these workspaces' accounts, such as one page of the list. */ | |
| 175 | 229 | accountsFor(workspaces: string[]): Promise<AccountSummary[]>; | |
| 230 | + | /** Every workspace worth reaching out to, most urgent first. */ | |
| 231 | + | signals(): Promise<Signal[]>; | |
| 232 | + | /** The business at a glance. */ | |
| 233 | + | overview(): Promise<Overview>; | |
| 234 | + | /** A workspace's sales record. */ | |
| 235 | + | sales(workspace: string): Promise<SalesRecord>; | |
| 236 | + | setSales(workspace: string, record: { stage: string; owner?: string | null; nextStep?: string | null; nextAt?: string | null }, by: string): Promise<Result<SalesRecord>>; | |
| 237 | + | addNote(workspace: string, text: string, by: string): Promise<Result<SalesRecord>>; | |
| 238 | + | /** A workspace's invoices from g1t, for staff. */ | |
| 239 | + | workspaceInvoices(workspace: string): Promise<WorkspaceInvoice[]>; | |
| 176 | 240 | } | |
| 177 | 241 | ||
| 178 | 242 | /** How much a workspace has earned g1t's trust with money. */ | |
| 179 | − | export type Trust = "new" | "paid" | "reviewed" | "internal"; | |
| 243 | + | export type Trust = "new" | "paid" | "established" | "reviewed" | "internal"; | |
| 180 | 244 | ||
| 181 | 245 | /** | |
| 182 | 246 | * How far a workspace's unpaid usage has gone this month, and where its | |
| 197 | 261 | spendLimitMicros: number | null; | |
| 198 | 262 | state: "ok" | "warning" | "stopped"; | |
| 199 | 263 | message: string | null; | |
| 264 | + | /** Charged this month: what the spend limit is measured against. */ | |
| 265 | + | spentMicros?: number; | |
| 266 | + | /** True while the owners have not chosen a limit, so the automatic one applies: $200, or twice last month's spend. */ | |
| 267 | + | defaultSpendLimit?: boolean; | |
| 268 | + | /** The most owners may set their own limit to; past it, they contact g1t. */ | |
| 269 | + | availableMicros?: number | null; | |
| 270 | + | /** How the ceiling grows from here, in a sentence. */ | |
| 271 | + | growth?: string | null; | |
| 200 | 272 | }; | |
| 201 | 273 | ||
| 202 | 274 | /** One metered unit: what it costs g1t and what it is sold at; the price follows the cost. */ | |
| 352 | 424 | limit(workspace: string, viewer: Viewer): Promise<Result<Limit>>; | |
| 353 | 425 | /** The same, for the services that enforce it. */ | |
| 354 | 426 | checkLimit(workspace: string): Promise<Result<Limit>>; | |
| 355 | − | /** The owner's own monthly ceiling, under g1t's; null removes it. Owners only. */ | |
| 356 | − | setSpendLimit(actor: User, workspace: string, spendLimitMicros: number | null): Promise<Result<Limit>>; | |
| 357 | 427 | /** | |
| 428 | + | * The owners' own monthly limit, up to what is available; null goes back | |
| 429 | + | * to the default, and `useFullLimit` uses everything available. Owners only. | |
| 430 | + | */ | |
| 431 | + | setSpendLimit(actor: User, workspace: string, spendLimitMicros: number | null, useFullLimit?: boolean): Promise<Result<Limit>>; | |
| 432 | + | /** The workspace's invoices from g1t, newest first. Members only. */ | |
| 433 | + | invoices(workspace: string, viewer: Viewer): Promise<Result<WorkspaceInvoice[]>>; | |
| 434 | + | /** | |
| 358 | 435 | * How long a sandbox ran for a workspace, reported when it stops. Its | |
| 359 | 436 | * cost is always recorded; seconds past the month's free minutes are | |
| 360 | 437 | * charged. False if `reference` was recorded before. |
| 214 | 214 | checkLimit: (workspace) => call("check_limit", { workspace }), | |
| 215 | 215 | prices: () => call("prices", {}), | |
| 216 | 216 | notePending: (workspace, source, costMicros) => call("note_pending", { workspace, source, costMicros }), | |
| 217 | − | setSpendLimit: (actor, workspace, spendLimitMicros) => | |
| 218 | − | call("set_spend_limit", { actor, workspace, spendLimitMicros }), | |
| 217 | + | setSpendLimit: (actor, workspace, spendLimitMicros, useFullLimit = false) => | |
| 218 | + | call("set_spend_limit", { actor, workspace, spendLimitMicros, use_full_limit: useFullLimit }), | |
| 219 | + | invoices: (workspace, viewer) => call("invoices", { workspace, viewer }), | |
| 219 | 220 | }; | |
| 220 | 221 | } | |
| 221 | 222 | ||
| 233 | 234 | enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }), | |
| 234 | 235 | invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }), | |
| 235 | 236 | accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }), | |
| 237 | + | signals: () => call("admin_signals", {}), | |
| 238 | + | overview: () => call("admin_overview", {}), | |
| 239 | + | sales: (workspace) => call("admin_sales", { workspace }), | |
| 240 | + | setSales: (workspace, record, by) => | |
| 241 | + | call("admin_set_sales", { | |
| 242 | + | workspace, | |
| 243 | + | stage: record.stage, | |
| 244 | + | owner: record.owner ?? null, | |
| 245 | + | next_step: record.nextStep ?? null, | |
| 246 | + | next_at: record.nextAt ?? null, | |
| 247 | + | by, | |
| 248 | + | }), | |
| 249 | + | addNote: (workspace, text, by) => call("admin_add_note", { workspace, text, by }), | |
| 250 | + | workspaceInvoices: (workspace) => call("admin_workspace_invoices", { workspace }), | |
| 236 | 251 | }; | |
| 237 | 252 | } | |
| 238 | 253 |
| 1 | + | -- Real invoices, trust that is hard to game, and sales records. | |
| 2 | + | ||
| 3 | + | -- Payments: which kind of card paid (prepaid cards never raise the | |
| 4 | + | -- limit), and whether the payment was disputed (never counts again). | |
| 5 | + | ALTER TABLE ledger ADD COLUMN funding TEXT; | |
| 6 | + | ALTER TABLE ledger ADD COLUMN disputed INTEGER NOT NULL DEFAULT 0; | |
| 7 | + | ||
| 8 | + | -- The owners chose to use everything available, with no limit of their | |
| 9 | + | -- own. Without it and without spend_limit_micros, the default applies. | |
| 10 | + | ALTER TABLE limits ADD COLUMN spend_limit_full INTEGER NOT NULL DEFAULT 0; | |
| 11 | + | ||
| 12 | + | -- A workspace's invoices from g1t: one when each month closes, and one | |
| 13 | + | -- each time it is charged near its limit. Itemised at Stripe, charged to | |
| 14 | + | -- the card on file, and kept in Stripe's billing page with a PDF. | |
| 15 | + | CREATE TABLE workspace_invoices ( | |
| 16 | + | invoice_id TEXT PRIMARY KEY, | |
| 17 | + | workspace TEXT NOT NULL, | |
| 18 | + | -- month or threshold. | |
| 19 | + | reason TEXT NOT NULL, | |
| 20 | + | -- YYYY-MM for a month, the date for a threshold. | |
| 21 | + | period TEXT NOT NULL, | |
| 22 | + | amount_micros INTEGER NOT NULL, | |
| 23 | + | -- paid, open, failed or void. | |
| 24 | + | status TEXT NOT NULL, | |
| 25 | + | hosted_url TEXT, | |
| 26 | + | pdf_url TEXT, | |
| 27 | + | -- Usage up to here is on this invoice. | |
| 28 | + | through_at TEXT NOT NULL, | |
| 29 | + | created_at TEXT NOT NULL, | |
| 30 | + | paid_at TEXT | |
| 31 | + | ); | |
| 32 | + | CREATE INDEX workspace_invoices_by_workspace ON workspace_invoices (workspace, created_at); | |
| 33 | + | ||
| 34 | + | CREATE TABLE workspace_invoice_lines ( | |
| 35 | + | invoice_id TEXT NOT NULL, | |
| 36 | + | position INTEGER NOT NULL, | |
| 37 | + | description TEXT NOT NULL, | |
| 38 | + | amount_micros INTEGER NOT NULL, | |
| 39 | + | PRIMARY KEY (invoice_id, position) | |
| 40 | + | ); | |
| 41 | + | ||
| 42 | + | -- What staff are doing about a workspace. | |
| 43 | + | CREATE TABLE sales_records ( | |
| 44 | + | workspace TEXT PRIMARY KEY, | |
| 45 | + | stage TEXT NOT NULL DEFAULT 'none', | |
| 46 | + | owner TEXT, | |
| 47 | + | next_step TEXT, | |
| 48 | + | next_at TEXT, | |
| 49 | + | updated_by TEXT NOT NULL, | |
| 50 | + | updated_at TEXT NOT NULL | |
| 51 | + | ); | |
| 52 | + | ||
| 53 | + | CREATE TABLE sales_notes ( | |
| 54 | + | id TEXT PRIMARY KEY, | |
| 55 | + | workspace TEXT NOT NULL, | |
| 56 | + | text TEXT NOT NULL, | |
| 57 | + | by TEXT NOT NULL, | |
| 58 | + | created_at TEXT NOT NULL | |
| 59 | + | ); | |
| 60 | + | CREATE INDEX sales_notes_by_workspace ON sales_notes (workspace, created_at); |
| 292 | 292 | for p in &paid { | |
| 293 | 293 | figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0); | |
| 294 | 294 | } | |
| 295 | + | let months = self.months_for(&account.workspaces, 6).await?; | |
| 295 | 296 | Ok(AccountSummary { | |
| 297 | + | months, | |
| 296 | 298 | charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(), | |
| 297 | 299 | cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(), | |
| 298 | 300 | paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(), |
| 1 | + | //! A workspace's invoices from g1t. | |
| 2 | + | //! | |
| 3 | + | //! Every time g1t charges a workspace's card, it is a real Stripe invoice: | |
| 4 | + | //! when each month closes, and when the workspace nears its limit mid-month | |
| 5 | + | //! (a threshold invoice, as Cloudflare and Fly do). Each is itemised by | |
| 6 | + | //! what was used since the last one, with any credit paid in advance taken | |
| 7 | + | //! off and anything left unpaid from before added, so its total is exactly | |
| 8 | + | //! what is owed. Stripe charges the card, emails the receipt, and keeps | |
| 9 | + | //! the invoice and its PDF in the workspace's billing page. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::billing::{EntryKind, InvoiceItem, InvoicesArgs, WorkspaceInvoice}; | |
| 12 | + | use g1t_contracts::time::rfc3339; | |
| 13 | + | use g1t_contracts::{FailureCode, Outcome}; | |
| 14 | + | use g1t_kit::now_ms; | |
| 15 | + | use serde::Deserialize; | |
| 16 | + | use serde_json::Value; | |
| 17 | + | use worker::Result; | |
| 18 | + | ||
| 19 | + | use crate::Billing; | |
| 20 | + | ||
| 21 | + | /// Stripe will not charge a card less than this. | |
| 22 | + | const MIN_INVOICE_MICROS: i64 = 500_000; | |
| 23 | + | ||
| 24 | + | /// The invoice's lines: what was used since the last one, by kind, then | |
| 25 | + | /// whatever makes the total what is owed. | |
| 26 | + | pub(crate) fn invoice_lines(used: &[(String, i64)], owed: i64) -> Vec<InvoiceItem> { | |
| 27 | + | let mut lines: Vec<InvoiceItem> = used | |
| 28 | + | .iter() | |
| 29 | + | .filter(|(_, amount)| *amount > 0) | |
| 30 | + | .map(|(kind, amount)| InvoiceItem { description: kind.clone(), amount_micros: *amount }) | |
| 31 | + | .collect(); | |
| 32 | + | let difference = owed - lines.iter().map(|l| l.amount_micros).sum::<i64>(); | |
| 33 | + | if difference < 0 { | |
| 34 | + | lines.push(InvoiceItem { description: "Paid in advance".to_owned(), amount_micros: difference }); | |
| 35 | + | } else if difference > 0 { | |
| 36 | + | lines.push(InvoiceItem { description: "Unpaid from earlier".to_owned(), amount_micros: difference }); | |
| 37 | + | } | |
| 38 | + | lines | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[derive(Deserialize)] | |
| 42 | + | struct InvoiceRow { | |
| 43 | + | invoice_id: String, | |
| 44 | + | workspace: String, | |
| 45 | + | reason: String, | |
| 46 | + | period: String, | |
| 47 | + | amount_micros: i64, | |
| 48 | + | status: String, | |
| 49 | + | hosted_url: Option<String>, | |
| 50 | + | pdf_url: Option<String>, | |
| 51 | + | created_at: String, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | #[derive(Deserialize)] | |
| 55 | + | struct LineRow { | |
| 56 | + | description: String, | |
| 57 | + | amount_micros: i64, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// A Stripe invoice, as far as billing reads it. | |
| 61 | + | #[derive(Deserialize)] | |
| 62 | + | struct StripeInvoice { | |
| 63 | + | id: String, | |
| 64 | + | #[serde(default)] | |
| 65 | + | status: Option<String>, | |
| 66 | + | #[serde(default)] | |
| 67 | + | hosted_invoice_url: Option<String>, | |
| 68 | + | #[serde(default)] | |
| 69 | + | invoice_pdf: Option<String>, | |
| 70 | + | #[serde(default)] | |
| 71 | + | amount_paid: i64, | |
| 72 | + | #[serde(default)] | |
| 73 | + | charge: Option<String>, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | impl Billing { | |
| 77 | + | /// Invoices the workspace for what it owes, charging its card. `Ok(Err)` | |
| 78 | + | /// says why not, when there was nothing to do or no card. | |
| 79 | + | pub(crate) async fn invoice_workspace( | |
| 80 | + | &self, | |
| 81 | + | workspace: &str, | |
| 82 | + | reason: &str, | |
| 83 | + | period: &str, | |
| 84 | + | ) -> Result<std::result::Result<WorkspaceInvoice, String>> { | |
| 85 | + | let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) }; | |
| 86 | + | let Some(account) = self.row(workspace).await? else { return Ok(Err("Nothing billed yet.".into())) }; | |
| 87 | + | let Some(customer) = account.customer_id else { return Ok(Err("No card on file.".into())) }; | |
| 88 | + | let owed = (-account.balance_micros).max(0); | |
| 89 | + | if owed < MIN_INVOICE_MICROS { | |
| 90 | + | return Ok(Err("Less is owed than Stripe will charge.".into())); | |
| 91 | + | } | |
| 92 | + | // What was used since the last invoice, by kind. | |
| 93 | + | #[derive(Deserialize)] | |
| 94 | + | struct Last { | |
| 95 | + | through_at: Option<String>, | |
| 96 | + | } | |
| 97 | + | let since = self | |
| 98 | + | .db | |
| 99 | + | .prepare("SELECT MAX(through_at) AS through_at FROM workspace_invoices WHERE workspace = ? AND status <> 'void'") | |
| 100 | + | .bind(&[workspace.into()])? | |
| 101 | + | .first::<Last>(None) | |
| 102 | + | .await? | |
| 103 | + | .and_then(|l| l.through_at) | |
| 104 | + | .unwrap_or_default(); | |
| 105 | + | #[derive(Deserialize)] | |
| 106 | + | struct Used { | |
| 107 | + | kind: String, | |
| 108 | + | charged: Option<i64>, | |
| 109 | + | } | |
| 110 | + | let now = rfc3339(now_ms()); | |
| 111 | + | let used: Vec<(String, i64)> = self | |
| 112 | + | .db | |
| 113 | + | .prepare( | |
| 114 | + | "SELECT CASE | |
| 115 | + | WHEN task = 'sandbox' THEN 'Sandbox time' | |
| 116 | + | WHEN task = 'deployments' THEN 'Deployments: builds and usage past the plan' | |
| 117 | + | WHEN billed_to = 'workspace' THEN 'Runs on your own model provider' | |
| 118 | + | ELSE 'Agents on g1t''s models' END AS kind, | |
| 119 | + | -SUM(amount_micros) AS charged | |
| 120 | + | FROM ledger WHERE workspace = ? AND kind = 'usage' AND created_at > ? AND created_at <= ? | |
| 121 | + | GROUP BY 1 ORDER BY charged DESC", | |
| 122 | + | ) | |
| 123 | + | .bind(&[workspace.into(), since.as_str().into(), now.as_str().into()])? | |
| 124 | + | .all() | |
| 125 | + | .await? | |
| 126 | + | .results::<Used>()? | |
| 127 | + | .into_iter() | |
| 128 | + | .map(|u| (u.kind, u.charged.unwrap_or(0))) | |
| 129 | + | .collect(); | |
| 130 | + | let lines = invoice_lines(&used, owed); | |
| 131 | + | let key = format!("ws-invoice/{workspace}/{reason}/{period}/{}", owed / 10_000); | |
| 132 | + | for (position, line) in lines.iter().enumerate() { | |
| 133 | + | let fields = [ | |
| 134 | + | ("customer", customer.clone()), | |
| 135 | + | ("amount", (line.amount_micros / 10_000).to_string()), | |
| 136 | + | ("currency", "usd".to_owned()), | |
| 137 | + | ("description", line.description.clone()), | |
| 138 | + | ("metadata[workspace]", workspace.to_owned()), | |
| 139 | + | ]; | |
| 140 | + | let _: Value = stripe.post_idempotent("/invoiceitems", &fields, &format!("{key}/item/{position}")).await?; | |
| 141 | + | } | |
| 142 | + | let description = match reason { | |
| 143 | + | "month" => format!("g1t usage for {workspace}, {period}"), | |
| 144 | + | _ => format!("g1t usage for {workspace}, charged as it neared its limit"), | |
| 145 | + | }; | |
| 146 | + | let fields = [ | |
| 147 | + | ("customer", customer.clone()), | |
| 148 | + | ("collection_method", "charge_automatically".to_owned()), | |
| 149 | + | ("auto_advance", "false".to_owned()), | |
| 150 | + | ("pending_invoice_items_behavior", "include".to_owned()), | |
| 151 | + | ("description", description), | |
| 152 | + | ("metadata[g1t_workspace]", workspace.to_owned()), | |
| 153 | + | ("metadata[reason]", reason.to_owned()), | |
| 154 | + | ("metadata[period]", period.to_owned()), | |
| 155 | + | ]; | |
| 156 | + | let draft: StripeInvoice = stripe.post_idempotent("/invoices", &fields, &key).await?; | |
| 157 | + | // A retry finds it finalized already; that is fine. | |
| 158 | + | let _ = stripe.post::<Value>(&format!("/invoices/{}/finalize", draft.id), &[]).await; | |
| 159 | + | // Charge the card now; a decline comes back as an error. | |
| 160 | + | let paid = stripe.post::<StripeInvoice>(&format!("/invoices/{}/pay", draft.id), &[("off_session", "true".to_owned())]).await; | |
| 161 | + | let invoice: StripeInvoice = stripe.get(&format!("/invoices/{}", draft.id)).await?; | |
| 162 | + | let total = lines.iter().map(|l| l.amount_micros).sum::<i64>(); | |
| 163 | + | let status = if invoice.status.as_deref() == Some("paid") { "paid" } else { "failed" }; | |
| 164 | + | let mut writes = vec![self | |
| 165 | + | .db | |
| 166 | + | .prepare( | |
| 167 | + | "INSERT OR REPLACE INTO workspace_invoices | |
| 168 | + | (invoice_id, workspace, reason, period, amount_micros, status, hosted_url, pdf_url, through_at, created_at, paid_at) | |
| 169 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 170 | + | ) | |
| 171 | + | .bind(&[ | |
| 172 | + | invoice.id.as_str().into(), | |
| 173 | + | workspace.into(), | |
| 174 | + | reason.into(), | |
| 175 | + | period.into(), | |
| 176 | + | (total as f64).into(), | |
| 177 | + | status.into(), | |
| 178 | + | crate::optional(invoice.hosted_invoice_url.as_deref()), | |
| 179 | + | crate::optional(invoice.invoice_pdf.as_deref()), | |
| 180 | + | now.as_str().into(), | |
| 181 | + | now.as_str().into(), | |
| 182 | + | crate::optional((status == "paid").then_some(now.as_str())), | |
| 183 | + | ])?]; | |
| 184 | + | for (position, line) in lines.iter().enumerate() { | |
| 185 | + | writes.push( | |
| 186 | + | self.db | |
| 187 | + | .prepare("INSERT OR REPLACE INTO workspace_invoice_lines (invoice_id, position, description, amount_micros) VALUES (?, ?, ?, ?)") | |
| 188 | + | .bind(&[invoice.id.as_str().into(), (position as u32).into(), line.description.as_str().into(), (line.amount_micros as f64).into()])?, | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | self.db.batch(writes).await?; | |
| 192 | + | if status == "paid" { | |
| 193 | + | self.credit_invoice(workspace, &invoice).await?; | |
| 194 | + | } else { | |
| 195 | + | let error = paid.err().map_or_else(|| "the card was declined".to_owned(), |e| e.to_string().chars().take(200).collect()); | |
| 196 | + | self.mark_declined(workspace, &error).await?; | |
| 197 | + | } | |
| 198 | + | Ok(Ok(WorkspaceInvoice { | |
| 199 | + | invoice_id: invoice.id, | |
| 200 | + | workspace: workspace.to_owned(), | |
| 201 | + | reason: reason.to_owned(), | |
| 202 | + | period: period.to_owned(), | |
| 203 | + | amount_micros: total, | |
| 204 | + | status: status.to_owned(), | |
| 205 | + | hosted_url: invoice.hosted_invoice_url, | |
| 206 | + | pdf_url: invoice.invoice_pdf, | |
| 207 | + | lines, | |
| 208 | + | created_at: now, | |
| 209 | + | })) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// Enters an invoice's payment once, with the kind of card that paid. | |
| 213 | + | async fn credit_invoice(&self, workspace: &str, invoice: &StripeInvoice) -> Result<bool> { | |
| 214 | + | let seen = self | |
| 215 | + | .db | |
| 216 | + | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 217 | + | .bind(&[invoice.id.as_str().into()])? | |
| 218 | + | .first::<Value>(None) | |
| 219 | + | .await?; | |
| 220 | + | if seen.is_some() { | |
| 221 | + | return Ok(false); | |
| 222 | + | } | |
| 223 | + | let amount = invoice.amount_paid * 10_000; | |
| 224 | + | if amount <= 0 { | |
| 225 | + | return Ok(false); | |
| 226 | + | } | |
| 227 | + | self.enter(workspace, EntryKind::TopUp, amount, &format!("Paid invoice {}", invoice.id), &invoice.id, None, None, None, None) | |
| 228 | + | .await?; | |
| 229 | + | // Prepaid cards pay, but never raise the limit. | |
| 230 | + | if let (Some(stripe), Some(charge)) = (&self.stripe, &invoice.charge) { | |
| 231 | + | if let Ok(charge) = stripe.get::<Value>(&format!("/charges/{charge}")).await { | |
| 232 | + | if let Some(funding) = charge["payment_method_details"]["card"]["funding"].as_str() { | |
| 233 | + | self.db | |
| 234 | + | .prepare("UPDATE ledger SET funding = ? WHERE reference = ?") | |
| 235 | + | .bind(&[funding.into(), invoice.id.as_str().into()])? | |
| 236 | + | .run() | |
| 237 | + | .await?; | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | Ok(true) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | pub(crate) async fn mark_declined(&self, workspace: &str, error: &str) -> Result<()> { | |
| 245 | + | let now = rfc3339(now_ms()); | |
| 246 | + | self.db | |
| 247 | + | .prepare( | |
| 248 | + | "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2) | |
| 249 | + | ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2", | |
| 250 | + | ) | |
| 251 | + | .bind(&[workspace.into(), now.as_str().into(), error.into()])? | |
| 252 | + | .run() | |
| 253 | + | .await?; | |
| 254 | + | Ok(()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// A workspace invoice paid later, on Stripe's page or by a retry. | |
| 258 | + | pub(crate) async fn workspace_invoice_paid(&self, invoice_id: &str) -> Result<Option<String>> { | |
| 259 | + | #[derive(Deserialize)] | |
| 260 | + | struct Row { | |
| 261 | + | workspace: String, | |
| 262 | + | } | |
| 263 | + | let Some(row) = self | |
| 264 | + | .db | |
| 265 | + | .prepare("SELECT workspace FROM workspace_invoices WHERE invoice_id = ?") | |
| 266 | + | .bind(&[invoice_id.into()])? | |
| 267 | + | .first::<Row>(None) | |
| 268 | + | .await? | |
| 269 | + | else { | |
| 270 | + | return Ok(None); | |
| 271 | + | }; | |
| 272 | + | let Some(stripe) = &self.stripe else { return Ok(None) }; | |
| 273 | + | let invoice: StripeInvoice = stripe.get(&format!("/invoices/{invoice_id}")).await?; | |
| 274 | + | self.db | |
| 275 | + | .prepare("UPDATE workspace_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ?") | |
| 276 | + | .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])? | |
| 277 | + | .run() | |
| 278 | + | .await?; | |
| 279 | + | let credited = self.credit_invoice(&row.workspace, &invoice).await?; | |
| 280 | + | Ok(Some(format!( | |
| 281 | + | "invoice {invoice_id} for {} paid{}", | |
| 282 | + | row.workspace, | |
| 283 | + | if credited { "" } else { " (already credited)" } | |
| 284 | + | ))) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | pub(crate) async fn workspace_invoices(&self, workspace: &str) -> Result<Vec<WorkspaceInvoice>> { | |
| 288 | + | let rows = self | |
| 289 | + | .db | |
| 290 | + | .prepare("SELECT * FROM workspace_invoices WHERE workspace = ? ORDER BY created_at DESC LIMIT 36") | |
| 291 | + | .bind(&[workspace.into()])? | |
| 292 | + | .all() | |
| 293 | + | .await? | |
| 294 | + | .results::<InvoiceRow>()?; | |
| 295 | + | let mut invoices = vec![]; | |
| 296 | + | for row in rows { | |
| 297 | + | let lines = self | |
| 298 | + | .db | |
| 299 | + | .prepare("SELECT description, amount_micros FROM workspace_invoice_lines WHERE invoice_id = ? ORDER BY position") | |
| 300 | + | .bind(&[row.invoice_id.as_str().into()])? | |
| 301 | + | .all() | |
| 302 | + | .await? | |
| 303 | + | .results::<LineRow>()? | |
| 304 | + | .into_iter() | |
| 305 | + | .map(|l| InvoiceItem { description: l.description, amount_micros: l.amount_micros }) | |
| 306 | + | .collect(); | |
| 307 | + | invoices.push(WorkspaceInvoice { | |
| 308 | + | invoice_id: row.invoice_id, | |
| 309 | + | workspace: row.workspace, | |
| 310 | + | reason: row.reason, | |
| 311 | + | period: row.period, | |
| 312 | + | amount_micros: row.amount_micros, | |
| 313 | + | status: row.status, | |
| 314 | + | hosted_url: row.hosted_url, | |
| 315 | + | pdf_url: row.pdf_url, | |
| 316 | + | lines, | |
| 317 | + | created_at: row.created_at, | |
| 318 | + | }); | |
| 319 | + | } | |
| 320 | + | Ok(invoices) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// `invoices`: for the workspace's members. | |
| 324 | + | pub(crate) async fn invoices(&self, a: InvoicesArgs) -> Result<Outcome<Vec<WorkspaceInvoice>>> { | |
| 325 | + | let workspace = a.workspace.to_lowercase(); | |
| 326 | + | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 327 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's invoices.")); | |
| 328 | + | } | |
| 329 | + | Ok(Outcome::Ok(self.workspace_invoices(&workspace).await?)) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | #[cfg(test)] | |
| 334 | + | mod tests { | |
| 335 | + | use super::*; | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn an_invoice_adds_up_to_what_is_owed() { | |
| 339 | + | let used = vec![("Agents on g1t's models".to_owned(), 40_000_000), ("Sandbox time".to_owned(), 10_000_000)]; | |
| 340 | + | // $10 of credit was paid in advance. | |
| 341 | + | let lines = invoice_lines(&used, 40_000_000); | |
| 342 | + | assert_eq!(lines.last().unwrap().description, "Paid in advance"); | |
| 343 | + | assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 40_000_000); | |
| 344 | + | // $5 was left unpaid from before. | |
| 345 | + | let lines = invoice_lines(&used, 55_000_000); | |
| 346 | + | assert_eq!(lines.last().unwrap().description, "Unpaid from earlier"); | |
| 347 | + | assert_eq!(lines.iter().map(|l| l.amount_micros).sum::<i64>(), 55_000_000); | |
| 348 | + | // Exactly what was used. | |
| 349 | + | assert_eq!(invoice_lines(&used, 50_000_000).len(), 2); | |
| 350 | + | } | |
| 351 | + | } |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | 19 | mod accounts; | |
| 20 | + | mod invoices; | |
| 21 | + | mod sales; | |
| 20 | 22 | mod webhooks; | |
| 21 | 23 | mod features; | |
| 22 | 24 | mod keeper; | |
| 1016 | 1018 | "admin_enterprise_billing" => reply(&billing.admin_enterprise_billing(args(body)?).await?), | |
| 1017 | 1019 | "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?), | |
| 1018 | 1020 | "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?), | |
| 1021 | + | "invoices" => reply(&billing.invoices(args(body)?).await?), | |
| 1022 | + | "admin_workspace_invoices" => { | |
| 1023 | + | let a: AdminWorkspaceInvoicesArgs = args(body)?; | |
| 1024 | + | reply(&billing.workspace_invoices(&a.workspace.to_lowercase()).await?) | |
| 1025 | + | } | |
| 1026 | + | "admin_signals" => reply(&billing.admin_signals(args(body)?).await?), | |
| 1027 | + | "admin_overview" => reply(&billing.admin_overview(args(body)?).await?), | |
| 1028 | + | "admin_sales" => reply(&billing.admin_sales(args(body)?).await?), | |
| 1029 | + | "admin_set_sales" => reply(&billing.admin_set_sales(args(body)?).await?), | |
| 1030 | + | "admin_add_note" => reply(&billing.admin_add_note(args(body)?).await?), | |
| 1019 | 1031 | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 1020 | 1032 | "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?), | |
| 1021 | 1033 | "admin_account" => reply(&billing.admin_account(args(body)?).await?), |
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | − | /// Never charged automatically for less. | |
| 72 | − | const AUTOPAY_MIN_CENTS: i64 = 500; | |
| 71 | + | /// The automatic monthly spend limit's floor: $200. | |
| 72 | + | pub(crate) const DEFAULT_SPEND_MICROS: i64 = 200_000_000; | |
| 73 | + | /// Established workspaces' ceiling: three times their steady monthly | |
| 74 | + | /// spend, up to $10,000. | |
| 75 | + | const ESTABLISHED_FACTOR: i64 = 3; | |
| 76 | + | const ESTABLISHED_MAX_MICROS: i64 = 10_000_000_000; | |
| 77 | + | /// A month counts toward Established at this much spend or more. | |
| 78 | + | const ESTABLISHED_MONTH_MICROS: i64 = 20_000_000; | |
| 79 | + | /// Payments raise trust once this old: past the time most bad cards are | |
| 80 | + | /// caught. | |
| 81 | + | const SETTLE_DAYS: u64 = 7; | |
| 82 | + | ||
| 83 | + | /// The automatic spend limit: $200, or twice last month's spend. | |
| 84 | + | pub(crate) fn automatic_spend_limit(last_month_charged: i64) -> i64 { | |
| 85 | + | DEFAULT_SPEND_MICROS.max(last_month_charged * 2) | |
| 86 | + | } | |
| 73 | 87 | ||
| 88 | + | /// An Established workspace's ceiling, from its last three months' | |
| 89 | + | /// charges, if each was steady enough. | |
| 90 | + | pub(crate) fn established_ceiling(months: &[i64]) -> Option<i64> { | |
| 91 | + | if months.len() < 3 || months.iter().any(|m| *m < ESTABLISHED_MONTH_MICROS) { | |
| 92 | + | return None; | |
| 93 | + | } | |
| 94 | + | let average = months.iter().sum::<i64>() / months.len() as i64; | |
| 95 | + | Some((average * ESTABLISHED_FACTOR).min(ESTABLISHED_MAX_MICROS)) | |
| 96 | + | } | |
| 97 | + | ||
| 74 | 98 | #[derive(Deserialize)] | |
| 75 | 99 | struct LimitRow { | |
| 76 | 100 | spend_limit_micros: Option<i64>, | |
| 101 | + | #[serde(default)] | |
| 102 | + | spend_limit_full: Option<i64>, | |
| 77 | 103 | autopay_failed_at: Option<String>, | |
| 78 | 104 | autopay_error: Option<String>, | |
| 79 | 105 | } | |
| 98 | 124 | let account = self.account_of(&workspace).await?; | |
| 99 | 125 | let row = self | |
| 100 | 126 | .db | |
| 101 | − | .prepare("SELECT spend_limit_micros, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?") | |
| 127 | + | .prepare("SELECT spend_limit_micros, spend_limit_full, autopay_failed_at, autopay_error FROM limits WHERE workspace = ?") | |
| 102 | 128 | .bind(&[workspace.as_str().into()])? | |
| 103 | 129 | .first::<LimitRow>(None) | |
| 104 | 130 | .await?; | |
| 171 | 197 | _ if account.terms.ceiling_micros.is_some() => (Trust::Reviewed, account.terms.ceiling_micros), | |
| 172 | 198 | _ => { | |
| 173 | 199 | let paid = self.live_paid(&members).await?; | |
| 174 | − | if paid > 0 { | |
| 175 | − | (Trust::Paid, Some(self.ceilings.for_paid(paid))) | |
| 176 | − | } else { | |
| 177 | − | (Trust::New, Some(self.ceilings.new)) | |
| 200 | + | let established = if paid > 0 { self.established(&members).await? } else { None }; | |
| 201 | + | match established { | |
| 202 | + | Some(ceiling) => (Trust::Established, Some(ceiling.max(self.ceilings.for_paid(paid)))), | |
| 203 | + | None if paid > 0 => (Trust::Paid, Some(self.ceilings.for_paid(paid))), | |
| 204 | + | None => (Trust::New, Some(self.ceilings.new)), | |
| 178 | 205 | } | |
| 179 | 206 | } | |
| 180 | 207 | }; | |
| 181 | − | let spend_limit = row.as_ref().and_then(|row| row.spend_limit_micros); | |
| 208 | + | // This month's charges, and last month's, for the spend limit. | |
| 209 | + | let (spent, last_month) = self.charged_months(&members, &month_start).await?; | |
| 210 | + | let spent = spent + pending; | |
| 211 | + | // The owners' own monthly limit: theirs, none, or the automatic one | |
| 212 | + | // ($200, or twice last month), which self-serve workspaces start on. | |
| 213 | + | let chosen = row.as_ref().and_then(|row| row.spend_limit_micros); | |
| 214 | + | let full = row.as_ref().and_then(|row| row.spend_limit_full).unwrap_or(0) == 1; | |
| 215 | + | let self_serve = matches!(trust, Trust::New | Trust::Paid | Trust::Established); | |
| 216 | + | let default_spend_limit = chosen.is_none() && !full && self_serve; | |
| 217 | + | let spend_limit = match (chosen, full) { | |
| 218 | + | (Some(own), _) => Some(own), | |
| 219 | + | (None, true) => None, | |
| 220 | + | (None, false) if self_serve => Some(automatic_spend_limit(last_month)), | |
| 221 | + | _ => None, | |
| 222 | + | }; | |
| 182 | 223 | // A card declined when g1t charged it at the limit stops work until | |
| 183 | 224 | // it is paid; any payment clears it. | |
| 184 | 225 | let declined = row.as_ref().and_then(|row| row.autopay_failed_at.clone().map(|at| (at, row.autopay_error.clone()))); | |
| 185 | − | let ceiling = match (trust_ceiling, spend_limit) { | |
| 186 | − | (Some(ceiling), Some(own)) => Some(ceiling.min(own)), | |
| 187 | − | (None, Some(own)) => Some(own), | |
| 188 | − | (ceiling, None) => ceiling, | |
| 226 | + | // Two limits: g1t's on what is unpaid, the owners' on what is spent. | |
| 227 | + | let ceiling = trust_ceiling; | |
| 228 | + | let risk = state(exposure, ceiling); | |
| 229 | + | let budget = state(spent, spend_limit); | |
| 230 | + | let over_budget = budget == LimitState::Stopped; | |
| 231 | + | let state = if declined.is_some() && exposure > 0 { | |
| 232 | + | LimitState::Stopped | |
| 233 | + | } else if risk == LimitState::Stopped || over_budget { | |
| 234 | + | LimitState::Stopped | |
| 235 | + | } else if risk == LimitState::Warning || budget == LimitState::Warning { | |
| 236 | + | LimitState::Warning | |
| 237 | + | } else { | |
| 238 | + | LimitState::Ok | |
| 189 | 239 | }; | |
| 190 | − | let state = if declined.is_some() && exposure > 0 { LimitState::Stopped } else { state(exposure, ceiling) }; | |
| 191 | 240 | let who = if account.kind == g1t_contracts::billing::AccountKind::Enterprise { | |
| 192 | 241 | format!("The {} enterprise, which pays for {workspace},", account.name) | |
| 193 | 242 | } else { | |
| 195 | 244 | }; | |
| 196 | 245 | let message = match state { | |
| 197 | 246 | LimitState::Ok => None, | |
| 247 | + | LimitState::Warning if budget == LimitState::Warning => Some(format!( | |
| 248 | + | "{who} has spent {} of its {} monthly spend limit. At the limit, its sandboxes, builds and apps stop until the month turns or an owner raises it under Billing.", | |
| 249 | + | dollars_plain(spent), | |
| 250 | + | dollars_plain(spend_limit.unwrap_or_default()), | |
| 251 | + | )), | |
| 198 | 252 | LimitState::Warning => Some(format!( | |
| 199 | − | "{who} has used {} of its {} limit this month. At the limit, its sandboxes, builds and apps stop until it pays or the month turns.", | |
| 253 | + | "{who} has {} of usage not yet paid for, of the {} g1t allows. With a card on file g1t charges it now; without one, at the limit its sandboxes, builds and apps stop until it pays.", | |
| 200 | 254 | dollars_plain(exposure), | |
| 201 | 255 | dollars_plain(ceiling.unwrap_or_default()), | |
| 202 | 256 | )), | |
| 204 | 258 | "{who} could not be charged for its usage ({}), so its sandboxes, builds and apps are stopped. An owner can pay under Billing with another card.", | |
| 205 | 259 | declined.as_ref().and_then(|(_, error)| error.clone()).unwrap_or_else(|| "the card was declined".to_owned()), | |
| 206 | 260 | )), | |
| 207 | − | LimitState::Stopped => Some(if spend_limit.is_some() && ceiling == spend_limit { | |
| 261 | + | LimitState::Stopped => Some(if over_budget { | |
| 208 | 262 | format!( | |
| 209 | − | "The {workspace} workspace reached the {} spend limit its owners set for this month, so its sandboxes, builds and apps are stopped. An owner can raise it under Billing.", | |
| 210 | − | dollars_plain(ceiling.unwrap_or_default()), | |
| 263 | + | "{who} reached its {} monthly spend limit, so its sandboxes, builds and apps are stopped until the month turns. An owner can raise it under Billing.", | |
| 264 | + | dollars_plain(spend_limit.unwrap_or_default()), | |
| 211 | 265 | ) | |
| 212 | 266 | } else { | |
| 213 | 267 | format!( | |
| 216 | 270 | ) | |
| 217 | 271 | }), | |
| 218 | 272 | }; | |
| 273 | + | let growth = match trust { | |
| 274 | + | Trust::New => Some("Pay g1t once, by card or credit, and this grows to $25; after that it grows with every payment.".to_owned()), | |
| 275 | + | Trust::Paid => Some(format!( | |
| 276 | + | "Grows to twice what you have paid, as payments clear (after {SETTLE_DAYS} days), up to $1,000. After three steady months it follows your monthly spend, up to $10,000, by itself." | |
| 277 | + | )), | |
| 278 | + | Trust::Established => Some("Follows your monthly spend, up to $10,000, by itself. For more, contact us.".to_owned()), | |
| 279 | + | Trust::Reviewed | Trust::Internal => None, | |
| 280 | + | }; | |
| 219 | 281 | Ok(Limit { | |
| 220 | 282 | workspace, | |
| 221 | 283 | account: account.id, | |
| 222 | 284 | account_name: account.name, | |
| 285 | + | spent_micros: spent, | |
| 286 | + | default_spend_limit, | |
| 287 | + | available_micros: trust_ceiling, | |
| 288 | + | growth, | |
| 223 | 289 | trust, | |
| 224 | 290 | exposure_micros: exposure, | |
| 225 | 291 | ceiling_micros: ceiling, | |
| 241 | 307 | .db | |
| 242 | 308 | .prepare(format!( | |
| 243 | 309 | "SELECT SUM(amount_micros) AS paid FROM ledger | |
| 244 | − | WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%'" | |
| 310 | + | WHERE workspace IN ({marks}) AND kind = 'top_up' AND reference NOT LIKE 'crd%' | |
| 311 | + | AND (amount_micros < 0 | |
| 312 | + | OR (disputed = 0 AND COALESCE(funding, '') <> 'prepaid' | |
| 313 | + | AND created_at <= '{settled}'))", | |
| 314 | + | settled = rfc3339(now_ms() - SETTLE_DAYS * 24 * 60 * 60 * 1000) | |
| 245 | 315 | )) | |
| 246 | 316 | .bind(members)? | |
| 247 | 317 | .first::<Paid>(None) | |
| 250 | 320 | .unwrap_or(0)) | |
| 251 | 321 | } | |
| 252 | 322 | ||
| 323 | + | /// This month's charges and last month's, across the workspaces. | |
| 324 | + | async fn charged_months(&self, members: &[JsValue], month_start: &str) -> Result<(i64, i64)> { | |
| 325 | + | #[derive(Deserialize)] | |
| 326 | + | struct Charged { | |
| 327 | + | this_month: Option<i64>, | |
| 328 | + | last_month: Option<i64>, | |
| 329 | + | } | |
| 330 | + | let last_start = format!("{}-01", previous_month(&month_start[..7])); | |
| 331 | + | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 332 | + | let row = self | |
| 333 | + | .db | |
| 334 | + | .prepare(format!( | |
| 335 | + | "SELECT | |
| 336 | + | -SUM(CASE WHEN created_at >= '{month_start}' THEN amount_micros END) AS this_month, | |
| 337 | + | -SUM(CASE WHEN created_at >= '{last_start}' AND created_at < '{month_start}' THEN amount_micros END) AS last_month | |
| 338 | + | FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= '{last_start}'" | |
| 339 | + | )) | |
| 340 | + | .bind(members)? | |
| 341 | + | .first::<Charged>(None) | |
| 342 | + | .await?; | |
| 343 | + | Ok(row.map_or((0, 0), |r| (r.this_month.unwrap_or(0).max(0), r.last_month.unwrap_or(0).max(0)))) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | /// An Established ceiling, if the workspaces have paid steadily: three | |
| 347 | + | /// full months of real spend, each invoiced and paid, nothing declined | |
| 348 | + | /// in 90 days and nothing ever disputed. | |
| 349 | + | async fn established(&self, members: &[JsValue]) -> Result<Option<i64>> { | |
| 350 | + | let marks = vec!["?"; members.len().max(1)].join(", "); | |
| 351 | + | let now = rfc3339(now_ms()); | |
| 352 | + | let mut months = vec![]; | |
| 353 | + | let mut month = previous_month(&now[..7]); | |
| 354 | + | for _ in 0..3 { | |
| 355 | + | months.push(month.clone()); | |
| 356 | + | month = previous_month(&month); | |
| 357 | + | } | |
| 358 | + | #[derive(Deserialize)] | |
| 359 | + | struct Count { | |
| 360 | + | n: Option<i64>, | |
| 361 | + | } | |
| 362 | + | let troubled = self | |
| 363 | + | .db | |
| 364 | + | .prepare(format!( | |
| 365 | + | "SELECT (SELECT COUNT(*) FROM ledger WHERE workspace IN ({marks}) AND disputed = 1) | |
| 366 | + | + (SELECT COUNT(*) FROM limits WHERE workspace IN ({marks}) AND autopay_failed_at >= '{since}') AS n", | |
| 367 | + | since = rfc3339(now_ms() - 90 * 24 * 60 * 60 * 1000) | |
| 368 | + | )) | |
| 369 | + | .bind(&[members, members].concat())? | |
| 370 | + | .first::<Count>(None) | |
| 371 | + | .await? | |
| 372 | + | .and_then(|c| c.n) | |
| 373 | + | .unwrap_or(0); | |
| 374 | + | if troubled > 0 { | |
| 375 | + | return Ok(None); | |
| 376 | + | } | |
| 377 | + | let mut charged = vec![]; | |
| 378 | + | for month in &months { | |
| 379 | + | #[derive(Deserialize)] | |
| 380 | + | struct Month { | |
| 381 | + | charged: Option<i64>, | |
| 382 | + | unpaid: Option<i64>, | |
| 383 | + | } | |
| 384 | + | let next = { | |
| 385 | + | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 386 | + | let number: u32 = month[5..7].parse().unwrap_or(1); | |
| 387 | + | if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) } | |
| 388 | + | }; | |
| 389 | + | let row = self | |
| 390 | + | .db | |
| 391 | + | .prepare(format!( | |
| 392 | + | "SELECT | |
| 393 | + | (SELECT -SUM(amount_micros) FROM ledger WHERE kind = 'usage' AND workspace IN ({marks}) | |
| 394 | + | AND created_at >= '{month}-01' AND created_at < '{next}-01') AS charged, | |
| 395 | + | (SELECT COUNT(*) FROM workspace_invoices WHERE workspace IN ({marks}) AND reason = 'month' | |
| 396 | + | AND period = '{month}' AND status <> 'paid') AS unpaid" | |
| 397 | + | )) | |
| 398 | + | .bind(&[members, members].concat())? | |
| 399 | + | .first::<Month>(None) | |
| 400 | + | .await?; | |
| 401 | + | let Some(row) = row else { return Ok(None) }; | |
| 402 | + | if row.unpaid.unwrap_or(0) > 0 { | |
| 403 | + | return Ok(None); | |
| 404 | + | } | |
| 405 | + | charged.push(row.charged.unwrap_or(0)); | |
| 406 | + | } | |
| 407 | + | Ok(established_ceiling(&charged)) | |
| 408 | + | } | |
| 409 | + | ||
| 253 | 410 | /// A refusal, with the reason, when the workspace's work is stopped. | |
| 254 | 411 | /// None while billing is off: a g1t without payments has no limits. | |
| 255 | 412 | pub(crate) async fn stopped<T>(&self, workspace: &str) -> Result<Option<Outcome<T>>> { | |
| 299 | 456 | /// nothing. Not for a workspace's own spend limit, which means stop, nor | |
| 300 | 457 | /// for enterprises, which are invoiced. | |
| 301 | 458 | pub(crate) async fn autopay(&self) -> Result<()> { | |
| 302 | − | let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else { | |
| 459 | + | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { | |
| 303 | 460 | return Ok(()); | |
| 304 | − | }; | |
| 461 | + | } | |
| 305 | 462 | #[derive(Deserialize)] | |
| 306 | 463 | struct Candidate { | |
| 307 | 464 | workspace: String, | |
| 308 | − | customer_id: Option<String>, | |
| 309 | 465 | } | |
| 310 | 466 | let month_start = format!("{}-01", &rfc3339(now_ms())[..7]); | |
| 467 | + | // With a card, and not already declined: a declined card waits for | |
| 468 | + | // the owners, rather than being tried again every few minutes. | |
| 311 | 469 | let candidates = self | |
| 312 | 470 | .db | |
| 313 | 471 | .prepare( | |
| 314 | − | "SELECT DISTINCT ledger.workspace AS workspace, accounts.customer_id AS customer_id | |
| 472 | + | "SELECT DISTINCT ledger.workspace AS workspace | |
| 315 | 473 | FROM ledger JOIN accounts ON accounts.workspace = ledger.workspace | |
| 316 | − | WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL", | |
| 474 | + | LEFT JOIN limits ON limits.workspace = ledger.workspace | |
| 475 | + | WHERE ledger.kind = 'usage' AND ledger.created_at >= ? AND accounts.customer_id IS NOT NULL | |
| 476 | + | AND limits.autopay_failed_at IS NULL", | |
| 317 | 477 | ) | |
| 318 | 478 | .bind(&[month_start.as_str().into()])? | |
| 319 | 479 | .all() | |
| 320 | 480 | .await? | |
| 321 | 481 | .results::<Candidate>()?; | |
| 322 | 482 | for candidate in candidates { | |
| 323 | − | let Some(customer) = candidate.customer_id else { continue }; | |
| 324 | 483 | let limit = self.limit_of(&candidate.workspace).await?; | |
| 325 | − | let own_limit = limit.spend_limit_micros.is_some() && limit.ceiling_micros == limit.spend_limit_micros; | |
| 326 | − | if limit.state == LimitState::Ok | |
| 327 | − | || own_limit | |
| 328 | − | || limit.trust == Trust::Internal | |
| 329 | − | || limit.account.starts_with("ent_") | |
| 330 | − | { | |
| 331 | − | continue; | |
| 332 | − | } | |
| 333 | − | // What it owes: its charges less what it has paid, never the cost | |
| 334 | − | // of what was free to it. At least the minimum, which is credit | |
| 335 | − | // toward what comes next. | |
| 336 | − | let balance = self.row(&candidate.workspace).await?.map_or(0, |row| row.balance_micros); | |
| 337 | − | let owed = (-balance).max(0); | |
| 338 | − | if owed == 0 { | |
| 484 | + | // Near g1t's ceiling on what is unpaid; the spend limit is the | |
| 485 | + | // owners' and stops work by itself, but what is owed is still owed. | |
| 486 | + | let near = limit.ceiling_micros.is_some_and(|ceiling| limit.exposure_micros * 5 >= ceiling * 4); | |
| 487 | + | if !near || limit.trust == Trust::Internal || limit.account.starts_with("ent_") { | |
| 339 | 488 | continue; | |
| 340 | 489 | } | |
| 341 | − | let cents = ((owed + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS); | |
| 342 | − | let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], owed / 1_000_000); | |
| 343 | − | let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace); | |
| 344 | − | let now = rfc3339(now_ms()); | |
| 345 | − | match stripe.charge_saved_card(&customer, cents, &description, &key).await { | |
| 346 | − | Ok(payment) if payment.status == "succeeded" => { | |
| 347 | − | // A retried charge is the same payment: credited once. | |
| 348 | − | let seen = self | |
| 349 | − | .db | |
| 350 | − | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 351 | − | .bind(&[payment.id.as_str().into()])? | |
| 352 | − | .first::<serde_json::Value>(None) | |
| 353 | − | .await?; | |
| 354 | − | if seen.is_some() { | |
| 355 | − | continue; | |
| 356 | − | } | |
| 357 | − | self.enter( | |
| 358 | − | &candidate.workspace, | |
| 359 | − | g1t_contracts::billing::EntryKind::TopUp, | |
| 360 | − | payment.amount_received.max(cents) * 10_000, | |
| 361 | − | &format!("Paid automatically by card, near the {} limit", dollars_plain(limit.ceiling_micros.unwrap_or_default())), | |
| 362 | − | &payment.id, | |
| 363 | − | None, | |
| 364 | − | None, | |
| 365 | − | None, | |
| 366 | − | Some(&customer), | |
| 367 | − | ) | |
| 368 | − | .await?; | |
| 369 | − | self.db | |
| 370 | − | .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?") | |
| 371 | − | .bind(&[candidate.workspace.as_str().into()])? | |
| 372 | − | .run() | |
| 373 | − | .await?; | |
| 374 | − | } | |
| 375 | − | outcome => { | |
| 376 | − | let error = match outcome { | |
| 377 | − | Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")), | |
| 378 | − | Err(error) => error.to_string().chars().take(200).collect(), | |
| 379 | − | }; | |
| 380 | − | self.db | |
| 381 | − | .prepare( | |
| 382 | − | "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2) | |
| 383 | − | ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2", | |
| 384 | − | ) | |
| 385 | − | .bind(&[candidate.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])? | |
| 386 | − | .run() | |
| 387 | − | .await?; | |
| 388 | − | } | |
| 490 | + | // An invoice for what it owes, charged to its card now: never | |
| 491 | + | // the cost of what was free to it. | |
| 492 | + | let today = rfc3339(now_ms())[..10].to_owned(); | |
| 493 | + | match self.invoice_workspace(&candidate.workspace, "threshold", &today).await? { | |
| 494 | + | Ok(_) => {} | |
| 495 | + | Err(why) => worker::console_log!("{}: no threshold invoice: {why}", candidate.workspace), | |
| 389 | 496 | } | |
| 390 | 497 | } | |
| 391 | 498 | Ok(()) | |
| 396 | 503 | /// workspace and month; a declined card stops work until it is paid. | |
| 397 | 504 | /// Comped workspaces owe nothing, and enterprises are invoiced. | |
| 398 | 505 | pub(crate) async fn close_months(&self) -> Result<()> { | |
| 399 | − | let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else { | |
| 506 | + | if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) { | |
| 400 | 507 | return Ok(()); | |
| 401 | − | }; | |
| 508 | + | } | |
| 402 | 509 | let now = rfc3339(now_ms()); | |
| 403 | 510 | let month_start = format!("{}-01", &now[..7]); | |
| 404 | 511 | let closing = previous_month(&now[..7]); | |
| 405 | 512 | #[derive(Deserialize)] | |
| 406 | 513 | struct Open { | |
| 407 | 514 | workspace: String, | |
| 408 | − | customer_id: String, | |
| 409 | 515 | balance: Option<i64>, | |
| 410 | 516 | } | |
| 411 | 517 | let open = self | |
| 412 | 518 | .db | |
| 413 | 519 | .prepare( | |
| 414 | − | "SELECT accounts.workspace AS workspace, accounts.customer_id AS customer_id, | |
| 520 | + | "SELECT accounts.workspace AS workspace, | |
| 415 | 521 | (SELECT SUM(amount_micros) FROM ledger | |
| 416 | 522 | WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance | |
| 417 | 523 | FROM accounts | |
| 452 | 558 | record("nothing", 0, None, None)?.run().await?; | |
| 453 | 559 | continue; | |
| 454 | 560 | } | |
| 455 | − | let cents = (owed + 9_999) / 10_000; | |
| 456 | − | let key = format!("close/{}/{closing}", account.workspace); | |
| 457 | − | let description = format!("g1t usage for {} in {closing}", account.workspace); | |
| 458 | − | match stripe.charge_saved_card(&account.customer_id, cents, &description, &key).await { | |
| 459 | − | Ok(payment) if payment.status == "succeeded" => { | |
| 460 | − | let seen = self | |
| 461 | − | .db | |
| 462 | − | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 463 | − | .bind(&[payment.id.as_str().into()])? | |
| 464 | − | .first::<serde_json::Value>(None) | |
| 465 | − | .await?; | |
| 466 | − | if seen.is_none() { | |
| 467 | − | self.enter( | |
| 468 | − | &account.workspace, | |
| 469 | − | g1t_contracts::billing::EntryKind::TopUp, | |
| 470 | − | payment.amount_received.max(cents) * 10_000, | |
| 471 | − | &format!("Usage for {closing}, charged to the card on file when the month closed"), | |
| 472 | − | &payment.id, | |
| 473 | − | None, | |
| 474 | − | None, | |
| 475 | − | None, | |
| 476 | − | Some(&account.customer_id), | |
| 477 | − | ) | |
| 478 | − | .await?; | |
| 479 | − | } | |
| 480 | − | record("paid", cents * 10_000, Some(&payment.id), None)?.run().await?; | |
| 561 | + | match self.invoice_workspace(&account.workspace, "month", &closing).await? { | |
| 562 | + | Ok(invoice) if invoice.status == "paid" => { | |
| 563 | + | record("paid", invoice.amount_micros, Some(&invoice.invoice_id), None)?.run().await?; | |
| 481 | 564 | } | |
| 482 | − | outcome => { | |
| 483 | − | let error = match outcome { | |
| 484 | − | Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")), | |
| 485 | − | Err(error) => error.to_string().chars().take(200).collect(), | |
| 486 | − | }; | |
| 487 | − | self.db | |
| 488 | − | .prepare( | |
| 489 | − | "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2) | |
| 490 | − | ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2", | |
| 491 | − | ) | |
| 492 | − | .bind(&[account.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])? | |
| 493 | − | .run() | |
| 494 | − | .await?; | |
| 495 | − | record("failed", cents * 10_000, None, Some(&error))?.run().await?; | |
| 565 | + | Ok(invoice) => { | |
| 566 | + | record("failed", invoice.amount_micros, Some(&invoice.invoice_id), Some("the card was declined"))?.run().await?; | |
| 567 | + | } | |
| 568 | + | Err(why) => { | |
| 569 | + | record("nothing", 0, None, Some(&why))?.run().await?; | |
| 496 | 570 | } | |
| 497 | 571 | } | |
| 498 | 572 | } | |
| 614 | 688 | if a.spend_limit_micros.is_some_and(|limit| limit < 0) { | |
| 615 | 689 | return Ok(Outcome::fail(FailureCode::Invalid, "A spend limit cannot be negative.")); | |
| 616 | 690 | } | |
| 617 | − | let limit = a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()); | |
| 691 | + | let limit = if a.use_full_limit { JsValue::NULL } else { a.spend_limit_micros.map_or(JsValue::NULL, |limit| (limit as f64).into()) }; | |
| 618 | 692 | self.db | |
| 619 | 693 | .prepare( | |
| 620 | − | "INSERT INTO limits (workspace, spend_limit_micros, updated_at) VALUES (?1, ?2, ?3) | |
| 621 | − | ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, updated_at = ?3", | |
| 694 | + | "INSERT INTO limits (workspace, spend_limit_micros, spend_limit_full, updated_at) VALUES (?1, ?2, ?3, ?4) | |
| 695 | + | ON CONFLICT (workspace) DO UPDATE SET spend_limit_micros = ?2, spend_limit_full = ?3, updated_at = ?4", | |
| 622 | 696 | ) | |
| 623 | − | .bind(&[workspace.as_str().into(), limit, rfc3339(now_ms()).into()])? | |
| 697 | + | .bind(&[workspace.as_str().into(), limit, (if a.use_full_limit { 1 } else { 0 }).into(), rfc3339(now_ms()).into()])? | |
| 624 | 698 | .run() | |
| 625 | 699 | .await?; | |
| 626 | 700 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) | |
| 675 | 749 | use super::*; | |
| 676 | 750 | ||
| 677 | 751 | #[test] | |
| 752 | + | fn the_automatic_spend_limit_follows_last_month() { | |
| 753 | + | assert_eq!(automatic_spend_limit(0), 200_000_000); | |
| 754 | + | assert_eq!(automatic_spend_limit(50_000_000), 200_000_000); | |
| 755 | + | assert_eq!(automatic_spend_limit(900_000_000), 1_800_000_000); | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | #[test] | |
| 759 | + | fn three_steady_months_make_a_workspace_established() { | |
| 760 | + | assert_eq!(established_ceiling(&[900_000_000, 850_000_000, 950_000_000]), Some(2_700_000_000)); | |
| 761 | + | assert_eq!(established_ceiling(&[5_000_000_000, 5_000_000_000, 5_000_000_000]), Some(10_000_000_000)); | |
| 762 | + | assert_eq!(established_ceiling(&[900_000_000, 10_000_000, 950_000_000]), None); | |
| 763 | + | assert_eq!(established_ceiling(&[900_000_000, 900_000_000]), None); | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | #[test] | |
| 678 | 767 | fn warnings_come_at_half_four_fifths_and_the_limit() { | |
| 679 | 768 | assert_eq!(warning_level(0, 300), 0); | |
| 680 | 769 | assert_eq!(warning_level(149, 300), 0); |
| 1 | + | //! What g1t's team needs to sell and support: month-by-month figures, the | |
| 2 | + | //! signals that say a workspace is worth a call, and what was done about | |
| 3 | + | //! it. Staff only, through sudo.g1t.sh. | |
| 4 | + | ||
| 5 | + | use g1t_contracts::billing::{ | |
| 6 | + | AdminAddNoteArgs, AdminOverviewArgs, AdminSalesArgs, AdminSetSalesArgs, AdminSignalsArgs, KindFigures, | |
| 7 | + | LimitState, MonthFigures, Overview, SalesNote, SalesRecord, Signal, SignalKind, TermsKind, Trust, | |
| 8 | + | }; | |
| 9 | + | use g1t_contracts::time::rfc3339; | |
| 10 | + | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 11 | + | use g1t_kit::now_ms; | |
| 12 | + | use serde::Deserialize; | |
| 13 | + | use worker::Result; | |
| 14 | + | use worker::wasm_bindgen::JsValue; | |
| 15 | + | ||
| 16 | + | use crate::Billing; | |
| 17 | + | use crate::features::dollars; | |
| 18 | + | use crate::limits::previous_month; | |
| 19 | + | ||
| 20 | + | pub(crate) const STAGES: &[&str] = &["none", "lead", "contacted", "negotiating", "won", "lost", "churn_risk"]; | |
| 21 | + | ||
| 22 | + | /// A workspace this much ahead of last month's pace is growing. | |
| 23 | + | const GROWING_FACTOR: f64 = 1.5; | |
| 24 | + | /// Below this last month, growth is noise. | |
| 25 | + | const GROWING_FROM_MICROS: i64 = 10_000_000; | |
| 26 | + | /// Spending this much a month may suit custom terms or an enterprise. | |
| 27 | + | const HIGH_SPEND_MICROS: i64 = 500_000_000; | |
| 28 | + | ||
| 29 | + | /// The six months ending with `month`, oldest first. | |
| 30 | + | pub(crate) fn last_months(month: &str, count: usize) -> Vec<String> { | |
| 31 | + | let mut months = vec![month.to_owned()]; | |
| 32 | + | while months.len() < count { | |
| 33 | + | let earlier = previous_month(months.last().unwrap()); | |
| 34 | + | months.push(earlier); | |
| 35 | + | } | |
| 36 | + | months.reverse(); | |
| 37 | + | months | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// How urgent a kind of signal is: lower first. | |
| 41 | + | fn urgency(kind: SignalKind) -> u8 { | |
| 42 | + | match kind { | |
| 43 | + | SignalKind::AtLimit => 0, | |
| 44 | + | SignalKind::Declined => 1, | |
| 45 | + | SignalKind::NearCeiling => 2, | |
| 46 | + | SignalKind::HighSpend => 3, | |
| 47 | + | SignalKind::Growing => 4, | |
| 48 | + | SignalKind::Established => 5, | |
| 49 | + | SignalKind::FirstPayment => 6, | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Whether this month, at its pace so far, is well ahead of last month. | |
| 54 | + | pub(crate) fn growing(this_month: i64, last_month: i64, day: u32, days_in_month: u32) -> bool { | |
| 55 | + | if last_month < GROWING_FROM_MICROS || day == 0 { | |
| 56 | + | return false; | |
| 57 | + | } | |
| 58 | + | let pace = this_month as f64 * f64::from(days_in_month) / f64::from(day); | |
| 59 | + | pace >= last_month as f64 * GROWING_FACTOR | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn days_in(month: &str) -> u32 { | |
| 63 | + | let year: i32 = month[..4].parse().unwrap_or(1970); | |
| 64 | + | match month[5..7].parse::<u32>().unwrap_or(1) { | |
| 65 | + | 2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29, | |
| 66 | + | 2 => 28, | |
| 67 | + | 4 | 6 | 9 | 11 => 30, | |
| 68 | + | _ => 31, | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[derive(Deserialize)] | |
| 73 | + | struct MonthRow { | |
| 74 | + | month: String, | |
| 75 | + | charged: Option<i64>, | |
| 76 | + | cost: Option<i64>, | |
| 77 | + | paid: Option<i64>, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[derive(Deserialize)] | |
| 81 | + | struct RecordRow { | |
| 82 | + | stage: String, | |
| 83 | + | owner: Option<String>, | |
| 84 | + | next_step: Option<String>, | |
| 85 | + | next_at: Option<String>, | |
| 86 | + | updated_at: String, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | #[derive(Deserialize)] | |
| 90 | + | struct NoteRow { | |
| 91 | + | id: String, | |
| 92 | + | text: String, | |
| 93 | + | by: String, | |
| 94 | + | created_at: String, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | impl Billing { | |
| 98 | + | /// Month-by-month figures for some workspaces (all, when empty). | |
| 99 | + | pub(crate) async fn months_for(&self, workspaces: &[String], count: usize) -> Result<Vec<MonthFigures>> { | |
| 100 | + | let months = last_months(&rfc3339(now_ms())[..7], count); | |
| 101 | + | let since = format!("{}-01", months[0]); | |
| 102 | + | let (filter, values): (String, Vec<JsValue>) = if workspaces.is_empty() { | |
| 103 | + | (String::new(), vec![]) | |
| 104 | + | } else { | |
| 105 | + | let marks = vec!["?"; workspaces.len()].join(", "); | |
| 106 | + | (format!("AND workspace IN ({marks})"), workspaces.iter().map(|w| JsValue::from(w.as_str())).collect()) | |
| 107 | + | }; | |
| 108 | + | let rows = self | |
| 109 | + | .db | |
| 110 | + | .prepare(format!( | |
| 111 | + | "SELECT substr(created_at, 1, 7) AS month, | |
| 112 | + | -SUM(CASE WHEN kind = 'usage' THEN amount_micros END) AS charged, | |
| 113 | + | SUM(CASE WHEN kind = 'usage' THEN cost_micros END) AS cost, | |
| 114 | + | SUM(CASE WHEN kind = 'top_up' AND reference NOT LIKE 'crd%' THEN amount_micros END) AS paid | |
| 115 | + | FROM ledger WHERE created_at >= '{since}' {filter} GROUP BY 1" | |
| 116 | + | )) | |
| 117 | + | .bind(&values)? | |
| 118 | + | .all() | |
| 119 | + | .await? | |
| 120 | + | .results::<MonthRow>()?; | |
| 121 | + | Ok(months | |
| 122 | + | .into_iter() | |
| 123 | + | .map(|month| { | |
| 124 | + | let row = rows.iter().find(|r| r.month == month); | |
| 125 | + | MonthFigures { | |
| 126 | + | charged_micros: row.and_then(|r| r.charged).unwrap_or(0), | |
| 127 | + | cost_micros: row.and_then(|r| r.cost).unwrap_or(0), | |
| 128 | + | paid_micros: row.and_then(|r| r.paid).unwrap_or(0), | |
| 129 | + | month, | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | .collect()) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | async fn record_row(&self, workspace: &str) -> Result<Option<RecordRow>> { | |
| 136 | + | self.db | |
| 137 | + | .prepare("SELECT * FROM sales_records WHERE workspace = ?") | |
| 138 | + | .bind(&[workspace.into()])? | |
| 139 | + | .first::<RecordRow>(None) | |
| 140 | + | .await | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | pub(crate) async fn admin_sales(&self, a: AdminSalesArgs) -> Result<SalesRecord> { | |
| 144 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 145 | + | let row = self.record_row(&workspace).await?; | |
| 146 | + | let notes = self | |
| 147 | + | .db | |
| 148 | + | .prepare("SELECT id, text, by, created_at FROM sales_notes WHERE workspace = ? ORDER BY created_at DESC LIMIT 100") | |
| 149 | + | .bind(&[workspace.as_str().into()])? | |
| 150 | + | .all() | |
| 151 | + | .await? | |
| 152 | + | .results::<NoteRow>()? | |
| 153 | + | .into_iter() | |
| 154 | + | .map(|n| SalesNote { id: n.id, text: n.text, by: n.by, created_at: n.created_at }) | |
| 155 | + | .collect(); | |
| 156 | + | Ok(match row { | |
| 157 | + | Some(row) => SalesRecord { | |
| 158 | + | workspace, | |
| 159 | + | stage: row.stage, | |
| 160 | + | owner: row.owner, | |
| 161 | + | next_step: row.next_step, | |
| 162 | + | next_at: row.next_at, | |
| 163 | + | notes, | |
| 164 | + | updated_at: Some(row.updated_at), | |
| 165 | + | }, | |
| 166 | + | None => SalesRecord { | |
| 167 | + | workspace, | |
| 168 | + | stage: "none".to_owned(), | |
| 169 | + | owner: None, | |
| 170 | + | next_step: None, | |
| 171 | + | next_at: None, | |
| 172 | + | notes, | |
| 173 | + | updated_at: None, | |
| 174 | + | }, | |
| 175 | + | }) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | pub(crate) async fn admin_set_sales(&self, a: AdminSetSalesArgs) -> Result<Outcome<SalesRecord>> { | |
| 179 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 180 | + | if workspace.is_empty() || a.by.trim().is_empty() { | |
| 181 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is making the change.")); | |
| 182 | + | } | |
| 183 | + | if !STAGES.contains(&a.stage.as_str()) { | |
| 184 | + | return Ok(Outcome::fail(FailureCode::Invalid, format!("A stage is one of: {}.", STAGES.join(", ")))); | |
| 185 | + | } | |
| 186 | + | let clean = |value: &Option<String>| value.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned); | |
| 187 | + | let (owner, next_step, next_at) = (clean(&a.owner), clean(&a.next_step), clean(&a.next_at)); | |
| 188 | + | let before = self.record_row(&workspace).await?; | |
| 189 | + | self.db | |
| 190 | + | .prepare( | |
| 191 | + | "INSERT INTO sales_records (workspace, stage, owner, next_step, next_at, updated_by, updated_at) | |
| 192 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 193 | + | ON CONFLICT (workspace) DO UPDATE SET stage = ?2, owner = ?3, next_step = ?4, next_at = ?5, | |
| 194 | + | updated_by = ?6, updated_at = ?7", | |
| 195 | + | ) | |
| 196 | + | .bind(&[ | |
| 197 | + | workspace.as_str().into(), | |
| 198 | + | a.stage.as_str().into(), | |
| 199 | + | crate::optional(owner.as_deref()), | |
| 200 | + | crate::optional(next_step.as_deref()), | |
| 201 | + | crate::optional(next_at.as_deref()), | |
| 202 | + | a.by.as_str().into(), | |
| 203 | + | rfc3339(now_ms()).into(), | |
| 204 | + | ])? | |
| 205 | + | .run() | |
| 206 | + | .await?; | |
| 207 | + | let account = self.account_of(&workspace).await?; | |
| 208 | + | let was = before.map_or_else(|| "none".to_owned(), |b| b.stage); | |
| 209 | + | let detail = format!( | |
| 210 | + | "{workspace}: stage {was} → {}{}{}", | |
| 211 | + | a.stage, | |
| 212 | + | owner.as_deref().map(|o| format!(", owner {o}")).unwrap_or_default(), | |
| 213 | + | next_step.as_deref().map(|s| format!(", next: {s}")).unwrap_or_default(), | |
| 214 | + | ); | |
| 215 | + | self.audit(&account.id, "sales", &detail, &a.by).await?; | |
| 216 | + | Ok(Outcome::Ok(self.admin_sales(AdminSalesArgs { workspace }).await?)) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | pub(crate) async fn admin_add_note(&self, a: AdminAddNoteArgs) -> Result<Outcome<SalesRecord>> { | |
| 220 | + | let workspace = a.workspace.trim().to_lowercase(); | |
| 221 | + | let text = a.text.trim(); | |
| 222 | + | if workspace.is_empty() || text.is_empty() || a.by.trim().is_empty() { | |
| 223 | + | return Ok(Outcome::fail(FailureCode::Invalid, "A note needs a workspace, some words, and who wrote it.")); | |
| 224 | + | } | |
| 225 | + | if text.chars().count() > 4000 { | |
| 226 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Keep a note under 4,000 characters.")); | |
| 227 | + | } | |
| 228 | + | let now = now_ms(); | |
| 229 | + | self.db | |
| 230 | + | .prepare("INSERT INTO sales_notes (id, workspace, text, by, created_at) VALUES (?, ?, ?, ?, ?)") | |
| 231 | + | .bind(&[new_id("note", now).into(), workspace.as_str().into(), text.into(), a.by.as_str().into(), rfc3339(now).into()])? | |
| 232 | + | .run() | |
| 233 | + | .await?; | |
| 234 | + | Ok(Outcome::Ok(self.admin_sales(AdminSalesArgs { workspace }).await?)) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// Every workspace worth reaching out to, most urgent first. | |
| 238 | + | pub(crate) async fn admin_signals(&self, _: AdminSignalsArgs) -> Result<Vec<Signal>> { | |
| 239 | + | let now = rfc3339(now_ms()); | |
| 240 | + | let month = &now[..7]; | |
| 241 | + | let last = previous_month(month); | |
| 242 | + | #[derive(Deserialize)] | |
| 243 | + | struct Active { | |
| 244 | + | workspace: String, | |
| 245 | + | this_month: Option<i64>, | |
| 246 | + | last_month: Option<i64>, | |
| 247 | + | first_paid: Option<String>, | |
| 248 | + | } | |
| 249 | + | let active = self | |
| 250 | + | .db | |
| 251 | + | .prepare( | |
| 252 | + | "SELECT workspace, | |
| 253 | + | -SUM(CASE WHEN kind = 'usage' AND created_at >= ?1 THEN amount_micros END) AS this_month, | |
| 254 | + | -SUM(CASE WHEN kind = 'usage' AND created_at >= ?2 AND created_at < ?1 THEN amount_micros END) AS last_month, | |
| 255 | + | MIN(CASE WHEN kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' THEN created_at END) AS first_paid | |
| 256 | + | FROM ledger GROUP BY workspace | |
| 257 | + | HAVING MAX(created_at) >= ?2 | |
| 258 | + | LIMIT 500", | |
| 259 | + | ) | |
| 260 | + | .bind(&[format!("{month}-01").into(), format!("{last}-01").into()])? | |
| 261 | + | .all() | |
| 262 | + | .await? | |
| 263 | + | .results::<Active>()?; | |
| 264 | + | let day: u32 = now[8..10].parse().unwrap_or(1); | |
| 265 | + | let fortnight_ago = rfc3339(now_ms() - 14 * 24 * 60 * 60 * 1000); | |
| 266 | + | let mut signals = vec![]; | |
| 267 | + | for row in active { | |
| 268 | + | let limit = self.limit_of(&row.workspace).await?; | |
| 269 | + | if limit.trust == Trust::Internal { | |
| 270 | + | continue; | |
| 271 | + | } | |
| 272 | + | let this_month = row.this_month.unwrap_or(0).max(0); | |
| 273 | + | let last_month = row.last_month.unwrap_or(0).max(0); | |
| 274 | + | let record = self.record_row(&row.workspace).await?; | |
| 275 | + | let (stage, owner) = record.map_or((None, None), |r| (Some(r.stage), r.owner)); | |
| 276 | + | let mut push = |kind: SignalKind, detail: String, value: i64| { | |
| 277 | + | signals.push(Signal { | |
| 278 | + | workspace: row.workspace.clone(), | |
| 279 | + | kind, | |
| 280 | + | detail, | |
| 281 | + | value_micros: value, | |
| 282 | + | stage: stage.clone(), | |
| 283 | + | owner: owner.clone(), | |
| 284 | + | }); | |
| 285 | + | }; | |
| 286 | + | let declined = limit.message.as_deref().is_some_and(|m| m.contains("could not be charged")); | |
| 287 | + | if declined { | |
| 288 | + | push(SignalKind::Declined, limit.message.clone().unwrap_or_default(), limit.exposure_micros); | |
| 289 | + | } else if limit.state == LimitState::Stopped { | |
| 290 | + | push(SignalKind::AtLimit, limit.message.clone().unwrap_or_default(), limit.exposure_micros.max(limit.spent_micros)); | |
| 291 | + | } else if let Some(available) = limit.available_micros.filter(|a| *a > 0) { | |
| 292 | + | if limit.exposure_micros * 5 >= available * 4 { | |
| 293 | + | push( | |
| 294 | + | SignalKind::NearCeiling, | |
| 295 | + | format!( | |
| 296 | + | "{} unpaid of the {} g1t allows it ({:?}); a call could raise it before it stops.", | |
| 297 | + | dollars(limit.exposure_micros), | |
| 298 | + | dollars(available), | |
| 299 | + | limit.trust | |
| 300 | + | ), | |
| 301 | + | limit.exposure_micros, | |
| 302 | + | ); | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | if last_month >= HIGH_SPEND_MICROS { | |
| 306 | + | let terms = self.terms_of(&row.workspace).await?; | |
| 307 | + | if terms.kind == TermsKind::Standard && !limit.account.starts_with("ent_") { | |
| 308 | + | push( | |
| 309 | + | SignalKind::HighSpend, | |
| 310 | + | format!("Spent {} last month on standard terms: worth offering custom terms or an enterprise.", dollars(last_month)), | |
| 311 | + | last_month, | |
| 312 | + | ); | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | if growing(this_month, last_month, day, days_in(month)) { | |
| 316 | + | push( | |
| 317 | + | SignalKind::Growing, | |
| 318 | + | format!( | |
| 319 | + | "{} so far this month, on pace for about {}, against {} last month.", | |
| 320 | + | dollars(this_month), | |
| 321 | + | dollars((this_month as f64 * f64::from(days_in(month)) / f64::from(day.max(1))) as i64), | |
| 322 | + | dollars(last_month) | |
| 323 | + | ), | |
| 324 | + | this_month, | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | if limit.trust == Trust::Established { | |
| 328 | + | push( | |
| 329 | + | SignalKind::Established, | |
| 330 | + | format!( | |
| 331 | + | "A steady customer: its limit now follows its spend ({} available).", | |
| 332 | + | limit.available_micros.map(dollars).unwrap_or_default() | |
| 333 | + | ), | |
| 334 | + | last_month, | |
| 335 | + | ); | |
| 336 | + | } | |
| 337 | + | if row.first_paid.as_deref().is_some_and(|at| at >= fortnight_ago.as_str()) { | |
| 338 | + | push(SignalKind::FirstPayment, "Paid g1t for the first time in the last two weeks: say hello.".to_owned(), this_month); | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | signals.sort_by(|a, b| urgency(a.kind).cmp(&urgency(b.kind)).then(b.value_micros.cmp(&a.value_micros))); | |
| 342 | + | Ok(signals) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// The business at a glance. | |
| 346 | + | pub(crate) async fn admin_overview(&self, _: AdminOverviewArgs) -> Result<Overview> { | |
| 347 | + | let now = rfc3339(now_ms()); | |
| 348 | + | let month = now[..7].to_owned(); | |
| 349 | + | let months = self.months_for(&[], 6).await?; | |
| 350 | + | #[derive(Deserialize)] | |
| 351 | + | struct KindRow { | |
| 352 | + | kind: Option<String>, | |
| 353 | + | charged: Option<i64>, | |
| 354 | + | cost: Option<i64>, | |
| 355 | + | } | |
| 356 | + | let by_kind = self | |
| 357 | + | .db | |
| 358 | + | .prepare( | |
| 359 | + | "SELECT CASE | |
| 360 | + | WHEN task = 'sandbox' THEN 'Sandbox time' | |
| 361 | + | WHEN task = 'deployments' THEN 'Deployments' | |
| 362 | + | WHEN billed_to = 'workspace' THEN 'Own-provider runs' | |
| 363 | + | ELSE 'Models' END AS kind, | |
| 364 | + | -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost | |
| 365 | + | FROM ledger WHERE kind = 'usage' AND created_at >= ? GROUP BY 1 ORDER BY charged DESC", | |
| 366 | + | ) | |
| 367 | + | .bind(&[format!("{month}-01").into()])? | |
| 368 | + | .all() | |
| 369 | + | .await? | |
| 370 | + | .results::<KindRow>()? | |
| 371 | + | .into_iter() | |
| 372 | + | .map(|r| KindFigures { | |
| 373 | + | kind: r.kind.unwrap_or_else(|| "Other".to_owned()), | |
| 374 | + | charged_micros: r.charged.unwrap_or(0), | |
| 375 | + | cost_micros: r.cost.unwrap_or(0), | |
| 376 | + | }) | |
| 377 | + | .collect(); | |
| 378 | + | #[derive(Deserialize)] | |
| 379 | + | struct Count { | |
| 380 | + | n: Option<i64>, | |
| 381 | + | } | |
| 382 | + | let count = |sql: &'static str, args: Vec<JsValue>| { | |
| 383 | + | let db = &self.db; | |
| 384 | + | async move { | |
| 385 | + | Ok::<i64, worker::Error>(db.prepare(sql).bind(&args)?.first::<Count>(None).await?.and_then(|c| c.n).unwrap_or(0)) | |
| 386 | + | } | |
| 387 | + | }; | |
| 388 | + | let paying = count( | |
| 389 | + | "SELECT COUNT(DISTINCT workspace) AS n FROM ledger | |
| 390 | + | WHERE kind = 'top_up' AND amount_micros > 0 AND reference NOT LIKE 'crd%' AND created_at >= ?", | |
| 391 | + | vec![rfc3339(now_ms() - 60 * 24 * 60 * 60 * 1000).into()], | |
| 392 | + | ) | |
| 393 | + | .await?; | |
| 394 | + | let open_invoices = count( | |
| 395 | + | "SELECT (SELECT COALESCE(SUM(amount_micros), 0) FROM workspace_invoices WHERE status IN ('open', 'failed')) | |
| 396 | + | + (SELECT COALESCE(SUM(amount_micros), 0) FROM enterprise_invoices WHERE status IN ('open', 'overdue')) AS n", | |
| 397 | + | vec![], | |
| 398 | + | ) | |
| 399 | + | .await?; | |
| 400 | + | let follow_ups = count( | |
| 401 | + | "SELECT COUNT(*) AS n FROM sales_records WHERE next_at IS NOT NULL AND next_at <= ? AND stage NOT IN ('won', 'lost')", | |
| 402 | + | vec![now[..10].into()], | |
| 403 | + | ) | |
| 404 | + | .await?; | |
| 405 | + | let signals = self.admin_signals(AdminSignalsArgs {}).await?; | |
| 406 | + | let tally = |kind: SignalKind| signals.iter().filter(|s| s.kind == kind).count() as u32; | |
| 407 | + | Ok(Overview { | |
| 408 | + | month, | |
| 409 | + | months, | |
| 410 | + | by_kind, | |
| 411 | + | paying_workspaces: paying as u32, | |
| 412 | + | stopped: tally(SignalKind::AtLimit), | |
| 413 | + | near_ceiling: tally(SignalKind::NearCeiling), | |
| 414 | + | declined: tally(SignalKind::Declined), | |
| 415 | + | open_invoices_micros: open_invoices, | |
| 416 | + | follow_ups_due: follow_ups as u32, | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[cfg(test)] | |
| 422 | + | mod tests { | |
| 423 | + | use super::*; | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn six_months_end_with_this_one() { | |
| 427 | + | assert_eq!(last_months("2026-02", 3), vec!["2025-12", "2026-01", "2026-02"]); | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | #[test] | |
| 431 | + | fn growth_is_judged_on_pace_not_on_the_month_so_far() { | |
| 432 | + | // Ten days in, $20 on a 30-day month is a $60 pace against $30. | |
| 433 | + | assert!(growing(20_000_000, 30_000_000, 10, 30)); | |
| 434 | + | assert!(!growing(10_000_000, 30_000_000, 10, 30)); | |
| 435 | + | // Too small last month to say. | |
| 436 | + | assert!(!growing(9_000_000, 1_000_000, 10, 30)); | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | #[test] | |
| 440 | + | fn the_most_urgent_comes_first() { | |
| 441 | + | assert!(urgency(SignalKind::AtLimit) < urgency(SignalKind::NearCeiling)); | |
| 442 | + | assert!(urgency(SignalKind::Declined) < urgency(SignalKind::Growing)); | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | #[test] | |
| 446 | + | fn february_knows_its_leap_years() { | |
| 447 | + | assert_eq!(days_in("2028-02"), 29); | |
| 448 | + | assert_eq!(days_in("2026-02"), 28); | |
| 449 | + | assert_eq!(days_in("2026-10"), 31); | |
| 450 | + | } | |
| 451 | + | } |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | /// `name=value` pairs as a form body. | |
| 107 | − | /// A payment made with no one there. | |
| 108 | − | #[derive(Debug, Deserialize)] | |
| 109 | − | pub struct PaymentIntent { | |
| 110 | − | pub id: String, | |
| 111 | − | /// `succeeded`, or anything else when it did not go through. | |
| 112 | − | pub status: String, | |
| 113 | − | #[serde(default)] | |
| 114 | − | pub amount_received: i64, | |
| 115 | − | } | |
| 116 | − | ||
| 117 | 107 | pub(crate) fn form(fields: &[(&str, String)]) -> String { | |
| 118 | 108 | fields | |
| 119 | 109 | .iter() | |
| 142 | 132 | self.call(Method::Post, path, Some(form(fields))).await | |
| 143 | 133 | } | |
| 144 | 134 | ||
| 135 | + | /// A form POST that Stripe does at most once for `key`, however often | |
| 136 | + | /// it is sent. | |
| 137 | + | pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>( | |
| 138 | + | &self, | |
| 139 | + | path: &str, | |
| 140 | + | fields: &[(&str, String)], | |
| 141 | + | key: &str, | |
| 142 | + | ) -> Result<T> { | |
| 143 | + | self.send(Method::Post, path, Some(form(fields)), Some(key)).await | |
| 144 | + | } | |
| 145 | + | ||
| 145 | 146 | pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> { | |
| 146 | 147 | self.call(Method::Delete, path, None).await | |
| 147 | 148 | } | |
| 185 | 186 | ))); | |
| 186 | 187 | } | |
| 187 | 188 | response.json().await | |
| 188 | − | } | |
| 189 | − | ||
| 190 | − | /// Charges the customer's saved card, with no one there: the automatic | |
| 191 | − | /// payment at a workspace's limit. `key` makes a retry the same charge. | |
| 192 | − | pub async fn charge_saved_card( | |
| 193 | − | &self, | |
| 194 | − | customer: &str, | |
| 195 | − | amount_cents: i64, | |
| 196 | − | description: &str, | |
| 197 | − | key: &str, | |
| 198 | − | ) -> Result<PaymentIntent> { | |
| 199 | − | #[derive(Deserialize)] | |
| 200 | − | struct Methods { | |
| 201 | − | data: Vec<Method_>, | |
| 202 | − | } | |
| 203 | − | #[derive(Deserialize)] | |
| 204 | − | struct Method_ { | |
| 205 | − | id: String, | |
| 206 | − | } | |
| 207 | − | let methods: Methods = self | |
| 208 | − | .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None) | |
| 209 | − | .await?; | |
| 210 | − | let Some(card) = methods.data.first() else { | |
| 211 | − | return Err(Error::RustError("no card on file".into())); | |
| 212 | − | }; | |
| 213 | − | let fields = [ | |
| 214 | − | ("amount", amount_cents.to_string()), | |
| 215 | − | ("currency", "usd".to_owned()), | |
| 216 | − | ("customer", customer.to_owned()), | |
| 217 | − | ("payment_method", card.id.clone()), | |
| 218 | − | ("off_session", "true".to_owned()), | |
| 219 | − | ("confirm", "true".to_owned()), | |
| 220 | − | ("description", description.to_owned()), | |
| 221 | − | ]; | |
| 222 | − | self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await | |
| 223 | 189 | } | |
| 224 | 190 | ||
| 225 | 191 | /// A customer for a workspace that has none yet. |
| 273 | 273 | "invoice.paid" => { | |
| 274 | 274 | if let Some(subscription) = object["subscription"].as_str() { | |
| 275 | 275 | self.settle_subscription(subscription).await? | |
| 276 | + | } else if let Some(done) = self.workspace_invoice_paid(&text("id")).await? { | |
| 277 | + | done | |
| 276 | 278 | } else { | |
| 277 | 279 | self.enterprise_invoice_paid(&text("id")).await? | |
| 278 | 280 | } | |
| 279 | 281 | } | |
| 280 | − | "invoice.payment_failed" => match object["subscription"].as_str() { | |
| 281 | − | Some(subscription) => self.settle_subscription(subscription).await?, | |
| 282 | − | None => "ignored: not a plan".to_owned(), | |
| 282 | + | "invoice.payment_failed" => match (object["subscription"].as_str(), object["metadata"]["g1t_workspace"].as_str()) { | |
| 283 | + | (Some(subscription), _) => self.settle_subscription(subscription).await?, | |
| 284 | + | (None, Some(workspace)) => { | |
| 285 | + | self.mark_declined(workspace, "the card was declined for an invoice").await?; | |
| 286 | + | format!("{workspace}: invoice payment failed; work stopped") | |
| 287 | + | } | |
| 288 | + | _ => "ignored: not g1t's".to_owned(), | |
| 283 | 289 | }, | |
| 284 | 290 | "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?, | |
| 285 | 291 | "invoice.voided" => self.enterprise_invoice_status(&text("id"), "void").await?, | |
| 455 | 461 | return Ok("ignored: not a workspace's customer".to_owned()); | |
| 456 | 462 | }; | |
| 457 | 463 | let now = rfc3339(now_ms()); | |
| 464 | + | // The disputed payment never counts toward trust again. | |
| 465 | + | for reference in [charge["payment_intent"].as_str(), charge["invoice"].as_str()].into_iter().flatten() { | |
| 466 | + | self.db | |
| 467 | + | .prepare("UPDATE ledger SET disputed = ? WHERE workspace = ? AND reference = ?") | |
| 468 | + | .bind(&[(if stop { 1 } else { 0 }).into(), workspace.as_str().into(), reference.into()])? | |
| 469 | + | .run() | |
| 470 | + | .await?; | |
| 471 | + | } | |
| 458 | 472 | if stop { | |
| 459 | 473 | self.db | |
| 460 | 474 | .prepare( |