Skip to content

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

213 linesCodeBlameRaw
1/**
2 * A workspace's complimentary enrollment: the monthly usage allowance (at
3 * ordinary prices), the recurring monthly fee apart from it, this month's
4 * standing, the Stripe subscription that stands for it, and its terms over
5 * time. Staff enroll each workspace by hand; nothing enrolls one on its own.
6 */
7import type { ComplimentaryStanding } from "@g1t/contracts";
8
9import { Badge, Button, Field, Input, Notice, Section, Textarea, When } from "~/components/ui";
10import { dollarsField, usd } from "~/lib/money";
11import type { SectionError } from "~/lib/review";
12
13const STATUS: Record<ComplimentaryStanding["status"], { label: string; tone: "mint" | "warn" | "plain" }> = {
14 active: { label: "Active", tone: "mint" },
15 pending: { label: "Waiting for Stripe", tone: "warn" },
16 ended: { label: "Ended", tone: "plain" },
17};
18
19function allowanceText(micros: number, unlimited?: boolean) {
20 return unlimited ? "Unlimited" : `${usd(micros, { cents: true })} a month`;
21}
22
23function feeText(cents?: number) {
24 return cents ? `${usd(cents * 10_000, { cents: true })} a month` : "Waived";
25}
26
27export function ComplimentarySection({
28 workspace,
29 standing,
30 unavailable,
31 pathname,
32 error,
33}: {
34 workspace: string;
35 standing: ComplimentaryStanding | null;
36 unavailable: string | null;
37 pathname: string;
38 error: SectionError;
39}) {
40 const values = error?.values;
41 const open = standing && standing.status !== "ended" ? standing : null;
42 const month = open?.month ?? null;
43 return (
44 <Section
45 id="complimentary"
46 title="Complimentary"
47 description="The plan's features, a monthly usage allowance at ordinary prices, and a monthly fee of its own. Past the allowance new work that costs money stops; nothing is ever charged for it. No rollover."
48 >
49 {unavailable && <Notice tone="error">Billing could not say: {unavailable}</Notice>}
50 {open ? (
51 <div className="mb-4 space-y-3">
52 <div className="flex flex-wrap items-center gap-2 text-sm">
53 <Badge tone={STATUS[open.status].tone}>{STATUS[open.status].label}</Badge>
54 <span className="text-fg-soft">{allowanceText(open.allowanceMicros, open.unlimited)} allowance</span>
55 <span className="text-faint">·</span>
56 <span className="text-fg-soft">Fee {feeText(open.feeCents)}</span>
57 <span className="text-faint">·</span>
58 <span className="text-fg-soft">{open.chargePastAllowance ? "Past it: their card is charged" : "Past it: work stops"}</span>
59 </div>
60 {month && (
61 <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm sm:grid-cols-3">
62 <Stat label="Used this month" value={usd(month.usedMicros, { cents: true })} />
63 <Stat label="Metered, not billed yet" value={usd(month.pendingMicros ?? 0, { cents: true })} />
64 <Stat label="Held by running work" value={usd(month.heldMicros ?? 0, { cents: true })} />
65 <Stat label="Remaining" value={month.unlimited ? "Unlimited" : usd(month.remainingMicros, { cents: true })} />
66 <Stat label="Covered past it by g1t" value={usd(month.overrunMicros ?? 0, { cents: true })} />
67 <Stat label="Resets" value={<When at={month.resetsAt} />} />
68 </dl>
69 )}
70 <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3">
71 <Stat label="Money received, ever" value={usd(open.moneyReceivedMicros ?? 0, { cents: true })} />
72 <Stat label="Promotional credit, ever" value={usd(open.promotionalCreditMicros ?? 0, { cents: true })} />
73 <Stat label="Gifted usage, ever" value={usd(open.giftedMicros ?? 0, { cents: true })} />
74 </dl>
75 <p className="text-xs text-muted">
76 Stripe {open.live ? "live" : "test"}: {open.customerId ?? "no customer yet"}, {open.subscriptionId ?? "no subscription yet"}
77 {open.stripeStatus ? ` (${open.stripeStatus})` : ""}. Grant {open.grantId}.
78 </p>
79 {open.pendingOp && (
80 <Notice tone="info">
81 Still owed at Stripe: {open.pendingOp}. Tried again every fifteen minutes{open.lastError ? `; last error: ${open.lastError}` : "."}
82 </Notice>
83 )}
84 </div>
85 ) : (
86 <p className="mb-4 text-sm text-muted">
87 {standing?.status === "ended"
88 ? `Not enrolled. The last enrollment ended ${standing.endedReason === "converted" ? "when the owner started the plan" : "by staff"}.`
89 : "Not enrolled: the workspace is on its own plan."}
90 </p>
91 )}
92
93 <form method="post" action={`${pathname}#complimentary`} className="space-y-4">
94 <input type="hidden" name="intent" value="complimentary" />
95 {error && <Notice tone="error">{error.error}</Notice>}
96 <div className="grid gap-4 sm:grid-cols-2">
97 <Field label="Monthly allowance $" hint="Usage at ordinary prices, each calendar month (UTC). Changing it keeps what this month used.">
98 <Input
99 name="allowance"
100 inputMode="decimal"
101 placeholder="100.00"
102 defaultValue={values?.allowance ?? (open ? dollarsField(open.allowanceMicros) : "100.00")}
103 />
104 </Field>
105 <Field label="Monthly fee $" hint="Recurring, invoiced by Stripe, apart from the allowance. Blank or 0: waived.">
106 <Input name="fee" inputMode="decimal" placeholder="Waived" defaultValue={values?.fee ?? (open?.feeCents ? dollarsField(open.feeCents * 10_000) : "")} />
107 </Field>
108 </div>
109 <fieldset className="space-y-2 text-sm">
110 <legend className="mb-1 text-xs text-faint">Past the allowance</legend>
111 <label className="flex cursor-pointer items-start gap-2.5">
112 <input
113 type="radio"
114 name="past"
115 value="stop"
116 defaultChecked={values ? values.past !== "charge" : !open?.chargePastAllowance}
117 className="mt-0.5"
118 />
119 <span>
120 <span className="block font-medium">New paid work stops</span>
121 <span className="block text-xs text-muted">Nothing is ever charged. For partners g1t carries entirely.</span>
122 </span>
123 </label>
124 <label className="flex cursor-pointer items-start gap-2.5">
125 <input
126 type="radio"
127 name="past"
128 value="charge"
129 defaultChecked={values ? values.past === "charge" : Boolean(open?.chargePastAllowance)}
130 className="mt-0.5"
131 />
132 <span>
133 <span className="block font-medium">Their card is charged</span>
134 <span className="block text-xs text-muted">
135 g1t covers the allowance; past it, usage is charged to the workspace's own card at the usual prices, up to its spend
136 limit. For employees and friends. Without a card, work stops until they add one.
137 </span>
138 </span>
139 </label>
140 </fieldset>
141 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
142 <input type="checkbox" name="unlimited" defaultChecked={values ? values.unlimited === "on" : Boolean(open?.unlimited)} className="mt-0.5" />
143 <span>
144 <span className="block font-medium">Unlimited allowance</span>
145 <span className="block text-xs text-muted">For g1t's own workspaces. Infrastructure limits still apply.</span>
146 </span>
147 </label>
148 <Field label="Note" hint="Required. Why, for whoever looks next.">
149 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner, $500 a month through Q1" />
150 </Field>
151 <Field label={`Type ${workspace} to confirm`}>
152 <Input name="confirmation" autoComplete="off" defaultValue="" />
153 </Field>
154 <div className="flex justify-end">
155 <Button type="submit">{open ? "Change terms" : "Enroll"}</Button>
156 </div>
157 </form>
158
159 {open && (
160 <form method="post" action={`${pathname}#complimentary`} className="mt-6 space-y-3 border-t border-line pt-4">
161 <input type="hidden" name="intent" value="end-complimentary" />
162 <p className="text-sm text-muted">
163 Ending it puts the workspace on Free now and cancels its Stripe subscription. Usage the allowance paid for stays paid for.
164 </p>
165 <div className="grid gap-3 sm:grid-cols-2">
166 <Field label="Note">
167 <Input name="note" required maxLength={500} placeholder="Why" />
168 </Field>
169 <Field label={`Type ${workspace} to end`}>
170 <Input name="confirmation" autoComplete="off" />
171 </Field>
172 </div>
173 <div className="flex justify-end">
174 <Button type="submit" variant="danger">
175 End enrollment
176 </Button>
177 </div>
178 </form>
179 )}
180
181 {standing?.periods && standing.periods.length > 0 && (
182 <div className="mt-6 border-t border-line pt-4">
183 <p className="text-xs text-faint">Terms over time</p>
184 <ul className="mt-2 space-y-1 text-xs">
185 {standing.periods.map((period) => (
186 <li key={period.id} className="flex flex-wrap gap-x-2 text-fg-soft">
187 <When at={period.startsAt} />
188 <span>→</span>
189 {period.endsAt ? <When at={period.endsAt} /> : <span>now</span>}
190 <span>
191 {allowanceText(period.allowanceMicros, period.unlimited)}, fee {feeText(period.feeCents)},{" "}
192 {period.chargePastAllowance ? "then their card" : "then stops"}
193 </span>
194 <span className="text-muted">
195 {period.createdBy}: {period.note}
196 </span>
197 </li>
198 ))}
199 </ul>
200 </div>
201 )}
202 </Section>
203 );
204}
205
206function Stat({ label, value }: { label: string; value: React.ReactNode }) {
207 return (
208 <div>
209 <dt className="text-xs text-faint">{label}</dt>
210 <dd className="tabular-nums text-fg-soft">{value}</dd>
211 </div>
212 );
213}