Commit

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.

syntaqxcommitted Parent6c69697Browse files
9 files+967−140/9 viewed
+148−10
1−import { CreditCard } from "lucide-react";
1+import { CreditCard, Rocket } from "lucide-react";
22 import { Form, Link, data, redirect, useNavigation } from "react-router";
33
4−import { MICROS_PER_DOLLAR } from "@g1t/contracts";
4+import { MICROS_PER_DOLLAR, type Feature, type FeatureState } from "@g1t/contracts";
55
66 import type { Route } from "./+types/billing";
77 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
3232 const url = new URL(request.url);
3333 const session = url.searchParams.get("session");
3434 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+ }
3540 await billing.confirm(slug, viewer, session);
3641 throw redirect(`/${slug}/-/billing?added=1`);
3742 }
38− const [account, ledger] = await Promise.all([
43+ const [account, ledger, features] = await Promise.all([
3944 billing.account(slug, viewer),
4045 billing.ledger(slug, viewer),
46+ billing.features(slug, viewer),
4147 ]);
4248 return {
4349 slug,
4450 role,
4551 account: unwrap(account),
4652 ledger: unwrap(ledger),
53+ features: unwrap(features),
4754 added: url.searchParams.has("added"),
55+ subscribed: url.searchParams.has("subscribed"),
4856 };
4957 }
5058
5260 assertSameOrigin(request);
5361 const user = requireUser(context, request);
5462 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+ }
5575 const dollars = Math.trunc(Number(form.get("amount")));
5676 const started = await billing.checkout(
5777 user,
7191 }
7292
7393 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;
7595 const { status } = account;
7696 const paying = useNavigation().state === "submitting";
7797 const empty = account.balanceMicros <= 0;
89109 </p>
90110 </div>
91111 )}
92− <h2 className="font-medium">Agent credit</h2>
112+ <h2 className="font-medium">Plans</h2>
93113 <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">
94136 g1t agents that work on this workspace's repositories are paid for from
95137 its credit: what the model cost, plus {account.marginPercent}%. Checks run
96138 free. With no credit, agents do not start.
108150 {added && (
109151 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
110152 )}
111− {status.free ? (
153+ {status.free && !features.some((state) => state.on && state.subscription) ? (
112154 <p className="mt-3 text-sm text-muted">
113155 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
114156 pricing starts.
120162 </p>
121163 ) : role === "owner" ? (
122164 <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>
124170 <div className="mt-2 flex flex-wrap gap-2">
125171 {AMOUNTS.map((amount) => (
126172 <Button
141187 test card 4242 4242 4242 4242 with any future date and any code.
142188 </p>
143189 )}
144− <div className="mt-2">
145− <ErrorText>{actionData?.error}</ErrorText>
146− </div>
147190 </Form>
148191 ) : (
149192 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
227270 </div>
228271 );
229272 }
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+}
+188−1
8484 pub enum EntryKind {
8585 /// Credit bought with a card.
8686 TopUp,
87− /// An agent's run.
87+ /// An agent's run, or a paid feature's usage past its allowance.
8888 Usage,
8989 }
9090
257257 pub added_micros: i64,
258258 }
259259
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+
260436 #[cfg(test)]
261437 mod tests {
262438 use super::*;
263439
264440 #[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]
265452 fn who_pays_is_read_as_the_runner_sends_it() {
266453 let run: StartRunArgs = serde_json::from_value(serde_json::json!({
267454 "workspace": "acme",
+74−0
8181 reason: "off" | "ended" | "used" | "pool" | null;
8282 };
8383
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+
84133 export interface BillingApi {
85134 status(): Promise<BillingStatus>;
86135 /** Members of the workspace only. */
108157 * Asks whether a workspace may start an agent and opens the run it will be
109158 * charged for. Null when billing is off; a failure when there is no credit.
110159 */
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>>;
111185 startRun(run: {
112186 workspace: string;
113187 repo: RepoPath;
+9−0
188188 canStart: (workspace) => call("can_start", { workspace }),
189189 trial: (workspace, exempt) => call("trial", { workspace, exempt }),
190190 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),
191200 };
192201 }
193202
+21−0
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;
+402−0
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+}
+17−0
77 //! g1t's margin comes off the balance. Every change is a ledger entry, and
88 //! a balance is always the sum of its ledger.
99 //!
10+//! Paid features (deployments) are bought separately, as monthly plans;
11+//! see `features`. They are never free.
12+//!
1013 //! Without a card processor configured the service says so and charges
1114 //! nothing, so that g1t still runs where billing has not been set up.
1215 //!
1316 //! Reached only through service bindings; see `g1t_contracts::billing` for
1417 //! the methods and their arguments.
1518
19+mod features;
1620 mod stripe;
1721
1822 use g1t_contracts::billing::*;
128132 free: bool,
129133 /// The free allowance on g1t's hosted models, when there is one.
130134 trial: Option<TrialConfig>,
135+ /// The Deployments plan's monthly price (`DEPLOYMENTS_MONTHLY_CENTS`).
136+ deployments_monthly_cents: u32,
131137 }
132138
133139 /// `TRIAL_WORKSPACE_MICROS`, `TRIAL_TOTAL_MICROS` and `TRIAL_UNTIL`.
685691 .and_then(|fee| fee.to_string().parse().ok())
686692 .unwrap_or(100_000),
687693 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),
688699 trial: {
689700 let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok());
690701 match (
716727 "trial" => reply(&billing.trial(args(body)?).await?),
717728 "start_run" => reply(&billing.start_run(args(body)?).await?),
718729 "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?),
719736 _ => Response::error("Unknown method", 404),
720737 }
721738 }
+104−2
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
33 //! requests and JSON answers.
44
55 use serde::Deserialize;
2222 /// What was paid, in cents.
2323 pub amount_total: Option<u32>,
2424 pub customer: Option<String>,
25+ /// For a plan's page: the subscription it started.
26+ #[serde(default)]
27+ pub subscription: Option<String>,
2528 }
2629
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+
2768 /// Percent-encodes a form value.
2869 fn encode(value: &str) -> String {
2970 let mut encoded = String::with_capacity(value.len());
132173 .await
133174 }
134175
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+
135237 pub async fn session(&self, id: &str) -> Result<Session> {
136238 self.call(
137239 Method::Get,
+4−1
3737 // of them empty turns it off.
3838 "TRIAL_WORKSPACE_MICROS": "1000000",
3939 "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"
4144 },
4245 // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the
4346 // runner decides who may start agents some other way.