Staff can enroll a workspace in complimentary terms from sudo, with a monthly usage allowance at ordinary prices and a monthly fee of its own, and choose what happens past the allowance: new paid work stops, or (for employees and friends g1t covers up to an amount) the workspace's own card is charged at the usual prices under its limits. Every workspace but flagon-io is Free when this deploys (#47).
Complimentary (billing 0064, src/complimentary.rs). A real Stripe customer and subscription stand for each enrollment: a $0 price when the fee is waived, the fee's price (invoiced, 30 days) when there is one, the plan's price under a 100% coupon only for g1t's own (100% terms) unlimited enrollment; the grant, allowance, fee and past-allowance choice are in its metadata. The workspace sees the enrollment (PlanKind::Complimentary) only once Stripe has the subscription. Every Stripe step is written down first (pending_op) under idempotency keys made from the grant, the tick retries what is owed, and one older than Stripe keeps keys looks for what it made rather than sending again. Terms are kept as periods: the allowance is drawn in the same D1 batch that posts the usage (allocate_usage.sql), from the period covering the usage's own time, per UTC month (allowance_use complimentary), so late usage lands where it happened, a changed amount keeps the month's consumption and a lowered one never makes a debt. Past it, reserve refuses and entitlements say hasPlan false (repos, caches and packages stop billable storage), and what running work cost past it is g1t's (given_micros); in charge mode with a card it is charged under the plan's spend limit and ceiling, and without a card it stops as stop mode does. Pending month-end usage and open reservations count against what is left. The allowance is its own ledger column (gift_micros, "Paid by your complimentary allowance"), promotional in margin, sales and g1t's spend, never revenue; sudo shows money received (plan and fee invoices, prepayments, bank transfers), promotional credit and gifted usage apart. Billing shows the allowance, used, metered, remaining and when it starts again. Owners and billing managers can start the plan instead (convert_to_paid) on the same customer and subscription: the item becomes the plan's price with payment_behavior=pending_if_incomplete, so a decline, a bank's 3DS check, a closed page or a second click leave the enrollment and its one subscription as they were; it ends only once Stripe shows the plan applied, by the answer, the owner's next look, the tick or the webhook. Month-end meters run up so far are put on the ledger under the allowance when an enrollment ends. A staff terms change is never mistaken for a conversion, a refused conversion is never replayed by the next, and an abandoned one puts a fee back to being invoiced. Late month-end figures. A source's figure for a month already charged or closed charges only what it adds, on that month and its allowances, keyed by the new total and guarded against a stale writer, keeping the highest figure; a lower one is never refunded on its own (one audit row for staff). An uncharged row of a closed month takes the higher figure for the close. Also: migration 0065 returns every account but flagon-io to standard terms and detaches enterprise members (with audit rows; production had none on 2026-10-14). The Stripe Tax guard covers subscription updates. Repos treats a workspace that may meter nothing more as free for storage and git operations. The earlier, uncommitted launch-readiness work rides along: durable workspace and enterprise invoice attempts (billing 0062, 0063), atomic allowance and usage posting, refund and payment-stop transactions, replay pagination, wrong-mode events left unclaimed, the 5 GB public storage cap on every plan with atomic reservations and native-copy locks (repos 0021), and the ops scripts (read-only live preflight, isolated sandbox harness, demo detach). Deploy: billing 0062-0065 and repos 0021 apply before the Workers (all additive; 0062's unique index checked against production: one trial grant, no shared card). Live charging stays off: billing is on Stripe's test key. Not yet: public webhook delivery, disputes end to end, test-clock renewal of an enrollment, the live cutover and a small live rehearsal, storage interrupted-write recovery tooling.
- Checked:
- cargo test for g1t-billing (290), g1t-contracts (312) and g1t-repos (357); cargo check of the workspace and of billing for wasm32; the billing Worker built; SQL conformance against the migrated schema (53, new: complimentary allocation, charge and stop modes, terms changes, late usage, 0065); TypeScript checks and production builds of the site and sudo, the site's tests (950), sudo's (156), contracts' (48), the deploy tool's (92), ops (34); against the real Worker and an isolated Stripe sandbox, every harness phase (protocol, run 7, enterprise 3, complimentary 12) and a 3DS challenge completed in headless Chrome on Stripe's hosted invoice, then settled as a conversion. Two independent reviews; every defect they found is fixed.
- G1t-Session:
- ses_01m4y8v7hae5csmqcavv6wprjy
| 9 | 9 | deployed apps, and, on the plan, storage and git traffic past the free | |
| 10 | 10 | amounts. Each is priced from what it costs g1t, plus 20%: runner minutes | |
| 11 | 11 | at a fixed rate for each machine, everything else at its cost plus 20%. | |
| 12 | − | It is charged to the workspace that owns the repository. There is no seat price, and on the plan there | |
| 13 | − | are no quotas: only your spend limit stops anything. | |
| 12 | + | It is charged to the workspace that owns the repository. There is no seat price. | |
| 13 | + | Your spend limit bounds paid usage; public storage and service size limits still apply. | |
| 14 | 14 | ||
| 15 | 15 | Wherever this page says an owner can do something to a workspace's | |
| 16 | 16 | billing, so can its [billing managers](/guides/workspaces/#roles-that-add-to-a-member): | |
| ⋯ | |||
| 41 | 41 | them are refused at the start of the push, with what to do: make the | |
| 42 | 42 | repository public, delete what you do not need, or start the plan, where | |
| 43 | 43 | storage past 1 GB is usage and pushes never stop. Public repositories are | |
| 44 | − | never charged for storage, on any plan; a free workspace's public | |
| 45 | − | repositories hold up to 5 GB together, and past it pushes to them are | |
| 46 | − | refused the same way. On the plan, pushes to public repositories never | |
| 47 | − | stop. | |
| 44 | + | never charged for storage. Every workspace, including paid workspaces, | |
| 45 | + | has a **5 GB public repository storage cap**. Pushes, imports, forks and | |
| 46 | + | changes that would add public storage need room within that cap. | |
| 47 | + | Recently deleted repositories still count until permanently deleted. | |
| 48 | + | In-flight writes reserve capacity; an interrupted write may retain its | |
| 49 | + | reservation until it is reconciled. | |
| 48 | 50 | ||
| 49 | 51 | Compute is not free. Workflows, the merge queue, | |
| 50 | 52 | deployments and semantic search need [the g1t plan](#the-g1t-plan), or a | |
| ⋯ | |||
| 112 | 114 | - Usage past $10 is charged the same way, **up to your | |
| 113 | 115 | [spend limit](#limits)**. | |
| 114 | 116 | ||
| 115 | − | There are **no quotas** on the plan: no count of projects, previews, | |
| 116 | − | build minutes, requests, custom domains, git operations or gigabytes | |
| 117 | − | stops a workspace. Everything that costs g1t money is metered from the | |
| 117 | + | The plan has no count limit on projects or previews. Public repository | |
| 118 | + | storage has the 5 GB cap above, and repository and request size limits | |
| 119 | + | still apply. Paid usage is metered from the | |
| 118 | 120 | first unit (past the free 1 GB of private storage and 50,000 git | |
| 119 | − | operations that every workspace has), and only your spend limit stops | |
| 120 | − | work. | |
| 121 | + | operations that every workspace has). Your spend limit also stops new | |
| 122 | + | paid work. | |
| 121 | 123 | ||
| 122 | 124 | **Included usage** pays for the month's usage first, at the same prices as | |
| 123 | 125 | everything else: runner minutes, builds, app traffic, custom domains, | |
| ⋯ | |||
| 845 | 847 | have a 100% discount. Their usage is shown at price and kept apart from | |
| 846 | 848 | what customers pay. | |
| 847 | 849 | ||
| 850 | + | ### Complimentary terms | |
| 851 | + | ||
| 852 | + | g1t can enroll a workspace in **complimentary** terms, such as for a | |
| 853 | + | design partner. A new workspace is never enrolled on its own: g1t staff | |
| 854 | + | choose each one. An enrollment gives: | |
| 855 | + | ||
| 856 | + | - **The plan's features**, without the plan's $21 a month. | |
| 857 | + | - **A monthly usage allowance**, such as $100. Usage counts against it at | |
| 858 | + | its ordinary price, the same prices everyone pays: it is an amount of | |
| 859 | + | usage g1t pays for, not a discount. It starts again on the 1st of each | |
| 860 | + | month (UTC), and what is unused does not carry over. | |
| 861 | + | - **A monthly fee of its own**, often none. When there is one, Stripe | |
| 862 | + | invoices it each month, apart from the allowance. | |
| 863 | + | ||
| 864 | + | **Billing** shows the allowance, what is used, what is metered but not | |
| 865 | + | billed yet, what is left, and when it starts again. Usage that is measured | |
| 866 | + | at the end of the month, such as storage and git operations, and work | |
| 867 | + | running now count against what is left. | |
| 868 | + | ||
| 869 | + | What happens at the allowance is part of the terms, and **Billing** says | |
| 870 | + | which: | |
| 871 | + | ||
| 872 | + | - **New work that costs money stops** until the month turns: new runner | |
| 873 | + | jobs, builds, deployments and other compute, and storage and caches past | |
| 874 | + | the free amounts. Nothing is ever charged for usage past it, whether or | |
| 875 | + | not a card is saved. Work already running when the allowance runs out | |
| 876 | + | finishes, and g1t covers what it cost past the allowance. | |
| 877 | + | - **Or usage past it is charged to your card**, such as when g1t covers a | |
| 878 | + | set amount of an employee's or a friend's usage. Past the allowance, | |
| 879 | + | usage is charged at the same prices as on the plan, up to your | |
| 880 | + | [spend limit](#limits) and the [ceiling](#the-ceiling), and invoiced | |
| 881 | + | when the month closes. Add a card on **Billing** first: without one, | |
| 882 | + | work stops at the allowance until you do. | |
| 883 | + | ||
| 884 | + | If g1t changes the amount, what the month already used still counts: | |
| 885 | + | raising it adds only the difference, and lowering it below what was used | |
| 886 | + | leaves nothing more this month, never a bill. | |
| 887 | + | ||
| 888 | + | An owner or billing manager can **start the g1t plan instead** from the | |
| 889 | + | allowance card on **Billing**. The plan uses the same Stripe customer and | |
| 890 | + | subscription, and nothing changes until its first payment goes through: | |
| 891 | + | if the card is declined, the bank asks to confirm the payment, or the page | |
| 892 | + | is closed, the complimentary terms stay as they were. Usage the allowance | |
| 893 | + | paid for before the plan started is never charged afterwards. | |
| 894 | + | ||
| 848 | 895 | Each change is made by g1t staff and recorded with who made it and why. To | |
| 849 | 896 | ask for one, write to [support@g1t.sh](mailto:support@g1t.sh). | |
| 850 | 897 | ||
| 245 | 245 | Custom | |
| 246 | 246 | </Segment> | |
| 247 | 247 | </div> | |
| 248 | − | <p className="mt-1.5 text-xs text-faint">Off every usage charge. 100%: nothing is charged, and the statement shows usage at price with the discount beside it.</p> | |
| 248 | + | <p className="mt-1.5 text-xs text-faint">Off usage charges. This does not change an existing Stripe subscription's monthly fee. A full discount requires ending that subscription first.</p> | |
| 249 | 249 | </fieldset> | |
| 250 | 250 | <div className="when-custom"> | |
| 251 | 251 | <Field label="Custom discount %" hint="A whole percent, 1 to 100."> | |
| ⋯ | |||
| 253 | 253 | </Field> | |
| 254 | 254 | </div> | |
| 255 | 255 | <div className="grid gap-4 sm:grid-cols-2"> | |
| 256 | − | <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget for it, at cost; blank: the default ($150)."> | |
| 256 | + | <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget at cost; blank: $150; zero: unlimited. This is not a monthly usage credit."> | |
| 257 | 257 | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 258 | 258 | </Field> | |
| 259 | 259 | <Field label="Until" hint="Blank: no end. UTC."> | |
| 1 | + | /** | |
| 2 | + | * A workspace's complimentary enrollment: the monthly usage allowance (at | |
| 3 | + | * ordinary prices), the recurring monthly fee apart from it, this month's | |
| 4 | + | * standing, the Stripe subscription that stands for it, and its terms over | |
| 5 | + | * time. Staff enroll each workspace by hand; nothing enrolls one on its own. | |
| 6 | + | */ | |
| 7 | + | import type { ComplimentaryStanding } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import { Badge, Button, Field, Input, Notice, Section, Textarea, When } from "~/components/ui"; | |
| 10 | + | import { dollarsField, usd } from "~/lib/money"; | |
| 11 | + | import type { SectionError } from "~/lib/review"; | |
| 12 | + | ||
| 13 | + | const STATUS: Record<ComplimentaryStanding["status"], { label: string; tone: "mint" | "warn" | "plain" }> = { | |
| 14 | + | active: { label: "Active", tone: "mint" }, | |
| 15 | + | pending: { label: "Waiting for Stripe", tone: "warn" }, | |
| 16 | + | ended: { label: "Ended", tone: "plain" }, | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | function allowanceText(micros: number, unlimited?: boolean) { | |
| 20 | + | return unlimited ? "Unlimited" : `${usd(micros, { cents: true })} a month`; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | function feeText(cents?: number) { | |
| 24 | + | return cents ? `${usd(cents * 10_000, { cents: true })} a month` : "Waived"; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export function ComplimentarySection({ | |
| 28 | + | workspace, | |
| 29 | + | standing, | |
| 30 | + | unavailable, | |
| 31 | + | pathname, | |
| 32 | + | error, | |
| 33 | + | }: { | |
| 34 | + | workspace: string; | |
| 35 | + | standing: ComplimentaryStanding | null; | |
| 36 | + | unavailable: string | null; | |
| 37 | + | pathname: string; | |
| 38 | + | error: SectionError; | |
| 39 | + | }) { | |
| 40 | + | const values = error?.values; | |
| 41 | + | const open = standing && standing.status !== "ended" ? standing : null; | |
| 42 | + | const month = open?.month ?? null; | |
| 43 | + | return ( | |
| 44 | + | <Section | |
| 45 | + | id="complimentary" | |
| 46 | + | title="Complimentary" | |
| 47 | + | description="The plan's features, a monthly usage allowance at ordinary prices, and a monthly fee of its own. Past the allowance new work that costs money stops; nothing is ever charged for it. No rollover." | |
| 48 | + | > | |
| 49 | + | {unavailable && <Notice tone="error">Billing could not say: {unavailable}</Notice>} | |
| 50 | + | {open ? ( | |
| 51 | + | <div className="mb-4 space-y-3"> | |
| 52 | + | <div className="flex flex-wrap items-center gap-2 text-sm"> | |
| 53 | + | <Badge tone={STATUS[open.status].tone}>{STATUS[open.status].label}</Badge> | |
| 54 | + | <span className="text-fg-soft">{allowanceText(open.allowanceMicros, open.unlimited)} allowance</span> | |
| 55 | + | <span className="text-faint">·</span> | |
| 56 | + | <span className="text-fg-soft">Fee {feeText(open.feeCents)}</span> | |
| 57 | + | <span className="text-faint">·</span> | |
| 58 | + | <span className="text-fg-soft">{open.chargePastAllowance ? "Past it: their card is charged" : "Past it: work stops"}</span> | |
| 59 | + | </div> | |
| 60 | + | {month && ( | |
| 61 | + | <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm sm:grid-cols-3"> | |
| 62 | + | <Stat label="Used this month" value={usd(month.usedMicros, { cents: true })} /> | |
| 63 | + | <Stat label="Metered, not billed yet" value={usd(month.pendingMicros ?? 0, { cents: true })} /> | |
| 64 | + | <Stat label="Held by running work" value={usd(month.heldMicros ?? 0, { cents: true })} /> | |
| 65 | + | <Stat label="Remaining" value={month.unlimited ? "Unlimited" : usd(month.remainingMicros, { cents: true })} /> | |
| 66 | + | <Stat label="Covered past it by g1t" value={usd(month.overrunMicros ?? 0, { cents: true })} /> | |
| 67 | + | <Stat label="Resets" value={<When at={month.resetsAt} />} /> | |
| 68 | + | </dl> | |
| 69 | + | )} | |
| 70 | + | <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"> | |
| 71 | + | <Stat label="Money received, ever" value={usd(open.moneyReceivedMicros ?? 0, { cents: true })} /> | |
| 72 | + | <Stat label="Promotional credit, ever" value={usd(open.promotionalCreditMicros ?? 0, { cents: true })} /> | |
| 73 | + | <Stat label="Gifted usage, ever" value={usd(open.giftedMicros ?? 0, { cents: true })} /> | |
| 74 | + | </dl> | |
| 75 | + | <p className="text-xs text-muted"> | |
| 76 | + | Stripe {open.live ? "live" : "test"}: {open.customerId ?? "no customer yet"}, {open.subscriptionId ?? "no subscription yet"} | |
| 77 | + | {open.stripeStatus ? ` (${open.stripeStatus})` : ""}. Grant {open.grantId}. | |
| 78 | + | </p> | |
| 79 | + | {open.pendingOp && ( | |
| 80 | + | <Notice tone="info"> | |
| 81 | + | Still owed at Stripe: {open.pendingOp}. Tried again every fifteen minutes{open.lastError ? `; last error: ${open.lastError}` : "."} | |
| 82 | + | </Notice> | |
| 83 | + | )} | |
| 84 | + | </div> | |
| 85 | + | ) : ( | |
| 86 | + | <p className="mb-4 text-sm text-muted"> | |
| 87 | + | {standing?.status === "ended" | |
| 88 | + | ? `Not enrolled. The last enrollment ended ${standing.endedReason === "converted" ? "when the owner started the plan" : "by staff"}.` | |
| 89 | + | : "Not enrolled: the workspace is on its own plan."} | |
| 90 | + | </p> | |
| 91 | + | )} | |
| 92 | + | ||
| 93 | + | <form method="post" action={`${pathname}#complimentary`} className="space-y-4"> | |
| 94 | + | <input type="hidden" name="intent" value="complimentary" /> | |
| 95 | + | {error && <Notice tone="error">{error.error}</Notice>} | |
| 96 | + | <div className="grid gap-4 sm:grid-cols-2"> | |
| 97 | + | <Field label="Monthly allowance $" hint="Usage at ordinary prices, each calendar month (UTC). Changing it keeps what this month used."> | |
| 98 | + | <Input | |
| 99 | + | name="allowance" | |
| 100 | + | inputMode="decimal" | |
| 101 | + | placeholder="100.00" | |
| 102 | + | defaultValue={values?.allowance ?? (open ? dollarsField(open.allowanceMicros) : "100.00")} | |
| 103 | + | /> | |
| 104 | + | </Field> | |
| 105 | + | <Field label="Monthly fee $" hint="Recurring, invoiced by Stripe, apart from the allowance. Blank or 0: waived."> | |
| 106 | + | <Input name="fee" inputMode="decimal" placeholder="Waived" defaultValue={values?.fee ?? (open?.feeCents ? dollarsField(open.feeCents * 10_000) : "")} /> | |
| 107 | + | </Field> | |
| 108 | + | </div> | |
| 109 | + | <fieldset className="space-y-2 text-sm"> | |
| 110 | + | <legend className="mb-1 text-xs text-faint">Past the allowance</legend> | |
| 111 | + | <label className="flex cursor-pointer items-start gap-2.5"> | |
| 112 | + | <input | |
| 113 | + | type="radio" | |
| 114 | + | name="past" | |
| 115 | + | value="stop" | |
| 116 | + | defaultChecked={values ? values.past !== "charge" : !open?.chargePastAllowance} | |
| 117 | + | className="mt-0.5" | |
| 118 | + | /> | |
| 119 | + | <span> | |
| 120 | + | <span className="block font-medium">New paid work stops</span> | |
| 121 | + | <span className="block text-xs text-muted">Nothing is ever charged. For partners g1t carries entirely.</span> | |
| 122 | + | </span> | |
| 123 | + | </label> | |
| 124 | + | <label className="flex cursor-pointer items-start gap-2.5"> | |
| 125 | + | <input | |
| 126 | + | type="radio" | |
| 127 | + | name="past" | |
| 128 | + | value="charge" | |
| 129 | + | defaultChecked={values ? values.past === "charge" : Boolean(open?.chargePastAllowance)} | |
| 130 | + | className="mt-0.5" | |
| 131 | + | /> | |
| 132 | + | <span> | |
| 133 | + | <span className="block font-medium">Their card is charged</span> | |
| 134 | + | <span className="block text-xs text-muted"> | |
| 135 | + | g1t covers the allowance; past it, usage is charged to the workspace's own card at the usual prices, up to its spend | |
| 136 | + | limit. For employees and friends. Without a card, work stops until they add one. | |
| 137 | + | </span> | |
| 138 | + | </span> | |
| 139 | + | </label> | |
| 140 | + | </fieldset> | |
| 141 | + | <label className="flex cursor-pointer items-start gap-2.5 text-sm"> | |
| 142 | + | <input type="checkbox" name="unlimited" defaultChecked={values ? values.unlimited === "on" : Boolean(open?.unlimited)} className="mt-0.5" /> | |
| 143 | + | <span> | |
| 144 | + | <span className="block font-medium">Unlimited allowance</span> | |
| 145 | + | <span className="block text-xs text-muted">For g1t's own workspaces. Infrastructure limits still apply.</span> | |
| 146 | + | </span> | |
| 147 | + | </label> | |
| 148 | + | <Field label="Note" hint="Required. Why, for whoever looks next."> | |
| 149 | + | <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner, $500 a month through Q1" /> | |
| 150 | + | </Field> | |
| 151 | + | <Field label={`Type ${workspace} to confirm`}> | |
| 152 | + | <Input name="confirmation" autoComplete="off" defaultValue="" /> | |
| 153 | + | </Field> | |
| 154 | + | <div className="flex justify-end"> | |
| 155 | + | <Button type="submit">{open ? "Change terms" : "Enroll"}</Button> | |
| 156 | + | </div> | |
| 157 | + | </form> | |
| 158 | + | ||
| 159 | + | {open && ( | |
| 160 | + | <form method="post" action={`${pathname}#complimentary`} className="mt-6 space-y-3 border-t border-line pt-4"> | |
| 161 | + | <input type="hidden" name="intent" value="end-complimentary" /> | |
| 162 | + | <p className="text-sm text-muted"> | |
| 163 | + | Ending it puts the workspace on Free now and cancels its Stripe subscription. Usage the allowance paid for stays paid for. | |
| 164 | + | </p> | |
| 165 | + | <div className="grid gap-3 sm:grid-cols-2"> | |
| 166 | + | <Field label="Note"> | |
| 167 | + | <Input name="note" required maxLength={500} placeholder="Why" /> | |
| 168 | + | </Field> | |
| 169 | + | <Field label={`Type ${workspace} to end`}> | |
| 170 | + | <Input name="confirmation" autoComplete="off" /> | |
| 171 | + | </Field> | |
| 172 | + | </div> | |
| 173 | + | <div className="flex justify-end"> | |
| 174 | + | <Button type="submit" variant="danger"> | |
| 175 | + | End enrollment | |
| 176 | + | </Button> | |
| 177 | + | </div> | |
| 178 | + | </form> | |
| 179 | + | )} | |
| 180 | + | ||
| 181 | + | {standing?.periods && standing.periods.length > 0 && ( | |
| 182 | + | <div className="mt-6 border-t border-line pt-4"> | |
| 183 | + | <p className="text-xs text-faint">Terms over time</p> | |
| 184 | + | <ul className="mt-2 space-y-1 text-xs"> | |
| 185 | + | {standing.periods.map((period) => ( | |
| 186 | + | <li key={period.id} className="flex flex-wrap gap-x-2 text-fg-soft"> | |
| 187 | + | <When at={period.startsAt} /> | |
| 188 | + | <span>→</span> | |
| 189 | + | {period.endsAt ? <When at={period.endsAt} /> : <span>now</span>} | |
| 190 | + | <span> | |
| 191 | + | {allowanceText(period.allowanceMicros, period.unlimited)}, fee {feeText(period.feeCents)},{" "} | |
| 192 | + | {period.chargePastAllowance ? "then their card" : "then stops"} | |
| 193 | + | </span> | |
| 194 | + | <span className="text-muted"> | |
| 195 | + | {period.createdBy}: {period.note} | |
| 196 | + | </span> | |
| 197 | + | </li> | |
| 198 | + | ))} | |
| 199 | + | </ul> | |
| 200 | + | </div> | |
| 201 | + | )} | |
| 202 | + | </Section> | |
| 203 | + | ); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | function Stat({ label, value }: { label: string; value: React.ReactNode }) { | |
| 207 | + | return ( | |
| 208 | + | <div> | |
| 209 | + | <dt className="text-xs text-faint">{label}</dt> | |
| 210 | + | <dd className="tabular-nums text-fg-soft">{value}</dd> | |
| 211 | + | </div> | |
| 212 | + | ); | |
| 213 | + | } |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 11 | 11 | import { parseCreditForm } from "./credits"; | |
| 12 | − | import { fields, parseAllowances, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms"; | |
| 12 | + | import { fields, parseAllowances, parseComplimentary, parseEmail, parseGoodwill, parseNote, parsePayment, parseSlug, parseTerms, text } from "./forms"; | |
| 13 | 13 | import { FORGIVE_COST_MICROS, goodwillWarning } from "./pricing"; | |
| 14 | 14 | import type { ActionData } from "./review"; | |
| 15 | 15 | import { admin, identity, priceBook } from "./services.server"; | |
| ⋯ | |||
| 61 | 61 | return back("allowances"); | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | + | if (intent === "complimentary" || intent === "end-complimentary") { | |
| 65 | + | // Enrollment is per workspace, never an enterprise's. | |
| 66 | + | const values = fields(form, "allowance", "unlimited", "fee", "past", "note", "confirmation"); | |
| 67 | + | if (subject.kind !== "workspace") return failed("complimentary", "Enroll each workspace on its own page.", values); | |
| 68 | + | if (subject.billedTo) return failed("complimentary", `${subject.billedTo.name} pays for this workspace. Detach it first.`, values); | |
| 69 | + | if (intent === "end-complimentary") { | |
| 70 | + | const note = parseNote(values.note); | |
| 71 | + | if (!note.ok) return failed("complimentary", note.error, values); | |
| 72 | + | if (values.confirmation !== subject.slug) { | |
| 73 | + | return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to end its enrollment. It goes back to Free now.`, values); | |
| 74 | + | } | |
| 75 | + | const ended = await admin.endComplimentary(subject.slug, note.value, staff.email); | |
| 76 | + | if (!ended.ok) return failed("complimentary", ended.error.message, values); | |
| 77 | + | return back("complimentary-ended"); | |
| 78 | + | } | |
| 79 | + | const terms = parseComplimentary(form); | |
| 80 | + | if (!terms.ok) return failed("complimentary", terms.error, values); | |
| 81 | + | if (values.confirmation !== subject.slug) { | |
| 82 | + | return failed("complimentary", `Type the workspace's slug, ${subject.slug}, to confirm these terms.`, values); | |
| 83 | + | } | |
| 84 | + | const { note, ...chosen } = terms.value; | |
| 85 | + | const saved = await admin.setComplimentary(subject.slug, chosen, note, staff.email); | |
| 86 | + | if (!saved.ok) return failed("complimentary", saved.error.message, values); | |
| 87 | + | return back("complimentary"); | |
| 88 | + | } | |
| 89 | + | ||
| 64 | 90 | if (intent === "attach") { | |
| 65 | 91 | const values = fields(form, "workspace", "target"); | |
| 66 | 92 | const section = isEnterprise ? "members" : "billed-to"; | |
| 235 | 235 | }; | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | + | /** The most a complimentary allowance or fee can be: billing's caps. */ | |
| 239 | + | export const MAX_COMPLIMENTARY_MICROS = 100_000 * MICROS_PER_DOLLAR; | |
| 240 | + | ||
| 241 | + | export type ComplimentaryInput = { allowanceMicros: number; unlimited: boolean; feeCents: number; chargePastAllowance: boolean; note: string }; | |
| 242 | + | ||
| 243 | + | /** | |
| 244 | + | * A complimentary enrollment's terms, from its form: the monthly usage | |
| 245 | + | * allowance (at ordinary prices) or unlimited, and the monthly fee (blank or | |
| 246 | + | * 0: waived). The two are separate amounts. A note is required. | |
| 247 | + | */ | |
| 248 | + | export function parseComplimentary(form: FormData): Parsed<ComplimentaryInput> { | |
| 249 | + | const unlimited = text(form, "unlimited") === "on"; | |
| 250 | + | const rawAllowance = text(form, "allowance"); | |
| 251 | + | const allowanceMicros = unlimited && !rawAllowance ? 0 : parseDollars(rawAllowance); | |
| 252 | + | if (allowanceMicros == null || allowanceMicros < 0) { | |
| 253 | + | return { ok: false, error: "The monthly allowance is dollars and cents, such as 100 or 500, valued at ordinary usage prices." }; | |
| 254 | + | } | |
| 255 | + | if (allowanceMicros > MAX_COMPLIMENTARY_MICROS) return { ok: false, error: "The allowance is at most $100,000 a month; for more, make it unlimited." }; | |
| 256 | + | const rawFee = text(form, "fee"); | |
| 257 | + | const feeMicros = rawFee ? parseDollars(rawFee) : 0; | |
| 258 | + | if (feeMicros == null || feeMicros < 0) return { ok: false, error: "The monthly fee is dollars and cents, or blank to waive it." }; | |
| 259 | + | if (feeMicros > MAX_COMPLIMENTARY_MICROS) return { ok: false, error: "The monthly fee is at most $100,000." }; | |
| 260 | + | if (feeMicros % 10_000 !== 0) return { ok: false, error: "The monthly fee is whole cents." }; | |
| 261 | + | if (!unlimited && allowanceMicros === 0 && feeMicros === 0) { | |
| 262 | + | return { ok: false, error: "No allowance and no fee gives nothing: set an allowance, or end the enrollment." }; | |
| 263 | + | } | |
| 264 | + | const note = parseNote(text(form, "note")); | |
| 265 | + | if (!note.ok) return note; | |
| 266 | + | const chargePastAllowance = text(form, "past") === "charge"; | |
| 267 | + | if (unlimited && chargePastAllowance) return { ok: false, error: "An unlimited allowance has nothing past it to charge." }; | |
| 268 | + | return { ok: true, value: { allowanceMicros, unlimited, feeCents: feeMicros / 10_000, chargePastAllowance, note: note.value } }; | |
| 269 | + | } | |
| 270 | + | ||
| 238 | 271 | /** The most one recorded payment can be; billing refuses more. */ | |
| 239 | 272 | export const MAX_PAYMENT_MICROS = 100_000 * MICROS_PER_DOLLAR; | |
| 240 | 273 | const MAX_REFERENCE = 100; |
| 25 | 25 | export const DONE: Record<string, string> = { | |
| 26 | 26 | terms: "Terms saved. They apply to charges from now on.", | |
| 27 | 27 | allowances: "Plan, pools and caps saved. They apply from now on.", | |
| 28 | + | complimentary: "Complimentary terms saved. A new enrollment shows to the workspace once Stripe has its subscription, usually within seconds.", | |
| 29 | + | "complimentary-ended": "Enrollment ended. The workspace is on Free now; its Stripe subscription is canceled.", | |
| 28 | 30 | attach: "Workspace moved onto the enterprise.", | |
| 29 | 31 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 30 | 32 | credit: "Credit given. It is on the balance and the statement, and the owners were emailed.", |
| 41 | 41 | trustAbout, | |
| 42 | 42 | } from "~/components/ui"; | |
| 43 | 43 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; | |
| 44 | + | import { ComplimentarySection } from "~/components/complimentary"; | |
| 44 | 45 | import { parseSlug, text } from "~/lib/forms"; | |
| 45 | 46 | import { usd } from "~/lib/money"; | |
| 46 | 47 | import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review"; | |
| ⋯ | |||
| 93 | 94 | const figures = billedTo | |
| 94 | 95 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 95 | 96 | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| 96 | − | const [enterprises, sales, invoices, plan, overages, book, credits] = await Promise.all([ | |
| 97 | + | const [enterprises, sales, invoices, plan, overages, book, credits, complimentary] = await Promise.all([ | |
| 97 | 98 | billedTo | |
| 98 | 99 | ? Promise.resolve([]) | |
| 99 | 100 | : admin | |
| ⋯ | |||
| 109 | 110 | settle(admin.overages()), | |
| 110 | 111 | settle(priceBook()), | |
| 111 | 112 | settle(admin.credits({ workspace: slug })), | |
| 113 | + | settle(admin.complimentary(slug)), | |
| 112 | 114 | ]); | |
| 113 | 115 | const done = doneKey(request.url); | |
| 114 | 116 | return { | |
| ⋯ | |||
| 135 | 137 | ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) } | |
| 136 | 138 | : null, | |
| 137 | 139 | creditsError: credits.ok ? null : credits.error, | |
| 140 | + | complimentary: complimentary.ok ? complimentary.value : null, | |
| 141 | + | complimentaryError: complimentary.ok ? null : complimentary.error, | |
| 138 | 142 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, | |
| 139 | 143 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| 140 | 144 | done: done && !SALES_DONE.has(done) ? DONE[done] : null, | |
| ⋯ | |||
| 187 | 191 | forgiveCap, | |
| 188 | 192 | credits, | |
| 189 | 193 | creditsError, | |
| 194 | + | complimentary, | |
| 195 | + | complimentaryError, | |
| 190 | 196 | ledger, | |
| 191 | 197 | audit, | |
| 192 | 198 | done, | |
| ⋯ | |||
| 355 | 361 | </Section> | |
| 356 | 362 | ) : ( | |
| 357 | 363 | <> | |
| 364 | + | <ComplimentarySection | |
| 365 | + | workspace={slug} | |
| 366 | + | standing={complimentary} | |
| 367 | + | unavailable={complimentaryError} | |
| 368 | + | pathname={pathname} | |
| 369 | + | error={error("complimentary")} | |
| 370 | + | /> | |
| 358 | 371 | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 359 | 372 | <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} /> | |
| 360 | 373 | </> | |
| ⋯ | |||
| 523 | 536 | ); | |
| 524 | 537 | } | |
| 525 | 538 | ||
| 526 | − | const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (100% discount)", enterprise: "Enterprise" }; | |
| 539 | + | const PLAN_LABEL: Record<string, string> = { | |
| 540 | + | free: "Free", | |
| 541 | + | paid: "The g1t plan", | |
| 542 | + | internal: "Internal (100% discount)", | |
| 543 | + | enterprise: "Enterprise", | |
| 544 | + | complimentary: "Complimentary", | |
| 545 | + | }; | |
| 527 | 546 | ||
| 528 | 547 | function gigabytes(bytes: number): string { | |
| 529 | 548 | return `${Math.round((bytes / 1e9) * 100) / 100} GB`; | |
| 10 | 10 | import type { ReactNode } from "react"; | |
| 11 | 11 | import { Form, Link } from "react-router"; | |
| 12 | 12 | ||
| 13 | − | import type { BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 13 | + | import type { BillingDetails, ComplimentaryMonth, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 14 | 14 | ||
| 15 | 15 | import type { PlanStatus } from "../lib/billing"; | |
| 16 | 16 | import { money, wholeDollars } from "../lib/money"; | |
| ⋯ | |||
| 22 | 22 | const FIELD = | |
| 23 | 23 | "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 24 | 24 | ||
| 25 | + | // --- Complimentary ------------------------------------------------------------------------------ | |
| 26 | + | ||
| 27 | + | /** | |
| 28 | + | * A complimentary enrollment's month: the allowance g1t gives, what is used, | |
| 29 | + | * metered and held against it, what is left and when it starts again. Past | |
| 30 | + | * it, new work that costs money stops: nothing is charged, card or not. An | |
| 31 | + | * owner can start the plan on the same billing from here. | |
| 32 | + | */ | |
| 33 | + | export function ComplimentaryCard({ | |
| 34 | + | month, | |
| 35 | + | owner, | |
| 36 | + | enabled, | |
| 37 | + | error, | |
| 38 | + | }: { | |
| 39 | + | month: ComplimentaryMonth; | |
| 40 | + | owner: boolean; | |
| 41 | + | enabled: boolean; | |
| 42 | + | error?: string; | |
| 43 | + | }) { | |
| 44 | + | const taken = month.usedMicros + (month.pendingMicros ?? 0) + (month.heldMicros ?? 0); | |
| 45 | + | const resets = new Date(month.resetsAt).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" }); | |
| 46 | + | const charged = Boolean(month.chargePastAllowance); | |
| 47 | + | const used = month.unlimited ? 0 : Math.min(100, (taken / Math.max(1, month.allowanceMicros)) * 100); | |
| 48 | + | return ( | |
| 49 | + | <Card | |
| 50 | + | id="complimentary" | |
| 51 | + | tone={!month.unlimited && month.remainingMicros <= 0 ? "warn" : "accent"} | |
| 52 | + | icon={<Sparkles size={16} />} | |
| 53 | + | title="Complimentary allowance" | |
| 54 | + | about={ | |
| 55 | + | charged | |
| 56 | + | ? `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, usage is charged to this workspace's card at the same prices, up to your spend limit. Unused allowance does not carry over.` | |
| 57 | + | : `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, new work that costs money stops until ${resets}; nothing is ever charged for it, and unused allowance does not carry over.` | |
| 58 | + | } | |
| 59 | + | aside={ | |
| 60 | + | <p className="shrink-0 sm:text-right"> | |
| 61 | + | <span className="text-2xl font-semibold tabular-nums tracking-tight">{month.unlimited ? "Unlimited" : money(month.allowanceMicros)}</span> | |
| 62 | + | {!month.unlimited && <span className="text-sm text-muted"> / month</span>} | |
| 63 | + | <span className="block text-xs text-faint">{month.feeCents ? `Fee ${money(month.feeCents * 10_000)} a month, invoiced` : "No monthly fee"}</span> | |
| 64 | + | </p> | |
| 65 | + | } | |
| 66 | + | > | |
| 67 | + | {!month.unlimited && ( | |
| 68 | + | <div className="mt-4"> | |
| 69 | + | <div className="h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> | |
| 70 | + | <div className={`h-full rounded-full ${month.remainingMicros <= 0 ? "bg-warn" : "bg-accent"}`} style={{ width: `${used}%` }} /> | |
| 71 | + | </div> | |
| 72 | + | </div> | |
| 73 | + | )} | |
| 74 | + | <dl className="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4"> | |
| 75 | + | <div> | |
| 76 | + | <dt className="text-xs text-faint">Used</dt> | |
| 77 | + | <dd className="tabular-nums">{money(month.usedMicros)}</dd> | |
| 78 | + | </div> | |
| 79 | + | <div> | |
| 80 | + | <dt className="text-xs text-faint">Metered, not billed yet</dt> | |
| 81 | + | <dd className="tabular-nums">{money((month.pendingMicros ?? 0) + (month.heldMicros ?? 0))}</dd> | |
| 82 | + | </div> | |
| 83 | + | <div> | |
| 84 | + | <dt className="text-xs text-faint">Remaining</dt> | |
| 85 | + | <dd className="tabular-nums">{month.unlimited ? "Unlimited" : money(month.remainingMicros)}</dd> | |
| 86 | + | </div> | |
| 87 | + | <div> | |
| 88 | + | <dt className="text-xs text-faint">Starts again</dt> | |
| 89 | + | <dd>{resets}</dd> | |
| 90 | + | </div> | |
| 91 | + | </dl> | |
| 92 | + | {charged && !month.hasCard && ( | |
| 93 | + | <p className="mt-4 text-sm text-muted"> | |
| 94 | + | No card yet: past the allowance, work stops until one is added.{" "} | |
| 95 | + | {owner && enabled && ( | |
| 96 | + | <Form method="post" className="inline"> | |
| 97 | + | <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…"> | |
| 98 | + | <CreditCard size={14} /> | |
| 99 | + | Add a card | |
| 100 | + | </SubmitButton> | |
| 101 | + | </Form> | |
| 102 | + | )} | |
| 103 | + | </p> | |
| 104 | + | )} | |
| 105 | + | {owner && enabled && !month.unlimited && ( | |
| 106 | + | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> | |
| 107 | + | <SubmitButton variant="outline" name="intent" value="convert" pending="Starting the plan…"> | |
| 108 | + | <CreditCard size={14} /> | |
| 109 | + | Start the g1t plan instead | |
| 110 | + | </SubmitButton> | |
| 111 | + | <span className="text-xs text-faint"> | |
| 112 | + | $21 a month with usage past what is included charged, on this workspace's billing. Nothing changes until the first payment | |
| 113 | + | goes through. | |
| 114 | + | </span> | |
| 115 | + | </Form> | |
| 116 | + | )} | |
| 117 | + | {error && ( | |
| 118 | + | <div className="mt-2"> | |
| 119 | + | <ErrorText>{error}</ErrorText> | |
| 120 | + | </div> | |
| 121 | + | )} | |
| 122 | + | </Card> | |
| 123 | + | ); | |
| 124 | + | } | |
| 125 | + | ||
| 25 | 126 | // --- The plan --------------------------------------------------------------------------------- | |
| 26 | 127 | ||
| 27 | 128 | /** | |
| ⋯ | |||
| 57 | 158 | }) { | |
| 58 | 159 | const plan = state?.plan; | |
| 59 | 160 | const subscription = state?.subscription ?? null; | |
| 60 | − | const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind); | |
| 161 | + | const on = ["paid", "canceling", "comped", "enterprise", "past_due", "complimentary"].includes(status.kind); | |
| 61 | 162 | const now = new Date(); | |
| 62 | 163 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); | |
| 63 | 164 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); | |
| ⋯ | |||
| 86 | 187 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span> | |
| 87 | 188 | <span className="text-sm text-muted"> / month</span> | |
| 88 | 189 | <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span> | |
| 89 | − | {status.kind !== "comped" && status.kind !== "enterprise" && <span className="block text-xs text-faint">Plus tax where it applies</span>} | |
| 190 | + | {status.kind !== "comped" && status.kind !== "enterprise" && status.kind !== "complimentary" && ( | |
| 191 | + | <span className="block text-xs text-faint">Plus tax where it applies</span> | |
| 192 | + | )} | |
| 90 | 193 | </p> | |
| 91 | 194 | } | |
| 92 | 195 | > | |
| 125 | 125 | ||
| 126 | 126 | /** Where the workspace stands with the g1t plan, for the plan card. */ | |
| 127 | 127 | export type PlanStatus = { | |
| 128 | − | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise"; | |
| 128 | + | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise" | "complimentary"; | |
| 129 | 129 | label: string; | |
| 130 | 130 | }; | |
| 131 | 131 | ||
| ⋯ | |||
| 135 | 135 | ): PlanStatus { | |
| 136 | 136 | if (entitlements?.plan === "internal") return { kind: "comped", label: "100% discount from g1t" }; | |
| 137 | 137 | if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" }; | |
| 138 | + | if (entitlements?.plan === "complimentary") return { kind: "complimentary", label: "Complimentary from g1t" }; | |
| 138 | 139 | if (plan?.included) return { kind: "comped", label: "Included by g1t" }; | |
| 139 | 140 | const subscription = plan?.subscription; | |
| 140 | 141 | if (subscription?.status === "past_due") return { kind: "past_due", label: "Payment failed" }; | |
| 238 | 238 | what: "Private storage", | |
| 239 | 239 | free: `${gigabytes(tier.freePrivateStorageBytes)}, never charged. Past it, pushes to private repositories stop until you make room or start the plan.`, | |
| 240 | 240 | plan: `${gigabytes(tier.freePrivateStorageBytes)} free, then at cost plus 20%. Pushes never stop.`, | |
| 241 | − | note: "Public repositories are never charged for storage.", | |
| 241 | + | note: "Public repository storage is free, with a 5 GB limit per workspace on every plan.", | |
| 242 | 242 | }, | |
| 243 | 243 | { | |
| 244 | 244 | what: "Git operations", |
| 16 | 16 | SpikeBanner, | |
| 17 | 17 | TrialCard, | |
| 18 | 18 | } from "../../components/billing"; | |
| 19 | − | import { AddOns, BillingOnStripeCard, BudgetAlerts, PlanSummary, StripeSkeleton } from "../../components/billing-settings"; | |
| 19 | + | import { AddOns, BillingOnStripeCard, BudgetAlerts, ComplimentaryCard, PlanSummary, StripeSkeleton } from "../../components/billing-settings"; | |
| 20 | 20 | import { StatementView } from "../../components/statement"; | |
| 21 | 21 | import { | |
| 22 | 22 | cardCheckResult, | |
| ⋯ | |||
| 44 | 44 | prepaid_pending: | |
| 45 | 45 | "Payment pending. Your prepaid balance goes up once the payment clears, and this page shows it the next time it loads. A bank transfer can take a few business days.", | |
| 46 | 46 | budget: "Budget alerts saved.", | |
| 47 | + | converted: "The g1t plan is on, on the same billing. Usage from now on draws on its included usage first.", | |
| 47 | 48 | limit: "Spend limit saved.", | |
| 48 | 49 | requested: "Sent. g1t answers within one business day, here and by email.", | |
| 49 | 50 | told: "Thanks. g1t looks at it and answers within one business day, here and by email.", | |
| ⋯ | |||
| 183 | 184 | if (!started.ok) return fail(feature, started.error.message); | |
| 184 | 185 | throw redirect(started.value.url); | |
| 185 | 186 | } | |
| 187 | + | case "convert": { | |
| 188 | + | // From complimentary to the plan, on the same customer and subscription: | |
| 189 | + | // a card page, or the bank's confirmation, when one is needed first. | |
| 190 | + | const converted = await billing.convertToPaid(user, slug, here); | |
| 191 | + | if (!converted.ok) return fail("complimentary", converted.error.message); | |
| 192 | + | if (converted.value.status === "converted") throw done("converted"); | |
| 193 | + | if (converted.value.url) throw redirect(converted.value.url); | |
| 194 | + | return fail("complimentary", converted.value.message); | |
| 195 | + | } | |
| 186 | 196 | case "card-check": { | |
| 187 | 197 | const started = await billing.cardCheck(user, slug, here); | |
| 188 | 198 | if (!started.ok) return fail("trial", started.error.message); | |
| ⋯ | |||
| 248 | 258 | const error = actionData as SectionError; | |
| 249 | 259 | const err = (section: string) => (error && error.section === section ? error.error : undefined); | |
| 250 | 260 | const standing = planStatus(plan, entitlements); | |
| 251 | − | const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due"; | |
| 261 | + | // A complimentary workspace charged past its allowance has the plan's limits too. | |
| 262 | + | const chargedPast = standing.kind === "complimentary" && Boolean(entitlements?.complimentary?.chargePastAllowance); | |
| 263 | + | const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due" || chargedPast; | |
| 252 | 264 | const free = standing.kind === "free" || standing.kind === "trial"; | |
| 253 | 265 | const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0; | |
| 254 | 266 | // The spend limit and its budget are the owners' to set only where | |
| ⋯ | |||
| 318 | 330 | <Await resolve={details}>{(loaded) => summary(loaded)}</Await> | |
| 319 | 331 | </Suspense> | |
| 320 | 332 | ||
| 333 | + | {entitlements?.complimentary && standing.kind === "complimentary" && ( | |
| 334 | + | <ComplimentaryCard month={entitlements.complimentary} owner={owner} enabled={status.enabled} error={err("complimentary")} /> | |
| 335 | + | )} | |
| 336 | + | ||
| 321 | 337 | {free && status.enabled && ( | |
| 322 | 338 | <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} /> | |
| 323 | 339 | )} | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | // Execute production SQL against the complete migrated schema. These verify | |
| 7 | + | // transaction/retry contracts; Stripe HTTP and Worker execution need staging. | |
| 8 | + | const source = name => readFileSync(new URL(`../services/billing/src/${name}.rs`, import.meta.url), "utf8"); | |
| 9 | + | const constant = (file, name) => { | |
| 10 | + | const match = source(file).match(new RegExp(`const ${name}: &str = "([\\s\\S]*?)";`)); | |
| 11 | + | assert.ok(match, `${file}::${name}`); | |
| 12 | + | return match[1]; | |
| 13 | + | }; | |
| 14 | + | const prepares = (file, from, to) => { | |
| 15 | + | const text = source(file).split(from)[1].split(to)[0]; | |
| 16 | + | return [...text.matchAll(/\.prepare\(\s*"([\s\S]*?)"\s*,?\s*\)/g)].map(m => m[1]); | |
| 17 | + | }; | |
| 18 | + | function database() { | |
| 19 | + | const db = new DatabaseSync(":memory:"); | |
| 20 | + | const dir = new URL("../services/billing/migrations/", import.meta.url); | |
| 21 | + | for (const name of readdirSync(dir).filter(n => n.endsWith(".sql")).sort()) db.exec(readFileSync(new URL(name, dir), "utf8")); | |
| 22 | + | return db; | |
| 23 | + | } | |
| 24 | + | const at = "2026-10-14T12:00:00Z"; | |
| 25 | + | test('late enterprise overdue writes cannot reopen terminal invoices or pause paid workspaces', () => { | |
| 26 | + | const db = database(); | |
| 27 | + | db.prepare("INSERT INTO accounts(workspace,balance_micros,created_at) VALUES ('fixture',0,?)").run(at); | |
| 28 | + | db.prepare("INSERT INTO enterprise_invoices(invoice_id,account_id,period,amount_micros,status,created_by,created_at) VALUES ('in_fixture','ent_fixture','now',10000000,'open','staff',?)").run(at); | |
| 29 | + | const update = db.prepare(constant('webhooks','ENTERPRISE_STATUS')); | |
| 30 | + | const stop = db.prepare(constant('webhooks','ENTERPRISE_OVERDUE_STOP')); | |
| 31 | + | update.get('overdue','in_fixture'); | |
| 32 | + | stop.run('fixture',at,'overdue','in_fixture'); | |
| 33 | + | assert.equal(db.prepare("SELECT autopay_error FROM limits WHERE workspace='fixture'").get().autopay_error,'overdue'); | |
| 34 | + | batch(db, [["UPDATE enterprise_invoices SET status='paid' WHERE invoice_id='in_fixture'", []], [constant('lib','CLEAR_STOP'), ['fixture']]]); | |
| 35 | + | assert.equal(update.get('overdue','in_fixture'), undefined); | |
| 36 | + | stop.run('fixture',at,'late overdue','in_fixture'); | |
| 37 | + | assert.equal(db.prepare("SELECT autopay_error FROM limits WHERE workspace='fixture'").get().autopay_error,null); | |
| 38 | + | db.exec("UPDATE enterprise_invoices SET status='void'"); | |
| 39 | + | assert.equal(update.get('overdue','in_fixture'), undefined); | |
| 40 | + | stop.run('fixture',at,'late overdue','in_fixture'); | |
| 41 | + | assert.equal(db.prepare("SELECT autopay_error FROM limits WHERE workspace='fixture'").get().autopay_error,null); | |
| 42 | + | }); | |
| 43 | + | test("enterprise invoice acquisition freezes one payload and rejects stale balances after completion", () => { | |
| 44 | + | const db = database(); | |
| 45 | + | db.prepare("INSERT INTO accounts(workspace,balance_micros,created_at) VALUES ('fixture',-10000000,?)").run(at); | |
| 46 | + | db.prepare("INSERT INTO account_members(workspace,account_id,added_by,added_at) VALUES ('fixture','ent_fixture','staff',?)").run(at); | |
| 47 | + | const snapshots = JSON.stringify([{workspace:'fixture',balance:-10000000,invoiced:0}]); | |
| 48 | + | const acquire = db.prepare(constant('enterprise_attempt', 'INSERT')); | |
| 49 | + | const payload = JSON.stringify({lines:[{workspace:'fixture',amountMicros:10000000}]}); | |
| 50 | + | acquire.run('ent_fixture','first',payload,1,snapshots); | |
| 51 | + | acquire.run('ent_fixture','second','{"amount":20000000}',2,snapshots); | |
| 52 | + | assert.deepEqual({...db.prepare('SELECT id,payload FROM enterprise_invoice_attempts').get()}, {id:'first',payload}); | |
| 53 | + | assert.throws(() => db.exec("UPDATE account_members SET account_id='ent_other' WHERE workspace='fixture'"), /pending enterprise invoice/); | |
| 54 | + | assert.throws(() => db.exec("DELETE FROM account_members WHERE workspace='fixture'"), /pending enterprise invoice/); | |
| 55 | + | db.prepare("INSERT INTO enterprise_invoices(invoice_id,account_id,period,amount_micros,status,created_by,created_at) VALUES ('in_first','ent_fixture','now',10000000,'open','staff',?)").run(at); | |
| 56 | + | db.exec("INSERT INTO enterprise_invoice_lines VALUES ('in_first','fixture',10000000); UPDATE enterprise_invoice_attempts SET completed=1"); | |
| 57 | + | db.prepare(constant('enterprise_attempt','RETIRE')).run('ent_fixture'); | |
| 58 | + | acquire.run('ent_fixture','stale','{}',3,snapshots); | |
| 59 | + | assert.equal(db.prepare('SELECT COUNT(*) n FROM enterprise_invoice_attempts').get().n,0); | |
| 60 | + | db.exec("UPDATE accounts SET balance_micros=-15000000 WHERE workspace='fixture'"); | |
| 61 | + | const fresh = JSON.stringify([{workspace:'fixture',balance:-15000000,invoiced:10000000}]); | |
| 62 | + | acquire.run('ent_fixture','additional','{"amount":5000000}',4,fresh); | |
| 63 | + | assert.equal(db.prepare('SELECT id FROM enterprise_invoice_attempts').get().id,'additional'); | |
| 64 | + | }); | |
| 65 | + | test("a late enterprise invoice writer cannot recreate old lines after completion and rename", () => { | |
| 66 | + | const db = database(); | |
| 67 | + | const writes = prepares('webhooks', 'async fn finish_enterprise_attempt(', '/// An enterprise invoice paid:') | |
| 68 | + | .filter(sql => sql.startsWith('INSERT INTO enterprise_invoice')); | |
| 69 | + | assert.equal(writes.length,2); | |
| 70 | + | db.prepare("INSERT INTO enterprise_invoice_attempts(account_id,id,payload,created_ms) VALUES ('ent_fixture','attempt','{}',1)").run(); | |
| 71 | + | const invoice = ['in_fixture','ent_fixture','now',10000000,null,'staff',at,'attempt']; | |
| 72 | + | const line = ['in_fixture','old',10000000,'attempt']; | |
| 73 | + | batch(db,[[writes[0],invoice],[writes[1],line]]); | |
| 74 | + | db.exec("UPDATE enterprise_invoice_attempts SET completed=1; UPDATE enterprise_invoice_lines SET workspace='renamed'"); | |
| 75 | + | batch(db,[[writes[0],invoice],[writes[1],line]]); | |
| 76 | + | assert.deepEqual(db.prepare('SELECT workspace FROM enterprise_invoice_lines').all().map(r=>r.workspace),['renamed']); | |
| 77 | + | db.prepare(constant('enterprise_attempt','RETIRE')).run('ent_fixture'); | |
| 78 | + | batch(db,[[writes[0],invoice],[writes[1],line]]); | |
| 79 | + | assert.equal(db.prepare('SELECT SUM(amount_micros) n FROM enterprise_invoice_lines').get().n,10000000); | |
| 80 | + | }); | |
| 81 | + | test("fee waivers refuse subscriptions that may still charge, including delinquent plans", () => { | |
| 82 | + | const db = database(); | |
| 83 | + | const insert = db.prepare("INSERT INTO subscriptions (workspace, feature, subscription_id, status, started_by, started_at, updated_at) VALUES (?, 'plan', ?, ?, 'staff', ?, ?)"); | |
| 84 | + | for (const status of ['active', 'past_due', 'incomplete', 'unpaid', 'canceled']) { | |
| 85 | + | insert.run(status, `sub_${status}`, status, at, at); | |
| 86 | + | const found = db.prepare(constant('accounts', 'FEE_WAIVER_SUBSCRIPTIONS')).get(status); | |
| 87 | + | assert.equal(Boolean(found), status !== 'canceled', status); | |
| 88 | + | } | |
| 89 | + | assert.equal(db.prepare(constant('accounts', 'FEE_WAIVER_SUBSCRIPTIONS')).get('new-workspace'), undefined); | |
| 90 | + | }); | |
| 91 | + | test("demo detach finds all sources through bounded compound selects", () => { | |
| 92 | + | const db = database(); | |
| 93 | + | db.prepare("INSERT INTO accounts (workspace,balance_micros,created_at) VALUES ('demo',8000000,?)").run(at); | |
| 94 | + | db.prepare("INSERT INTO allowance_use (kind,scope,month,used) VALUES ('plan_credit','allowance-only','2026-10',500)").run(); | |
| 95 | + | const sql = constant('detach', 'FOUND').replace('{internal}', constant('sales', 'INTERNAL_SQL')); | |
| 96 | + | const rows = db.prepare(sql).all(null, '2026-10'); | |
| 97 | + | assert.deepEqual(rows.map(r => r.workspace), ['allowance-only', 'demo']); | |
| 98 | + | assert.equal(rows[0].plan_credit, 500); | |
| 99 | + | assert.equal(rows[1].balance, 8000000); | |
| 100 | + | assert.deepEqual(db.prepare(sql).all('demo', '2026-10').map(r => r.workspace), ['demo']); | |
| 101 | + | // D1 rejects the former long UNION. Each candidate CTE stays small. | |
| 102 | + | for (const cte of sql.split('AS MATERIALIZED (').slice(1)) { | |
| 103 | + | assert.ok((cte.split(')')[0].match(/UNION/g) ?? []).length <= 3); | |
| 104 | + | } | |
| 105 | + | }); | |
| 106 | + | function batch(db, writes) { | |
| 107 | + | db.exec("BEGIN"); | |
| 108 | + | try { for (const [sql, args] of writes) db.prepare(sql).run(...args); db.exec("COMMIT"); } | |
| 109 | + | catch (e) { db.exec("ROLLBACK"); throw e; } | |
| 110 | + | } | |
| 111 | + | const entries = prepares("lib", "pub(crate) fn entry_statements(", "/// What follows an entry"); | |
| 112 | + | const stops = prepares("lib", "fn after_entry_statements(", "async fn account("); | |
| 113 | + | const extras = prepares("tax", "pub(crate) fn extras_statements(", "/// A payment's tax and fee"); | |
| 114 | + | function payment(reference, amount = 25_000_000) { | |
| 115 | + | assert.equal(entries.length, 2); | |
| 116 | + | assert.equal(stops.length, 1); | |
| 117 | + | assert.equal(extras.length, 1); | |
| 118 | + | return [ | |
| 119 | + | [entries[0], [reference, "fixture", "top_up", amount, "Paid in advance", null, reference, null, at]], | |
| 120 | + | [entries[1], ["fixture", amount, "cus_fixture", at]], | |
| 121 | + | [stops[0], ["fixture", amount]], | |
| 122 | + | [constant("lib", "CLEAR_STOP"), ["fixture"]], | |
| 123 | + | [extras[0], [`${reference}/tax`, "fixture", "tax", 2_000_000, reference, "pi_fixture", null, at]], | |
| 124 | + | ]; | |
| 125 | + | } | |
| 126 | + | test("payment, balance, dispute repayment and tax commit once together", () => { | |
| 127 | + | const db = database(); | |
| 128 | + | db.prepare("INSERT INTO limits (workspace, updated_at, dispute_owed_micros, autopay_failed_at) VALUES (?, ?, ?, ?)").run("fixture", at, 30_000_000, at); | |
| 129 | + | batch(db, payment("cs_fixture")); | |
| 130 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, 25_000_000); | |
| 131 | + | assert.equal(db.prepare("SELECT dispute_owed_micros FROM limits WHERE workspace='fixture'").get().dispute_owed_micros, 5_000_000); | |
| 132 | + | assert.throws(() => batch(db, payment("cs_fixture")), /UNIQUE/); | |
| 133 | + | assert.equal(db.prepare("SELECT dispute_owed_micros FROM limits WHERE workspace='fixture'").get().dispute_owed_micros, 5_000_000); | |
| 134 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM tax_and_fees").get().n, 1); | |
| 135 | + | batch(db, payment("cs_second", 5_000_000)); | |
| 136 | + | assert.equal(db.prepare("SELECT autopay_failed_at FROM limits WHERE workspace='fixture'").get().autopay_failed_at, null); | |
| 137 | + | db.close(); | |
| 138 | + | }); | |
| 139 | + | test("a failure writing tax rolls back the credit and payment-stop updates", () => { | |
| 140 | + | const db = database(); | |
| 141 | + | db.prepare("INSERT INTO limits (workspace, updated_at, dispute_owed_micros, autopay_failed_at) VALUES (?, ?, ?, ?)").run("fixture", at, 25_000_000, at); | |
| 142 | + | db.exec("CREATE TRIGGER fail_tax BEFORE INSERT ON tax_and_fees BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); | |
| 143 | + | assert.throws(() => batch(db, payment("cs_fixture")), /injected failure/); | |
| 144 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM ledger").get().n, 0); | |
| 145 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM accounts WHERE workspace='fixture'").get().n, 0); | |
| 146 | + | assert.equal(db.prepare("SELECT dispute_owed_micros FROM limits WHERE workspace='fixture'").get().dispute_owed_micros, 25_000_000); | |
| 147 | + | db.exec("DROP TRIGGER fail_tax"); | |
| 148 | + | batch(db, payment("cs_fixture")); | |
| 149 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM tax_and_fees").get().n, 1); | |
| 150 | + | db.close(); | |
| 151 | + | }); | |
| 152 | + | test("payment never clears an open dispute", () => { | |
| 153 | + | const db = database(); | |
| 154 | + | db.prepare("INSERT INTO limits (workspace, updated_at, autopay_failed_at) VALUES (?, ?, ?)").run("fixture", at, at); | |
| 155 | + | db.prepare("INSERT INTO disputes (dispute_id, workspace, charge_id, status, updated_at) VALUES ('dp_fixture', 'fixture', 'ch_fixture', 'open', ?)").run(at); | |
| 156 | + | batch(db, payment("cs_fixture")); | |
| 157 | + | assert.equal(db.prepare("SELECT autopay_failed_at FROM limits WHERE workspace='fixture'").get().autopay_failed_at, at); | |
| 158 | + | db.close(); | |
| 159 | + | }); | |
| 160 | + | test("concurrent invoice requests freeze one payload even as usage changes", () => { | |
| 161 | + | const db = database(); | |
| 162 | + | db.prepare("INSERT INTO accounts (workspace, balance_micros, created_at) VALUES ('fixture', -10000000, ?)").run(at); | |
| 163 | + | const insert = db.prepare(constant("invoice_attempt", "INSERT")); | |
| 164 | + | insert.run("attempt_a", "fixture", '{"amount":1000}', 1000, -10_000_000); | |
| 165 | + | db.exec("UPDATE accounts SET balance_micros=-20000000 WHERE workspace='fixture'"); | |
| 166 | + | insert.run("attempt_b", "fixture", '{"amount":2000}', 2000, -20_000_000); | |
| 167 | + | assert.deepEqual({ ...db.prepare("SELECT id, payload FROM invoice_attempts").get() }, { id: "attempt_a", payload: '{"amount":1000}' }); | |
| 168 | + | assert.equal(db.prepare(constant("invoice_attempt", "RETIRE")).run("fixture").changes, 0); | |
| 169 | + | assert.equal(db.prepare(constant("closing", "UNPAID_INVOICES_SQL")).get("fixture").n, 1); | |
| 170 | + | db.close(); | |
| 171 | + | }); | |
| 172 | + | test("a stale invoice calculation cannot create an attempt after payment changes the balance", () => { | |
| 173 | + | const db = database(); | |
| 174 | + | db.prepare("INSERT INTO accounts (workspace, balance_micros, created_at) VALUES ('fixture', 0, ?)").run(at); | |
| 175 | + | const result = db.prepare(constant("invoice_attempt", "INSERT")).run("attempt_a", "fixture", "{}", 1000, -10_000_000); | |
| 176 | + | assert.equal(result.changes, 0); | |
| 177 | + | db.close(); | |
| 178 | + | }); | |
| 179 | + | test("the migration registry covers every billing migration", () => { | |
| 180 | + | const dir = new URL("../services/billing/migrations/", import.meta.url); | |
| 181 | + | const lib = source("lib"); | |
| 182 | + | for (const name of readdirSync(dir).filter(n => n.endsWith(".sql"))) assert.ok(lib.includes(`include_str!("../migrations/${name}")`), name); | |
| 183 | + | }); | |
| 184 | + | ||
| 185 | + | const sqlFile = name => readFileSync(new URL(`../services/billing/src/${name}.sql`, import.meta.url), "utf8"); | |
| 186 | + | function refund(db, cents, tax, plan = false) { | |
| 187 | + | const reference = `refund/ch_fixture/${cents + tax}`; | |
| 188 | + | const writes = [[sqlFile(plan ? "refund_plan" : "refund_ledger"), [reference, "fixture", cents * 10_000, reference, at, "refund/ch_fixture/%"]]]; | |
| 189 | + | if (!plan) writes.push([constant("webhooks", "REFUND_BALANCE"), [reference, "fixture"]]); | |
| 190 | + | writes.push([sqlFile("refund_extras"), [`${reference}/tax`, "fixture", "tax", tax * 10_000, reference, "pi_fixture", at, "refund/ch_fixture/%"]]); | |
| 191 | + | batch(db, writes); | |
| 192 | + | } | |
| 193 | + | test("partial refunds, duplicate deliveries and reversed ordering debit only the cumulative total", () => { | |
| 194 | + | for (const order of [[1000, 2500, 2500], [2500, 1000, 2500]]) { | |
| 195 | + | const db = database(); | |
| 196 | + | batch(db, payment("cs_fixture")); | |
| 197 | + | for (const cents of order) refund(db, cents, cents * 0.08); | |
| 198 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, 0); | |
| 199 | + | assert.equal(db.prepare("SELECT SUM(amount_micros) n FROM tax_and_fees WHERE workspace='fixture'").get().n, 0); | |
| 200 | + | db.close(); | |
| 201 | + | } | |
| 202 | + | }); | |
| 203 | + | test("refund tax failure rolls back the reversal and retries without a second debit", () => { | |
| 204 | + | const db = database(); | |
| 205 | + | batch(db, payment("cs_fixture")); | |
| 206 | + | db.exec("CREATE TRIGGER fail_refund_tax BEFORE INSERT ON tax_and_fees WHEN NEW.amount_micros < 0 BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); | |
| 207 | + | assert.throws(() => refund(db, 2500, 200), /injected failure/); | |
| 208 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, 25_000_000); | |
| 209 | + | db.exec("DROP TRIGGER fail_refund_tax"); | |
| 210 | + | refund(db, 2500, 200); | |
| 211 | + | refund(db, 2500, 200); | |
| 212 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, 0); | |
| 213 | + | db.close(); | |
| 214 | + | }); | |
| 215 | + | test("plan refunds reduce revenue without debiting prepaid usage credit", () => { | |
| 216 | + | const db = database(); | |
| 217 | + | batch(db, payment("cs_fixture")); | |
| 218 | + | refund(db, 1000, 80, true); | |
| 219 | + | refund(db, 2500, 200, true); | |
| 220 | + | refund(db, 1000, 80, true); | |
| 221 | + | assert.equal(db.prepare("SELECT SUM(amount_micros) n FROM plan_payments WHERE workspace='fixture'").get().n, -25_000_000); | |
| 222 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, 25_000_000); | |
| 223 | + | db.close(); | |
| 224 | + | }); | |
| 225 | + | ||
| 226 | + | test("one card cannot fund concurrent trials or another trial after being replaced", () => { | |
| 227 | + | const db = database(); | |
| 228 | + | const grantSql = prepares("credits", "pub(crate) async fn ensure_grant(", "/// Takes up to")[0]; | |
| 229 | + | const card = db.prepare("INSERT INTO card_checks (workspace, setup_intent, payment_method, fingerprint, funding, checked_by, checked_at) VALUES (?, ?, ?, ?, 'credit', 'fixture', ?)"); | |
| 230 | + | card.run("trial_a", "seti_a", "pm_a", "same_card", at); | |
| 231 | + | card.run("trial_b", "seti_b", "pm_b", "same_card", at); | |
| 232 | + | const grant = db.prepare(grantSql); | |
| 233 | + | grant.run("trial_a", "2026-10", 5_000_000, at, 100_000_000); | |
| 234 | + | grant.run("trial_b", "2026-10", 5_000_000, at, 100_000_000); | |
| 235 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM trial_grants WHERE workspace IN ('trial_a','trial_b')").get().n, 1); | |
| 236 | + | db.exec("UPDATE card_checks SET fingerprint='replacement_card' WHERE workspace='trial_a'"); | |
| 237 | + | grant.run("trial_b", "2026-10", 5_000_000, at, 100_000_000); | |
| 238 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM trial_grants WHERE workspace='trial_b'").get().n, 0); | |
| 239 | + | db.close(); | |
| 240 | + | }); | |
| 241 | + | ||
| 242 | + | function usage(id, reference, workspace, gross, { plan = 10_000_000, trial = 1, pool = 1_000_000, repo = 1_000_000, given = 0, when = at } = {}) { | |
| 243 | + | const insert = prepares("storage", "pub(crate) async fn post_usage_with(", "/// Takes `charged`")[0]; | |
| 244 | + | const balance = prepares("storage", "pub(crate) fn balance_if_posted(", "/// Writes down")[0]; | |
| 245 | + | const month = when.slice(0, 7); | |
| 246 | + | return [ | |
| 247 | + | [insert, [id, workspace, -gross, "Usage", `${workspace}/repo`, "sandbox", gross, reference, when, 0, 0, 0, 0, null]], | |
| 248 | + | [sqlFile("allocate_usage"), [id, workspace, month, plan, trial, `${workspace}/repo`, pool, repo, given]], | |
| 249 | + | [constant("allocation", "PLAN"), [id, month]], | |
| 250 | + | [constant("allocation", "GIFT"), [id, month]], | |
| 251 | + | [constant("allocation", "TRIAL"), [id]], | |
| 252 | + | [constant("allocation", "OSS_POOL"), [id, month]], | |
| 253 | + | [constant("allocation", "OSS_REPO"), [id, month, `${workspace}/repo`]], | |
| 254 | + | [balance, [workspace, at, id]], | |
| 255 | + | ]; | |
| 256 | + | } | |
| 257 | + | test("usage allocation, source debits and ledger posting conserve credit across retries", () => { | |
| 258 | + | const db = database(); | |
| 259 | + | db.prepare("INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at) VALUES ('fixture', '2026-10', 1000000, 0, ?)").run(at); | |
| 260 | + | batch(db, usage("usage_a", "run/a", "fixture", 12_000_000)); | |
| 261 | + | const read = () => ({ ...db.prepare("SELECT amount_micros, credit_micros, trial_micros, oss_micros FROM ledger WHERE id='usage_a'").get() }); | |
| 262 | + | assert.deepEqual(read(), { amount_micros: 0, credit_micros: 10_000_000, trial_micros: 1_000_000, oss_micros: 1_000_000 }); | |
| 263 | + | batch(db, usage("duplicate", "run/a", "fixture", 12_000_000)); | |
| 264 | + | assert.equal(db.prepare("SELECT SUM(used) n FROM allowance_use WHERE kind='plan_credit'").get().n, 10_000_000); | |
| 265 | + | batch(db, usage("usage_b", "run/b", "fixture", 2_000_000)); | |
| 266 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='fixture'").get().balance_micros, -2_000_000); | |
| 267 | + | db.close(); | |
| 268 | + | }); | |
| 269 | + | test("an interrupted usage transaction consumes no allowance and a retry posts once", () => { | |
| 270 | + | const db = database(); | |
| 271 | + | db.exec("CREATE TRIGGER fail_usage_balance BEFORE INSERT ON accounts WHEN NEW.workspace='fixture' BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); | |
| 272 | + | assert.throws(() => batch(db, usage("usage_a", "run/a", "fixture", 5_000_000)), /injected failure/); | |
| 273 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM ledger WHERE id='usage_a'").get().n, 0); | |
| 274 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM allowance_use WHERE scope='fixture'").get().n, 0); | |
| 275 | + | db.exec("DROP TRIGGER fail_usage_balance"); | |
| 276 | + | batch(db, usage("usage_b", "run/a", "fixture", 5_000_000)); | |
| 277 | + | assert.equal(db.prepare("SELECT used FROM allowance_use WHERE scope='fixture' AND kind='plan_credit'").get().used, 5_000_000); | |
| 278 | + | db.close(); | |
| 279 | + | }); | |
| 280 | + | test("different workspaces share one bounded OSS pool and free overruns remain explicit", () => { | |
| 281 | + | const db = database(); | |
| 282 | + | batch(db, usage("a", "run/a", "a", 800_000, { plan: 0, trial: 0 })); | |
| 283 | + | batch(db, usage("b", "run/b", "b", 800_000, { plan: 0, trial: 0, given: 1 })); | |
| 284 | + | assert.equal(db.prepare("SELECT used FROM allowance_use WHERE kind='oss_pool'").get().used, 1_000_000); | |
| 285 | + | assert.deepEqual({ ...db.prepare("SELECT amount_micros, oss_micros, given_micros FROM ledger WHERE id='b'").get() }, { amount_micros: 0, oss_micros: 200_000, given_micros: 600_000 }); | |
| 286 | + | db.close(); | |
| 287 | + | }); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | // Complimentary enrollment against the complete migrated schema, using the | |
| 7 | + | // production SQL: the allocation that posts usage, and the terms change as | |
| 8 | + | // `admin_set_complimentary` writes it. | |
| 9 | + | const source = name => readFileSync(new URL(`../services/billing/src/${name}.rs`, import.meta.url), "utf8"); | |
| 10 | + | const sqlFile = name => readFileSync(new URL(`../services/billing/src/${name}.sql`, import.meta.url), "utf8"); | |
| 11 | + | const constant = (file, name) => { | |
| 12 | + | const match = source(file).match(new RegExp(`const ${name}: &str = "([\\s\\S]*?)";`)); | |
| 13 | + | assert.ok(match, `${file}::${name}`); | |
| 14 | + | return match[1]; | |
| 15 | + | }; | |
| 16 | + | const prepares = (file, from, to) => { | |
| 17 | + | const text = source(file).split(from)[1].split(to)[0]; | |
| 18 | + | return [...text.matchAll(/\.prepare\(\s*"([\s\S]*?)"\s*,?\s*\)/g)].map(m => m[1]); | |
| 19 | + | }; | |
| 20 | + | const migrations = new URL("../services/billing/migrations/", import.meta.url); | |
| 21 | + | const names = readdirSync(migrations).filter(n => n.endsWith(".sql")).sort(); | |
| 22 | + | function database(before = null) { | |
| 23 | + | const db = new DatabaseSync(":memory:"); | |
| 24 | + | for (const name of names.filter(n => before === null || n < before)) db.exec(readFileSync(new URL(name, migrations), "utf8")); | |
| 25 | + | return db; | |
| 26 | + | } | |
| 27 | + | function batch(db, writes) { | |
| 28 | + | db.exec("BEGIN"); | |
| 29 | + | try { for (const [sql, args] of writes) db.prepare(sql).run(...args); db.exec("COMMIT"); } | |
| 30 | + | catch (e) { db.exec("ROLLBACK"); throw e; } | |
| 31 | + | } | |
| 32 | + | const at = "2026-10-14T12:00:00.000Z"; | |
| 33 | + | function usage(id, reference, workspace, gross, { plan = 10_000_000, trial = 1, given = 0, when = at } = {}) { | |
| 34 | + | const insert = prepares("storage", "pub(crate) async fn post_usage_with(", "/// Takes `charged`")[0]; | |
| 35 | + | const balance = prepares("storage", "pub(crate) fn balance_if_posted(", "/// Writes down")[0]; | |
| 36 | + | const month = when.slice(0, 7); | |
| 37 | + | return [ | |
| 38 | + | [insert, [id, workspace, -gross, "Usage", `${workspace}/repo`, "sandbox", gross, reference, when, 0, 0, 0, 0, null]], | |
| 39 | + | [sqlFile("allocate_usage"), [id, workspace, month, plan, trial, `${workspace}/repo`, 1_000_000, 1_000_000, given]], | |
| 40 | + | [constant("allocation", "PLAN"), [id, month]], | |
| 41 | + | [constant("allocation", "GIFT"), [id, month]], | |
| 42 | + | [constant("allocation", "TRIAL"), [id]], | |
| 43 | + | [constant("allocation", "OSS_POOL"), [id, month]], | |
| 44 | + | [constant("allocation", "OSS_REPO"), [id, month, `${workspace}/repo`]], | |
| 45 | + | [balance, [workspace, when, id]], | |
| 46 | + | ]; | |
| 47 | + | } | |
| 48 | + | const enrolledAt = "2026-10-01T00:00:00.000Z"; | |
| 49 | + | function enroll(db, workspace, allowance, { unlimited = 0, overage = "stop" } = {}) { | |
| 50 | + | db.prepare(`INSERT INTO complimentary (workspace, grant_id, status, allowance_micros, unlimited, fee_cents, note, created_by, created_at, created_ms, updated_by, updated_at, subscription_id, overage) | |
| 51 | + | VALUES (?, ?, 'active', ?, ?, 0, 'partner', 'staff', ?, 0, 'staff', ?, ?, ?)`).run(workspace, `cmp_${workspace}`, allowance, unlimited, enrolledAt, enrolledAt, `sub_${workspace}`, overage); | |
| 52 | + | db.prepare(`INSERT INTO complimentary_periods (id, workspace, grant_id, starts_at, allowance_micros, unlimited, fee_cents, note, created_by, created_at, overage) | |
| 53 | + | VALUES (?, ?, ?, ?, ?, ?, 0, 'partner', 'staff', ?, ?)`).run(`p_${workspace}`, workspace, `cmp_${workspace}`, enrolledAt, allowance, unlimited, enrolledAt, overage); | |
| 54 | + | } | |
| 55 | + | const termsChange = prepares("complimentary", "// One batch: the open period closes", "self.audit("); | |
| 56 | + | function changeTerms(db, workspace, allowance, when, overage = "stop") { | |
| 57 | + | assert.equal(termsChange.length, 3); | |
| 58 | + | batch(db, [ | |
| 59 | + | [termsChange[0], [workspace, when]], | |
| 60 | + | [termsChange[1], [`p_${when}`, workspace, `cmp_${workspace}`, when, allowance, 0, 0, "changed", "staff", overage]], | |
| 61 | + | [termsChange[2], [workspace, allowance, 0, 0, "changed", "staff", when, JSON.stringify({ op: "terms", period: `p_${when}` }), overage]], | |
| 62 | + | ]); | |
| 63 | + | } | |
| 64 | + | const line = (db, id) => ({ ...db.prepare("SELECT amount_micros, credit_micros, gift_micros, trial_micros, oss_micros, given_micros FROM ledger WHERE id=?").get(id) }); | |
| 65 | + | const giftUsed = (db, workspace, month = "2026-10") => | |
| 66 | + | db.prepare("SELECT used FROM allowance_use WHERE kind='complimentary' AND scope=? AND month=?").get(workspace, month)?.used ?? 0; | |
| 67 | + | ||
| 68 | + | test("a complimentary allowance pays first and alone, and past it g1t covers rather than charging", () => { | |
| 69 | + | const db = database(); | |
| 70 | + | enroll(db, "acme", 10_000_000); | |
| 71 | + | db.prepare("INSERT INTO trial_grants (workspace, month, granted_micros, used_micros, created_at) VALUES ('acme', '2026-10', 5000000, 0, ?)").run(at); | |
| 72 | + | batch(db, usage("a", "run/a", "acme", 6_000_000)); | |
| 73 | + | assert.deepEqual(line(db, "a"), { amount_micros: 0, credit_micros: 0, gift_micros: 6_000_000, trial_micros: 0, oss_micros: 0, given_micros: 0 }); | |
| 74 | + | // Running work that outlasts the allowance: the rest is g1t's, recorded | |
| 75 | + | // apart from the allowance, and nothing is owed. | |
| 76 | + | batch(db, usage("b", "run/b", "acme", 6_000_000)); | |
| 77 | + | assert.deepEqual(line(db, "b"), { amount_micros: 0, credit_micros: 0, gift_micros: 4_000_000, trial_micros: 0, oss_micros: 0, given_micros: 2_000_000 }); | |
| 78 | + | assert.equal(giftUsed(db, "acme"), 10_000_000); | |
| 79 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='acme'").get().balance_micros, 0); | |
| 80 | + | // Plan credit, the trial and the pools were never touched. | |
| 81 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM allowance_use WHERE kind IN ('plan_credit','oss_pool','oss_repo')").get().n, 0); | |
| 82 | + | assert.equal(db.prepare("SELECT used_micros FROM trial_grants WHERE workspace='acme'").get().used_micros, 0); | |
| 83 | + | // A duplicate delivery consumes nothing more. | |
| 84 | + | batch(db, usage("dup", "run/b", "acme", 6_000_000)); | |
| 85 | + | assert.equal(giftUsed(db, "acme"), 10_000_000); | |
| 86 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM ledger").get().n, 2); | |
| 87 | + | db.close(); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test("changing the amount keeps the month's consumption; lowering it never creates a debt", () => { | |
| 91 | + | const db = database(); | |
| 92 | + | enroll(db, "acme", 10_000_000); | |
| 93 | + | batch(db, usage("a", "run/a", "acme", 8_000_000, { when: "2026-10-10T00:00:00.000Z" })); | |
| 94 | + | changeTerms(db, "acme", 5_000_000, "2026-10-11T00:00:00.000Z"); | |
| 95 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM complimentary_periods WHERE workspace='acme' AND ends_at IS NULL").get().n, 1); | |
| 96 | + | assert.equal(db.prepare("SELECT allowance_micros FROM complimentary WHERE workspace='acme'").get().allowance_micros, 5_000_000); | |
| 97 | + | // Lowered below what was used: nothing more from it, nothing owed. | |
| 98 | + | batch(db, usage("b", "run/b", "acme", 1_000_000, { when: "2026-10-12T00:00:00.000Z" })); | |
| 99 | + | assert.deepEqual(line(db, "b"), { amount_micros: 0, credit_micros: 0, gift_micros: 0, trial_micros: 0, oss_micros: 0, given_micros: 1_000_000 }); | |
| 100 | + | assert.equal(giftUsed(db, "acme"), 8_000_000, "the earlier draw is kept, not reset"); | |
| 101 | + | // Raised: only what is over the month's use is left. | |
| 102 | + | changeTerms(db, "acme", 20_000_000, "2026-10-13T00:00:00.000Z"); | |
| 103 | + | batch(db, usage("c", "run/c", "acme", 15_000_000, { when: "2026-10-14T00:00:00.000Z" })); | |
| 104 | + | assert.equal(line(db, "c").gift_micros, 12_000_000); | |
| 105 | + | assert.equal(line(db, "c").given_micros, 3_000_000); | |
| 106 | + | assert.equal(giftUsed(db, "acme"), 20_000_000); | |
| 107 | + | // A new month starts the allowance again, with no rollover. | |
| 108 | + | batch(db, usage("d", "run/d", "acme", 25_000_000, { when: "2026-11-01T00:00:01.000Z" })); | |
| 109 | + | assert.equal(line(db, "d").gift_micros, 20_000_000); | |
| 110 | + | assert.equal(giftUsed(db, "acme", "2026-11"), 20_000_000); | |
| 111 | + | assert.equal(db.prepare("SELECT SUM(amount_micros) n FROM ledger").get().n, 0, "nothing was ever charged"); | |
| 112 | + | db.close(); | |
| 113 | + | }); | |
| 114 | + | ||
| 115 | + | test("late usage draws on the period it happened in, not the one it is posted in", () => { | |
| 116 | + | const db = database(); | |
| 117 | + | enroll(db, "acme", 10_000_000); | |
| 118 | + | // Converted to the plan on the 15th. | |
| 119 | + | db.exec("UPDATE complimentary_periods SET ends_at='2026-10-15T00:00:00.000Z'; UPDATE complimentary SET status='ended', ended_reason='converted'"); | |
| 120 | + | batch(db, usage("before", "run/before", "acme", 3_000_000, { when: "2026-10-14T23:00:00.000Z" })); | |
| 121 | + | assert.equal(line(db, "before").gift_micros, 3_000_000, "gifted usage is never repriced after conversion"); | |
| 122 | + | assert.equal(line(db, "before").amount_micros, 0); | |
| 123 | + | batch(db, usage("after", "run/after", "acme", 3_000_000, { when: "2026-10-15T01:00:00.000Z" })); | |
| 124 | + | assert.deepEqual(line(db, "after"), { amount_micros: 0, credit_micros: 3_000_000, gift_micros: 0, trial_micros: 0, oss_micros: 0, given_micros: 0 }); | |
| 125 | + | db.close(); | |
| 126 | + | }); | |
| 127 | + | ||
| 128 | + | test("charged past the allowance: g1t pays the allowance, the workspace's own card the rest", () => { | |
| 129 | + | const db = database(); | |
| 130 | + | enroll(db, "friend", 10_000_000, { overage: "charge" }); | |
| 131 | + | // No card yet: past the allowance it is g1t's, never a debt. | |
| 132 | + | batch(db, usage("z", "run/z", "friend", 12_000_000, { when: "2026-11-02T00:00:00.000Z" })); | |
| 133 | + | assert.deepEqual(line(db, "z"), { amount_micros: 0, credit_micros: 0, gift_micros: 10_000_000, trial_micros: 0, oss_micros: 0, given_micros: 2_000_000 }); | |
| 134 | + | db.prepare("INSERT INTO card_checks (workspace, setup_intent, payment_method, checked_by, checked_at) VALUES ('friend', 'seti_1', 'pm_1', 'owner', ?)").run(at); | |
| 135 | + | batch(db, usage("a", "run/a", "friend", 6_000_000)); | |
| 136 | + | batch(db, usage("b", "run/b", "friend", 7_000_000)); | |
| 137 | + | // 4 of 7 from the allowance, 3 charged; nothing covered by g1t. | |
| 138 | + | assert.deepEqual(line(db, "b"), { amount_micros: -3_000_000, credit_micros: 0, gift_micros: 4_000_000, trial_micros: 0, oss_micros: 0, given_micros: 0 }); | |
| 139 | + | assert.equal(db.prepare("SELECT balance_micros FROM accounts WHERE workspace='friend'").get().balance_micros, -3_000_000); | |
| 140 | + | // Switched to stop mid-month: from then, past the allowance is g1t's. | |
| 141 | + | changeTerms(db, "friend", 10_000_000, "2026-10-15T00:00:00.000Z", "stop"); | |
| 142 | + | batch(db, usage("c", "run/c", "friend", 1_000_000, { when: "2026-10-16T00:00:00.000Z" })); | |
| 143 | + | assert.deepEqual(line(db, "c"), { amount_micros: 0, credit_micros: 0, gift_micros: 0, trial_micros: 0, oss_micros: 0, given_micros: 1_000_000 }); | |
| 144 | + | // Usage from before the switch, posted late, keeps the period it happened in. | |
| 145 | + | batch(db, usage("d", "run/d", "friend", 2_000_000, { when: "2026-10-14T00:00:00.000Z" })); | |
| 146 | + | assert.equal(line(db, "d").amount_micros, -2_000_000); | |
| 147 | + | db.close(); | |
| 148 | + | }); | |
| 149 | + | ||
| 150 | + | test("an enrollment's fee paid while enrolled is not evidence of the plan", () => { | |
| 151 | + | const db = database(); | |
| 152 | + | enroll(db, "acme", 10_000_000); | |
| 153 | + | db.exec("UPDATE complimentary_periods SET ends_at='2026-10-15T00:00:00.000Z'"); | |
| 154 | + | const evidence = db.prepare(constant("storage", "PLAN_EVIDENCE")); | |
| 155 | + | db.exec("UPDATE complimentary_periods SET fee_cents = 2500"); | |
| 156 | + | db.prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES ('in_fee', 'acme', 25000000, '2026-10-02T00:00:00.000Z')").run(); | |
| 157 | + | assert.equal(evidence.get("acme", "2026-10-01", "2026-11-01", "2026-10").had, 0); | |
| 158 | + | // The conversion's first plan payment, made while the period is still open, is the plan. | |
| 159 | + | db.prepare("INSERT INTO plan_payments (invoice_id, workspace, amount_micros, paid_at) VALUES ('in_plan', 'acme', 21000000, '2026-10-14T23:59:00.000Z')").run(); | |
| 160 | + | assert.equal(evidence.get("acme", "2026-10-01", "2026-11-01", "2026-10").had, 1); | |
| 161 | + | db.close(); | |
| 162 | + | }); | |
| 163 | + | ||
| 164 | + | test("an unlimited enrollment pays everything and still records what it used", () => { | |
| 165 | + | const db = database(); | |
| 166 | + | enroll(db, "flagon-io", 0, { unlimited: 1 }); | |
| 167 | + | batch(db, usage("a", "run/a", "flagon-io", 900_000_000)); | |
| 168 | + | assert.equal(line(db, "a").gift_micros, 900_000_000); | |
| 169 | + | assert.equal(line(db, "a").given_micros, 0); | |
| 170 | + | assert.equal(giftUsed(db, "flagon-io"), 900_000_000); | |
| 171 | + | db.close(); | |
| 172 | + | }); | |
| 173 | + | ||
| 174 | + | test("an interrupted posting consumes no complimentary allowance", () => { | |
| 175 | + | const db = database(); | |
| 176 | + | enroll(db, "acme", 10_000_000); | |
| 177 | + | db.exec("CREATE TRIGGER fail_balance BEFORE INSERT ON accounts WHEN NEW.workspace='acme' BEGIN SELECT RAISE(ABORT, 'injected failure'); END"); | |
| 178 | + | assert.throws(() => batch(db, usage("a", "run/a", "acme", 4_000_000)), /injected failure/); | |
| 179 | + | assert.equal(giftUsed(db, "acme"), 0); | |
| 180 | + | db.exec("DROP TRIGGER fail_balance"); | |
| 181 | + | batch(db, usage("b", "run/a", "acme", 4_000_000)); | |
| 182 | + | assert.equal(giftUsed(db, "acme"), 4_000_000); | |
| 183 | + | db.close(); | |
| 184 | + | }); | |
| 185 | + | ||
| 186 | + | test("one open period per workspace", () => { | |
| 187 | + | const db = database(); | |
| 188 | + | enroll(db, "acme", 1); | |
| 189 | + | assert.throws(() => db.prepare(`INSERT INTO complimentary_periods (id, workspace, grant_id, starts_at, allowance_micros, note, created_by, created_at) | |
| 190 | + | VALUES ('second', 'acme', 'cmp_acme', ?, 1, 'x', 'staff', ?)`).run(at, at), /UNIQUE/); | |
| 191 | + | db.close(); | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test("migration 0065 returns every workspace but flagon-io to Free, with an audit entry", () => { | |
| 195 | + | const db = database("0065"); | |
| 196 | + | const account = db.prepare("INSERT INTO billing_accounts (id, kind, name, terms_kind, discount_percent, team_granted, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, 'staff', ?)"); | |
| 197 | + | account.run("ws_flagon-io-x", "workspace", "flagon-io-x", "comped", 100, 0, at); | |
| 198 | + | db.prepare("UPDATE billing_accounts SET terms_kind='custom' WHERE id='ws_flagon-io'").run(); | |
| 199 | + | account.run("ws_partner", "workspace", "partner", "comped", 100, 0, at); | |
| 200 | + | account.run("ws_given", "workspace", "given", "standard", 0, 1, at); | |
| 201 | + | account.run("ws_plain", "workspace", "plain", "standard", 0, 0, at); | |
| 202 | + | db.prepare("INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES ('member', 'ent_x', 'staff', ?)").run(at); | |
| 203 | + | // One on an enterprise invoice still being made: kept, not a failed deploy. | |
| 204 | + | db.prepare("INSERT INTO account_members (workspace, account_id, added_by, added_at) VALUES ('invoicing', 'ent_y', 'staff', ?)").run(at); | |
| 205 | + | db.prepare("INSERT INTO enterprise_invoice_attempts (account_id, id, payload, created_ms) VALUES ('ent_y', 'att', ?, 1)") | |
| 206 | + | .run(JSON.stringify({ lines: [{ workspace: "invoicing", amountMicros: 1 }] })); | |
| 207 | + | const flagonBefore = db.prepare("SELECT * FROM billing_accounts WHERE id='ws_flagon-io'").get(); | |
| 208 | + | db.exec(readFileSync(new URL(names.find(n => n.startsWith("0065")), migrations), "utf8")); | |
| 209 | + | const terms = Object.fromEntries(db.prepare("SELECT id, terms_kind, discount_percent, team_granted FROM billing_accounts").all().map(r => [r.id, [r.terms_kind, r.discount_percent, r.team_granted]])); | |
| 210 | + | assert.deepEqual({ ...db.prepare("SELECT * FROM billing_accounts WHERE id='ws_flagon-io'").get() }, { ...flagonBefore }, "flagon-io is untouched"); | |
| 211 | + | assert.deepEqual(terms["ws_flagon-io-x"], ["standard", 0, 0], "only flagon-io itself is kept"); | |
| 212 | + | assert.deepEqual(terms.ws_partner, ["standard", 0, 0]); | |
| 213 | + | assert.deepEqual(terms.ws_given, ["standard", 0, 0]); | |
| 214 | + | assert.deepEqual(terms.ws_plain, ["standard", 0, 0]); | |
| 215 | + | assert.deepEqual(db.prepare("SELECT workspace FROM account_members").all().map(r => r.workspace), ["invoicing"]); | |
| 216 | + | const audited = db.prepare("SELECT account FROM admin_actions WHERE by='billing' ORDER BY account").all().map(r => r.account); | |
| 217 | + | assert.deepEqual(audited, ["ent_x", "ws_flagon-io-x", "ws_given", "ws_partner"]); | |
| 218 | + | db.close(); | |
| 219 | + | }); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | // A month-end source's figure reported after its month was charged or | |
| 7 | + | // closed: the production SQL of `late_correction` (src/storage.rs). | |
| 8 | + | const source = name => readFileSync(new URL(`../services/billing/src/${name}.rs`, import.meta.url), "utf8"); | |
| 9 | + | const sqlFile = name => readFileSync(new URL(`../services/billing/src/${name}.sql`, import.meta.url), "utf8"); | |
| 10 | + | const constant = (file, name) => { | |
| 11 | + | const match = source(file).match(new RegExp(`const ${name}: &str = "([\\s\\S]*?)";`)); | |
| 12 | + | assert.ok(match, `${file}::${name}`); | |
| 13 | + | return match[1]; | |
| 14 | + | }; | |
| 15 | + | function database() { | |
| 16 | + | const db = new DatabaseSync(":memory:"); | |
| 17 | + | const dir = new URL("../services/billing/migrations/", import.meta.url); | |
| 18 | + | for (const name of readdirSync(dir).filter(n => n.endsWith(".sql")).sort()) db.exec(readFileSync(new URL(name, dir), "utf8")); | |
| 19 | + | return db; | |
| 20 | + | } | |
| 21 | + | function batch(db, writes) { | |
| 22 | + | db.exec("BEGIN"); | |
| 23 | + | try { | |
| 24 | + | const rows = writes.map(([sql, args]) => db.prepare(sql).all(...args)); | |
| 25 | + | db.exec("COMMIT"); | |
| 26 | + | return rows; | |
| 27 | + | } catch (e) { | |
| 28 | + | db.exec("ROLLBACK"); | |
| 29 | + | throw e; | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | const now = "2026-11-03T10:00:00.000Z"; | |
| 33 | + | // One late report, as `late_correction` sends it: the raise, the guarded | |
| 34 | + | // ledger line, the allocation and the balance, in one batch. | |
| 35 | + | function late(db, id, workspace, newCost, charge, oldRaw, { plan = 0 } = {}) { | |
| 36 | + | const raise = constant("storage", "LATE_RAISE"); | |
| 37 | + | const ledger = constant("storage", "LATE_LEDGER"); | |
| 38 | + | const balanceSql = source("storage").split("pub(crate) fn balance_if_posted(")[1].match(/\.prepare\(\s*"([\s\S]*?)"\s*,?\s*\)/)[1]; | |
| 39 | + | const reference = `storage/${workspace}/2026-10/late/${newCost}`; | |
| 40 | + | const rows = batch(db, [ | |
| 41 | + | [raise, [workspace, "storage", "2026-10", newCost, newCost, now, null, oldRaw]], | |
| 42 | + | [ledger, [id, workspace, -charge, "Late", "storage", newCost - oldRaw, reference, "2026-10-31T23:59:59Z", null, "2026-10", newCost]], | |
| 43 | + | [sqlFile("allocate_usage"), [id, workspace, "2026-10", plan, 0, "", 0, 0, 0]], | |
| 44 | + | [constant("allocation", "PLAN"), [id, "2026-10"]], | |
| 45 | + | [constant("allocation", "GIFT"), [id, "2026-10"]], | |
| 46 | + | [balanceSql, [workspace, now, id]], | |
| 47 | + | ]); | |
| 48 | + | return rows[1].length === 1; | |
| 49 | + | } | |
| 50 | + | function charged(db, workspace, cost) { | |
| 51 | + | db.prepare("INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, updated_at, charged_at) VALUES (?, 'storage', '2026-10', ?, ?, ?, ?)") | |
| 52 | + | .run(workspace, cost, cost, now, now); | |
| 53 | + | } | |
| 54 | + | const owed = (db, workspace) => db.prepare("SELECT balance_micros FROM accounts WHERE workspace=?").get(workspace)?.balance_micros ?? 0; | |
| 55 | + | ||
| 56 | + | test("a late higher figure charges only what it adds, once", () => { | |
| 57 | + | const db = database(); | |
| 58 | + | charged(db, "acme", 5_000_000); | |
| 59 | + | assert.equal(late(db, "a", "acme", 7_000_000, 2_400_000, 5_000_000), true); | |
| 60 | + | assert.equal(owed(db, "acme"), -2_400_000); | |
| 61 | + | assert.equal(db.prepare("SELECT cost_micros FROM pending_usage WHERE workspace='acme'").get().cost_micros, 7_000_000); | |
| 62 | + | // The same report again: worked out from the new figure, nothing changes. | |
| 63 | + | assert.equal(late(db, "b", "acme", 7_000_000, 2_400_000, 5_000_000), false); | |
| 64 | + | assert.equal(owed(db, "acme"), -2_400_000); | |
| 65 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM ledger").get().n, 1); | |
| 66 | + | db.close(); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("of two late reports worked out from the same figure, only one is charged", () => { | |
| 70 | + | const db = database(); | |
| 71 | + | charged(db, "acme", 5_000_000); | |
| 72 | + | assert.equal(late(db, "a", "acme", 8_000_000, 3_600_000, 5_000_000), true); | |
| 73 | + | // A stale writer that read 5,000,000 before the first moved it. | |
| 74 | + | assert.equal(late(db, "b", "acme", 6_000_000, 1_200_000, 5_000_000), false); | |
| 75 | + | assert.equal(owed(db, "acme"), -3_600_000); | |
| 76 | + | assert.equal(db.prepare("SELECT cost_micros FROM pending_usage WHERE workspace='acme'").get().cost_micros, 8_000_000); | |
| 77 | + | db.close(); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | test("a closed month with no row yet takes the late figure as a charged row", () => { | |
| 81 | + | const db = database(); | |
| 82 | + | db.prepare("INSERT INTO month_closes (workspace, month, status, amount_micros, closed_at) VALUES ('acme', '2026-10', 'charged', 0, ?)").run(now); | |
| 83 | + | // The ordinary report is refused for a closed month… | |
| 84 | + | db.prepare(constant("storage", "SET_PENDING")).run("acme", "storage", "2026-10", 1, 1, now, null); | |
| 85 | + | assert.equal(db.prepare("SELECT COUNT(*) n FROM pending_usage").get().n, 0); | |
| 86 | + | // …and the late one charges it, and marks the row charged. | |
| 87 | + | assert.equal(late(db, "a", "acme", 4_000_000, 4_800_000, 0), true); | |
| 88 | + | assert.equal(owed(db, "acme"), -4_800_000); | |
| 89 | + | assert.ok(db.prepare("SELECT charged_at FROM pending_usage WHERE workspace='acme'").get().charged_at.startsWith("late: ")); | |
| 90 | + | db.close(); | |
| 91 | + | }); | |
| 92 | + | ||
| 93 | + | test("a late correction draws on that month's allowances first", () => { | |
| 94 | + | const db = database(); | |
| 95 | + | charged(db, "acme", 1_000_000); | |
| 96 | + | db.prepare("INSERT INTO allowance_use (kind, scope, month, used) VALUES ('plan_credit', 'acme', '2026-10', 9000000)").run(); | |
| 97 | + | assert.equal(late(db, "a", "acme", 3_000_000, 2_400_000, 1_000_000, { plan: 10_000_000 }), true); | |
| 98 | + | assert.deepEqual({ ...db.prepare("SELECT amount_micros, credit_micros FROM ledger").get() }, { amount_micros: -1_400_000, credit_micros: 1_000_000 }); | |
| 99 | + | assert.equal(db.prepare("SELECT used FROM allowance_use WHERE kind='plan_credit'").get().used, 10_000_000); | |
| 100 | + | db.close(); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | test("only a raise is charged: a lower late figure changes nothing", () => { | |
| 104 | + | const src = source("storage"); | |
| 105 | + | assert.match(src, /pub\(crate\) fn late_delta\(charged_net: i64, new_net: i64\) -> i64 \{\s*\(new_net - charged_net\)\.max\(0\)/); | |
| 106 | + | assert.match(src, /if new_cost < raw_old \{[\s\S]*?return Ok\(true\);/); | |
| 107 | + | }); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | const source = readFileSync(new URL("../services/repos/src/public_storage.rs", import.meta.url), "utf8"); | |
| 7 | + | const sql = name => source.match(new RegExp(`const ${name}: &str = "([\\s\\S]*?)";`))[1]; | |
| 8 | + | function database() { | |
| 9 | + | const db = new DatabaseSync(":memory:"); | |
| 10 | + | const dir = new URL("../services/repos/migrations/", import.meta.url); | |
| 11 | + | for (const name of readdirSync(dir).filter(n => n.endsWith(".sql")).sort()) db.exec(readFileSync(new URL(name, dir), "utf8")); | |
| 12 | + | return db; | |
| 13 | + | } | |
| 14 | + | function repo(db, id, bytes, workspace = "team", privateRepo = 0) { | |
| 15 | + | db.prepare("INSERT INTO repos(id,namespace,name,owner_id,stored_bytes,is_private,created_at) VALUES (?,?,?,'owner',?,?,'2026-10-14T00:00:00Z')") | |
| 16 | + | .run(id, workspace, id, bytes, privateRepo); | |
| 17 | + | } | |
| 18 | + | const reserve = (db, id, bytes, cap = 5_000_000_000) => db.prepare(sql("RESERVE")).get({ 1: id, 2: bytes, 3: cap }); | |
| 19 | + | ||
| 20 | + | test("overlapping pushes cannot reserve the same final capacity", () => { | |
| 21 | + | const db = database(); | |
| 22 | + | repo(db, "first", 4_900_000_000); repo(db, "second", 0); | |
| 23 | + | assert.ok(reserve(db, "first", 80_000_000)); | |
| 24 | + | assert.equal(reserve(db, "second", 80_000_000), undefined); | |
| 25 | + | assert.ok(reserve(db, "second", 20_000_000)); | |
| 26 | + | assert.equal(reserve(db, "first", 1), undefined); | |
| 27 | + | assert.equal(db.prepare("SELECT SUM(stored_bytes + storage_reserved_bytes) n FROM repos").get().n, 5_000_000_000); | |
| 28 | + | assert.equal(db.prepare("SELECT SUM(stored_bytes) n FROM repos").get().n, 4_900_000_000, "reservations are not measured storage billed to customers"); | |
| 29 | + | db.close(); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("deleted retained data and interrupted write reservations still consume capacity", () => { | |
| 33 | + | const db = database(); | |
| 34 | + | repo(db, "retained", 4_950_000_000); repo(db, "active", 0); | |
| 35 | + | db.exec("UPDATE repos SET deleted_at = '2026-10-14T00:00:00Z' WHERE id = 'retained'"); | |
| 36 | + | assert.ok(reserve(db, "active", 50_000_000)); | |
| 37 | + | assert.equal(reserve(db, "active", 1), undefined); | |
| 38 | + | db.exec("DELETE FROM repos WHERE id = 'retained'"); | |
| 39 | + | assert.ok(reserve(db, "active", 1)); | |
| 40 | + | db.close(); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("concurrent visibility changes cannot bypass public capacity", () => { | |
| 44 | + | const db = database(); | |
| 45 | + | repo(db, "public", 4_000_000_000); repo(db, "private_a", 750_000_000, "team", 1); repo(db, "private_b", 750_000_000, "team", 1); | |
| 46 | + | assert.ok(db.prepare(sql("MAKE_PUBLIC")).get({ 1: "private_a", 2: 5_000_000_000 })); | |
| 47 | + | assert.equal(db.prepare(sql("MAKE_PUBLIC")).get({ 1: "private_b", 2: 5_000_000_000 }), undefined); | |
| 48 | + | assert.ok(reserve(db, "private_b", 100_000_000)); | |
| 49 | + | assert.equal(db.prepare("SELECT is_private FROM repos WHERE id='private_b'").get().is_private, 1); | |
| 50 | + | db.close(); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | test("transfer admission uses destination capacity at the moment of mutation", () => { | |
| 54 | + | const db = database(); | |
| 55 | + | repo(db, "existing", 4_500_000_000, "destination"); repo(db, "moving", 600_000_000); | |
| 56 | + | assert.equal(db.prepare(sql("TRANSFER")).get({ 1: "moving", 2: "destination", 3: "team", 4: 5_000_000_000 }), undefined); | |
| 57 | + | assert.equal(db.prepare("SELECT namespace FROM repos WHERE id='moving'").get().namespace, "team"); | |
| 58 | + | db.exec("DELETE FROM repos WHERE id='existing'"); | |
| 59 | + | assert.ok(db.prepare(sql("TRANSFER")).get({ 1: "moving", 2: "destination", 3: "team", 4: 5_000_000_000 })); | |
| 60 | + | db.close(); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("native copy freezes new source writes until its storage snapshot is taken", () => { | |
| 64 | + | const db = database(); | |
| 65 | + | repo(db, "source", 100); repo(db, "fork", 0); | |
| 66 | + | db.exec("INSERT INTO storage_copy_locks VALUES ('source','fork','2026-10-14T00:00:00Z')"); | |
| 67 | + | assert.equal(reserve(db, "source", 20), undefined); | |
| 68 | + | assert.ok(reserve(db, "fork", 100)); | |
| 69 | + | db.exec("DELETE FROM storage_copy_locks WHERE repo_id='source'"); | |
| 70 | + | assert.ok(reserve(db, "source", 20)); | |
| 71 | + | db.close(); | |
| 72 | + | }); |
| 188 | 188 | /// workspace's last trial run that went past its trial credit. | |
| 189 | 189 | #[serde(default)] | |
| 190 | 190 | pub given_micros: i64, | |
| 191 | + | /// For usage: what a complimentary allowance from g1t paid of it. | |
| 192 | + | #[serde(default)] | |
| 193 | + | pub gift_micros: i64, | |
| 191 | 194 | /// For usage: what the account's discount took off its price. The | |
| 192 | 195 | /// price is `-amount_micros` plus this and what paid for it. | |
| 193 | 196 | #[serde(default)] | |
| ⋯ | |||
| 861 | 864 | Internal, | |
| 862 | 865 | /// Paid for by an enterprise account, invoiced. | |
| 863 | 866 | Enterprise, | |
| 867 | + | /// Enrolled by g1t staff (`admin_set_complimentary`): the plan's | |
| 868 | + | /// features, a monthly usage allowance valued at ordinary prices, and a | |
| 869 | + | /// monthly fee of its own (often none). Never charged on demand: past | |
| 870 | + | /// the allowance new billable work stops. | |
| 871 | + | Complimentary, | |
| 864 | 872 | } | |
| 865 | 873 | ||
| 866 | 874 | impl PlanKind { | |
| ⋯ | |||
| 870 | 878 | PlanKind::Paid => "paid", | |
| 871 | 879 | PlanKind::Internal => "internal", | |
| 872 | 880 | PlanKind::Enterprise => "enterprise", | |
| 881 | + | PlanKind::Complimentary => "complimentary", | |
| 873 | 882 | } | |
| 874 | 883 | } | |
| 875 | 884 | ||
| 876 | 885 | /// Whether usage past what is included may be charged (on demand). | |
| 877 | 886 | pub fn on_demand(self) -> bool { | |
| 878 | − | !matches!(self, PlanKind::Free) | |
| 887 | + | !matches!(self, PlanKind::Free | PlanKind::Complimentary) | |
| 879 | 888 | } | |
| 880 | 889 | } | |
| 881 | 890 | ||
| ⋯ | |||
| 1046 | 1055 | /// Where usage stands against what is included and the limits, from 50%. | |
| 1047 | 1056 | #[serde(default)] | |
| 1048 | 1057 | pub alerts: Vec<UsageAlert>, | |
| 1058 | + | /// A complimentary enrollment's allowance this month, when the plan is | |
| 1059 | + | /// `complimentary` (or g1t's own with one). | |
| 1060 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 1061 | + | pub complimentary: Option<ComplimentaryMonth>, | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | /// A complimentary allowance this calendar month (UTC), as the workspace's | |
| 1065 | + | /// Billing page and sudo show it. Usage counts at its ordinary price. | |
| 1066 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 1067 | + | #[serde(rename_all = "camelCase")] | |
| 1068 | + | pub struct ComplimentaryMonth { | |
| 1069 | + | /// `YYYY-MM`. | |
| 1070 | + | pub month: String, | |
| 1071 | + | /// This month's allowance; `unlimited` ignores it. | |
| 1072 | + | pub allowance_micros: i64, | |
| 1073 | + | #[serde(default)] | |
| 1074 | + | pub unlimited: bool, | |
| 1075 | + | /// Usage this month the allowance paid for. | |
| 1076 | + | pub used_micros: i64, | |
| 1077 | + | /// Usage this month not on the ledger yet (storage, git operations and | |
| 1078 | + | /// other month-end meters), which the allowance will pay for. | |
| 1079 | + | #[serde(default)] | |
| 1080 | + | pub pending_micros: i64, | |
| 1081 | + | /// What work running now holds against it. | |
| 1082 | + | #[serde(default)] | |
| 1083 | + | pub held_micros: i64, | |
| 1084 | + | /// What is left after all three; 0 at most stops new billable work. | |
| 1085 | + | pub remaining_micros: i64, | |
| 1086 | + | /// What g1t covered this month past the allowance (work already | |
| 1087 | + | /// running when it ran out): never charged to the workspace. | |
| 1088 | + | #[serde(default)] | |
| 1089 | + | pub overrun_micros: i64, | |
| 1090 | + | /// When the next month's allowance starts: RFC 3339. | |
| 1091 | + | pub resets_at: String, | |
| 1092 | + | /// The recurring monthly fee, in cents; 0: waived. | |
| 1093 | + | #[serde(default)] | |
| 1094 | + | pub fee_cents: u32, | |
| 1095 | + | /// Past the allowance, usage is charged to the workspace's own card at | |
| 1096 | + | /// ordinary prices, under its limits; false: new paid work stops. | |
| 1097 | + | #[serde(default)] | |
| 1098 | + | pub charge_past_allowance: bool, | |
| 1099 | + | /// A card is on file to charge past the allowance. | |
| 1100 | + | #[serde(default)] | |
| 1101 | + | pub has_card: bool, | |
| 1049 | 1102 | } | |
| 1050 | 1103 | ||
| 1051 | 1104 | /// One level reached: 50, 75, 90 or 100 percent of something. | |
| ⋯ | |||
| 1305 | 1358 | pub by: String, | |
| 1306 | 1359 | } | |
| 1307 | 1360 | ||
| 1361 | + | // --- Complimentary enrollment (sudo) ----------------------------------------- | |
| 1362 | + | // | |
| 1363 | + | // Staff enroll a workspace by hand; no workspace is ever enrolled on its | |
| 1364 | + | // own. The enrollment is the plan's features, a monthly usage allowance at | |
| 1365 | + | // ordinary prices, and a recurring monthly fee of its own, each set apart. | |
| 1366 | + | // A real Stripe customer and subscription stand for it. | |
| 1367 | + | ||
| 1368 | + | /// The most a monthly complimentary allowance can be, against a slipped | |
| 1369 | + | /// finger: $100,000. `unlimited` is how to give more. | |
| 1370 | + | pub const MAX_COMPLIMENTARY_MICROS: i64 = 100_000 * MICROS_PER_DOLLAR; | |
| 1371 | + | /// The most a complimentary enrollment's monthly fee can be: $100,000. | |
| 1372 | + | pub const MAX_COMPLIMENTARY_FEE_CENTS: u32 = 10_000_000; | |
| 1373 | + | ||
| 1374 | + | /// `admin_set_complimentary`: enrolls a workspace, or changes the terms of | |
| 1375 | + | /// its enrollment from now on. Changing the amount keeps what this month | |
| 1376 | + | /// already used; lowering it below that leaves nothing more this month and | |
| 1377 | + | /// never a debt. Recorded with who and why. Returns | |
| 1378 | + | /// `Outcome<ComplimentaryStanding>`. | |
| 1379 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1380 | + | pub struct AdminSetComplimentaryArgs { | |
| 1381 | + | pub workspace: String, | |
| 1382 | + | /// The monthly usage allowance, valued at ordinary prices. | |
| 1383 | + | pub allowance_micros: i64, | |
| 1384 | + | /// No monthly cap on the allowance (g1t's own workspaces). g1t's | |
| 1385 | + | /// infrastructure limits still apply. | |
| 1386 | + | #[serde(default)] | |
| 1387 | + | pub unlimited: bool, | |
| 1388 | + | /// The recurring monthly fee, in cents, before tax; 0 waives it. | |
| 1389 | + | #[serde(default)] | |
| 1390 | + | pub fee_cents: u32, | |
| 1391 | + | /// Past the allowance, charge the workspace's own card at ordinary | |
| 1392 | + | /// prices under its limits (an employee's or a friend's workspace g1t | |
| 1393 | + | /// covers up to an amount); false: new paid work stops there. | |
| 1394 | + | #[serde(default)] | |
| 1395 | + | pub charge_past_allowance: bool, | |
| 1396 | + | pub note: String, | |
| 1397 | + | pub by: String, | |
| 1398 | + | } | |
| 1399 | + | ||
| 1400 | + | /// `admin_end_complimentary`: ends an enrollment now. The workspace goes | |
| 1401 | + | /// back to Free; usage the allowance paid for stays paid for. Its Stripe | |
| 1402 | + | /// subscription is canceled. Returns `Outcome<ComplimentaryStanding>`. | |
| 1403 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1404 | + | pub struct AdminEndComplimentaryArgs { | |
| 1405 | + | pub workspace: String, | |
| 1406 | + | pub note: String, | |
| 1407 | + | pub by: String, | |
| 1408 | + | } | |
| 1409 | + | ||
| 1410 | + | /// `admin_complimentary`: a workspace's enrollment, if it ever had one. | |
| 1411 | + | /// Returns `Option<ComplimentaryStanding>`. | |
| 1412 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1413 | + | pub struct AdminComplimentaryArgs { | |
| 1414 | + | pub workspace: String, | |
| 1415 | + | } | |
| 1416 | + | ||
| 1417 | + | /// A complimentary enrollment, as sudo shows it. | |
| 1418 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 1419 | + | #[serde(rename_all = "camelCase")] | |
| 1420 | + | pub struct ComplimentaryStanding { | |
| 1421 | + | pub workspace: String, | |
| 1422 | + | pub grant_id: String, | |
| 1423 | + | /// `pending` (Stripe not confirmed yet), `active` or `ended`. | |
| 1424 | + | pub status: String, | |
| 1425 | + | pub allowance_micros: i64, | |
| 1426 | + | #[serde(default)] | |
| 1427 | + | pub unlimited: bool, | |
| 1428 | + | #[serde(default)] | |
| 1429 | + | pub fee_cents: u32, | |
| 1430 | + | #[serde(default)] | |
| 1431 | + | pub charge_past_allowance: bool, | |
| 1432 | + | /// Whether its Stripe objects are live-mode ones. | |
| 1433 | + | #[serde(default)] | |
| 1434 | + | pub live: bool, | |
| 1435 | + | #[serde(default)] | |
| 1436 | + | pub customer_id: Option<String>, | |
| 1437 | + | #[serde(default)] | |
| 1438 | + | pub subscription_id: Option<String>, | |
| 1439 | + | #[serde(default)] | |
| 1440 | + | pub stripe_status: Option<String>, | |
| 1441 | + | /// A Stripe step still owed (`create`, `terms`, `cancel`, `convert`), | |
| 1442 | + | /// tried again every fifteen minutes. | |
| 1443 | + | #[serde(default)] | |
| 1444 | + | pub pending_op: Option<String>, | |
| 1445 | + | #[serde(default)] | |
| 1446 | + | pub last_error: Option<String>, | |
| 1447 | + | pub note: String, | |
| 1448 | + | pub created_by: String, | |
| 1449 | + | pub created_at: String, | |
| 1450 | + | pub updated_by: String, | |
| 1451 | + | pub updated_at: String, | |
| 1452 | + | #[serde(default)] | |
| 1453 | + | pub ended_at: Option<String>, | |
| 1454 | + | /// `revoked` or `converted`. | |
| 1455 | + | #[serde(default)] | |
| 1456 | + | pub ended_reason: Option<String>, | |
| 1457 | + | /// This month, while it is active. | |
| 1458 | + | #[serde(default)] | |
| 1459 | + | pub month: Option<ComplimentaryMonth>, | |
| 1460 | + | /// Its terms over time, newest first. | |
| 1461 | + | #[serde(default)] | |
| 1462 | + | pub periods: Vec<ComplimentaryPeriod>, | |
| 1463 | + | /// Money the workspace actually paid g1t, all time: plan and fee | |
| 1464 | + | /// invoices, payments in advance and bank transfers, net of refunds. | |
| 1465 | + | #[serde(default)] | |
| 1466 | + | pub money_received_micros: i64, | |
| 1467 | + | /// Credit g1t gave it (promotional and goodwill), all time: never money. | |
| 1468 | + | #[serde(default)] | |
| 1469 | + | pub promotional_credit_micros: i64, | |
| 1470 | + | /// Usage complimentary allowances paid for, all time: never money. | |
| 1471 | + | #[serde(default)] | |
| 1472 | + | pub gifted_micros: i64, | |
| 1473 | + | } | |
| 1474 | + | ||
| 1475 | + | /// One stretch of an enrollment's terms. | |
| 1476 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 1477 | + | #[serde(rename_all = "camelCase")] | |
| 1478 | + | pub struct ComplimentaryPeriod { | |
| 1479 | + | pub id: String, | |
| 1480 | + | pub starts_at: String, | |
| 1481 | + | #[serde(default)] | |
| 1482 | + | pub ends_at: Option<String>, | |
| 1483 | + | pub allowance_micros: i64, | |
| 1484 | + | #[serde(default)] | |
| 1485 | + | pub unlimited: bool, | |
| 1486 | + | #[serde(default)] | |
| 1487 | + | pub fee_cents: u32, | |
| 1488 | + | #[serde(default)] | |
| 1489 | + | pub charge_past_allowance: bool, | |
| 1490 | + | pub note: String, | |
| 1491 | + | pub created_by: String, | |
| 1492 | + | } | |
| 1493 | + | ||
| 1494 | + | /// `convert_to_paid`: an owner or billing manager of a complimentary | |
| 1495 | + | /// workspace starts the g1t plan on the same Stripe customer and | |
| 1496 | + | /// subscription. The plan, and usage past the included amount, start only | |
| 1497 | + | /// once its first payment succeeds; until then the enrollment stands. Safe | |
| 1498 | + | /// to call again: a conversion waiting on the bank is answered, never | |
| 1499 | + | /// started twice. Returns `Outcome<Conversion>`. | |
| 1500 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1501 | + | #[serde(rename_all = "camelCase")] | |
| 1502 | + | pub struct ConvertToPaidArgs { | |
| 1503 | + | pub actor: crate::User, | |
| 1504 | + | pub workspace: String, | |
| 1505 | + | pub return_url: String, | |
| 1506 | + | } | |
| 1507 | + | ||
| 1508 | + | /// Where a conversion stands. | |
| 1509 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 1510 | + | #[serde(rename_all = "camelCase")] | |
| 1511 | + | pub struct Conversion { | |
| 1512 | + | /// `converted`; `needs_card` (no saved card: `url` checks one); | |
| 1513 | + | /// `needs_action` (the bank wants the person: `url` is the invoice to | |
| 1514 | + | /// pay); `declined` (nothing changed; the enrollment stands). | |
| 1515 | + | pub status: String, | |
| 1516 | + | #[serde(default)] | |
| 1517 | + | pub url: Option<String>, | |
| 1518 | + | pub message: String, | |
| 1519 | + | } | |
| 1520 | + | ||
| 1308 | 1521 | // --- Overages and goodwill (sudo) -------------------------------------------- | |
| 1309 | 1522 | ||
| 1310 | 1523 | /// What a one-time goodwill credit would come to: g1t's margin on the | |
| 69 | 69 | ossMicros?: number; | |
| 70 | 70 | /** For usage: what g1t covered itself, such as a trial's last run past its credit. */ | |
| 71 | 71 | givenMicros?: number; | |
| 72 | + | /** For usage: what a complimentary allowance from g1t paid of it. */ | |
| 73 | + | giftMicros?: number; | |
| 72 | 74 | /** For usage: what the account's discount took off its price. */ | |
| 73 | 75 | discountMicros?: number; | |
| 74 | 76 | /** For a credit from g1t, and for what of one expired or was revoked: its kind. */ | |
| ⋯ | |||
| 206 | 208 | export const UNLIMITED_MICROS = 1_000_000_000_000_000; | |
| 207 | 209 | ||
| 208 | 210 | /** What a workspace pays g1t on, as far as compute is concerned. Mirrors `PlanKind`. */ | |
| 209 | − | export type PlanKind = "free" | "paid" | "internal" | "enterprise"; | |
| 211 | + | export type PlanKind = "free" | "paid" | "internal" | "enterprise" | "complimentary"; | |
| 210 | 212 | ||
| 211 | 213 | // `ComputeKind` (what compute is for), `PaidBy` (who pays first: credit, trial, oss, on_demand) and | |
| 212 | 214 | // `Reservation` are in `./compute`, with the gate that calls `reserve` and `settle`. | |
| ⋯ | |||
| 292 | 294 | spike?: Spike | null; | |
| 293 | 295 | /** Where usage stands against what is included and the limits, from 50%. */ | |
| 294 | 296 | alerts?: UsageAlert[]; | |
| 297 | + | /** A complimentary enrollment's allowance this month. */ | |
| 298 | + | complimentary?: ComplimentaryMonth | null; | |
| 295 | 299 | }; | |
| 296 | 300 | ||
| 301 | + | /** A complimentary allowance this calendar month (UTC). Usage counts at its ordinary price. Mirrors `ComplimentaryMonth`. */ | |
| 302 | + | export type ComplimentaryMonth = { | |
| 303 | + | /** `YYYY-MM`. */ | |
| 304 | + | month: string; | |
| 305 | + | allowanceMicros: number; | |
| 306 | + | unlimited?: boolean; | |
| 307 | + | /** Usage this month the allowance paid for. */ | |
| 308 | + | usedMicros: number; | |
| 309 | + | /** Metered this month and not on the ledger yet (storage, git operations), which the allowance will pay for. */ | |
| 310 | + | pendingMicros?: number; | |
| 311 | + | /** What running work holds against it. */ | |
| 312 | + | heldMicros?: number; | |
| 313 | + | /** What is left after all three: at 0, new work that costs money stops. */ | |
| 314 | + | remainingMicros: number; | |
| 315 | + | /** What g1t covered past the allowance this month: never charged. */ | |
| 316 | + | overrunMicros?: number; | |
| 317 | + | /** RFC 3339: when next month's allowance starts. */ | |
| 318 | + | resetsAt: string; | |
| 319 | + | /** The recurring monthly fee, in cents; 0: waived. */ | |
| 320 | + | feeCents?: number; | |
| 321 | + | /** Past the allowance, usage is charged to the workspace's own card under its limits; otherwise new paid work stops. */ | |
| 322 | + | chargePastAllowance?: boolean; | |
| 323 | + | /** A card is on file to charge past the allowance. */ | |
| 324 | + | hasCard?: boolean; | |
| 325 | + | }; | |
| 326 | + | ||
| 327 | + | /** A complimentary enrollment, as sudo shows it. Mirrors `ComplimentaryStanding`. */ | |
| 328 | + | export type ComplimentaryStanding = { | |
| 329 | + | workspace: string; | |
| 330 | + | grantId: string; | |
| 331 | + | /** `pending` (Stripe not confirmed yet), `active` or `ended`. */ | |
| 332 | + | status: "pending" | "active" | "ended"; | |
| 333 | + | allowanceMicros: number; | |
| 334 | + | unlimited?: boolean; | |
| 335 | + | feeCents?: number; | |
| 336 | + | chargePastAllowance?: boolean; | |
| 337 | + | live?: boolean; | |
| 338 | + | customerId?: string | null; | |
| 339 | + | subscriptionId?: string | null; | |
| 340 | + | stripeStatus?: string | null; | |
| 341 | + | /** A Stripe step still owed, tried again every fifteen minutes. */ | |
| 342 | + | pendingOp?: "create" | "terms" | "cancel" | "convert" | null; | |
| 343 | + | lastError?: string | null; | |
| 344 | + | note: string; | |
| 345 | + | createdBy: string; | |
| 346 | + | createdAt: string; | |
| 347 | + | updatedBy: string; | |
| 348 | + | updatedAt: string; | |
| 349 | + | endedAt?: string | null; | |
| 350 | + | endedReason?: "revoked" | "converted" | null; | |
| 351 | + | month?: ComplimentaryMonth | null; | |
| 352 | + | periods?: ComplimentaryPeriod[]; | |
| 353 | + | /** Money actually paid to g1t, all time: plan and fee invoices, prepayments, bank transfers. */ | |
| 354 | + | moneyReceivedMicros?: number; | |
| 355 | + | /** Promotional and goodwill credit from g1t, all time: never money. */ | |
| 356 | + | promotionalCreditMicros?: number; | |
| 357 | + | /** Usage complimentary allowances paid for, all time: never money. */ | |
| 358 | + | giftedMicros?: number; | |
| 359 | + | }; | |
| 360 | + | ||
| 361 | + | export type ComplimentaryPeriod = { | |
| 362 | + | id: string; | |
| 363 | + | startsAt: string; | |
| 364 | + | endsAt?: string | null; | |
| 365 | + | allowanceMicros: number; | |
| 366 | + | unlimited?: boolean; | |
| 367 | + | feeCents?: number; | |
| 368 | + | chargePastAllowance?: boolean; | |
| 369 | + | note: string; | |
| 370 | + | createdBy: string; | |
| 371 | + | }; | |
| 372 | + | ||
| 373 | + | /** Where an owner's conversion to the plan stands. */ | |
| 374 | + | export type Conversion = { | |
| 375 | + | status: "converted" | "needs_card" | "needs_action" | "declined"; | |
| 376 | + | url?: string | null; | |
| 377 | + | message: string; | |
| 378 | + | }; | |
| 379 | + | ||
| 297 | 380 | /** A hold on a start's estimated cost, as billing answers it (`Reservation` in `./compute`, and more). */ | |
| 298 | 381 | export type ReservationHeld = Reservation & { | |
| 299 | 382 | /** What is held, at cost; may be less than the estimate for a free workspace's last bit of trial. */ | |
| ⋯ | |||
| 606 | 689 | setTerms(id: string, terms: Terms, by: string): Promise<Result<PayingAccount>>; | |
| 607 | 690 | /** Team on or off without charge, and the account's share of the pools. Needs a note. */ | |
| 608 | 691 | setAllowances(id: string, allowances: Allowances, note: string, by: string): Promise<Result<PayingAccount>>; | |
| 692 | + | /** A workspace's complimentary enrollment, if it ever had one. */ | |
| 693 | + | complimentary(workspace: string): Promise<ComplimentaryStanding | null>; | |
| 694 | + | /** | |
| 695 | + | * Enrolls a workspace, or changes its terms from now on (the month's use is kept). Needs a note. | |
| 696 | + | * `chargePastAllowance`: past the allowance, its own card is charged (employees, friends); otherwise work stops. | |
| 697 | + | */ | |
| 698 | + | setComplimentary( | |
| 699 | + | workspace: string, | |
| 700 | + | terms: { allowanceMicros: number; unlimited: boolean; feeCents: number; chargePastAllowance: boolean }, | |
| 701 | + | note: string, | |
| 702 | + | by: string, | |
| 703 | + | ): Promise<Result<ComplimentaryStanding>>; | |
| 704 | + | /** Ends an enrollment now: back to Free, its Stripe subscription canceled. */ | |
| 705 | + | endComplimentary(workspace: string, note: string, by: string): Promise<Result<ComplimentaryStanding>>; | |
| 609 | 706 | createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>; | |
| 610 | 707 | attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>; | |
| 611 | 708 | /** Credit for a workspace: promotional, goodwill or a refund; the owners are emailed. */ | |
| ⋯ | |||
| 967 | 1064 | confirmSubscription(workspace: string, viewer: Viewer, session: string): Promise<Result<FeatureState>>; | |
| 968 | 1065 | /** Ends a plan at the end of its period, or (`resume`) takes that back. Owners only. */ | |
| 969 | 1066 | cancelSubscription(actor: User, workspace: string, feature: Feature, resume?: boolean): Promise<Result<FeatureState>>; | |
| 1067 | + | /** A complimentary workspace starts the plan on its own subscription; nothing changes until the first payment succeeds. Owners and billing managers. */ | |
| 1068 | + | convertToPaid(actor: User, workspace: string, returnUrl: string): Promise<Result<Conversion>>; | |
| 970 | 1069 | /** Which of the workspaces are free (on no paid plan); none where payments are not set up. */ | |
| 971 | 1070 | freeWorkspaces(workspaces: string[]): Promise<string[]>; | |
| 972 | 1071 | /** Whether a feature works for a workspace now; a failure with the reason when not. */ | |
| 598 | 598 | call("confirm_subscription", { workspace, viewer, session }), | |
| 599 | 599 | cancelSubscription: (actor, workspace, feature, resume = false) => | |
| 600 | 600 | call("cancel_subscription", { actor, workspace, feature, resume }), | |
| 601 | + | convertToPaid: (actor, workspace, returnUrl) => call("convert_to_paid", { actor, workspace, returnUrl }), | |
| 601 | 602 | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 602 | 603 | freeWorkspaces: (workspaces) => call("free_workspaces", { workspaces }), | |
| 603 | 604 | chargeFeature: (charge) => call("charge_feature", charge), | |
| ⋯ | |||
| 635 | 636 | account: (id) => call("admin_account", { id }), | |
| 636 | 637 | setTerms: (id, terms, by) => call("admin_set_terms", { id, terms, by }), | |
| 637 | 638 | setAllowances: (id, allowances, note, by) => call("admin_set_allowances", { id, allowances, note, by }), | |
| 639 | + | complimentary: (workspace) => call("admin_complimentary", { workspace }), | |
| 640 | + | setComplimentary: (workspace, terms, note, by) => | |
| 641 | + | call("admin_set_complimentary", { | |
| 642 | + | workspace, | |
| 643 | + | allowance_micros: terms.allowanceMicros, | |
| 644 | + | unlimited: terms.unlimited, | |
| 645 | + | fee_cents: terms.feeCents, | |
| 646 | + | charge_past_allowance: terms.chargePastAllowance, | |
| 647 | + | note, | |
| 648 | + | by, | |
| 649 | + | }), | |
| 650 | + | endComplimentary: (workspace, note, by) => call("admin_end_complimentary", { workspace, note, by }), | |
| 638 | 651 | createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }), | |
| 639 | 652 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 640 | 653 | credit: (workspace, amountMicros, note, by, options = { kind: "goodwill" }) => | |
| 34 | 34 | import type { Result } from "./result"; | |
| 35 | 35 | ||
| 36 | 36 | /** What a workspace pays for. */ | |
| 37 | − | export type ComputePlan = "free" | "paid" | "internal" | "enterprise"; | |
| 37 | + | export type ComputePlan = "free" | "paid" | "internal" | "enterprise" | "complimentary"; | |
| 38 | 38 | ||
| 39 | 39 | /** | |
| 40 | 40 | * What kind of compute is being started. Billing also knows `agent` and | |
| ⋯ | |||
| 94 | 94 | ||
| 95 | 95 | const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0); | |
| 96 | 96 | ||
| 97 | − | const PLANS: ReadonlySet<string> = new Set(["free", "paid", "internal", "enterprise"]); | |
| 97 | + | const PLANS: ReadonlySet<string> = new Set(["free", "paid", "internal", "enterprise", "complimentary"]); | |
| 98 | 98 | ||
| 99 | 99 | /** | |
| 100 | 100 | * Billing's `entitlements`, as this module reads them. Null when they do | |
| 1 | + | // Read-only live configuration check. Never installs secrets or creates charges. | |
| 2 | + | import assert from 'node:assert/strict'; | |
| 3 | + | import { readFileSync } from 'node:fs'; | |
| 4 | + | ||
| 5 | + | const key = readFileSync('.secrets/stripe-live-key.txt', 'utf8').trim(); | |
| 6 | + | const signingSecret = readFileSync('.secrets/stripe-live-webhook.txt', 'utf8').trim(); | |
| 7 | + | assert.ok(/^(sk|rk)_live_/.test(key), 'Expected a live API key'); | |
| 8 | + | assert.ok(/^whsec_\S+$/.test(signingSecret), 'Expected a webhook signing secret'); | |
| 9 | + | const source = readFileSync('services/billing/src/webhooks.rs', 'utf8'); | |
| 10 | + | const url = source.match(/WEBHOOK_URL: &str = "([^"]+)"/)[1]; | |
| 11 | + | const required = [...source.match(/const EVENTS: &\[&str\] = &\[([\s\S]*?)\];/)[1].matchAll(/"([a-z_.]+)"/g)].map(m => m[1]); | |
| 12 | + | const version = readFileSync('services/billing/src/stripe.rs', 'utf8').match(/STRIPE_VERSION: &str = "([^"]+)"/)[1]; | |
| 13 | + | async function get(path) { | |
| 14 | + | const response = await fetch(`https://api.stripe.com/v1${path}`, { | |
| 15 | + | headers: { Authorization: `Bearer ${key}`, 'Stripe-Version': version }, | |
| 16 | + | }); | |
| 17 | + | if (!response.ok) throw Error(`Stripe read failed: HTTP ${response.status}`); | |
| 18 | + | return response.json(); | |
| 19 | + | } | |
| 20 | + | const [account, tax] = await Promise.all([get('/account'), get('/tax/settings')]); | |
| 21 | + | const endpoints = []; | |
| 22 | + | let after; | |
| 23 | + | do { | |
| 24 | + | const page = await get(`/webhook_endpoints?limit=100${after ? `&starting_after=${encodeURIComponent(after)}` : ''}`); | |
| 25 | + | endpoints.push(...page.data.filter(e => e.url === url)); | |
| 26 | + | after = page.has_more ? page.data.at(-1)?.id : undefined; | |
| 27 | + | assert.ok(!page.has_more || after, 'Malformed endpoint pagination'); | |
| 28 | + | } while (after); | |
| 29 | + | const matching = endpoints.map(e => ({ | |
| 30 | + | id: e.id, url: e.url, status: e.status, live: e.livemode, apiVersion: e.api_version, | |
| 31 | + | missingEvents: e.enabled_events.includes('*') ? [] : required.filter(t => !e.enabled_events.includes(t)), | |
| 32 | + | })); | |
| 33 | + | const ready = account.charges_enabled && tax.status === 'active' | |
| 34 | + | && matching.some(e => e.live && e.status === 'enabled' && e.apiVersion === '2026-08-26.dahlia' && !e.missingEvents.length); | |
| 35 | + | console.log(JSON.stringify({ | |
| 36 | + | checkedAt: new Date().toISOString(), account: account.id, | |
| 37 | + | chargesEnabled: account.charges_enabled, payoutsEnabled: account.payouts_enabled, | |
| 38 | + | taxStatus: tax.status, endpoints: matching, configurationReady: ready, | |
| 39 | + | signingSecretFilePresent: true, | |
| 40 | + | limitations: ['Stored signing secret cannot be matched to an existing endpoint through the read API', | |
| 41 | + | 'Does not verify deployed secrets, delivery, application readiness, or settlement'], | |
| 42 | + | }, null, 2)); | |
| 43 | + | if (!ready) process.exitCode = 1; |
Comments
Sign in to comment.