| 1 | import { ArrowUpRight, CreditCard, FileText } from "lucide-react"; |
| 2 | import { Suspense } from "react"; |
| 3 | import { Await, Form, Link, data, redirect } from "react-router"; |
| 4 | |
| 5 | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/billing"; |
| 8 | import { |
| 9 | Alerts, |
| 10 | CreditsCard, |
| 11 | OverageCard, |
| 12 | PrepayCard, |
| 13 | RaiseCard, |
| 14 | type SectionError, |
| 15 | SpendLimitCard, |
| 16 | SpikeBanner, |
| 17 | TrialCard, |
| 18 | } from "../../components/billing"; |
| 19 | import { AddOns, BillingOnStripeCard, BudgetAlerts, ComplimentaryCard, PlanSummary, StripeSkeleton } from "../../components/billing-settings"; |
| 20 | import { StatementView } from "../../components/statement"; |
| 21 | import { |
| 22 | cardCheckResult, |
| 23 | parseBudgetAlerts, |
| 24 | parseLimitRequest, |
| 25 | parsePrepay, |
| 26 | parseSpendLimit, |
| 27 | planStatus, |
| 28 | } from "../../lib/billing"; |
| 29 | import { planReturnNotice, prepaymentSettled } from "../../lib/billing-return"; |
| 30 | import { wholeDollars } from "../../lib/money"; |
| 31 | import { isStaff } from "../../lib/usage"; |
| 32 | import { page } from "../../lib/meta"; |
| 33 | import { billing } from "../../lib/services.server"; |
| 34 | import { assertSameOrigin, getViewer, managesBilling, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 35 | import { Card } from "../../components/ui/card"; |
| 36 | import { SubmitButton } from "../../components/ui"; |
| 37 | import { Unavailable } from "../../components/boundary"; |
| 38 | import { isFailed, orElse, part } from "../../lib/partial"; |
| 39 | |
| 40 | /** The trial and pools as published, when the price book cannot be read. */ |
| 41 | const DEFAULT_TRIAL_MICROS = 5_000_000; |
| 42 | |
| 43 | /** What a change says once it is done (`?done=`). */ |
| 44 | const DONE: Record<string, string> = { |
| 45 | prepaid: "Payment received. Your prepaid balance and what you can use went up by as much.", |
| 46 | prepaid_pending: |
| 47 | "Payment pending. Your prepaid balance goes up once the payment clears, and this page shows it the next time it loads. A bank transfer can take a few business days.", |
| 48 | budget: "Budget alerts saved.", |
| 49 | converted: "The g1t plan is on, on the same billing. Usage from now on draws on its included usage first.", |
| 50 | limit: "Spend limit saved.", |
| 51 | requested: "Sent. g1t answers within one business day, here and by email.", |
| 52 | told: "Thanks. g1t looks at it and answers within one business day, here and by email.", |
| 53 | keep: "Compute is running again for 24 hours, unless spending doubles again first.", |
| 54 | stop: "New compute stays paused until an owner chooses Keep going.", |
| 55 | }; |
| 56 | |
| 57 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 58 | return page(args, { title: `Billing · ${params.owner} · g1t` }); |
| 59 | } |
| 60 | |
| 61 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 62 | const viewer = getViewer(context); |
| 63 | const role = roleIn(viewer, params.owner); |
| 64 | // Members only; to anyone else the page does not exist. |
| 65 | if (!role) throw data(null, { status: 404 }); |
| 66 | const slug = params.owner.toLowerCase(); |
| 67 | const url = new URL(request.url); |
| 68 | const here = `/${slug}/-/billing`; |
| 69 | |
| 70 | // Back from Stripe: record what happened, then drop the ids from the address. |
| 71 | const cardCheck = url.searchParams.get("card_check"); |
| 72 | if (cardCheck) { |
| 73 | const checked = await billing.confirmCardCheck(slug, viewer, cardCheck); |
| 74 | throw redirect(checked.ok ? `${here}?checked=1#trial` : `${here}?problem=${encodeURIComponent(checked.error.message)}#trial`); |
| 75 | } |
| 76 | const session = url.searchParams.get("session"); |
| 77 | if (session) { |
| 78 | if (url.searchParams.get("plan")) { |
| 79 | const started = await billing.confirmSubscription(slug, viewer, session); |
| 80 | const doneKey = url.searchParams.get("plan") === "security" ? "security_on" : "subscribed"; |
| 81 | throw redirect(started.ok ? `${here}?done=${doneKey}` : `${here}?problem=${encodeURIComponent(started.error.message)}`); |
| 82 | } |
| 83 | // A card payment usually clears on the way back; a bank transfer never |
| 84 | // has yet. Say which, rather than "received" for both. |
| 85 | const before = await billing.account(slug, viewer).catch(() => null); |
| 86 | const paid = await billing.confirm(slug, viewer, session); |
| 87 | if (!paid.ok) throw redirect(`${here}?problem=${encodeURIComponent(paid.error.message)}#prepay`); |
| 88 | const ledger = await billing.ledger(slug, viewer).catch(() => null); |
| 89 | const settled = prepaymentSettled(before?.ok ? before.value.balanceMicros : null, paid.value.balanceMicros, ledger?.ok ? ledger.value : null); |
| 90 | throw redirect(`${here}?done=${settled ? "prepaid" : "prepaid_pending"}#prepay`); |
| 91 | } |
| 92 | // A plan started on the card already saved comes straight back. |
| 93 | const plans = url.searchParams.getAll("plan"); |
| 94 | if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`); |
| 95 | |
| 96 | const now = new Date(); |
| 97 | const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10); |
| 98 | const today = now.toISOString().slice(0, 10); |
| 99 | const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day"; |
| 100 | // Billing on Stripe (the card, and the way to Stripe's billing page) is |
| 101 | // for owners and billing managers; other members see the plan and usage. |
| 102 | const manages = managesBilling(viewer, params.owner); |
| 103 | // Stripe's own (the saved card, the upcoming invoice, whether Stripe Tax |
| 104 | // needs an address) is streamed: the page does not wait on Stripe, and |
| 105 | // its sections hold their place. When Stripe's side does not answer, the |
| 106 | // card that shows it says so; the plan's summary does without it. |
| 107 | const details = part( |
| 108 | billing.billingDetails(slug, viewer).then((result) => (result.ok ? result.value : null)), |
| 109 | "Billing on Stripe", |
| 110 | ); |
| 111 | const [account, statement, features, limit, entitled, requests, book, credits, report] = await Promise.all([ |
| 112 | billing.account(slug, viewer), |
| 113 | billing.statement(slug, viewer, url.searchParams.get("month"), group), |
| 114 | billing.features(slug, viewer), |
| 115 | // The account, its statement and the plan are what the page is about. |
| 116 | // Each of these is a card beside them, which says so in its place when |
| 117 | // billing does not answer for it. |
| 118 | part(billing.limit(slug, viewer), "Your spend limit"), |
| 119 | part(billing.entitlements(slug), "Your allowance and alerts"), |
| 120 | part(billing.limitRequests(slug, viewer), "Your limit requests"), |
| 121 | // The price book only fills in published defaults, which stand in for it. |
| 122 | billing.prices().catch(() => null), |
| 123 | part(billing.credits(slug, viewer), "Credit from g1t"), |
| 124 | // This month's usage so far: the plan's summary shows "—" without it. |
| 125 | billing.usageReport(slug, viewer, { from: monthStart, until: today }).catch(() => null), |
| 126 | ]); |
| 127 | const entitlements = orElse(entitled, null); |
| 128 | const featureStates = unwrap(features); |
| 129 | const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS; |
| 130 | const done = url.searchParams.get("done"); |
| 131 | const plan = featureStates.find((state) => state.plan.feature === "plan") ?? null; |
| 132 | const securityPlan = featureStates.find((state) => state.plan.feature === "security") ?? null; |
| 133 | // Credit from g1t for everything; grants scoped to models belong to usage no longer offered. |
| 134 | const general = !isFailed(credits) && credits.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : []; |
| 135 | return { |
| 136 | slug, |
| 137 | role, |
| 138 | // Billing managers manage billing as owners do. |
| 139 | managesBilling: manages, |
| 140 | staff: isStaff(viewer), |
| 141 | account: unwrap(account), |
| 142 | statement: unwrap(statement), |
| 143 | group, |
| 144 | plan, |
| 145 | securityPlan, |
| 146 | limit: isFailed(limit) ? limit : limit.ok ? limit.value : null, |
| 147 | entitlements, |
| 148 | /** Set when the allowance, alerts and trial could not be read: said once, at the top. */ |
| 149 | entitlementsFailed: isFailed(entitled) ? entitled : null, |
| 150 | requests: isFailed(requests) ? requests : requests.ok ? requests.value : [], |
| 151 | credits: isFailed(credits) ? credits : general.length > 0 && credits.ok ? { ...credits.value, grants: general } : null, |
| 152 | report: report?.ok ? report.value : null, |
| 153 | details, |
| 154 | trialMicros, |
| 155 | // The first month's limit on usage not yet paid for, from the price book. |
| 156 | firstMonthMicros: book?.free?.paidStartCeilingMicros ?? null, |
| 157 | notice: |
| 158 | url.searchParams.has("checked") && entitlements |
| 159 | ? cardCheckResult(entitlements, trialMicros) |
| 160 | : done === "subscribed" |
| 161 | ? planReturnNotice(plan, done) |
| 162 | : done === "security_on" |
| 163 | ? planReturnNotice(securityPlan, done) |
| 164 | : done && DONE[done] |
| 165 | ? DONE[done] |
| 166 | : null, |
| 167 | problem: url.searchParams.get("problem"), |
| 168 | }; |
| 169 | } |
| 170 | |
| 171 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 172 | assertSameOrigin(request); |
| 173 | const user = requireUser(context, request); |
| 174 | const form = await request.formData(); |
| 175 | const slug = params.owner.toLowerCase(); |
| 176 | // Spelled out: a form post arrives at a data address, not the page's. |
| 177 | const here = `${new URL(request.url).origin}/${slug}/-/billing`; |
| 178 | const intent = String(form.get("intent") ?? ""); |
| 179 | const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 }); |
| 180 | const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`); |
| 181 | |
| 182 | switch (intent) { |
| 183 | case "portal": { |
| 184 | // The card, billing address, tax IDs, invoices and receipts, and |
| 185 | // ending the plan, are all on Stripe's own billing page. |
| 186 | const started = await billing.billingPortal(user, slug, here); |
| 187 | // From the plan's card (Manage billing) the error shows there. |
| 188 | if (!started.ok) return fail(form.get("from") === "plan" ? "plan" : "stripe", started.error.message); |
| 189 | throw redirect(started.value.url); |
| 190 | } |
| 191 | case "subscribe": { |
| 192 | // The plan. Security and quality comes with it: billing refuses it on its own. |
| 193 | const feature = form.get("feature") === "security" ? "security" : "plan"; |
| 194 | const started = await billing.subscribe(user, slug, feature, `${here}?plan=${feature}`); |
| 195 | if (!started.ok) return fail(feature, started.error.message); |
| 196 | throw redirect(started.value.url); |
| 197 | } |
| 198 | case "convert": { |
| 199 | // From complimentary to the plan, on the same customer and subscription: |
| 200 | // a card page, or the bank's confirmation, when one is needed first. |
| 201 | const converted = await billing.convertToPaid(user, slug, here); |
| 202 | if (!converted.ok) return fail("complimentary", converted.error.message); |
| 203 | if (converted.value.status === "converted") throw done("converted"); |
| 204 | if (converted.value.url) throw redirect(converted.value.url); |
| 205 | return fail("complimentary", converted.value.message); |
| 206 | } |
| 207 | case "card-check": { |
| 208 | const started = await billing.cardCheck(user, slug, here); |
| 209 | if (!started.ok) return fail("trial", started.error.message); |
| 210 | throw redirect(started.value.url); |
| 211 | } |
| 212 | case "budget": { |
| 213 | const parsed = parseBudgetAlerts({ alerts: form.getAll("alert"), pause: form.get("pause"), webhook: form.get("webhook") }); |
| 214 | if (!parsed.ok) return fail("budget", parsed.error); |
| 215 | const set = await billing.setBudget(user, slug, { amountMicros: null, keepLimit: true, ...parsed.value }); |
| 216 | if (!set.ok) return fail("budget", set.error.message); |
| 217 | throw done("budget", "#budget"); |
| 218 | } |
| 219 | case "spend-limit": { |
| 220 | const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") }); |
| 221 | if (!parsed.ok) return fail("limit", parsed.error); |
| 222 | let micros = parsed.value.micros; |
| 223 | if (parsed.value.raiseOnce && micros == null) { |
| 224 | // The one-time raise with no amount: as high as it goes. |
| 225 | const limit = await billing.limit(slug, user); |
| 226 | micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null; |
| 227 | if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit."); |
| 228 | } |
| 229 | const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce); |
| 230 | if (!set.ok) return fail("limit", set.error.message); |
| 231 | throw done("limit", "#limit"); |
| 232 | } |
| 233 | case "request": { |
| 234 | const parsed = parseLimitRequest({ |
| 235 | kind: form.get("kind"), |
| 236 | amount: form.get("amount"), |
| 237 | reason: form.get("reason"), |
| 238 | expected: form.get("expected"), |
| 239 | }); |
| 240 | const section = String(form.get("kind")) === "overage" ? "overage" : "raise"; |
| 241 | if (!parsed.ok) return fail(section, parsed.error); |
| 242 | const sent = await billing.requestLimit(user, slug, parsed.value); |
| 243 | if (!sent.ok) return fail(section, sent.error.message); |
| 244 | throw done(section === "overage" ? "told" : "requested", `#${section}`); |
| 245 | } |
| 246 | case "spike": { |
| 247 | const keep = String(form.get("decision")) === "keep"; |
| 248 | const answered = await billing.confirmSpike(user, slug, keep); |
| 249 | if (!answered.ok) return fail("spike", answered.error.message); |
| 250 | throw done(keep ? "keep" : "stop"); |
| 251 | } |
| 252 | case "prepay": { |
| 253 | const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") }); |
| 254 | if (!parsed.ok) return fail("prepay", parsed.error); |
| 255 | const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method); |
| 256 | if (!started.ok) return fail("prepay", started.error.message); |
| 257 | throw redirect(started.value.url); |
| 258 | } |
| 259 | default: |
| 260 | return fail("plan", "Unknown action."); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { |
| 265 | const { slug, role, staff, account, statement, group, plan, securityPlan, entitlements, entitlementsFailed, credits, report, details, trialMicros, firstMonthMicros, notice, problem } = |
| 266 | loaderData; |
| 267 | const limit = orElse(loaderData.limit, null); |
| 268 | const requests = orElse(loaderData.requests, []); |
| 269 | const { status } = account; |
| 270 | const owner = role === "owner" || loaderData.managesBilling; |
| 271 | const error = actionData as SectionError; |
| 272 | const err = (section: string) => (error && error.section === section ? error.error : undefined); |
| 273 | const standing = planStatus(plan, entitlements); |
| 274 | // A complimentary workspace charged past its allowance has the plan's limits too. |
| 275 | const chargedPast = standing.kind === "complimentary" && Boolean(entitlements?.complimentary?.chargePastAllowance); |
| 276 | const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due" || chargedPast; |
| 277 | const free = standing.kind === "free" || standing.kind === "trial"; |
| 278 | const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0; |
| 279 | // The spend limit and its budget are the owners' to set only where |
| 280 | // billing lets them: on the plan, never a free workspace's ceiling. |
| 281 | const selfServe = !!limit && (limit.trust === "paid" || limit.trust === "established"); |
| 282 | const summary = (loaded: Awaited<typeof details>) => ( |
| 283 | <PlanSummary |
| 284 | slug={slug} |
| 285 | state={plan} |
| 286 | status={standing} |
| 287 | details={orElse(loaded, null)} |
| 288 | report={report} |
| 289 | owner={owner} |
| 290 | enabled={status.enabled} |
| 291 | live={status.live} |
| 292 | staff={staff} |
| 293 | firstMonthMicros={firstMonthMicros} |
| 294 | error={err("plan")} |
| 295 | /> |
| 296 | ); |
| 297 | |
| 298 | return ( |
| 299 | <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]"> |
| 300 | <div className="min-w-0"> |
| 301 | {status.free && ( |
| 302 | <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5"> |
| 303 | <h2 className="font-medium">Free while g1t is being built out</h2> |
| 304 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> |
| 305 | Usage is recorded with what it costs, so Usage shows what you use, but nothing is charged for now. We will say so well |
| 306 | before anything is. |
| 307 | </p> |
| 308 | </div> |
| 309 | )} |
| 310 | {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>} |
| 311 | {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>} |
| 312 | |
| 313 | <Suspense fallback={null}> |
| 314 | <Await resolve={details}> |
| 315 | {(loaded) => |
| 316 | !isFailed(loaded) && loaded?.taxAddressNeededAt ? ( |
| 317 | <div role="alert" className="mb-6 flex flex-wrap items-start gap-3 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm"> |
| 318 | <div className="min-w-0 flex-1 basis-64"> |
| 319 | <p className="font-medium">Add your billing address in Stripe</p> |
| 320 | <p className="mt-1 text-muted"> |
| 321 | Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the |
| 322 | address is saved.{owner ? "" : " An owner or billing manager adds it from Manage billing."} |
| 323 | </p> |
| 324 | </div> |
| 325 | {owner && status.enabled && ( |
| 326 | <Form method="post"> |
| 327 | <SubmitButton variant="accent" name="intent" value="portal" pending="Opening Stripe…"> |
| 328 | Manage billing |
| 329 | <ArrowUpRight size={14} /> |
| 330 | </SubmitButton> |
| 331 | </Form> |
| 332 | )} |
| 333 | </div> |
| 334 | ) : null |
| 335 | } |
| 336 | </Await> |
| 337 | </Suspense> |
| 338 | {entitlementsFailed && ( |
| 339 | <div className="mb-6"> |
| 340 | <Unavailable what={entitlementsFailed.what} /> |
| 341 | </div> |
| 342 | )} |
| 343 | {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />} |
| 344 | {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>} |
| 345 | <Alerts alerts={entitlements?.alerts ?? []} /> |
| 346 | |
| 347 | <Suspense fallback={summary(null)}> |
| 348 | <Await resolve={details}>{(loaded) => summary(loaded)}</Await> |
| 349 | </Suspense> |
| 350 | |
| 351 | {entitlements?.complimentary && standing.kind === "complimentary" && ( |
| 352 | <ComplimentaryCard month={entitlements.complimentary} owner={owner} enabled={status.enabled} error={err("complimentary")} /> |
| 353 | )} |
| 354 | |
| 355 | {free && status.enabled && ( |
| 356 | <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} /> |
| 357 | )} |
| 358 | |
| 359 | {paying && isFailed(loaderData.limit) && ( |
| 360 | <div className="mb-6"> |
| 361 | <Unavailable what={loaderData.limit.what} /> |
| 362 | </div> |
| 363 | )} |
| 364 | {paying && limit && selfServe && ( |
| 365 | <> |
| 366 | <SpendLimitCard limit={limit} owner={owner} error={err("limit")} /> |
| 367 | <BudgetAlerts limit={limit} owner={owner} error={err("budget")} /> |
| 368 | <RaiseCard requests={requests} owner={owner} error={err("raise")} /> |
| 369 | <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} error={err("prepay")} /> |
| 370 | </> |
| 371 | )} |
| 372 | |
| 373 | {isFailed(credits) ? ( |
| 374 | <div className="mb-6"> |
| 375 | <Unavailable what={credits.what} /> |
| 376 | </div> |
| 377 | ) : ( |
| 378 | credits && <CreditsCard credits={credits} /> |
| 379 | )} |
| 380 | |
| 381 | {/* Past requests are listed on the limit's cards: without them, say so rather than show none. */} |
| 382 | {isFailed(loaderData.requests) && (paying || (limit && selfServe)) && ( |
| 383 | <div className="mb-6"> |
| 384 | <Unavailable what={loaderData.requests.what} compact /> |
| 385 | </div> |
| 386 | )} |
| 387 | {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />} |
| 388 | |
| 389 | <AddOns plan={plan} security={securityPlan} error={err("security")} /> |
| 390 | |
| 391 | {status.enabled && owner && ( |
| 392 | <Suspense fallback={<StripeSkeleton />}> |
| 393 | <Await resolve={details}> |
| 394 | {(loaded) => |
| 395 | isFailed(loaded) ? ( |
| 396 | <div className="mb-6"> |
| 397 | <Unavailable what={loaded.what} /> |
| 398 | </div> |
| 399 | ) : ( |
| 400 | <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} /> |
| 401 | ) |
| 402 | } |
| 403 | </Await> |
| 404 | </Suspense> |
| 405 | )} |
| 406 | |
| 407 | <StatementView slug={slug} statement={statement} group={group} /> |
| 408 | </div> |
| 409 | |
| 410 | <aside className="space-y-5 text-sm"> |
| 411 | <Card asChild className="p-5"> |
| 412 | <section> |
| 413 | <h3 className="font-medium">How it is charged</h3> |
| 414 | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> |
| 415 | <li> |
| 416 | The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2100) * 10_000)} a month for the workspace, with $10 of usage |
| 417 | included. Runner minutes are a flat rate per machine; other usage is charged at what it costs g1t plus{" "} |
| 418 | {account.marginPercent}%, from the first second, after what is included. |
| 419 | </li> |
| 420 | <li>Credit from g1t comes off what you owe, the soonest-expiring first.</li> |
| 421 | <li> |
| 422 | Each month closes with an itemised invoice. No card is charged less than{" "} |
| 423 | {wholeDollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR)}; less carries over. |
| 424 | </li> |
| 425 | <li> |
| 426 | Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address you keep on Stripe, and it is |
| 427 | its own line on every receipt and invoice. |
| 428 | </li> |
| 429 | <li>No card processing fee on top: it is part of the price.</li> |
| 430 | <li>Invoices and receipts are on Stripe's billing page, under Manage billing.</li> |
| 431 | <li>No seats: add as many people as you like, once the workspace is on the plan.</li> |
| 432 | </ul> |
| 433 | <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm"> |
| 434 | <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg"> |
| 435 | <FileText size={14} /> Usage, by product and project |
| 436 | </Link> |
| 437 | <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg"> |
| 438 | <CreditCard size={14} /> Pricing and today's prices |
| 439 | </Link> |
| 440 | <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg"> |
| 441 | <ArrowUpRight size={14} /> How usage and billing work |
| 442 | </a> |
| 443 | </div> |
| 444 | </section> |
| 445 | </Card> |
| 446 | </aside> |
| 447 | </div> |
| 448 | ); |
| 449 | } |