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")}

+
+
+ {i18n.t("workspace.settings.invoiceDetails.empty")}

} + > + {(details) => ( + + + + + + + + + +
+ + +
+ + {(err) =>
{localizeError(i18n.t, err())}
} +
+ + } + > +
+
+

{details().name}

+

{details().address.line1}

+ {(line) =>

{line()}

}
+

+ {[details().address.city, details().address.state, details().address.postalCode] + .filter(Boolean) + .join(", ")} +

+

{details().address.country}

+
+ +
+
+ )} +
+
+
+
+ ) +} 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