| 1 | /** |
| 2 | * Billing settings' newer sections: the plan at a glance with the upcoming |
| 3 | * invoice, the budget's alerts, add-ons, and Billing on Stripe. The card, |
| 4 | * billing address, tax IDs, invoices and receipts, and ending the plan, are |
| 5 | * all managed on Stripe's own billing page (the customer portal): g1t has |
| 6 | * no form for any of them. Each form posts to the billing route's action |
| 7 | * with an `intent`. |
| 8 | */ |
| 9 | import { ArrowUpRight, Bell, CreditCard, FileText, Mail, Sparkles } from "lucide-react"; |
| 10 | import type { ReactNode } from "react"; |
| 11 | import { Form, Link } from "react-router"; |
| 12 | |
| 13 | import type { BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts"; |
| 14 | |
| 15 | import type { PlanStatus } from "../lib/billing"; |
| 16 | import { money, wholeDollars } from "../lib/money"; |
| 17 | import { Card } from "./billing"; |
| 18 | import { ErrorText, SubmitButton } from "./ui"; |
| 19 | import { Checkbox } from "./ui/checkbox"; |
| 20 | import { Skeleton } from "./ui/skeleton"; |
| 21 | |
| 22 | const FIELD = |
| 23 | "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; |
| 24 | |
| 25 | // --- The plan --------------------------------------------------------------------------------- |
| 26 | |
| 27 | /** |
| 28 | * The plan at a glance: its name and standing, the billing period, the |
| 29 | * included usage, and the upcoming invoice, from g1t's own ledger. |
| 30 | */ |
| 31 | export function PlanSummary({ |
| 32 | slug, |
| 33 | state, |
| 34 | status, |
| 35 | details, |
| 36 | report, |
| 37 | owner, |
| 38 | enabled, |
| 39 | live, |
| 40 | staff, |
| 41 | firstMonthMicros, |
| 42 | error, |
| 43 | }: { |
| 44 | slug: string; |
| 45 | state: FeatureState | null; |
| 46 | status: PlanStatus; |
| 47 | details: BillingDetails | null; |
| 48 | report: UsageReport | null; |
| 49 | owner: boolean; |
| 50 | enabled: boolean; |
| 51 | /** False while Stripe is in test mode, where cards are not real. */ |
| 52 | live: boolean; |
| 53 | staff: boolean; |
| 54 | /** The first month's limit on usage not yet paid for, from the price book. */ |
| 55 | firstMonthMicros?: number | null; |
| 56 | error?: string; |
| 57 | }) { |
| 58 | const plan = state?.plan; |
| 59 | const subscription = state?.subscription ?? null; |
| 60 | const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind); |
| 61 | const now = new Date(); |
| 62 | const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); |
| 63 | const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); |
| 64 | const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`; |
| 65 | const upcoming = details?.upcoming; |
| 66 | return ( |
| 67 | <Card |
| 68 | id="plan" |
| 69 | tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"} |
| 70 | icon={<Sparkles size={16} />} |
| 71 | title={ |
| 72 | <> |
| 73 | {plan?.title ?? "g1t"} |
| 74 | <span className={`rounded-full px-2 py-0.5 text-xs ${on && status.kind !== "past_due" && status.kind !== "canceling" ? "bg-success/15 text-success" : status.kind === "free" || status.kind === "trial" ? "border border-line text-muted" : "bg-warn/15 text-warn"}`}> |
| 75 | {status.kind === "canceling" && subscription?.periodEnd ? `Ends on ${endsOn(subscription.periodEnd)}` : status.label} |
| 76 | </span> |
| 77 | </> |
| 78 | } |
| 79 | about={ |
| 80 | on |
| 81 | ? `Billing period ${period}. One price for the workspace, never per seat.` |
| 82 | : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Checks, workflows and deployments need the plan, or the trial." |
| 83 | } |
| 84 | aside={ |
| 85 | <p className="shrink-0 sm:text-right"> |
| 86 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span> |
| 87 | <span className="text-sm text-muted"> / month</span> |
| 88 | <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span> |
| 89 | {status.kind !== "comped" && status.kind !== "enterprise" && <span className="block text-xs text-faint">Plus tax where it applies</span>} |
| 90 | </p> |
| 91 | } |
| 92 | > |
| 93 | <div className="mt-4 grid gap-4 sm:grid-cols-2"> |
| 94 | {report?.included ? ( |
| 95 | <div className="rounded-lg border border-line bg-bg/40 p-3"> |
| 96 | <p className="flex justify-between text-sm"> |
| 97 | <span className="text-muted">Included usage</span> |
| 98 | <span className="tabular-nums"> |
| 99 | {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span> |
| 100 | </span> |
| 101 | </p> |
| 102 | <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> |
| 103 | <div className="h-full rounded-full bg-accent" style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }} /> |
| 104 | </div> |
| 105 | <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p> |
| 106 | </div> |
| 107 | ) : ( |
| 108 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> |
| 109 | <p className="text-muted">Usage at price this month</p> |
| 110 | <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p> |
| 111 | {(report?.discountPercent ?? 0) > 0 && report && ( |
| 112 | <p className="text-xs text-faint"> |
| 113 | Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)} |
| 114 | </p> |
| 115 | )} |
| 116 | </div> |
| 117 | )} |
| 118 | <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm"> |
| 119 | <p className="flex justify-between"> |
| 120 | <span className="text-muted">Upcoming invoice</span> |
| 121 | <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span> |
| 122 | </p> |
| 123 | <p className="text-xs text-faint">Including add-ons and usage, from g1t's ledger{upcoming ? `; the month closes ${new Date(upcoming.closesAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}` : ""}.</p> |
| 124 | {upcoming && ( |
| 125 | <details className="mt-1.5"> |
| 126 | <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary> |
| 127 | <dl className="mt-1.5 space-y-1 text-xs"> |
| 128 | <div className="flex justify-between"> |
| 129 | <dt className="text-muted">Plan and add-ons</dt> |
| 130 | <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd> |
| 131 | </div> |
| 132 | <div className="flex justify-between"> |
| 133 | <dt className="text-muted">Usage owed, after included usage, credit and discount</dt> |
| 134 | <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd> |
| 135 | </div> |
| 136 | </dl> |
| 137 | </details> |
| 138 | )} |
| 139 | </div> |
| 140 | </div> |
| 141 | <div className="mt-4 flex flex-wrap items-center gap-2"> |
| 142 | <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg"> |
| 143 | <FileText size={14} /> Usage |
| 144 | </Link> |
| 145 | {owner && enabled && (status.kind === "free" || status.kind === "trial") && ( |
| 146 | <Form method="post" className="contents"> |
| 147 | <input type="hidden" name="feature" value="plan" /> |
| 148 | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> |
| 149 | <CreditCard size={14} /> |
| 150 | Start the g1t plan |
| 151 | </SubmitButton> |
| 152 | </Form> |
| 153 | )} |
| 154 | {owner && enabled && subscription && (status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && ( |
| 155 | // Ending the plan, keeping it after all, and the card it renews on |
| 156 | // are all on Stripe's billing page. |
| 157 | <Form method="post" className="contents"> |
| 158 | <input type="hidden" name="from" value="plan" /> |
| 159 | <SubmitButton variant={status.kind === "paid" ? "outline" : "accent"} name="intent" value="portal" pending="Opening Stripe…"> |
| 160 | {status.kind === "past_due" ? <CreditCard size={14} /> : null} |
| 161 | {status.kind === "past_due" ? "Update payment method" : "Manage billing"} |
| 162 | <ArrowUpRight size={14} /> |
| 163 | </SubmitButton> |
| 164 | </Form> |
| 165 | )} |
| 166 | <a href="mailto:sales@g1t.sh?subject=Custom%20needs" className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 167 | <Mail size={14} /> Custom needs? Contact us |
| 168 | </a> |
| 169 | </div> |
| 170 | {owner && enabled && subscription && status.kind === "paid" && ( |
| 171 | <p className="mt-3 text-xs text-faint">To end the plan at the end of the period, use Manage billing. Nothing more is charged for it after that.</p> |
| 172 | )} |
| 173 | {status.kind === "canceling" && ( |
| 174 | <p className="mt-3 text-sm text-muted"> |
| 175 | The plan ends at the end of the period and nothing more is charged for it.{" "} |
| 176 | {owner ? "To keep it, use Manage billing." : "An owner or billing manager can keep it from Manage billing."} |
| 177 | </p> |
| 178 | )} |
| 179 | {status.kind === "past_due" && ( |
| 180 | <p className="mt-3 text-sm text-warn"> |
| 181 | The last renewal payment failed, so the plan's features are stopped until it is paid.{" "} |
| 182 | {owner ? "Update the payment method, and Stripe tries the payment again." : "An owner or billing manager can update the payment method."} |
| 183 | </p> |
| 184 | )} |
| 185 | {staff && enabled && !live && (status.kind === "free" || status.kind === "trial") && ( |
| 186 | <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p> |
| 187 | )} |
| 188 | {(status.kind === "free" || status.kind === "trial") && ( |
| 189 | <p className="mt-3 text-xs text-faint"> |
| 190 | In its first month on the plan, a workspace can owe at most {wholeDollars(firstMonthMicros ?? 100_000_000)} of usage |
| 191 | not yet paid for, past what is included, so a stolen card cannot run up a bill. Work waits at whichever comes first: |
| 192 | that limit or your spend limit. After the first month, the limit grows as payments clear. |
| 193 | </p> |
| 194 | )} |
| 195 | <ErrorText>{error}</ErrorText> |
| 196 | </Card> |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | /** A plan's last day as people read it: "Nov 12, 2026". */ |
| 201 | export function endsOn(periodEnd: string): string { |
| 202 | return new Date(periodEnd).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); |
| 203 | } |
| 204 | |
| 205 | // --- Budget alerts -------------------------------------------------------------------------------- |
| 206 | |
| 207 | /** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */ |
| 208 | export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { |
| 209 | const levels = limit.alertLevels ?? [100, 90, 75, 50]; |
| 210 | const pause = limit.pauseAtLimit ?? true; |
| 211 | const spent = limit.spentMicros ?? 0; |
| 212 | const of = limit.spendLimitMicros; |
| 213 | return ( |
| 214 | <Card id="budget" icon={<Bell size={16} />} title="Budget alerts" about="On usage past what the plan includes, measured against the spend limit above. Each alert is emailed to the owners once a month."> |
| 215 | {of != null && ( |
| 216 | <div className="mt-3"> |
| 217 | <div className="flex justify-between text-sm"> |
| 218 | <span className="text-muted">Spent this month</span> |
| 219 | <span className="tabular-nums"> |
| 220 | {money(spent)} <span className="text-faint">of {money(of)}</span> |
| 221 | </span> |
| 222 | </div> |
| 223 | <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation"> |
| 224 | <div className={`h-full rounded-full ${spent >= of ? "bg-danger" : spent * 4 >= of * 3 ? "bg-warn" : "bg-success"}`} style={{ width: `${Math.min(100, (spent / Math.max(1, of)) * 100)}%` }} /> |
| 225 | {levels.filter((l) => l < 100).map((l) => ( |
| 226 | <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} /> |
| 227 | ))} |
| 228 | </div> |
| 229 | </div> |
| 230 | )} |
| 231 | {owner ? ( |
| 232 | <Form method="post" className="mt-4 space-y-3 text-sm"> |
| 233 | <input type="hidden" name="intent" value="budget" /> |
| 234 | <fieldset className="flex flex-wrap items-center gap-3"> |
| 235 | <legend className="mb-1.5 text-xs text-muted">Alert at</legend> |
| 236 | {[50, 75, 90, 100].map((level) => ( |
| 237 | <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums"> |
| 238 | <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} /> |
| 239 | {level}% |
| 240 | </label> |
| 241 | ))} |
| 242 | </fieldset> |
| 243 | <label className="flex cursor-pointer flex-wrap items-center gap-2"> |
| 244 | <Checkbox name="pause" defaultChecked={pause} /> |
| 245 | Pause usage at 100% |
| 246 | <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span> |
| 247 | </label> |
| 248 | <label className="block"> |
| 249 | <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span> |
| 250 | <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} /> |
| 251 | </label> |
| 252 | <SubmitButton variant="outline" match={{ intent: "budget" }} pending="Saving…"> |
| 253 | Save alerts |
| 254 | </SubmitButton> |
| 255 | </Form> |
| 256 | ) : ( |
| 257 | <p className="mt-3 text-sm text-muted"> |
| 258 | Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%. |
| 259 | </p> |
| 260 | )} |
| 261 | <ErrorText>{error}</ErrorText> |
| 262 | </Card> |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | // --- Billing on Stripe --------------------------------------------------------------------------- |
| 267 | |
| 268 | /** |
| 269 | * Billing on Stripe: the saved card, read-only, and one way to Stripe's |
| 270 | * billing page, where the card, the billing address, tax IDs, invoices and |
| 271 | * receipts are managed. Owners and billing managers only. |
| 272 | */ |
| 273 | export function BillingOnStripeCard({ |
| 274 | details, |
| 275 | enabled, |
| 276 | live, |
| 277 | staff, |
| 278 | error, |
| 279 | }: { |
| 280 | details: BillingDetails | null; |
| 281 | enabled: boolean; |
| 282 | /** False while Stripe is in test mode, where cards are not real. */ |
| 283 | live: boolean; |
| 284 | staff: boolean; |
| 285 | error?: string; |
| 286 | }) { |
| 287 | const method = details?.paymentMethod; |
| 288 | return ( |
| 289 | <Card |
| 290 | id="stripe" |
| 291 | icon={<CreditCard size={16} />} |
| 292 | title="Billing on Stripe" |
| 293 | about="Your card, billing address, tax IDs, invoices and receipts are kept on Stripe's billing page. g1t never sees card numbers." |
| 294 | aside={ |
| 295 | enabled ? ( |
| 296 | <Form method="post"> |
| 297 | <SubmitButton variant="outline" name="intent" value="portal" pending="Opening Stripe…"> |
| 298 | Manage billing |
| 299 | <ArrowUpRight size={14} /> |
| 300 | </SubmitButton> |
| 301 | </Form> |
| 302 | ) : null |
| 303 | } |
| 304 | > |
| 305 | <div className="mt-3 text-sm"> |
| 306 | {method ? ( |
| 307 | <p className="flex flex-wrap items-center gap-2"> |
| 308 | <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span> |
| 309 | {method.last4 && ( |
| 310 | <span> |
| 311 | ending <span className="font-mono">{method.last4}</span> |
| 312 | </span> |
| 313 | )} |
| 314 | {method.expMonth && method.expYear && ( |
| 315 | <span className="text-muted"> |
| 316 | · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear} |
| 317 | </span> |
| 318 | )} |
| 319 | </p> |
| 320 | ) : ( |
| 321 | <p className="text-muted">No card yet. Starting the plan saves the card you pay with.</p> |
| 322 | )} |
| 323 | {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>} |
| 324 | {staff && !live && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>} |
| 325 | </div> |
| 326 | <ErrorText>{error}</ErrorText> |
| 327 | </Card> |
| 328 | ); |
| 329 | } |
| 330 | |
| 331 | // --- Add-ons ------------------------------------------------------------------------------------ |
| 332 | |
| 333 | export function AddOns({ plan, security, error }: { plan: FeatureState | null; security: FeatureState | null; error?: string }) { |
| 334 | const planOn = !!plan?.on; |
| 335 | const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = []; |
| 336 | if (security) { |
| 337 | rows.push({ |
| 338 | key: "security", |
| 339 | name: security.plan.title, |
| 340 | about: "Custom patterns, validity checks, code scanning and dependency review on private repositories. No price of its own: scans are charged at cost plus 20%.", |
| 341 | price: "With the plan", |
| 342 | on: security.on, |
| 343 | label: security.on ? "On" : "Needs the plan", |
| 344 | control: null, |
| 345 | }); |
| 346 | } |
| 347 | rows.push({ |
| 348 | key: "deployments", |
| 349 | name: "Production deployments", |
| 350 | about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.", |
| 351 | price: "With the plan", |
| 352 | on: planOn, |
| 353 | label: planOn ? "On" : "Needs the plan", |
| 354 | control: null, |
| 355 | }); |
| 356 | return ( |
| 357 | <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface"> |
| 358 | <h2 className="px-5 pt-5 font-medium">Add-ons</h2> |
| 359 | <ul className="mt-3 divide-y divide-line border-t border-line"> |
| 360 | {rows.map((row) => ( |
| 361 | <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm"> |
| 362 | <div className="min-w-0 flex-1 basis-60"> |
| 363 | <p className="font-medium">{row.name}</p> |
| 364 | <p className="text-xs text-muted">{row.about}</p> |
| 365 | </div> |
| 366 | <span className="tabular-nums text-muted">{row.price}</span> |
| 367 | <span className={`rounded-full px-2 py-0.5 text-xs ${row.on ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{row.label}</span> |
| 368 | {row.control} |
| 369 | </li> |
| 370 | ))} |
| 371 | </ul> |
| 372 | {error && ( |
| 373 | <div className="px-5 pb-4"> |
| 374 | <ErrorText>{error}</ErrorText> |
| 375 | </div> |
| 376 | )} |
| 377 | </section> |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | /** Billing on Stripe while Stripe is asked: the same shape. */ |
| 382 | export function StripeSkeleton() { |
| 383 | return ( |
| 384 | <div aria-busy="true"> |
| 385 | <span role="status" className="sr-only"> |
| 386 | Loading from Stripe… |
| 387 | </span> |
| 388 | {[5.5].map((rem, i) => ( |
| 389 | <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}> |
| 390 | <Skeleton className="h-4 w-36" /> |
| 391 | <Skeleton className="mt-3 h-3 w-72 max-w-full" /> |
| 392 | <Skeleton className="mt-4 h-8 w-full" /> |
| 393 | </div> |
| 394 | ))} |
| 395 | </div> |
| 396 | ); |
| 397 | } |