From f3a3062a4115209b9afa2be6a315cee0cafc4b3e Mon Sep 17 00:00:00 2001
From: Alan Rice <13180538+vaprdev@users.noreply.github.com>
Date: Tue, 21 Jul 2026 08:13:38 +0000
Subject: [PATCH] feat(console): add editable invoice details
---
packages/console/app/src/i18n/en.ts | 11 ++
.../routes/workspace/[id]/settings/index.tsx | 3 +-
.../[id]/settings/settings-section.module.css | 75 +++++++++
.../[id]/settings/settings-section.tsx | 154 ++++++++++++++++++
packages/console/core/src/billing.ts | 53 ++++++
5 files changed, 295 insertions(+), 1 deletion(-)
diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts
index 413037022a..37928e6691 100644
--- a/packages/console/app/src/i18n/en.ts
+++ b/packages/console/app/src/i18n/en.ts
@@ -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.",
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/index.tsx b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx
index 7c8f1fd173..97a942fe25 100644
--- a/packages/console/app/src/routes/workspace/[id]/settings/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx
@@ -1,10 +1,11 @@
-import { SettingsSection } from "./settings-section"
+import { InvoiceDetailsSection, SettingsSection } from "./settings-section"
export default function () {
return (
)
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
index 6764a0534b..033cbd7242 100644
--- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
@@ -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);
+ }
+ }
}
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
index a6795c1ba6..896e8b49eb 100644
--- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
@@ -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() {
)
}
+
+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 (
+
+
+
+
{i18n.t("workspace.settings.invoiceDetails.title")}
+
{i18n.t("workspace.settings.invoiceDetails.subtitle")}
+
+
+
+
+ )
+}
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index 879cd8c677..894b8ab306 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -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