Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 1 | import { Link, data, redirect, useLocation } from "react-router"; |
| 2 | ||
| 3 | import type { CreditKind, CreditMonth } from "@g1t/contracts"; | |
| 4 | ||
| 5 | import type { Route } from "./+types/credits"; | |
| 6 | import { CreditList } from "~/components/billing"; | |
| 7 | import { Button, Input, Notice, PageHeader, Section, Select, Stat } from "~/components/ui"; | |
| 8 | import { monthLong } from "~/lib/chart"; | |
| 9 | import { CREDIT_KINDS, isCreditKind, kindLabel, parseMonth } from "~/lib/credits"; | |
| 10 | import { fields, parseNote, parseSlug } from "~/lib/forms"; | |
| 11 | import { parseBy } from "~/lib/ledgers"; | |
| 12 | import { usd } from "~/lib/money"; | |
| 13 | import { DONE, doneKey } from "~/lib/review"; | |
| 14 | import { admin } from "~/lib/services.server"; | |
| 15 | import { settle } from "~/lib/settle"; | |
| 16 | import { requireStaff } from "~/lib/staff"; | |
| 17 | ||
| 18 | export const meta: Route.MetaFunction = () => [{ title: "Credits & refunds · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 19 | ||
| 20 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 21 | requireStaff(context); | |
| 22 | const url = new URL(request.url); | |
| 23 | const kindParam = url.searchParams.get("kind") ?? ""; | |
| 24 | const kind: CreditKind | null = isCreditKind(kindParam) ? kindParam : null; | |
| 25 | const month = parseMonth(url.searchParams.get("month")); | |
| 26 | const by = parseBy(url.searchParams.get("by")); | |
| 27 | const slug = parseSlug(url.searchParams.get("workspace") ?? ""); | |
| 28 | const workspace = slug.ok ? slug.value : null; | |
| 29 | const result = await settle(admin.credits({ kind, month, by, workspace })); | |
| 30 | const done = doneKey(request.url); | |
| 31 | return { | |
| 32 | kind, | |
| 33 | month, | |
| 34 | by, | |
| 35 | workspace, | |
| 36 | credits: result.ok ? result.value : null, | |
| 37 | error: result.ok ? null : result.error, | |
| 38 | done: done ? DONE[done] : null, | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | export async function action({ request, context }: Route.ActionArgs) { | |
| 43 | const staff = requireStaff(context); | |
| 44 | const form = await request.formData(); | |
| 45 | const values = fields(form, "id", "reason", "workspace"); | |
| 46 | const fail = (error: string) => data({ error, section: "credits", values }, { status: 422 }); | |
| 47 | if (values.id === "" || form.get("intent") !== "revoke-credit") return fail("Unknown action."); | |
| 48 | const reason = parseNote(values.reason); | |
| 49 | if (!reason.ok) return fail(reason.error); | |
| 50 | const result = await admin.revokeCredit(values.id, reason.value, staff.email); | |
| 51 | if (!result.ok) return fail(result.error.message); | |
| 52 | const back = new URL(request.url); | |
| 53 | back.searchParams.set("done", "revoked"); | |
| 54 | throw redirect(`${back.pathname}${back.search}#top`); | |
| 55 | } | |
| 56 | ||
| 57 | /** Each month's kinds, with a total line when there is more than one. */ | |
| 58 | function byMonth(months: CreditMonth[]): { month: string; rows: CreditMonth[]; total: CreditMonth }[] { | |
| 59 | const out: { month: string; rows: CreditMonth[]; total: CreditMonth }[] = []; | |
| 60 | for (const row of months) { | |
| 61 | let group = out.find((g) => g.month === row.month); | |
| 62 | if (!group) { | |
| 63 | group = { month: row.month, rows: [], total: { ...row, kind: row.kind, givenMicros: 0, grants: 0, usedMicros: 0, expiredMicros: 0, revokedMicros: 0 } }; | |
| 64 | out.push(group); | |
| 65 | } | |
| 66 | group.rows.push(row); | |
| 67 | group.total.givenMicros += row.givenMicros; | |
| 68 | group.total.grants += row.grants; | |
| 69 | group.total.usedMicros += row.usedMicros; | |
| 70 | group.total.expiredMicros += row.expiredMicros; | |
| 71 | group.total.revokedMicros += row.revokedMicros; | |
| 72 | } | |
| 73 | return out; | |
| 74 | } | |
| 75 | ||
| 76 | export default function Credits({ loaderData, actionData }: Route.ComponentProps) { | |
| 77 | const { kind, month, by, workspace, credits, error, done } = loaderData; | |
| 78 | const { pathname, search } = useLocation(); | |
| 79 | const failed = actionData && "error" in actionData ? { error: actionData.error, values: actionData.values } : null; | |
| 80 | const filtered = kind != null || month != null || by != null || workspace != null; | |
| 81 | const thisMonth = new Date().toISOString().slice(0, 7); | |
| 82 | const now = byMonth(credits?.months ?? []).find((g) => g.month === thisMonth)?.total; | |
| 83 | const open = (credits?.grants ?? []).filter((grant) => grant.state === "open"); | |
| 84 | ||
| 85 | return ( | |
| 86 | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 87 | <PageHeader | |
| 88 | title="Credits & refunds" | |
| 89 | description="Every credit staff have given: promotional and goodwill credit is given away when spent; a refund gives back money already paid. Give credit from a workspace's page, under Billing." | |
| 90 | /> | |
| 91 | {done && ( | |
| 92 | <div className="mt-6"> | |
| 93 | <Notice tone="ok">{done}</Notice> | |
| 94 | </div> | |
| 95 | )} | |
| 96 | {error ? ( | |
| 97 | <div className="mt-6"> | |
| 98 | <Notice tone="warn">Billing did not answer for credits: {error}</Notice> | |
| 99 | </div> | |
| 100 | ) : ( | |
| 101 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 102 | <Stat label="Given this month" value={usd(now?.givenMicros ?? 0, { cents: true })} hint={`${now?.grants ?? 0} credit${now?.grants === 1 ? "" : "s"}`} /> | |
| 103 | <Stat label="Spent this month" value={usd(now?.usedMicros ?? 0, { cents: true })} hint="On usage, every kind" /> | |
| 104 | <Stat label="Taken back this month" value={usd((now?.expiredMicros ?? 0) + (now?.revokedMicros ?? 0), { cents: true })} hint="Expired or revoked unused" /> | |
| 105 | <Stat | |
| 106 | label="Left to spend" | |
| 107 | value={usd(open.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })} | |
| 108 | hint={`${open.length} open credit${open.length === 1 ? "" : "s"}${filtered ? ", filtered" : ""}`} | |
| 109 | /> | |
| 110 | </div> | |
| 111 | )} | |
| 112 | ||
| 113 | {credits && credits.months.length > 0 && ( | |
| 114 | <Section className="mt-6" title="By month" description="The last 12 months by kind. Spent is what credit paid for that month, whenever it was given."> | |
| 115 | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 116 | <table className="w-full min-w-[34rem] text-sm"> | |
| 117 | <thead> | |
| 118 | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 119 | <th className="px-4 py-2 font-medium sm:pl-5">Month</th> | |
| 120 | <th className="px-4 py-2 font-medium">Kind</th> | |
| 121 | <th className="px-4 py-2 text-right font-medium">Given</th> | |
| 122 | <th className="px-4 py-2 text-right font-medium">Spent</th> | |
| 123 | <th className="px-4 py-2 text-right font-medium">Expired</th> | |
| 124 | <th className="px-4 py-2 text-right font-medium sm:pr-5">Revoked</th> | |
| 125 | </tr> | |
| 126 | </thead> | |
| 127 | <tbody> | |
| 128 | {byMonth(credits.months).map((group) => ( | |
| 129 | <MonthRows key={group.month} group={group} /> | |
| 130 | ))} | |
| 131 | </tbody> | |
| 132 | </table> | |
| 133 | </div> | |
| 134 | </Section> | |
| 135 | )} | |
| 136 | ||
| 137 | <form method="get" action="/credits" className="mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> | |
| 138 | <Select name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="sm:w-40"> | |
| 139 | <option value="">Any kind</option> | |
| 140 | {CREDIT_KINDS.map((option) => ( | |
| 141 | <option key={option.value} value={option.value}> | |
| 142 | {option.title} | |
| 143 | </option> | |
| 144 | ))} | |
| 145 | </Select> | |
| 146 | <Input type="month" name="month" defaultValue={month ?? ""} aria-label="Month given" className="sm:w-44" /> | |
| 147 | <Select name="by" defaultValue={by ?? ""} aria-label="Given by" className="sm:w-56"> | |
| 148 | <option value="">Anyone</option> | |
| 149 | {(credits?.staff ?? []).map((email) => ( | |
| 150 | <option key={email} value={email}> | |
| 151 | {email} | |
| 152 | </option> | |
| 153 | ))} | |
| 154 | </Select> | |
| 155 | <Input name="workspace" defaultValue={workspace ?? ""} placeholder="Workspace" aria-label="Workspace" className="font-mono sm:w-44" /> | |
| 156 | <div className="flex items-center gap-2"> | |
| 157 | <Button type="submit" variant="quiet" className="py-2"> | |
| 158 | Apply | |
| 159 | </Button> | |
| 160 | {filtered && ( | |
| 161 | <Link to="/credits" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 162 | Clear | |
| 163 | </Link> | |
| 164 | )} | |
| 165 | </div> | |
| 166 | </form> | |
| 167 | ||
| 168 | {credits && ( | |
| 169 | <Section | |
| 170 | className="mt-4" | |
| 171 | title={filtered ? "Credits that match" : "Every credit"} | |
| 172 | description={`Newest first${credits.grants.length >= 200 ? ", the newest 200" : ""}. Revoking takes back what is left; what was spent stays spent.`} | |
| 173 | > | |
| 174 | <CreditList grants={credits.grants} pathname={`${pathname}${search}`} error={failed} showWorkspace /> | |
| 175 | </Section> | |
| 176 | )} | |
| 177 | </main> | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | function MonthRows({ group }: { group: { month: string; rows: CreditMonth[]; total: CreditMonth } }) { | |
| 182 | const cell = "tabular px-4 py-2 text-right"; | |
| 183 | return ( | |
| 184 | <> | |
| 185 | {group.rows.map((row, index) => ( | |
| 186 | <tr key={row.kind} className={index === 0 ? "border-t border-line" : ""}> | |
| 187 | <td className="px-4 py-2 sm:pl-5">{index === 0 ? monthLong(group.month) : ""}</td> | |
| 188 | <td className="px-4 py-2 text-fg-soft"> | |
| 189 | {kindLabel(row.kind)} | |
| 190 | <span className="text-faint"> · {row.grants}</span> | |
| 191 | </td> | |
| 192 | <td className={cell}>{usd(row.givenMicros, { cents: true })}</td> | |
| 193 | <td className={cell}>{usd(row.usedMicros, { cents: true })}</td> | |
| 194 | <td className={`${cell} text-muted`}>{usd(row.expiredMicros, { cents: true })}</td> | |
| 195 | <td className={`${cell} text-muted sm:pr-5`}>{usd(row.revokedMicros, { cents: true })}</td> | |
| 196 | </tr> | |
| 197 | ))} | |
| 198 | {group.rows.length > 1 && ( | |
| 199 | <tr className="text-muted"> | |
| 200 | <td className="px-4 py-2 sm:pl-5" /> | |
| 201 | <td className="px-4 py-2 text-xs">All kinds</td> | |
| 202 | <td className={cell}>{usd(group.total.givenMicros, { cents: true })}</td> | |
| 203 | <td className={cell}>{usd(group.total.usedMicros, { cents: true })}</td> | |
| 204 | <td className={cell}>{usd(group.total.expiredMicros, { cents: true })}</td> | |
| 205 | <td className={`${cell} sm:pr-5`}>{usd(group.total.revokedMicros, { cents: true })}</td> | |
| 206 | </tr> | |
| 207 | )} | |
| 208 | </> | |
| 209 | ); | |
| 210 | } |