| 1 | /** |
| 2 | * Moving workspaces off Stripe's test mode before the live key, history |
| 3 | * kept: one workspace on its page, every workspace on the Stripe page. A |
| 4 | * preview (dry run) lists what will change; the change itself needs the |
| 5 | * slug, or "detach", typed. sudo ships no JavaScript: the apply buttons say |
| 6 | * they are running with app.css's run-button rules. |
| 7 | */ |
| 8 | import { Eye, Unplug } from "lucide-react"; |
| 9 | |
| 10 | import type { TestStripeDetach, WorkspaceDetach } from "@g1t/contracts"; |
| 11 | |
| 12 | import { Badge, Button, EmptyState, Field, Input, Notice, Section } from "~/components/ui"; |
| 13 | import { usd } from "~/lib/money"; |
| 14 | import type { SectionError } from "~/lib/review"; |
| 15 | import { CONFIRM_ALL, DEFAULT_EXCLUDED, detachChanges } from "~/lib/test-detach"; |
| 16 | |
| 17 | const WHAT = |
| 18 | "Plans canceled; the Stripe customer, cached card and card checks forgotten (the trial grant stays); open checkouts removed; AI auto-reload and a declined autopay cleared; open invoices void; past months closed as skipped; AI credit closed; test payments reversed and what is owed written off as goodwill. The ledger, statements and audit log keep every line."; |
| 19 | |
| 20 | /** The busy-aware submit for a detach: "Detaching…" while the page waits. */ |
| 21 | function ApplyButton({ label }: { label: string }) { |
| 22 | return ( |
| 23 | <Button type="submit" variant="danger" className="run-button"> |
| 24 | <span className="run-idle inline-flex items-center gap-2"> |
| 25 | <Unplug size={14} /> |
| 26 | {label} |
| 27 | </span> |
| 28 | <span className="run-busy"> |
| 29 | <span className="run-spinner" aria-hidden="true" /> |
| 30 | Detaching… |
| 31 | </span> |
| 32 | </Button> |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | function CancelAtStripe({ defaultChecked = false }: { defaultChecked?: boolean }) { |
| 37 | return ( |
| 38 | <label className="flex items-start gap-2 text-sm"> |
| 39 | <input type="checkbox" name="cancelAtStripe" defaultChecked={defaultChecked} className="mt-0.5 accent-(--g1t-accent)" /> |
| 40 | <span> |
| 41 | <span className="block font-medium">Also cancel the subscriptions at Stripe</span> |
| 42 | <span className="block text-xs text-muted">Test key only. Without it, they are canceled here and left in Stripe's test mode.</span> |
| 43 | </span> |
| 44 | </label> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** One workspace's changes, with its balance before and after. */ |
| 49 | function Changes({ row }: { row: WorkspaceDetach }) { |
| 50 | return ( |
| 51 | <ul className="list-disc space-y-0.5 pl-4 text-sm text-fg-soft"> |
| 52 | {detachChanges(row).map((line) => ( |
| 53 | <li key={line}>{line}</li> |
| 54 | ))} |
| 55 | </ul> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | /** Every workspace a detach reaches, as a table. */ |
| 60 | export function DetachTable({ rows }: { rows: WorkspaceDetach[] }) { |
| 61 | return ( |
| 62 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> |
| 63 | <table className="w-full min-w-xl text-sm"> |
| 64 | <thead> |
| 65 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 66 | <th className="px-4 py-2 font-medium sm:pl-5">Workspace</th> |
| 67 | <th className="px-4 py-2 font-medium">What changes</th> |
| 68 | <th className="px-4 py-2 text-right font-medium sm:pr-5">Balance</th> |
| 69 | </tr> |
| 70 | </thead> |
| 71 | <tbody> |
| 72 | {rows.map((row) => ( |
| 73 | <tr key={row.workspace} className="border-b border-line align-top last:border-0"> |
| 74 | <td className="px-4 py-2.5 sm:pl-5"> |
| 75 | <a href={`/workspaces/${encodeURIComponent(row.workspace)}`} className="font-mono text-xs hover:underline"> |
| 76 | {row.workspace} |
| 77 | </a> |
| 78 | {row.done && ( |
| 79 | <span className="mt-1 block"> |
| 80 | <Badge tone="mint">Detached</Badge> |
| 81 | </span> |
| 82 | )} |
| 83 | </td> |
| 84 | <td className="px-4 py-2.5"> |
| 85 | <Changes row={row} /> |
| 86 | </td> |
| 87 | <td className="tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5"> |
| 88 | <span className="text-faint">{usd(row.balance_before_micros, { cents: true })}</span> |
| 89 | {" → "} |
| 90 | <span className="font-medium">{usd(row.balance_after_micros, { cents: true })}</span> |
| 91 | </td> |
| 92 | </tr> |
| 93 | ))} |
| 94 | </tbody> |
| 95 | </table> |
| 96 | </div> |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** The workspace page's section: preview, then confirm with the slug. */ |
| 101 | export function WorkspaceTestDetachSection({ |
| 102 | workspace, |
| 103 | pathname, |
| 104 | preview, |
| 105 | error, |
| 106 | }: { |
| 107 | workspace: string; |
| 108 | pathname: string; |
| 109 | preview: TestStripeDetach | null; |
| 110 | error: SectionError; |
| 111 | }) { |
| 112 | const row = preview?.workspaces.find((w) => w.workspace === workspace) ?? null; |
| 113 | return ( |
| 114 | <Section |
| 115 | id="test-detach" |
| 116 | title="Detach from Stripe test mode" |
| 117 | description={`Before billing takes a live Stripe key. ${WHAT} Refused once the key is live.`} |
| 118 | > |
| 119 | <div className="space-y-4"> |
| 120 | {error && <Notice tone="error">{error.error}</Notice>} |
| 121 | {!preview ? ( |
| 122 | <form method="post" action={`${pathname}#test-detach`}> |
| 123 | <input type="hidden" name="intent" value="test-detach" /> |
| 124 | <Button type="submit" variant="quiet"> |
| 125 | <Eye size={14} /> |
| 126 | Preview what changes |
| 127 | </Button> |
| 128 | </form> |
| 129 | ) : !row ? ( |
| 130 | <Notice tone="ok">Nothing from Stripe's test mode is left on {workspace}: a detach would change nothing.</Notice> |
| 131 | ) : ( |
| 132 | <> |
| 133 | <Changes row={row} /> |
| 134 | <p className="tabular text-sm text-muted"> |
| 135 | Balance {usd(row.balance_before_micros, { cents: true })} → {usd(row.balance_after_micros, { cents: true })}. |
| 136 | </p> |
| 137 | <form method="post" action={`${pathname}#test-detach`} className="space-y-4"> |
| 138 | <input type="hidden" name="intent" value="test-detach" /> |
| 139 | <input type="hidden" name="confirm" value="yes" /> |
| 140 | {row.subscriptions > 0 && <CancelAtStripe defaultChecked={error?.values?.cancelAtStripe === "on"} />} |
| 141 | <Field |
| 142 | label="Confirm" |
| 143 | hint={ |
| 144 | <> |
| 145 | Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to detach it. Kept in the audit log. |
| 146 | </> |
| 147 | } |
| 148 | > |
| 149 | <Input name="confirmation" required placeholder={workspace} className="font-mono" /> |
| 150 | </Field> |
| 151 | <div className="flex justify-end"> |
| 152 | <ApplyButton label="Detach from test mode" /> |
| 153 | </div> |
| 154 | </form> |
| 155 | </> |
| 156 | )} |
| 157 | </div> |
| 158 | </Section> |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | /** The Stripe page's section: every workspace, previewed, then confirmed. */ |
| 163 | export function EveryWorkspaceDetachSection({ |
| 164 | live, |
| 165 | result, |
| 166 | error, |
| 167 | exclude, |
| 168 | }: { |
| 169 | live: boolean; |
| 170 | result: TestStripeDetach | null; |
| 171 | error: string | null; |
| 172 | exclude: string | null; |
| 173 | }) { |
| 174 | const excludeValue = exclude ?? DEFAULT_EXCLUDED.join(", "); |
| 175 | const preview = result?.dry_run ? result : null; |
| 176 | const applied = result && !result.dry_run ? result : null; |
| 177 | return ( |
| 178 | <Section |
| 179 | id="test-detach" |
| 180 | title="Detach every workspace from test mode" |
| 181 | description={`Run before switching billing to the live key. ${WHAT} Comped and enterprise workspaces are skipped, and so is anything excluded below.`} |
| 182 | className="mt-6" |
| 183 | > |
| 184 | {live ? ( |
| 185 | <Notice tone="info">Billing is on the live key: test-mode data can no longer be detached.</Notice> |
| 186 | ) : ( |
| 187 | <div className="space-y-4"> |
| 188 | {error && <Notice tone="error">{error}</Notice>} |
| 189 | <form method="post" action="/stripe#test-detach" className="flex flex-col gap-2 sm:flex-row sm:items-end"> |
| 190 | <input type="hidden" name="intent" value="test-detach-preview" /> |
| 191 | <Field label="Leave alone" hint="Slugs, separated by commas. Blank leaves nothing out." className="grow"> |
| 192 | <Input name="exclude" defaultValue={excludeValue} className="font-mono" /> |
| 193 | </Field> |
| 194 | <Button type="submit" variant="quiet" className="shrink-0"> |
| 195 | <Eye size={14} /> |
| 196 | Preview |
| 197 | </Button> |
| 198 | </form> |
| 199 | |
| 200 | {applied && ( |
| 201 | <> |
| 202 | <Notice tone={applied.remaining > 0 ? "warn" : "ok"}> |
| 203 | Detached {applied.workspaces.length} workspace{applied.workspaces.length === 1 ? "" : "s"}. |
| 204 | {applied.remaining > 0 ? ` ${applied.remaining} more to go: preview and detach again.` : " Each is in its audit log."} |
| 205 | </Notice> |
| 206 | {applied.workspaces.length > 0 && <DetachTable rows={applied.workspaces} />} |
| 207 | </> |
| 208 | )} |
| 209 | |
| 210 | {preview && |
| 211 | (preview.workspaces.length === 0 ? ( |
| 212 | <EmptyState title="Nothing to detach">No workspace outside the exclusions holds anything from Stripe's test mode.</EmptyState> |
| 213 | ) : ( |
| 214 | <> |
| 215 | <p className="text-sm text-muted"> |
| 216 | {preview.workspaces.length} workspace{preview.workspaces.length === 1 ? "" : "s"} will change |
| 217 | {preview.workspaces.length > 25 ? ", 25 a run" : ""}: |
| 218 | </p> |
| 219 | <DetachTable rows={preview.workspaces} /> |
| 220 | <form method="post" action="/stripe#test-detach" className="space-y-4 border-t border-line pt-4"> |
| 221 | <input type="hidden" name="intent" value="test-detach-all" /> |
| 222 | <input type="hidden" name="exclude" value={excludeValue} /> |
| 223 | {preview.workspaces.some((w) => w.subscriptions > 0) && <CancelAtStripe />} |
| 224 | <Field label="Confirm" hint={<>Type <span className="font-mono text-muted">{CONFIRM_ALL}</span> to detach every workspace listed. Each is kept in its audit log.</>}> |
| 225 | <Input name="confirmation" required placeholder={CONFIRM_ALL} className="font-mono" /> |
| 226 | </Field> |
| 227 | <div className="flex justify-end"> |
| 228 | <ApplyButton label={`Detach ${Math.min(preview.workspaces.length, 25)} workspace${preview.workspaces.length === 1 ? "" : "s"}`} /> |
| 229 | </div> |
| 230 | </form> |
| 231 | </> |
| 232 | ))} |
| 233 | |
| 234 | {result && result.skipped.length > 0 && ( |
| 235 | <div> |
| 236 | <p className="mb-1.5 text-xs text-faint">Left alone, though they hold test-mode data</p> |
| 237 | <ul className="space-y-0.5 text-sm"> |
| 238 | {result.skipped.map((s) => ( |
| 239 | <li key={s.workspace}> |
| 240 | <span className="font-mono text-xs">{s.workspace}</span> <span className="text-muted">— {s.reason}</span> |
| 241 | </li> |
| 242 | ))} |
| 243 | </ul> |
| 244 | </div> |
| 245 | )} |
| 246 | </div> |
| 247 | )} |
| 248 | </Section> |
| 249 | ); |
| 250 | } |