Paid features: a workspace turns on Deployments with a monthly plan
Like Workers for Platforms, or Vercel's Pro, but per feature: an owner turns a feature on from Billing, pays its monthly plan through Stripe Checkout in subscription mode, and the feature works while the plan is paid. Never free: FREE_WHILE_BUILDING and the free model allowance do not cover it. - Billing: `features`, `subscribe`, `confirm_subscription`, `cancel_subscription` (at the end of the period, or taken back), `has_feature` (asked by the service that provides it before paid work) and `charge_feature` (usage past the allowance, from credit at cost plus the margin, once per reference). A plan is asked about again once its period is over, so renewals and failed payments need no webhook. Migration 0003 adds `subscriptions`. - Deployments: $5 a month (DEPLOYMENTS_MONTHLY_CENTS), including 10 apps, 1M requests and 3M CPU-ms; past that, Cloudflare's price plus 20%. - The Billing page gains Plans, with what each costs and includes, and lets owners add credit for plan usage while agents are free.
| 1 | − | import { CreditCard } from "lucide-react"; | |
| 1 | + | import { CreditCard, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; | |
| 4 | + | import { MICROS_PER_DOLLAR, type Feature, type FeatureState } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/billing"; | |
| 7 | 7 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 32 | 32 | const url = new URL(request.url); | |
| 33 | 33 | const session = url.searchParams.get("session"); | |
| 34 | 34 | if (session) { | |
| 35 | + | // The same page returns from both a credit payment and a plan. | |
| 36 | + | if (url.searchParams.get("plan")) { | |
| 37 | + | await billing.confirmSubscription(slug, viewer, session); | |
| 38 | + | throw redirect(`/${slug}/-/billing?subscribed=1`); | |
| 39 | + | } | |
| 35 | 40 | await billing.confirm(slug, viewer, session); | |
| 36 | 41 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 37 | 42 | } | |
| 38 | − | const [account, ledger] = await Promise.all([ | |
| 43 | + | const [account, ledger, features] = await Promise.all([ | |
| 39 | 44 | billing.account(slug, viewer), | |
| 40 | 45 | billing.ledger(slug, viewer), | |
| 46 | + | billing.features(slug, viewer), | |
| 41 | 47 | ]); | |
| 42 | 48 | return { | |
| 43 | 49 | slug, | |
| 44 | 50 | role, | |
| 45 | 51 | account: unwrap(account), | |
| 46 | 52 | ledger: unwrap(ledger), | |
| 53 | + | features: unwrap(features), | |
| 47 | 54 | added: url.searchParams.has("added"), | |
| 55 | + | subscribed: url.searchParams.has("subscribed"), | |
| 48 | 56 | }; | |
| 49 | 57 | } | |
| 50 | 58 | ||
| 52 | 60 | assertSameOrigin(request); | |
| 53 | 61 | const user = requireUser(context, request); | |
| 54 | 62 | const form = await request.formData(); | |
| 63 | + | const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`; | |
| 64 | + | const intent = form.get("intent"); | |
| 65 | + | if (intent === "subscribe" || intent === "cancel" || intent === "resume") { | |
| 66 | + | const feature = String(form.get("feature")) as Feature; | |
| 67 | + | if (intent !== "subscribe") { | |
| 68 | + | const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume"); | |
| 69 | + | return changed.ok ? null : { error: changed.error.message }; | |
| 70 | + | } | |
| 71 | + | const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`); | |
| 72 | + | if (!started.ok) return { error: started.error.message }; | |
| 73 | + | throw redirect(started.value.url); | |
| 74 | + | } | |
| 55 | 75 | const dollars = Math.trunc(Number(form.get("amount"))); | |
| 56 | 76 | const started = await billing.checkout( | |
| 57 | 77 | user, | |
| 71 | 91 | } | |
| 72 | 92 | ||
| 73 | 93 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| 74 | − | const { slug, role, account, ledger, added } = loaderData; | |
| 94 | + | const { slug, role, account, ledger, features, added, subscribed } = loaderData; | |
| 75 | 95 | const { status } = account; | |
| 76 | 96 | const paying = useNavigation().state === "submitting"; | |
| 77 | 97 | const empty = account.balanceMicros <= 0; | |
| 89 | 109 | </p> | |
| 90 | 110 | </div> | |
| 91 | 111 | )} | |
| 92 | − | <h2 className="font-medium">Agent credit</h2> | |
| 112 | + | <h2 className="font-medium">Plans</h2> | |
| 93 | 113 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 114 | + | Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of | |
| 115 | + | g1t is. | |
| 116 | + | </p> | |
| 117 | + | {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>} | |
| 118 | + | <div className="mt-5 space-y-4"> | |
| 119 | + | {features.map((state) => ( | |
| 120 | + | <PlanCard | |
| 121 | + | key={state.plan.feature} | |
| 122 | + | state={state} | |
| 123 | + | owner={role === "owner"} | |
| 124 | + | enabled={account.status.enabled} | |
| 125 | + | live={account.status.live} | |
| 126 | + | busy={paying} | |
| 127 | + | /> | |
| 128 | + | ))} | |
| 129 | + | </div> | |
| 130 | + | <div className="mt-2"> | |
| 131 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 132 | + | </div> | |
| 133 | + | ||
| 134 | + | <h2 className="mt-12 font-medium">Agent credit</h2> | |
| 135 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 94 | 136 | g1t agents that work on this workspace's repositories are paid for from | |
| 95 | 137 | its credit: what the model cost, plus {account.marginPercent}%. Checks run | |
| 96 | 138 | free. With no credit, agents do not start. | |
| 108 | 150 | {added && ( | |
| 109 | 151 | <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p> | |
| 110 | 152 | )} | |
| 111 | − | {status.free ? ( | |
| 153 | + | {status.free && !features.some((state) => state.on && state.subscription) ? ( | |
| 112 | 154 | <p className="mt-3 text-sm text-muted"> | |
| 113 | 155 | Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when | |
| 114 | 156 | pricing starts. | |
| 120 | 162 | </p> | |
| 121 | 163 | ) : role === "owner" ? ( | |
| 122 | 164 | <Form method="post" className="mt-4"> | |
| 123 | − | <p className="text-sm text-muted">Add credit by card:</p> | |
| 165 | + | <p className="text-sm text-muted"> | |
| 166 | + | {status.free | |
| 167 | + | ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:" | |
| 168 | + | : "Add credit by card:"} | |
| 169 | + | </p> | |
| 124 | 170 | <div className="mt-2 flex flex-wrap gap-2"> | |
| 125 | 171 | {AMOUNTS.map((amount) => ( | |
| 126 | 172 | <Button | |
| 141 | 187 | test card 4242 4242 4242 4242 with any future date and any code. | |
| 142 | 188 | </p> | |
| 143 | 189 | )} | |
| 144 | − | <div className="mt-2"> | |
| 145 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 146 | − | </div> | |
| 147 | 190 | </Form> | |
| 148 | 191 | ) : ( | |
| 149 | 192 | <p className="mt-3 text-sm text-muted">An owner can add credit.</p> | |
| 227 | 270 | </div> | |
| 228 | 271 | ); | |
| 229 | 272 | } | |
| 273 | + | ||
| 274 | + | /** A paid feature: what it costs and includes, and its plan. */ | |
| 275 | + | function PlanCard({ | |
| 276 | + | state, | |
| 277 | + | owner, | |
| 278 | + | enabled, | |
| 279 | + | live, | |
| 280 | + | busy, | |
| 281 | + | }: { | |
| 282 | + | state: FeatureState; | |
| 283 | + | owner: boolean; | |
| 284 | + | enabled: boolean; | |
| 285 | + | live: boolean; | |
| 286 | + | busy: boolean; | |
| 287 | + | }) { | |
| 288 | + | const { plan, subscription } = state; | |
| 289 | + | const ending = subscription?.status === "canceling"; | |
| 290 | + | const owed = subscription?.status === "past_due"; | |
| 291 | + | return ( | |
| 292 | + | <section | |
| 293 | + | className={`rounded-xl border p-5 ${ | |
| 294 | + | state.on && subscription | |
| 295 | + | ? "border-accent/40 bg-accent/5" | |
| 296 | + | : owed | |
| 297 | + | ? "border-warn/40 bg-warn/5" | |
| 298 | + | : "border-line bg-surface" | |
| 299 | + | }`} | |
| 300 | + | > | |
| 301 | + | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 302 | + | <div className="min-w-0"> | |
| 303 | + | <h3 className="flex flex-wrap items-center gap-2 font-medium"> | |
| 304 | + | <Rocket size={15} className="text-accent" /> | |
| 305 | + | {plan.title} | |
| 306 | + | {state.on && subscription && ( | |
| 307 | + | <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent"> | |
| 308 | + | {ending ? "Ends " : "On"} | |
| 309 | + | {subscription.periodEnd && ( | |
| 310 | + | <> | |
| 311 | + | {ending ? "" : " · renews "} | |
| 312 | + | {new Date(subscription.periodEnd).toLocaleDateString()} | |
| 313 | + | </> | |
| 314 | + | )} | |
| 315 | + | </span> | |
| 316 | + | )} | |
| 317 | + | {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>} | |
| 318 | + | </h3> | |
| 319 | + | <p className="mt-1 text-sm text-muted"> | |
| 320 | + | Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge. | |
| 321 | + | </p> | |
| 322 | + | </div> | |
| 323 | + | <p className="shrink-0 text-right"> | |
| 324 | + | <span className="text-2xl font-semibold tabular-nums tracking-tight"> | |
| 325 | + | ${(plan.monthlyCents / 100).toFixed(0)} | |
| 326 | + | </span> | |
| 327 | + | <span className="text-sm text-muted"> / month</span> | |
| 328 | + | </p> | |
| 329 | + | </div> | |
| 330 | + | <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2"> | |
| 331 | + | {plan.includes.map((line) => ( | |
| 332 | + | <li key={line} className="flex gap-2"> | |
| 333 | + | <span className="text-accent">✓</span> | |
| 334 | + | {line} | |
| 335 | + | </li> | |
| 336 | + | ))} | |
| 337 | + | </ul> | |
| 338 | + | <p className="mt-3 text-xs text-faint">{plan.overage}</p> | |
| 339 | + | {!enabled ? ( | |
| 340 | + | <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p> | |
| 341 | + | ) : !owner ? ( | |
| 342 | + | !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p> | |
| 343 | + | ) : ( | |
| 344 | + | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> | |
| 345 | + | <input type="hidden" name="feature" value={plan.feature} /> | |
| 346 | + | {!state.on || !subscription ? ( | |
| 347 | + | <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}> | |
| 348 | + | <CreditCard size={14} /> | |
| 349 | + | Turn on {plan.title} | |
| 350 | + | </Button> | |
| 351 | + | ) : ending ? ( | |
| 352 | + | <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}> | |
| 353 | + | Keep {plan.title} | |
| 354 | + | </Button> | |
| 355 | + | ) : ( | |
| 356 | + | <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}> | |
| 357 | + | Turn off at the end of the period | |
| 358 | + | </Button> | |
| 359 | + | )} | |
| 360 | + | {!live && !state.on && ( | |
| 361 | + | <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span> | |
| 362 | + | )} | |
| 363 | + | </Form> | |
| 364 | + | )} | |
| 365 | + | </section> | |
| 366 | + | ); | |
| 367 | + | } |
| 84 | 84 | pub enum EntryKind { | |
| 85 | 85 | /// Credit bought with a card. | |
| 86 | 86 | TopUp, | |
| 87 | − | /// An agent's run. | |
| 87 | + | /// An agent's run, or a paid feature's usage past its allowance. | |
| 88 | 88 | Usage, | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 257 | 257 | pub added_micros: i64, | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | /// A paid feature a workspace turns on with a monthly plan, the way | |
| 261 | + | /// Cloudflare's Workers for Platforms or Vercel's Pro are bought. Never | |
| 262 | + | /// free: `FREE_WHILE_BUILDING` and the free model allowance do not cover | |
| 263 | + | /// it. | |
| 264 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 265 | + | #[serde(rename_all = "snake_case")] | |
| 266 | + | pub enum Feature { | |
| 267 | + | /// Previews per pull request and production on g1t.page. | |
| 268 | + | Deployments, | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | impl Feature { | |
| 272 | + | pub const ALL: [Feature; 1] = [Feature::Deployments]; | |
| 273 | + | ||
| 274 | + | pub fn as_str(self) -> &'static str { | |
| 275 | + | match self { | |
| 276 | + | Feature::Deployments => "deployments", | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | pub fn parse(name: &str) -> Option<Feature> { | |
| 281 | + | Feature::ALL.into_iter().find(|feature| feature.as_str() == name) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | pub fn title(self) -> &'static str { | |
| 285 | + | match self { | |
| 286 | + | Feature::Deployments => "Deployments", | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// What the Deployments plan includes each month; usage past it is charged | |
| 292 | + | /// at cost plus the margin. The billing service describes the plan with | |
| 293 | + | /// these and the deployments service meters against them. | |
| 294 | + | pub mod deployments_allowance { | |
| 295 | + | /// Apps deployed at once: production and previews together. | |
| 296 | + | pub const APPS: u32 = 10; | |
| 297 | + | pub const REQUESTS: u64 = 1_000_000; | |
| 298 | + | pub const CPU_MS: u64 = 3_000_000; | |
| 299 | + | /// What Cloudflare charges g1t past that, in millionths of a dollar. | |
| 300 | + | pub const MICROS_PER_APP_MONTH: i64 = 20_000; | |
| 301 | + | pub const MICROS_PER_MILLION_REQUESTS: i64 = 300_000; | |
| 302 | + | pub const MICROS_PER_MILLION_CPU_MS: i64 = 20_000; | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// What a feature's plan costs and includes. | |
| 306 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 307 | + | #[serde(rename_all = "camelCase")] | |
| 308 | + | pub struct Plan { | |
| 309 | + | pub feature: Feature, | |
| 310 | + | pub title: String, | |
| 311 | + | /// Charged every month while the plan is on, in cents. | |
| 312 | + | pub monthly_cents: u32, | |
| 313 | + | /// What the monthly price includes, one line each, for people to read. | |
| 314 | + | pub includes: Vec<String>, | |
| 315 | + | /// How usage past the allowance is charged, for people to read. | |
| 316 | + | pub overage: String, | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 320 | + | #[serde(rename_all = "snake_case")] | |
| 321 | + | pub enum SubscriptionStatus { | |
| 322 | + | /// Paid up; the feature works. | |
| 323 | + | Active, | |
| 324 | + | /// Paid up to the end of the period, and ends then. | |
| 325 | + | Canceling, | |
| 326 | + | /// The last payment failed; the feature is off until it is paid. | |
| 327 | + | PastDue, | |
| 328 | + | /// Ended. | |
| 329 | + | Canceled, | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | impl SubscriptionStatus { | |
| 333 | + | /// Whether the feature works in this state. | |
| 334 | + | pub fn on(self) -> bool { | |
| 335 | + | matches!(self, SubscriptionStatus::Active | SubscriptionStatus::Canceling) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | /// A workspace's plan for one feature. | |
| 340 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 341 | + | #[serde(rename_all = "camelCase")] | |
| 342 | + | pub struct Subscription { | |
| 343 | + | pub feature: Feature, | |
| 344 | + | pub status: SubscriptionStatus, | |
| 345 | + | /// RFC 3339: when the period paid for ends, and the plan renews or | |
| 346 | + | /// ends. | |
| 347 | + | pub period_end: Option<String>, | |
| 348 | + | /// Username of whoever turned it on. | |
| 349 | + | pub started_by: String, | |
| 350 | + | /// RFC 3339. | |
| 351 | + | pub started_at: String, | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | /// A feature as a workspace sees it: what it costs, and its plan if it has | |
| 355 | + | /// one. | |
| 356 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 357 | + | #[serde(rename_all = "camelCase")] | |
| 358 | + | pub struct FeatureState { | |
| 359 | + | pub plan: Plan, | |
| 360 | + | pub subscription: Option<Subscription>, | |
| 361 | + | /// Whether the feature works for the workspace now. | |
| 362 | + | pub on: bool, | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | /// `features`: every paid feature and the workspace's plan for each. | |
| 366 | + | /// Members only. Returns `Outcome<Vec<FeatureState>>`. | |
| 367 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 368 | + | pub struct FeaturesArgs { | |
| 369 | + | pub workspace: String, | |
| 370 | + | pub viewer: Viewer, | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// `subscribe`: starts the card page for a feature's monthly plan. Owners | |
| 374 | + | /// only. Returns `Outcome<Checkout>`; the page's id comes back to | |
| 375 | + | /// `return_url` as `session`, for `confirm_subscription`. | |
| 376 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 377 | + | #[serde(rename_all = "camelCase")] | |
| 378 | + | pub struct SubscribeArgs { | |
| 379 | + | pub actor: User, | |
| 380 | + | pub workspace: String, | |
| 381 | + | pub feature: Feature, | |
| 382 | + | pub return_url: String, | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// `confirm_subscription`: turns the feature on once the processor says | |
| 386 | + | /// the plan was paid for. Safe to call any number of times. Returns | |
| 387 | + | /// `Outcome<FeatureState>`. | |
| 388 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 389 | + | pub struct ConfirmSubscriptionArgs { | |
| 390 | + | pub workspace: String, | |
| 391 | + | pub viewer: Viewer, | |
| 392 | + | pub session: String, | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /// `cancel_subscription` (`resume` false) ends a plan at the end of the | |
| 396 | + | /// period paid for; with `resume` true, takes that back. Owners only. | |
| 397 | + | /// Returns `Outcome<FeatureState>`. | |
| 398 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 399 | + | pub struct CancelSubscriptionArgs { | |
| 400 | + | pub actor: User, | |
| 401 | + | pub workspace: String, | |
| 402 | + | pub feature: Feature, | |
| 403 | + | #[serde(default)] | |
| 404 | + | pub resume: bool, | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /// `has_feature`: whether a feature works for a workspace now, asked by the | |
| 408 | + | /// service that provides it before doing paid work. Returns | |
| 409 | + | /// `Outcome<bool>`: a failure, with the reason to show, when it does not. | |
| 410 | + | /// True everywhere when no card processor is configured. | |
| 411 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 412 | + | pub struct HasFeatureArgs { | |
| 413 | + | pub workspace: String, | |
| 414 | + | pub feature: Feature, | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// `charge_feature`: usage of a feature past its plan's allowance, charged | |
| 418 | + | /// from the workspace's credit at cost plus the margin, whatever | |
| 419 | + | /// `FREE_WHILE_BUILDING` says. Called by the service that provides it. | |
| 420 | + | /// Charged once per `reference`. Returns `Outcome<bool>`: false if that | |
| 421 | + | /// reference was charged before. | |
| 422 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 423 | + | #[serde(rename_all = "camelCase")] | |
| 424 | + | pub struct ChargeFeatureArgs { | |
| 425 | + | pub workspace: String, | |
| 426 | + | pub feature: Feature, | |
| 427 | + | /// What it cost g1t, in millionths of a dollar, before the margin. | |
| 428 | + | pub cost_micros: i64, | |
| 429 | + | pub description: String, | |
| 430 | + | /// `namespace/name`, when the usage was one repository's. | |
| 431 | + | pub repo: Option<String>, | |
| 432 | + | /// Unique to this charge, e.g. `deployments/acme/2026-10`. | |
| 433 | + | pub reference: String, | |
| 434 | + | } | |
| 435 | + | ||
| 260 | 436 | #[cfg(test)] | |
| 261 | 437 | mod tests { | |
| 262 | 438 | use super::*; | |
| 263 | 439 | ||
| 264 | 440 | #[test] | |
| 441 | + | fn features_are_named_as_the_site_sends_them() { | |
| 442 | + | assert_eq!( | |
| 443 | + | serde_json::to_value(Feature::Deployments).unwrap(), | |
| 444 | + | serde_json::json!("deployments") | |
| 445 | + | ); | |
| 446 | + | assert_eq!(Feature::parse("deployments"), Some(Feature::Deployments)); | |
| 447 | + | assert!(SubscriptionStatus::Canceling.on()); | |
| 448 | + | assert!(!SubscriptionStatus::PastDue.on()); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | #[test] | |
| 265 | 452 | fn who_pays_is_read_as_the_runner_sends_it() { | |
| 266 | 453 | let run: StartRunArgs = serde_json::from_value(serde_json::json!({ | |
| 267 | 454 | "workspace": "acme", |
| 81 | 81 | reason: "off" | "ended" | "used" | "pool" | null; | |
| 82 | 82 | }; | |
| 83 | 83 | ||
| 84 | + | /** | |
| 85 | + | * A paid feature a workspace turns on with a monthly plan, as Cloudflare's | |
| 86 | + | * Workers for Platforms or Vercel's Pro are bought. Never free: neither | |
| 87 | + | * `free` nor the model allowance covers it. Mirrors `Feature` in | |
| 88 | + | * `crates/contracts/src/billing.rs`. | |
| 89 | + | */ | |
| 90 | + | export type Feature = "deployments"; | |
| 91 | + | ||
| 92 | + | /** What the Deployments plan includes each month. Mirrors `deployments_allowance`. */ | |
| 93 | + | export const DEPLOYMENTS_ALLOWANCE = { | |
| 94 | + | apps: 10, | |
| 95 | + | requests: 1_000_000, | |
| 96 | + | cpuMs: 3_000_000, | |
| 97 | + | /** What Cloudflare charges g1t past that, in millionths of a dollar. */ | |
| 98 | + | microsPerAppMonth: 20_000, | |
| 99 | + | microsPerMillionRequests: 300_000, | |
| 100 | + | microsPerMillionCpuMs: 20_000, | |
| 101 | + | } as const; | |
| 102 | + | ||
| 103 | + | export type FeaturePlan = { | |
| 104 | + | feature: Feature; | |
| 105 | + | title: string; | |
| 106 | + | /** Charged every month while the plan is on, in cents. */ | |
| 107 | + | monthlyCents: number; | |
| 108 | + | /** What the price includes, one line each. */ | |
| 109 | + | includes: string[]; | |
| 110 | + | /** How usage past the allowance is charged. */ | |
| 111 | + | overage: string; | |
| 112 | + | }; | |
| 113 | + | ||
| 114 | + | export type SubscriptionStatus = "active" | "canceling" | "past_due" | "canceled"; | |
| 115 | + | ||
| 116 | + | export type Subscription = { | |
| 117 | + | feature: Feature; | |
| 118 | + | status: SubscriptionStatus; | |
| 119 | + | /** RFC 3339: when the period paid for ends. */ | |
| 120 | + | periodEnd: string | null; | |
| 121 | + | startedBy: string; | |
| 122 | + | startedAt: string; | |
| 123 | + | }; | |
| 124 | + | ||
| 125 | + | /** A feature as a workspace sees it. */ | |
| 126 | + | export type FeatureState = { | |
| 127 | + | plan: FeaturePlan; | |
| 128 | + | subscription: Subscription | null; | |
| 129 | + | /** Whether the feature works for the workspace now. */ | |
| 130 | + | on: boolean; | |
| 131 | + | }; | |
| 132 | + | ||
| 84 | 133 | export interface BillingApi { | |
| 85 | 134 | status(): Promise<BillingStatus>; | |
| 86 | 135 | /** Members of the workspace only. */ | |
| 108 | 157 | * Asks whether a workspace may start an agent and opens the run it will be | |
| 109 | 158 | * charged for. Null when billing is off; a failure when there is no credit. | |
| 110 | 159 | */ | |
| 160 | + | /** Every paid feature and the workspace's plan for each. Members only. */ | |
| 161 | + | features(workspace: string, viewer: Viewer): Promise<Result<FeatureState[]>>; | |
| 162 | + | /** | |
| 163 | + | * Starts the card page for a feature's monthly plan. Owners only. The | |
| 164 | + | * page's id comes back to `returnUrl` as `session`. | |
| 165 | + | */ | |
| 166 | + | subscribe(actor: User, workspace: string, feature: Feature, returnUrl: string): Promise<Result<{ url: string }>>; | |
| 167 | + | /** Turns the feature on once the plan is paid for. Safe to repeat. */ | |
| 168 | + | confirmSubscription(workspace: string, viewer: Viewer, session: string): Promise<Result<FeatureState>>; | |
| 169 | + | /** Ends a plan at the end of its period, or (`resume`) takes that back. Owners only. */ | |
| 170 | + | cancelSubscription(actor: User, workspace: string, feature: Feature, resume?: boolean): Promise<Result<FeatureState>>; | |
| 171 | + | /** Whether a feature works for a workspace now; a failure with the reason when not. */ | |
| 172 | + | hasFeature(workspace: string, feature: Feature): Promise<Result<boolean>>; | |
| 173 | + | /** | |
| 174 | + | * Usage past a plan's allowance, charged from credit at cost plus the | |
| 175 | + | * margin, once per `reference`. False if it was charged before. | |
| 176 | + | */ | |
| 177 | + | chargeFeature(charge: { | |
| 178 | + | workspace: string; | |
| 179 | + | feature: Feature; | |
| 180 | + | costMicros: number; | |
| 181 | + | description: string; | |
| 182 | + | repo?: string | null; | |
| 183 | + | reference: string; | |
| 184 | + | }): Promise<Result<boolean>>; | |
| 111 | 185 | startRun(run: { | |
| 112 | 186 | workspace: string; | |
| 113 | 187 | repo: RepoPath; |
| 188 | 188 | canStart: (workspace) => call("can_start", { workspace }), | |
| 189 | 189 | trial: (workspace, exempt) => call("trial", { workspace, exempt }), | |
| 190 | 190 | startRun: (run) => call("start_run", run), | |
| 191 | + | features: (workspace, viewer) => call("features", { workspace, viewer }), | |
| 192 | + | subscribe: (actor, workspace, feature, returnUrl) => | |
| 193 | + | call("subscribe", { actor, workspace, feature, returnUrl }), | |
| 194 | + | confirmSubscription: (workspace, viewer, session) => | |
| 195 | + | call("confirm_subscription", { workspace, viewer, session }), | |
| 196 | + | cancelSubscription: (actor, workspace, feature, resume = false) => | |
| 197 | + | call("cancel_subscription", { actor, workspace, feature, resume }), | |
| 198 | + | hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }), | |
| 199 | + | chargeFeature: (charge) => call("charge_feature", charge), | |
| 191 | 200 | }; | |
| 192 | 201 | } | |
| 193 | 202 |
| 1 | + | -- Paid features a workspace turns on with a monthly plan (deployments). | |
| 2 | + | -- Never free: FREE_WHILE_BUILDING does not cover them. | |
| 3 | + | ||
| 4 | + | CREATE TABLE subscriptions ( | |
| 5 | + | workspace TEXT NOT NULL, | |
| 6 | + | -- deployments. | |
| 7 | + | feature TEXT NOT NULL, | |
| 8 | + | -- The payment provider's subscription. | |
| 9 | + | subscription_id TEXT NOT NULL, | |
| 10 | + | -- active, canceling, past_due or canceled. | |
| 11 | + | status TEXT NOT NULL, | |
| 12 | + | -- When the period paid for ends; the plan is asked about again after. | |
| 13 | + | period_end TEXT, | |
| 14 | + | started_by TEXT NOT NULL, | |
| 15 | + | started_at TEXT NOT NULL, | |
| 16 | + | updated_at TEXT NOT NULL, | |
| 17 | + | PRIMARY KEY (workspace, feature) | |
| 18 | + | ); | |
| 19 | + | ||
| 20 | + | -- A card page for a plan rather than for credit. | |
| 21 | + | ALTER TABLE checkouts ADD COLUMN feature TEXT; |
| 1 | + | //! Paid features a workspace turns on with a monthly plan, the way | |
| 2 | + | //! Cloudflare's Workers for Platforms is bought: a price that includes an | |
| 3 | + | //! allowance, and usage past it charged from credit at cost plus the | |
| 4 | + | //! margin. None of it is free, whatever `FREE_WHILE_BUILDING` says. | |
| 5 | + | ||
| 6 | + | use g1t_contracts::billing::deployments_allowance as allowance; | |
| 7 | + | use g1t_contracts::billing::*; | |
| 8 | + | use g1t_contracts::time::rfc3339; | |
| 9 | + | use g1t_contracts::{FailureCode, Outcome, Role, new_id}; | |
| 10 | + | use g1t_kit::now_ms; | |
| 11 | + | use serde::Deserialize; | |
| 12 | + | use worker::Result; | |
| 13 | + | ||
| 14 | + | use crate::stripe::StripeSubscription; | |
| 15 | + | use crate::{Billing, Touched, members_only, optional}; | |
| 16 | + | ||
| 17 | + | #[derive(Deserialize)] | |
| 18 | + | struct SubscriptionRow { | |
| 19 | + | feature: String, | |
| 20 | + | subscription_id: String, | |
| 21 | + | status: String, | |
| 22 | + | period_end: Option<String>, | |
| 23 | + | started_by: String, | |
| 24 | + | started_at: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | #[derive(Deserialize)] | |
| 28 | + | struct PlanCheckoutRow { | |
| 29 | + | workspace: String, | |
| 30 | + | created_by: String, | |
| 31 | + | feature: String, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | fn status_from(text: &str) -> SubscriptionStatus { | |
| 35 | + | match text { | |
| 36 | + | "active" => SubscriptionStatus::Active, | |
| 37 | + | "canceling" => SubscriptionStatus::Canceling, | |
| 38 | + | "past_due" => SubscriptionStatus::PastDue, | |
| 39 | + | _ => SubscriptionStatus::Canceled, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | fn status_text(status: SubscriptionStatus) -> &'static str { | |
| 44 | + | match status { | |
| 45 | + | SubscriptionStatus::Active => "active", | |
| 46 | + | SubscriptionStatus::Canceling => "canceling", | |
| 47 | + | SubscriptionStatus::PastDue => "past_due", | |
| 48 | + | SubscriptionStatus::Canceled => "canceled", | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// What the processor's state for a plan means here. | |
| 53 | + | fn status_of(subscription: &StripeSubscription) -> SubscriptionStatus { | |
| 54 | + | match subscription.status.as_str() { | |
| 55 | + | "active" | "trialing" if subscription.cancel_at_period_end => SubscriptionStatus::Canceling, | |
| 56 | + | "active" | "trialing" => SubscriptionStatus::Active, | |
| 57 | + | "past_due" | "unpaid" | "incomplete" | "paused" => SubscriptionStatus::PastDue, | |
| 58 | + | _ => SubscriptionStatus::Canceled, | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | fn dollars(micros: i64) -> String { | |
| 63 | + | format!("${:.2}", micros as f64 / MICROS_PER_DOLLAR as f64) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl SubscriptionRow { | |
| 67 | + | fn subscription(&self) -> Option<Subscription> { | |
| 68 | + | Some(Subscription { | |
| 69 | + | feature: Feature::parse(&self.feature)?, | |
| 70 | + | status: status_from(&self.status), | |
| 71 | + | period_end: self.period_end.clone(), | |
| 72 | + | started_by: self.started_by.clone(), | |
| 73 | + | started_at: self.started_at.clone(), | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl Billing { | |
| 79 | + | pub(crate) fn plan(&self, feature: Feature) -> Plan { | |
| 80 | + | match feature { | |
| 81 | + | Feature::Deployments => Plan { | |
| 82 | + | feature, | |
| 83 | + | title: feature.title().to_owned(), | |
| 84 | + | monthly_cents: self.deployments_monthly_cents, | |
| 85 | + | includes: vec![ | |
| 86 | + | format!( | |
| 87 | + | "{} apps deployed at once, production and previews together", | |
| 88 | + | allowance::APPS | |
| 89 | + | ), | |
| 90 | + | format!("{} million requests", allowance::REQUESTS / 1_000_000), | |
| 91 | + | format!("{} million CPU milliseconds", allowance::CPU_MS / 1_000_000), | |
| 92 | + | "Previews that cost nothing while no one visits them".to_owned(), | |
| 93 | + | ], | |
| 94 | + | overage: format!( | |
| 95 | + | "Past that, from credit: {} per extra app a month, {} per million requests and {} per million CPU milliseconds (Cloudflare's price plus {}%).", | |
| 96 | + | dollars(crate::charge_micros( | |
| 97 | + | allowance::MICROS_PER_APP_MONTH as f64 / MICROS_PER_DOLLAR as f64, | |
| 98 | + | self.margin_percent | |
| 99 | + | )), | |
| 100 | + | dollars(crate::charge_micros( | |
| 101 | + | allowance::MICROS_PER_MILLION_REQUESTS as f64 / MICROS_PER_DOLLAR as f64, | |
| 102 | + | self.margin_percent | |
| 103 | + | )), | |
| 104 | + | dollars(crate::charge_micros( | |
| 105 | + | allowance::MICROS_PER_MILLION_CPU_MS as f64 / MICROS_PER_DOLLAR as f64, | |
| 106 | + | self.margin_percent | |
| 107 | + | )), | |
| 108 | + | self.margin_percent | |
| 109 | + | ), | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | async fn subscription_row(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 115 | + | self.db | |
| 116 | + | .prepare( | |
| 117 | + | "SELECT feature, subscription_id, status, period_end, started_by, started_at | |
| 118 | + | FROM subscriptions WHERE workspace = ? AND feature = ?", | |
| 119 | + | ) | |
| 120 | + | .bind(&[workspace.into(), feature.as_str().into()])? | |
| 121 | + | .first::<SubscriptionRow>(None) | |
| 122 | + | .await | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// Writes down what the processor says about a plan. | |
| 126 | + | async fn record( | |
| 127 | + | &self, | |
| 128 | + | workspace: &str, | |
| 129 | + | feature: Feature, | |
| 130 | + | subscription: &StripeSubscription, | |
| 131 | + | started_by: &str, | |
| 132 | + | ) -> Result<()> { | |
| 133 | + | let now = rfc3339(now_ms()); | |
| 134 | + | let period_end = subscription.period_end().map(|seconds| rfc3339(seconds.max(0) as u64 * 1000)); | |
| 135 | + | self.db | |
| 136 | + | .prepare( | |
| 137 | + | "INSERT INTO subscriptions | |
| 138 | + | (workspace, feature, subscription_id, status, period_end, started_by, started_at, updated_at) | |
| 139 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) | |
| 140 | + | ON CONFLICT (workspace, feature) DO UPDATE SET | |
| 141 | + | subscription_id = ?3, status = ?4, period_end = ?5, updated_at = ?7, | |
| 142 | + | started_by = CASE WHEN subscription_id = ?3 THEN started_by ELSE ?6 END, | |
| 143 | + | started_at = CASE WHEN subscription_id = ?3 THEN started_at ELSE ?7 END", | |
| 144 | + | ) | |
| 145 | + | .bind(&[ | |
| 146 | + | workspace.into(), | |
| 147 | + | feature.as_str().into(), | |
| 148 | + | subscription.id.as_str().into(), | |
| 149 | + | status_text(status_of(subscription)).into(), | |
| 150 | + | optional(period_end.as_deref()), | |
| 151 | + | started_by.into(), | |
| 152 | + | now.as_str().into(), | |
| 153 | + | ])? | |
| 154 | + | .run() | |
| 155 | + | .await?; | |
| 156 | + | Ok(()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// A workspace's plan for a feature, asking the processor again once | |
| 160 | + | /// the period it last knew of is over. | |
| 161 | + | async fn current(&self, workspace: &str, feature: Feature) -> Result<Option<SubscriptionRow>> { | |
| 162 | + | let Some(row) = self.subscription_row(workspace, feature).await? else { | |
| 163 | + | return Ok(None); | |
| 164 | + | }; | |
| 165 | + | let stale = row.period_end.as_deref().is_none_or(|end| end <= rfc3339(now_ms()).as_str()) | |
| 166 | + | && row.status != "canceled"; | |
| 167 | + | if let (true, Some(stripe)) = (stale, &self.stripe) { | |
| 168 | + | let subscription = stripe.subscription(&row.subscription_id).await?; | |
| 169 | + | self.record(workspace, feature, &subscription, &row.started_by).await?; | |
| 170 | + | return self.subscription_row(workspace, feature).await; | |
| 171 | + | } | |
| 172 | + | Ok(Some(row)) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | async fn state(&self, workspace: &str, feature: Feature) -> Result<FeatureState> { | |
| 176 | + | let subscription = self | |
| 177 | + | .current(workspace, feature) | |
| 178 | + | .await? | |
| 179 | + | .and_then(|row| row.subscription()); | |
| 180 | + | Ok(FeatureState { | |
| 181 | + | plan: self.plan(feature), | |
| 182 | + | on: self.stripe.is_none() || subscription.as_ref().is_some_and(|s| s.status.on()), | |
| 183 | + | subscription, | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | pub(crate) async fn features(&self, a: FeaturesArgs) -> Result<Outcome<Vec<FeatureState>>> { | |
| 188 | + | let workspace = a.workspace.to_lowercase(); | |
| 189 | + | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 190 | + | return Ok(members_only()); | |
| 191 | + | } | |
| 192 | + | let mut states = Vec::new(); | |
| 193 | + | for feature in Feature::ALL { | |
| 194 | + | states.push(self.state(&workspace, feature).await?); | |
| 195 | + | } | |
| 196 | + | Ok(Outcome::Ok(states)) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | pub(crate) async fn subscribe(&self, a: SubscribeArgs) -> Result<Outcome<Checkout>> { | |
| 200 | + | let workspace = a.workspace.to_lowercase(); | |
| 201 | + | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 202 | + | return Ok(Outcome::fail( | |
| 203 | + | FailureCode::Forbidden, | |
| 204 | + | "Only an owner can turn on a paid feature.", | |
| 205 | + | )); | |
| 206 | + | } | |
| 207 | + | let Some(stripe) = &self.stripe else { | |
| 208 | + | return Ok(Outcome::fail( | |
| 209 | + | FailureCode::Conflict, | |
| 210 | + | "Payments are not set up on this g1t, so every feature is already on.", | |
| 211 | + | )); | |
| 212 | + | }; | |
| 213 | + | if self.state(&workspace, a.feature).await?.subscription.is_some_and(|s| s.status.on()) { | |
| 214 | + | return Ok(Outcome::fail( | |
| 215 | + | FailureCode::Conflict, | |
| 216 | + | format!("{} is already on for {workspace}.", a.feature.title()), | |
| 217 | + | )); | |
| 218 | + | } | |
| 219 | + | let plan = self.plan(a.feature); | |
| 220 | + | let customer = self.row(&workspace).await?.and_then(|row| row.customer_id); | |
| 221 | + | let session = stripe | |
| 222 | + | .start_subscription( | |
| 223 | + | &workspace, | |
| 224 | + | a.feature.as_str(), | |
| 225 | + | &plan.title, | |
| 226 | + | plan.monthly_cents, | |
| 227 | + | customer.as_deref(), | |
| 228 | + | &a.return_url, | |
| 229 | + | ) | |
| 230 | + | .await?; | |
| 231 | + | let Some(url) = session.url else { | |
| 232 | + | return Err(worker::Error::RustError( | |
| 233 | + | "the card processor returned no payment page".into(), | |
| 234 | + | )); | |
| 235 | + | }; | |
| 236 | + | self.db | |
| 237 | + | .prepare( | |
| 238 | + | "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature) | |
| 239 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 240 | + | ) | |
| 241 | + | .bind(&[ | |
| 242 | + | session.id.into(), | |
| 243 | + | workspace.into(), | |
| 244 | + | plan.monthly_cents.into(), | |
| 245 | + | a.actor.username.into(), | |
| 246 | + | rfc3339(now_ms()).into(), | |
| 247 | + | a.feature.as_str().into(), | |
| 248 | + | ])? | |
| 249 | + | .run() | |
| 250 | + | .await?; | |
| 251 | + | Ok(Outcome::Ok(Checkout { url })) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | pub(crate) async fn confirm_subscription( | |
| 255 | + | &self, | |
| 256 | + | a: ConfirmSubscriptionArgs, | |
| 257 | + | ) -> Result<Outcome<FeatureState>> { | |
| 258 | + | let workspace = a.workspace.to_lowercase(); | |
| 259 | + | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 260 | + | return Ok(members_only()); | |
| 261 | + | } | |
| 262 | + | let checkout = self | |
| 263 | + | .db | |
| 264 | + | .prepare( | |
| 265 | + | "SELECT workspace, created_by, feature FROM checkouts | |
| 266 | + | WHERE id = ? AND workspace = ? AND status = 'open' AND feature IS NOT NULL", | |
| 267 | + | ) | |
| 268 | + | .bind(&[a.session.as_str().into(), workspace.as_str().into()])? | |
| 269 | + | .first::<PlanCheckoutRow>(None) | |
| 270 | + | .await?; | |
| 271 | + | let (Some(stripe), Some(checkout)) = (&self.stripe, checkout) else { | |
| 272 | + | // Unknown, someone else's, or already done: show where it stands. | |
| 273 | + | return Ok(Outcome::Ok(self.state(&workspace, Feature::Deployments).await?)); | |
| 274 | + | }; | |
| 275 | + | let Some(feature) = Feature::parse(&checkout.feature) else { | |
| 276 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such feature.")); | |
| 277 | + | }; | |
| 278 | + | let session = stripe.session(&a.session).await?; | |
| 279 | + | if let (Some(subscription_id), true) = (&session.subscription, session.payment_status == "paid") { | |
| 280 | + | let claimed = self | |
| 281 | + | .db | |
| 282 | + | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") | |
| 283 | + | .bind(&[a.session.as_str().into()])? | |
| 284 | + | .first::<Touched>(None) | |
| 285 | + | .await?; | |
| 286 | + | if claimed.is_some() { | |
| 287 | + | let subscription = stripe.subscription(subscription_id).await?; | |
| 288 | + | self.record(&checkout.workspace, feature, &subscription, &checkout.created_by) | |
| 289 | + | .await?; | |
| 290 | + | // Keep the card's customer, so later payments need no retyping. | |
| 291 | + | self.db | |
| 292 | + | .prepare( | |
| 293 | + | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 294 | + | VALUES (?1, 0, ?2, ?3) | |
| 295 | + | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", | |
| 296 | + | ) | |
| 297 | + | .bind(&[ | |
| 298 | + | checkout.workspace.as_str().into(), | |
| 299 | + | optional(session.customer.as_deref()), | |
| 300 | + | rfc3339(now_ms()).into(), | |
| 301 | + | ])? | |
| 302 | + | .run() | |
| 303 | + | .await?; | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | Ok(Outcome::Ok(self.state(&workspace, feature).await?)) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | pub(crate) async fn cancel_subscription( | |
| 310 | + | &self, | |
| 311 | + | a: CancelSubscriptionArgs, | |
| 312 | + | ) -> Result<Outcome<FeatureState>> { | |
| 313 | + | let workspace = a.workspace.to_lowercase(); | |
| 314 | + | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 315 | + | return Ok(Outcome::fail( | |
| 316 | + | FailureCode::Forbidden, | |
| 317 | + | "Only an owner can change a workspace's plans.", | |
| 318 | + | )); | |
| 319 | + | } | |
| 320 | + | let (Some(stripe), Some(row)) = (&self.stripe, self.current(&workspace, a.feature).await?) else { | |
| 321 | + | return Ok(Outcome::fail( | |
| 322 | + | FailureCode::NotFound, | |
| 323 | + | format!("{} is not on for {workspace}.", a.feature.title()), | |
| 324 | + | )); | |
| 325 | + | }; | |
| 326 | + | let subscription = stripe | |
| 327 | + | .cancel_at_period_end(&row.subscription_id, !a.resume) | |
| 328 | + | .await?; | |
| 329 | + | self.record(&workspace, a.feature, &subscription, &row.started_by) | |
| 330 | + | .await?; | |
| 331 | + | Ok(Outcome::Ok(self.state(&workspace, a.feature).await?)) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | pub(crate) async fn has_feature(&self, a: HasFeatureArgs) -> Result<Outcome<bool>> { | |
| 335 | + | let workspace = a.workspace.to_lowercase(); | |
| 336 | + | if self.state(&workspace, a.feature).await?.on { | |
| 337 | + | return Ok(Outcome::Ok(true)); | |
| 338 | + | } | |
| 339 | + | Ok(Outcome::fail( | |
| 340 | + | FailureCode::PaymentRequired, | |
| 341 | + | format!( | |
| 342 | + | "{} is a paid feature, and it is not on for {workspace}. An owner can turn it on under Billing on the workspace's page.", | |
| 343 | + | a.feature.title() | |
| 344 | + | ), | |
| 345 | + | )) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | pub(crate) async fn charge_feature(&self, a: ChargeFeatureArgs) -> Result<Outcome<bool>> { | |
| 349 | + | if self.stripe.is_none() || a.cost_micros <= 0 { | |
| 350 | + | return Ok(Outcome::Ok(false)); | |
| 351 | + | } | |
| 352 | + | let workspace = a.workspace.to_lowercase(); | |
| 353 | + | let seen = self | |
| 354 | + | .db | |
| 355 | + | .prepare("SELECT id FROM ledger WHERE reference = ?") | |
| 356 | + | .bind(&[a.reference.as_str().into()])? | |
| 357 | + | .first::<Touched>(None) | |
| 358 | + | .await?; | |
| 359 | + | if seen.is_some() { | |
| 360 | + | return Ok(Outcome::Ok(false)); | |
| 361 | + | } | |
| 362 | + | let cost = a.cost_micros as f64 / MICROS_PER_DOLLAR as f64; | |
| 363 | + | // Never free: the margin applies whatever FREE_WHILE_BUILDING says. | |
| 364 | + | let charge = crate::charge_micros(cost, self.margin_percent); | |
| 365 | + | let now = now_ms(); | |
| 366 | + | let timestamp = rfc3339(now); | |
| 367 | + | self.db | |
| 368 | + | .batch(vec![ | |
| 369 | + | self.db | |
| 370 | + | .prepare( | |
| 371 | + | "INSERT INTO ledger | |
| 372 | + | (id, workspace, kind, amount_micros, description, repo, task, | |
| 373 | + | cost_micros, reference, created_at, billed_to) | |
| 374 | + | VALUES (?, ?, 'usage', ?, ?, ?, ?, ?, ?, ?, 'g1t')", | |
| 375 | + | ) | |
| 376 | + | .bind(&[ | |
| 377 | + | new_id("led", now).into(), | |
| 378 | + | workspace.as_str().into(), | |
| 379 | + | (-(charge as f64)).into(), | |
| 380 | + | a.description.as_str().into(), | |
| 381 | + | optional(a.repo.as_deref()), | |
| 382 | + | a.feature.as_str().into(), | |
| 383 | + | (a.cost_micros as f64).into(), | |
| 384 | + | a.reference.as_str().into(), | |
| 385 | + | timestamp.as_str().into(), | |
| 386 | + | ])?, | |
| 387 | + | self.db | |
| 388 | + | .prepare( | |
| 389 | + | "INSERT INTO accounts (workspace, balance_micros, created_at) | |
| 390 | + | VALUES (?1, ?2, ?3) | |
| 391 | + | ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2", | |
| 392 | + | ) | |
| 393 | + | .bind(&[ | |
| 394 | + | workspace.as_str().into(), | |
| 395 | + | (-(charge as f64)).into(), | |
| 396 | + | timestamp.as_str().into(), | |
| 397 | + | ])?, | |
| 398 | + | ]) | |
| 399 | + | .await?; | |
| 400 | + | Ok(Outcome::Ok(true)) | |
| 401 | + | } | |
| 402 | + | } |
| 7 | 7 | //! g1t's margin comes off the balance. Every change is a ledger entry, and | |
| 8 | 8 | //! a balance is always the sum of its ledger. | |
| 9 | 9 | //! | |
| 10 | + | //! Paid features (deployments) are bought separately, as monthly plans; | |
| 11 | + | //! see `features`. They are never free. | |
| 12 | + | //! | |
| 10 | 13 | //! Without a card processor configured the service says so and charges | |
| 11 | 14 | //! nothing, so that g1t still runs where billing has not been set up. | |
| 12 | 15 | //! | |
| 13 | 16 | //! Reached only through service bindings; see `g1t_contracts::billing` for | |
| 14 | 17 | //! the methods and their arguments. | |
| 15 | 18 | ||
| 19 | + | mod features; | |
| 16 | 20 | mod stripe; | |
| 17 | 21 | ||
| 18 | 22 | use g1t_contracts::billing::*; | |
| 128 | 132 | free: bool, | |
| 129 | 133 | /// The free allowance on g1t's hosted models, when there is one. | |
| 130 | 134 | trial: Option<TrialConfig>, | |
| 135 | + | /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`). | |
| 136 | + | deployments_monthly_cents: u32, | |
| 131 | 137 | } | |
| 132 | 138 | ||
| 133 | 139 | /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`. | |
| 685 | 691 | .and_then(|fee| fee.to_string().parse().ok()) | |
| 686 | 692 | .unwrap_or(100_000), | |
| 687 | 693 | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 694 | + | deployments_monthly_cents: env | |
| 695 | + | .var("DEPLOYMENTS_MONTHLY_CENTS") | |
| 696 | + | .ok() | |
| 697 | + | .and_then(|cents| cents.to_string().parse().ok()) | |
| 698 | + | .unwrap_or(500), | |
| 688 | 699 | trial: { | |
| 689 | 700 | let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()); | |
| 690 | 701 | match ( | |
| 716 | 727 | "trial" => reply(&billing.trial(args(body)?).await?), | |
| 717 | 728 | "start_run" => reply(&billing.start_run(args(body)?).await?), | |
| 718 | 729 | "finish_run" => reply(&billing.finish_run(args(body)?).await?), | |
| 730 | + | "features" => reply(&billing.features(args(body)?).await?), | |
| 731 | + | "subscribe" => reply(&billing.subscribe(args(body)?).await?), | |
| 732 | + | "confirm_subscription" => reply(&billing.confirm_subscription(args(body)?).await?), | |
| 733 | + | "cancel_subscription" => reply(&billing.cancel_subscription(args(body)?).await?), | |
| 734 | + | "has_feature" => reply(&billing.has_feature(args(body)?).await?), | |
| 735 | + | "charge_feature" => reply(&billing.charge_feature(args(body)?).await?), | |
| 719 | 736 | _ => Response::error("Unknown method", 404), | |
| 720 | 737 | } | |
| 721 | 738 | } |
| 1 | − | //! The card processor, behind the two calls billing needs: start a payment | |
| 2 | − | //! page, and ask whether a payment was made. Stripe speaks form-encoded | |
| 1 | + | //! The card processor, behind the calls billing needs: start a payment | |
| 2 | + | //! page, ask whether a payment was made, and read or end a monthly plan. Stripe speaks form-encoded | |
| 3 | 3 | //! requests and JSON answers. | |
| 4 | 4 | ||
| 5 | 5 | use serde::Deserialize; | |
| 22 | 22 | /// What was paid, in cents. | |
| 23 | 23 | pub amount_total: Option<u32>, | |
| 24 | 24 | pub customer: Option<String>, | |
| 25 | + | /// For a plan's page: the subscription it started. | |
| 26 | + | #[serde(default)] | |
| 27 | + | pub subscription: Option<String>, | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 30 | + | /// A monthly plan. | |
| 31 | + | #[derive(Deserialize)] | |
| 32 | + | pub struct StripeSubscription { | |
| 33 | + | pub id: String, | |
| 34 | + | /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`… | |
| 35 | + | pub status: String, | |
| 36 | + | #[serde(default)] | |
| 37 | + | pub cancel_at_period_end: bool, | |
| 38 | + | /// Unix seconds. Older API versions carry it here… | |
| 39 | + | #[serde(default)] | |
| 40 | + | pub current_period_end: Option<i64>, | |
| 41 | + | /// …newer ones on each item. | |
| 42 | + | #[serde(default)] | |
| 43 | + | pub items: Option<Items>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[derive(Deserialize)] | |
| 47 | + | pub struct Items { | |
| 48 | + | pub data: Vec<Item>, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | #[derive(Deserialize)] | |
| 52 | + | pub struct Item { | |
| 53 | + | #[serde(default)] | |
| 54 | + | pub current_period_end: Option<i64>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl StripeSubscription { | |
| 58 | + | /// When the period paid for ends, in Unix seconds. | |
| 59 | + | pub fn period_end(&self) -> Option<i64> { | |
| 60 | + | self.current_period_end.or_else(|| { | |
| 61 | + | self.items | |
| 62 | + | .as_ref() | |
| 63 | + | .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max()) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 27 | 68 | /// Percent-encodes a form value. | |
| 28 | 69 | fn encode(value: &str) -> String { | |
| 29 | 70 | let mut encoded = String::with_capacity(value.len()); | |
| 132 | 173 | .await | |
| 133 | 174 | } | |
| 134 | 175 | ||
| 176 | + | /// Starts a page on which a feature's monthly plan is paid for by card. | |
| 177 | + | pub async fn start_subscription( | |
| 178 | + | &self, | |
| 179 | + | workspace: &str, | |
| 180 | + | feature: &str, | |
| 181 | + | title: &str, | |
| 182 | + | monthly_cents: u32, | |
| 183 | + | customer: Option<&str>, | |
| 184 | + | return_url: &str, | |
| 185 | + | ) -> Result<Session> { | |
| 186 | + | let separator = if return_url.contains('?') { '&' } else { '?' }; | |
| 187 | + | let mut fields = vec![ | |
| 188 | + | ("mode", "subscription".to_owned()), | |
| 189 | + | ("payment_method_types[0]", "card".to_owned()), | |
| 190 | + | ( | |
| 191 | + | "success_url", | |
| 192 | + | format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"), | |
| 193 | + | ), | |
| 194 | + | ("cancel_url", return_url.to_owned()), | |
| 195 | + | ("client_reference_id", workspace.to_owned()), | |
| 196 | + | ("metadata[workspace]", workspace.to_owned()), | |
| 197 | + | ("metadata[feature]", feature.to_owned()), | |
| 198 | + | ("subscription_data[metadata][workspace]", workspace.to_owned()), | |
| 199 | + | ("subscription_data[metadata][feature]", feature.to_owned()), | |
| 200 | + | ("line_items[0][quantity]", "1".to_owned()), | |
| 201 | + | ("line_items[0][price_data][currency]", "usd".to_owned()), | |
| 202 | + | ( | |
| 203 | + | "line_items[0][price_data][unit_amount]", | |
| 204 | + | monthly_cents.to_string(), | |
| 205 | + | ), | |
| 206 | + | ( | |
| 207 | + | "line_items[0][price_data][recurring][interval]", | |
| 208 | + | "month".to_owned(), | |
| 209 | + | ), | |
| 210 | + | ( | |
| 211 | + | "line_items[0][price_data][product_data][name]", | |
| 212 | + | format!("g1t {title} for {workspace}"), | |
| 213 | + | ), | |
| 214 | + | ]; | |
| 215 | + | if let Some(customer) = customer { | |
| 216 | + | fields.push(("customer", customer.to_owned())); | |
| 217 | + | } | |
| 218 | + | self.call(Method::Post, "/checkout/sessions", Some(form(&fields))) | |
| 219 | + | .await | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> { | |
| 223 | + | self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None) | |
| 224 | + | .await | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Ends a plan when its period does (`cancel` true), or takes that back. | |
| 228 | + | pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> { | |
| 229 | + | self.call( | |
| 230 | + | Method::Post, | |
| 231 | + | &format!("/subscriptions/{}", encode(id)), | |
| 232 | + | Some(form(&[("cancel_at_period_end", cancel.to_string())])), | |
| 233 | + | ) | |
| 234 | + | .await | |
| 235 | + | } | |
| 236 | + | ||
| 135 | 237 | pub async fn session(&self, id: &str) -> Result<Session> { | |
| 136 | 238 | self.call( | |
| 137 | 239 | Method::Get, |
| 37 | 37 | // of them empty turns it off. | |
| 38 | 38 | "TRIAL_WORKSPACE_MICROS": "1000000", | |
| 39 | 39 | "TRIAL_TOTAL_MICROS": "40000000", | |
| 40 | − | "TRIAL_UNTIL": "2026-10-23T06:59:59Z" | |
| 40 | + | "TRIAL_UNTIL": "2026-10-23T06:59:59Z", | |
| 41 | + | // The Deployments plan's monthly price, in cents. Turning the | |
| 42 | + | // feature on starts it; FREE_WHILE_BUILDING does not cover it. | |
| 43 | + | "DEPLOYMENTS_MONTHLY_CENTS": "500" | |
| 41 | 44 | }, | |
| 42 | 45 | // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the | |
| 43 | 46 | // runner decides who may start agents some other way. |