Compare commits

...

1 Commits

Author SHA1 Message Date
Alan Rice f3a3062a41 feat(console): add editable invoice details 2026-07-21 08:13:38 +00:00
5 changed files with 295 additions and 1 deletions
+11
View File
@@ -544,6 +544,17 @@ export const dict = {
"workspace.settings.updating": "Updating...",
"workspace.settings.save": "Save",
"workspace.settings.edit": "Edit",
"workspace.settings.invoiceDetails.title": "Invoice details",
"workspace.settings.invoiceDetails.subtitle": "These details appear in the Bill to section of your invoices.",
"workspace.settings.invoiceDetails.empty": "Enable billing to add invoice details.",
"workspace.settings.invoiceDetails.name": "Company or legal name",
"workspace.settings.invoiceDetails.address1": "Address line 1",
"workspace.settings.invoiceDetails.address2": "Address line 2 (optional)",
"workspace.settings.invoiceDetails.city": "City",
"workspace.settings.invoiceDetails.state": "State or region (optional)",
"workspace.settings.invoiceDetails.postalCode": "Postal code",
"workspace.settings.invoiceDetails.country": "Country code",
"workspace.settings.invoiceDetails.saving": "Saving...",
"workspace.billing.title": "Billing",
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
@@ -1,10 +1,11 @@
import { SettingsSection } from "./settings-section"
import { InvoiceDetailsSection, SettingsSection } from "./settings-section"
export default function () {
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<SettingsSection />
<InvoiceDetailsSection />
</div>
</div>
)
@@ -91,4 +91,79 @@
margin-top: calc(var(--space-1) * -1);
}
}
[data-slot="empty-message"] {
color: var(--color-text-muted);
margin: 0;
}
[data-slot="invoice-summary"] {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
p {
color: var(--color-text);
line-height: 1.5;
margin: 0;
}
}
[data-slot="invoice-form"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
&[data-wide] {
grid-column: 1 / -1;
}
span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
}
input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
min-width: 0;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="form-actions"],
[data-slot="form-error"] {
grid-column: 1 / -1;
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
}
}
}
@@ -8,6 +8,8 @@ import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
@@ -47,6 +49,39 @@ const updateWorkspace = action(async (form: FormData) => {
)
}, "workspace.update")
const getInvoiceDetails = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
if (Actor.userRole() !== "admin") return { isAdmin: false, details: null }
return { isAdmin: true, details: await Billing.getInvoiceDetails() }
}, workspaceID)
}, "workspace.invoiceDetails.get")
const updateInvoiceDetails = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
Billing.updateInvoiceDetails({
name: String(form.get("name") ?? ""),
line1: String(form.get("line1") ?? ""),
line2: String(form.get("line2") ?? ""),
city: String(form.get("city") ?? ""),
state: String(form.get("state") ?? ""),
postalCode: String(form.get("postalCode") ?? ""),
country: String(form.get("country") ?? ""),
})
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getInvoiceDetails.key },
)
}, "workspace.invoiceDetails.update")
export function SettingsSection() {
const params = useParams()
const i18n = useI18n()
@@ -124,3 +159,122 @@ export function SettingsSection() {
</section>
)
}
export function InvoiceDetailsSection() {
const params = useParams()
const i18n = useI18n()
const invoiceInfo = createAsync(() => getInvoiceDetails(params.id!))
const submission = useSubmission(updateInvoiceDetails)
const [store, setStore] = createStore({ show: false })
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) setStore("show", false)
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
}
return (
<Show when={invoiceInfo()?.isAdmin}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.settings.invoiceDetails.title")}</h2>
<p>{i18n.t("workspace.settings.invoiceDetails.subtitle")}</p>
</div>
<div data-slot="section-content">
<Show
when={invoiceInfo()?.details}
fallback={<p data-slot="empty-message">{i18n.t("workspace.settings.invoiceDetails.empty")}</p>}
>
{(details) => (
<Show
when={!store.show}
fallback={
<form action={updateInvoiceDetails} method="post" data-slot="invoice-form">
<label>
<span>{i18n.t("workspace.settings.invoiceDetails.name")}</span>
<input required name="name" value={details().name} autocomplete="organization" />
</label>
<label data-wide>
<span>{i18n.t("workspace.settings.invoiceDetails.address1")}</span>
<input required name="line1" value={details().address.line1} autocomplete="address-line1" />
</label>
<label data-wide>
<span>{i18n.t("workspace.settings.invoiceDetails.address2")}</span>
<input name="line2" value={details().address.line2} autocomplete="address-line2" />
</label>
<label>
<span>{i18n.t("workspace.settings.invoiceDetails.city")}</span>
<input required name="city" value={details().address.city} autocomplete="address-level2" />
</label>
<label>
<span>{i18n.t("workspace.settings.invoiceDetails.state")}</span>
<input name="state" value={details().address.state} autocomplete="address-level1" />
</label>
<label>
<span>{i18n.t("workspace.settings.invoiceDetails.postalCode")}</span>
<input
required
name="postalCode"
value={details().address.postalCode}
autocomplete="postal-code"
/>
</label>
<label>
<span>{i18n.t("workspace.settings.invoiceDetails.country")}</span>
<input
required
name="country"
value={details().address.country}
autocomplete="country-code"
minlength="2"
maxlength="2"
placeholder="US"
/>
</label>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending
? i18n.t("workspace.settings.invoiceDetails.saving")
: i18n.t("workspace.settings.save")}
</button>
<button type="reset" data-color="ghost" onClick={() => setStore("show", false)}>
{i18n.t("common.cancel")}
</button>
</div>
<Show when={submission.result?.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</form>
}
>
<div data-slot="invoice-summary">
<div>
<p>{details().name}</p>
<p>{details().address.line1}</p>
<Show when={details().address.line2}>{(line) => <p>{line()}</p>}</Show>
<p>
{[details().address.city, details().address.state, details().address.postalCode]
.filter(Boolean)
.join(", ")}
</p>
<p>{details().address.country}</p>
</div>
<button data-color="primary" onClick={show}>
{i18n.t("workspace.settings.edit")}
</button>
</div>
</Show>
)}
</Show>
</div>
</section>
</Show>
)
}
+53
View File
@@ -42,6 +42,59 @@ export namespace Billing {
)
}
export const getInvoiceDetails = async () => {
Actor.assertAdmin()
const billing = await get()
if (!billing?.customerID) return null
const customer = await stripe().customers.retrieve(billing.customerID)
if (customer.deleted) return null
return {
name: customer.name ?? "",
address: {
line1: customer.address?.line1 ?? "",
line2: customer.address?.line2 ?? "",
city: customer.address?.city ?? "",
state: customer.address?.state ?? "",
postalCode: customer.address?.postal_code ?? "",
country: customer.address?.country ?? "",
},
}
}
export const updateInvoiceDetails = fn(
z.object({
name: z.string().trim().min(1).max(255),
line1: z.string().trim().min(1).max(255),
line2: z.string().trim().max(255),
city: z.string().trim().min(1).max(255),
state: z.string().trim().max(255),
postalCode: z.string().trim().min(1).max(32),
country: z
.string()
.trim()
.length(2)
.transform((value) => value.toUpperCase()),
}),
async (input) => {
Actor.assertAdmin()
const billing = await get()
if (!billing?.customerID) throw new Error("Enable billing before updating invoice details.")
await stripe().customers.update(billing.customerID, {
name: input.name,
address: {
line1: input.line1,
line2: input.line2,
city: input.city,
state: input.state,
postal_code: input.postalCode,
country: input.country,
},
})
},
)
export const payments = async () => {
return await Database.use((tx) =>
tx