flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/routes/workspace/billing.tsx

212 lines8,236 bytesCodeBlame
1import { CreditCard } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
4import { MICROS_PER_DOLLAR } from "@g1t/contracts";
5
6import type { Route } from "./+types/billing";
7import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8import { billing } from "../../lib/services.server";
9import {
10 assertSameOrigin,
11 getViewer,
12 requireUser,
13 roleIn,
14 unwrap,
15} from "../../lib/session.server";
16
17/** What can be added in one payment, in dollars. */
18const AMOUNTS = [10, 25, 50, 100];
19
20export function meta({ params }: Route.MetaArgs) {
21 return [{ title: `Billing · ${params.owner} · g1t` }];
22}
23
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 const role = roleIn(viewer, params.owner);
27 // Members only; to anyone else the page does not exist.
28 if (!role) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30
31 // Back from the payment page: credit it, then drop the id from the address.
32 const url = new URL(request.url);
33 const session = url.searchParams.get("session");
34 if (session) {
35 await billing.confirm(slug, viewer, session);
36 throw redirect(`/${slug}/-/billing?added=1`);
37 }
38 const [account, ledger] = await Promise.all([
39 billing.account(slug, viewer),
40 billing.ledger(slug, viewer),
41 ]);
42 return {
43 slug,
44 role,
45 account: unwrap(account),
46 ledger: unwrap(ledger),
47 added: url.searchParams.has("added"),
48 };
49}
50
51export async function action({ request, params, context }: Route.ActionArgs) {
52 assertSameOrigin(request);
53 const user = requireUser(context, request);
54 const form = await request.formData();
55 const dollars = Math.trunc(Number(form.get("amount")));
56 const started = await billing.checkout(
57 user,
58 params.owner,
59 Number.isFinite(dollars) ? dollars * 100 : 0,
60 // Spelled out: a form post arrives at a data address, not the page's.
61 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
62 );
63 if (!started.ok) return { error: started.error.message };
64 throw redirect(started.value.url);
65}
66
67/** Millionths of a dollar as dollars, to the cent or finer. */
68function dollars(micros: number, digits = 2): string {
69 const sign = micros < 0 ? "−" : "";
70 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
71}
72
73export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
74 const { slug, role, account, ledger, added } = loaderData;
75 const { status } = account;
76 const paying = useNavigation().state === "submitting";
77 const empty = account.balanceMicros <= 0;
78 return (
79 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
80 <div className="min-w-0">
81 <h2 className="font-medium">Agent credit</h2>
82 <p className="mt-1 max-w-2xl text-sm text-muted">
83 g1t agents that work on this workspace's repositories are paid for from
84 its credit: what the model cost, plus {account.marginPercent}%. Checks run
85 free. With no credit, agents do not start.
86 </p>
87
88 <div
89 className={`mt-5 rounded-xl border p-5 ${
90 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
91 }`}
92 >
93 <p className="text-xs text-muted">Balance</p>
94 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
95 {dollars(account.balanceMicros)}
96 </p>
97 {added && (
98 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
99 )}
100 {!status.enabled ? (
101 <p className="mt-3 text-sm text-muted">
102 Payments are not set up on this g1t yet, so nothing is charged and
103 agents are limited to selected accounts.
104 </p>
105 ) : role === "owner" ? (
106 <Form method="post" className="mt-4">
107 <p className="text-sm text-muted">Add credit by card:</p>
108 <div className="mt-2 flex flex-wrap gap-2">
109 {AMOUNTS.map((amount) => (
110 <Button
111 key={amount}
112 variant={amount === 25 ? "accent" : "quiet"}
113 type="submit"
114 name="amount"
115 value={amount}
116 disabled={paying}
117 >
118 <CreditCard size={14} />${amount}
119 </Button>
120 ))}
121 </div>
122 {!status.live && (
123 <p className="mt-3 text-xs text-faint">
124 Payments are in test mode. No real card is charged; use Stripe's
125 test card 4242 4242 4242 4242 with any future date and any code.
126 </p>
127 )}
128 <div className="mt-2">
129 <ErrorText>{actionData?.error}</ErrorText>
130 </div>
131 </Form>
132 ) : (
133 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
134 )}
135 </div>
136
137 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
138 <div className="mt-3">
139 {ledger.length === 0 ? (
140 <EmptyState title="Nothing yet">
141 Each agent run and each payment appears here.
142 </EmptyState>
143 ) : (
144 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
145 {ledger.map((entry) => (
146 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
147 <div className="min-w-0 grow">
148 {entry.repo && entry.number ? (
149 <Link
150 to={`/${entry.repo}/pull/${entry.number}`}
151 className="block truncate font-medium hover:underline"
152 >
153 {entry.description}
154 </Link>
155 ) : (
156 <p className="truncate font-medium">{entry.description}</p>
157 )}
158 <p className="mt-0.5 text-xs text-faint">
159 <TimeAgo at={entry.createdAt} />
160 {entry.model && ` · ${entry.model}`}
161 {entry.createdBy && ` · ${entry.createdBy}`}
162 </p>
163 </div>
164 <span
165 className={`shrink-0 font-mono text-sm tabular-nums ${
166 entry.amountMicros > 0 ? "text-accent" : "text-muted"
167 }`}
168 >
169 {entry.amountMicros > 0 && "+"}
170 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
171 </span>
172 </li>
173 ))}
174 </ul>
175 )}
176 </div>
177 </div>
178
179 <aside className="space-y-5 text-sm">
180 <section className="rounded-xl border border-line bg-surface p-5">
181 <h3 className="font-medium">How it is charged</h3>
182 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
183 <li>
184 Each run is charged when it finishes, to the workspace that owns the
185 repository, whoever assigned the issue.
186 </li>
187 <li>
188 A change, a review, a revision and a catch-up are each a run. The
189 statement links each to its pull request.
190 </li>
191 <li>
192 Every pull request's session ends with what that run cost before
193 the margin.
194 </li>
195 <li>
196 With your own model provider, connected under{" "}
197 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
198 Integrations
199 </Link>
200 , the provider bills you for the model and each run here is a flat{" "}
201 {dollars(account.orchestrationFeeMicros)}.
202 </li>
203 <li>
204 Only members of <span className="font-mono text-fg">{slug}</span> can
205 put agents to work on its repositories.
206 </li>
207 </ul>
208 </section>
209 </aside>
210 </div>
211 );
212}