From f566fda0b200cfe61b11cad55143bbd62f6a0259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 03:47:54 +0000 Subject: [PATCH] =?UTF-8?q?invoices,=20extracted=20into=20plugins/invoices?= =?UTF-8?q?=20=E2=80=94=20the=20platform=20half?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 --- scripts/theme-baseline.json | 1 - src/apps/officer-web/App.tsx | 2 - .../Dashboard/Invoices/InvoicesScreen.tsx | 35 - .../Dashboard/Invoices/defaultLayout.ts | 11 - .../Screens/Dashboard/Invoices/index.tsx | 1 - .../officer-web/Screens/Dashboard/index.tsx | 1 - src/apps/officer-web/state/usePageTitle.ts | 1 - src/databases/officer_db/src/index.ts | 1 - .../officer_db/src/invoiceshelf/index.ts | 12 - .../officer_db/src/invoiceshelf/queries.ts | 212 ----- .../officer_db/src/invoiceshelf/schema.ts | 58 -- src/databases/officer_db/src/schema.ts | 1 - src/servers/api/invoiceshelf/router.ts | 16 - src/servers/app-store/catalogue.ts | 15 - src/servers/app-store/members.test.ts | 7 +- src/servers/hono.ts | 2 - src/servers/permissions/registry.ts | 9 - src/servers/plugins/marketplace.ts | 21 + src/servers/sidecar/invoiceshelf/config.ts | 326 -------- src/servers/sidecar/invoiceshelf/index.ts | 177 ----- src/servers/sidecar/invoiceshelf/routes.ts | 322 -------- src/servers/sidecar/invoiceshelf/upstream.ts | 202 ----- .../src/AppRegistry/AppRegistry.tsx | 2 - .../src/apps/Invoices/AccountSwitcher.tsx | 88 --- .../src/apps/Invoices/ConfirmDialog.tsx | 50 -- .../src/apps/Invoices/ConnectionSection.tsx | 449 ----------- .../src/apps/Invoices/CustomersListView.tsx | 348 --------- .../src/apps/Invoices/DashboardView.tsx | 278 ------- .../src/apps/Invoices/DocumentEditor.tsx | 732 ----------------- .../src/apps/Invoices/EstimatesListView.tsx | 407 ---------- .../src/apps/Invoices/ExpensesListView.tsx | 313 -------- .../src/apps/Invoices/InvoicesListView.tsx | 406 ---------- .../src/apps/Invoices/InvoicesNav.tsx | 121 --- .../src/apps/Invoices/InvoicesView.tsx | 154 ---- .../src/apps/Invoices/InvoicesViewHeader.tsx | 36 - .../src/apps/Invoices/ItemsListView.tsx | 262 ------- .../src/apps/Invoices/PaymentsListView.tsx | 302 ------- .../officerdev/src/apps/Invoices/PdfPane.tsx | 91 --- .../src/apps/Invoices/RecordEditors.tsx | 734 ------------------ .../src/apps/Invoices/RecurringListView.tsx | 322 -------- .../src/apps/Invoices/ReportsView.tsx | 211 ----- .../src/apps/Invoices/SendDocumentDialog.tsx | 147 ---- .../src/apps/Invoices/components.tsx | 281 ------- .../officerdev/src/apps/Invoices/format.ts | 152 ---- .../officerdev/src/apps/Invoices/index.ts | 25 - .../officerdev/src/apps/Invoices/shared.ts | 454 ----------- .../src/apps/Invoices/useInvoiceShelfData.ts | 574 -------------- .../src/apps/Invoices/useInvoicesSection.ts | 94 --- src/workspaces/officerdev/src/index.ts | 3 - 49 files changed, 26 insertions(+), 8443 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx delete mode 100644 src/databases/officer_db/src/invoiceshelf/index.ts delete mode 100644 src/databases/officer_db/src/invoiceshelf/queries.ts delete mode 100644 src/databases/officer_db/src/invoiceshelf/schema.ts delete mode 100644 src/servers/api/invoiceshelf/router.ts delete mode 100644 src/servers/sidecar/invoiceshelf/config.ts delete mode 100644 src/servers/sidecar/invoiceshelf/index.ts delete mode 100644 src/servers/sidecar/invoiceshelf/routes.ts delete mode 100644 src/servers/sidecar/invoiceshelf/upstream.ts delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/EstimatesListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/ExpensesListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesNav.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/InvoicesViewHeader.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/ItemsListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/PaymentsListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/PdfPane.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/RecordEditors.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/RecurringListView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/ReportsView.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/SendDocumentDialog.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/components.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/format.ts delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/index.ts delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/shared.ts delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/useInvoiceShelfData.ts delete mode 100644 src/workspaces/officerdev/src/apps/Invoices/useInvoicesSection.ts diff --git a/scripts/theme-baseline.json b/scripts/theme-baseline.json index 91fb88ed..6f7651b3 100644 --- a/scripts/theme-baseline.json +++ b/scripts/theme-baseline.json @@ -9,7 +9,6 @@ "src/workspaces/officerdev/src/apps/Dav": 7, "src/workspaces/officerdev/src/apps/FileBrowser": 15, "src/workspaces/officerdev/src/apps/FileViewer": 38, - "src/workspaces/officerdev/src/apps/Invoices": 95, "src/workspaces/officerdev/src/apps/Preview": 4, "src/workspaces/officerdev/src/apps/QrTransfer": 5, "src/workspaces/officerdev/src/apps/SystemMonitor": 1, diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 84de90dd..e09bd5db 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -107,8 +107,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx deleted file mode 100644 index 430e4d3d..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { LayoutNode } from 'officerdev'; -import { Navigate, useParams } from 'react-router'; -import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; -import { defaultLayout } from './defaultLayout'; - -// /invoices uses the Workspace/Panel system (like /command-hq): the section nav on the -// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the -// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the -// sidecar and never reach the browser. -// -// The open section is :section in the URL; list filters, the selected record and the open editor are query -// params. So the panels read the URL rather than passing state to each other, and this screen is the single -// place that decides what an absent or bogus section means. - -export const InvoicesScreen = () => { - const { section } = useParams(); - const workspace = useDashboardState('screens/invoices', defaultLayout); - - // Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a - // default while the address bar says something else — the nav highlight is derived from the URL. - if (!isInvoicesSection(section)) { - return ; - } - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts deleted file mode 100644 index e2a68d91..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'invoices-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'invoices-nav', appType: 'invoices-nav' }, size: 20 }, - { node: { type: 'panel', id: 'invoices-view', appType: 'invoices-view' }, size: 80 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx deleted file mode 100644 index 3769a7a2..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './InvoicesScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 056eaff5..902d20c6 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -15,7 +15,6 @@ export * from './Calendar'; export * from './Contacts'; export * from './CommandHQ'; export * from './Gitea'; -export * from './Invoices'; export * from './Wallet'; export * from './SystemMonitor'; export * from './Activity'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 1b7a7cfe..94c72a89 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -25,7 +25,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/plugins'), title: 'Marketplace' }, { match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' }, { match: (p) => p.startsWith('/gitea'), title: 'Gitea' }, - { match: (p) => p.startsWith('/invoices'), title: 'Invoices' }, { match: (p) => p.startsWith('/wallet'), title: 'Wallet' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' }, diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 5cc80889..b14ea104 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -46,7 +46,6 @@ export * from './user-data'; // ── Plugins — exported only so tsgo stays clean; nothing mounts them ────────────────────────────── export * from './dav'; -export * from './invoiceshelf'; export * from './notify'; export * from './wallet'; diff --git a/src/databases/officer_db/src/invoiceshelf/index.ts b/src/databases/officer_db/src/invoiceshelf/index.ts deleted file mode 100644 index 257db90c..00000000 --- a/src/databases/officer_db/src/invoiceshelf/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - listInvoiceshelfAccounts, - getActiveInvoiceshelfCredentials, - getInvoiceshelfCredentials, - createInvoiceshelfAccount, - updateInvoiceshelfAccount, - setActiveInvoiceshelfAccount, - deleteInvoiceshelfAccount, - recordInvoiceshelfProbe, -} from './queries'; - -export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries'; diff --git a/src/databases/officer_db/src/invoiceshelf/queries.ts b/src/databases/officer_db/src/invoiceshelf/queries.ts deleted file mode 100644 index be2122fd..00000000 --- a/src/databases/officer_db/src/invoiceshelf/queries.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { eq, and, desc } from 'drizzle-orm'; -import { db } from '../db'; -import { invoiceshelfAccounts } from './schema'; -import { encryptSecret, decryptSecret } from '../crypto'; - -// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption -// to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/invoiceshelf.ts. -// -// Two return types, and the split is the safety property: -// InvoiceshelfAccount — safe to serialize to the browser. Has NO token field at all, not even masked. -// InvoiceshelfCredentials — url + decrypted token + company, for the sidecar's own upstream calls. -// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list -// response the moment someone forgot to strip it. - -export type InvoiceshelfAccount = { - id: number; - label: string; - url: string; - companyId: number | null; - companyName: string | null; - version: string | null; - isActive: boolean; - lastSeenAt: Date | null; - createdAt: Date; -}; - -export type InvoiceshelfCredentials = { - id: number; - label: string; - url: string; - token: string; - companyId: number | null; -}; - -const accountCols = { - id: invoiceshelfAccounts.id, - label: invoiceshelfAccounts.label, - url: invoiceshelfAccounts.url, - companyId: invoiceshelfAccounts.companyId, - companyName: invoiceshelfAccounts.companyName, - version: invoiceshelfAccounts.version, - isActive: invoiceshelfAccounts.isActive, - lastSeenAt: invoiceshelfAccounts.lastSeenAt, - createdAt: invoiceshelfAccounts.createdAt, -}; - -/** Every account the owner has added, active first then newest. Never includes the token. */ -export async function listInvoiceshelfAccounts(userId: number): Promise { - return db - .select(accountCols) - .from(invoiceshelfAccounts) - .where(eq(invoiceshelfAccounts.userId, userId)) - .orderBy(desc(invoiceshelfAccounts.isActive), desc(invoiceshelfAccounts.createdAt)); -} - -/** The selected account with its token decrypted, or null when none is added. */ -export async function getActiveInvoiceshelfCredentials(userId: number): Promise { - const [row] = await db - .select() - .from(invoiceshelfAccounts) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); - if (!row) return null; - return { - id: row.id, - label: row.label, - url: row.url, - token: decryptSecret('invoiceshelf', row.token), - companyId: row.companyId, - }; -} - -/** One account's credentials by id — for probing a specific account rather than the active one. */ -export async function getInvoiceshelfCredentials(userId: number, id: number): Promise { - const [row] = await db - .select() - .from(invoiceshelfAccounts) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))); - if (!row) return null; - return { - id: row.id, - label: row.label, - url: row.url, - token: decryptSecret('invoiceshelf', row.token), - companyId: row.companyId, - }; -} - -type CreateInvoiceshelfAccountParams = { - userId: number; - label: string; - url: string; - token: string; - companyId: number | null; - companyName: string | null; - version: string | null; - /** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */ - activate: boolean; -}; - -/** Add an account. The token is encrypted before write; the returned row carries no token. */ -export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccountParams): Promise { - const { userId, label, url, token, companyId, companyName, version, activate } = params; - return db.transaction(async (tx) => { - if (activate) { - await tx - .update(invoiceshelfAccounts) - .set({ isActive: false, updatedAt: new Date() }) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); - } - const [row] = await tx - .insert(invoiceshelfAccounts) - .values({ - userId, - label, - url, - token: encryptSecret('invoiceshelf', token), - companyId, - companyName, - version, - isActive: activate, - lastSeenAt: version ? new Date() : null, - }) - .returning(accountCols); - return row!; - }); -} - -type UpdateInvoiceshelfAccountParams = { - label?: string; - url?: string; - token?: string; - companyId?: number | null; - companyName?: string | null; - version?: string | null; -}; - -/** Edit an account. Omitted fields are left alone; a supplied token is re-encrypted. */ -export async function updateInvoiceshelfAccount( - userId: number, - id: number, - params: UpdateInvoiceshelfAccountParams, -): Promise { - const set: Record = { updatedAt: new Date() }; - if (params.label !== undefined) set.label = params.label; - if (params.url !== undefined) set.url = params.url; - if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token); - if (params.companyId !== undefined) set.companyId = params.companyId; - if (params.companyName !== undefined) set.companyName = params.companyName; - if (params.version !== undefined) set.version = params.version; - - const [row] = await db - .update(invoiceshelfAccounts) - .set(set) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) - .returning(accountCols); - return row ?? null; -} - -/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */ -export async function setActiveInvoiceshelfAccount(userId: number, id: number): Promise { - return db.transaction(async (tx) => { - await tx - .update(invoiceshelfAccounts) - .set({ isActive: false, updatedAt: new Date() }) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true))); - const [row] = await tx - .update(invoiceshelfAccounts) - .set({ isActive: true, updatedAt: new Date() }) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) - .returning(accountCols); - return row ?? null; - }); -} - -/** - * Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the - * account in use would leave the owner with accounts added but none selected, which reads as "not connected" - * and is a confusing place to land. - */ -export async function deleteInvoiceshelfAccount(userId: number, id: number): Promise { - return db.transaction(async (tx) => { - const [deleted] = await tx - .delete(invoiceshelfAccounts) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))) - .returning({ id: invoiceshelfAccounts.id, wasActive: invoiceshelfAccounts.isActive }); - if (!deleted) return false; - - if (deleted.wasActive) { - const [next] = await tx - .select({ id: invoiceshelfAccounts.id }) - .from(invoiceshelfAccounts) - .where(eq(invoiceshelfAccounts.userId, userId)) - .orderBy(desc(invoiceshelfAccounts.createdAt)) - .limit(1); - if (next) { - await tx - .update(invoiceshelfAccounts) - .set({ isActive: true, updatedAt: new Date() }) - .where(eq(invoiceshelfAccounts.id, next.id)); - } - } - return true; - }); -} - -/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */ -export async function recordInvoiceshelfProbe(userId: number, id: number, version: string | null): Promise { - await db - .update(invoiceshelfAccounts) - .set({ version, lastSeenAt: new Date() }) - .where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id))); -} diff --git a/src/databases/officer_db/src/invoiceshelf/schema.ts b/src/databases/officer_db/src/invoiceshelf/schema.ts deleted file mode 100644 index 82261399..00000000 --- a/src/databases/officer_db/src/invoiceshelf/schema.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; -import { users } from '../auth/schema'; - -// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar. -// -// This used to be INVOICESHELF_URL + INVOICESHELF_TOKEN + INVOICESHELF_COMPANY_ID in the platform-wide -// `.env`, which was wrong twice over: Bun auto-loads `.env` into EVERY process started in the platform -// directory, so `officer` itself held a Sanctum token it has no code to use — and connecting a books -// instance was a shell task on the server rather than something the owner could do from the app. -// -// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the -// same shape offscale_servers and photos_config use. Uniqueness is on the label rather than the URL, -// because the same instance with a different company is a different account here, and two of those share -// a URL AND a token. -// -// `token` is encrypted at rest via ../crypto.ts. A Sanctum token can read and write the entire books — every -// invoice, customer and payment — so a DB dump must not hand it over. Encryption is confined to -// queries/invoiceshelf.ts; nothing outside that file sees ciphertext, and no route ever returns the token. -export const invoiceshelfAccounts = pgTable( - 'invoiceshelf_accounts', - { - id: serial('id').primaryKey(), - userId: integer('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - /** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */ - label: text('label').notNull(), - // Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator. - url: text('url').notNull(), - token: text('token').notNull(), // encrypted - /** - * The company every request is scoped to, pinned explicitly. - * - * This is the sharpest edge in InvoiceShelf's API: the `company` header scopes almost every route, and a - * wrong or missing value does NOT error — it silently returns another company's books. Storing the - * choice means it is made once, visibly, instead of falling back per request. - */ - companyId: integer('company_id'), - /** Shown in the switcher next to the label, so two companies on one token are told apart at a glance. */ - companyName: text('company_name'), - /** InvoiceShelf version seen at the last successful probe — shown in the UI, never used for behaviour. */ - version: text('version'), - isActive: boolean('is_active').notNull().default(false), - lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label), - // At most one active account per owner, enforced by the DB rather than by convention: a partial unique - // index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction, - // but a bug there fails loudly here instead of silently leaving two active and the UI picking one. - uniqueIndex('uq_invoiceshelf_accounts_one_active') - .on(t.userId) - .where(sql`${t.isActive}`), - ], -); diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index a329eb7b..57e4d6f4 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections // export * from './notify/schema'; // push_devices officer-notify // export * from './dav/schema'; // dav_app_passwords officer-caldav -// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf // export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache // ── Plugin tables are NOT here ─────────────────────────────────────────────────────────────────── diff --git a/src/servers/api/invoiceshelf/router.ts b/src/servers/api/invoiceshelf/router.ts deleted file mode 100644 index 9074f5d3..00000000 --- a/src/servers/api/invoiceshelf/router.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createSidecarProxy } from '../../sidecar/create-proxy'; - -// /api/invoiceshelf/* — auth, then forward to officer-invoiceshelf. No routes of its own and no invoiceshelf knowledge: -// this file must never grow app logic. -// -// The sidecar owns the InvoiceShelf contract and holds its credentials. - -const proxy = createSidecarProxy({ - name: 'invoiceshelf', - prefix: '/api/invoiceshelf', -}); - -export const invoiceshelfRouter = proxy.router; - -/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getInvoiceshelfServerUrl = proxy.getHttpUrl; diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 493399dd..d471bc6f 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -184,21 +184,6 @@ export const CATALOGUE: CatalogueEntry[] = [ { key: 'secret', label: 'Access token', type: 'secret', required: true }, ], }, - { - id: 'invoiceshelf', - ui: { name: 'Invoices', icon: 'Receipt', color: '#0891b2', rootRoute: '/invoices', routes: ['/invoices'] }, - process: 'officer-invoiceshelf', - label: 'Invoices', - summary: 'InvoiceShelf — clients, estimates and invoices', - members: 'accounts', - modes: ['existing', 'provisioned'], - permission: 'invoices', - composeTemplate: 'invoiceshelf', - existingFields: [ - { key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true }, - { key: 'secret', label: 'API token', type: 'secret', required: true }, - ], - }, // Vault was here until 2026-08-18, when it became `plugins/vault/`. // // Deleting the entry is REQUIRED, not tidying — the trap `EXTRACTING-A-PLUGIN.md` records as having diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index 0f821191..505ef388 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -18,8 +18,11 @@ describe('what adding a member would do', () => { describe('which installed services have work to do', () => { it('skips single-tenant daemons entirely', () => { - const ids = servicesNeedingMemberProvision(['invoiceshelf', 'vnc', 'memos']).map((e) => e.id); - expect(ids.sort()).toEqual(['invoiceshelf', 'memos']); + // caldav and memos provision accounts; vnc has no user concept. Both survivors are services with no + // extraction planned — this assertion has been re-pointed four times by services leaving for + // `plugins/`, so it now names ones that are staying. + const ids = servicesNeedingMemberProvision(['caldav', 'vnc', 'memos']).map((e) => e.id); + expect(ids.sort()).toEqual(['caldav', 'memos']); }); it('ignores services that are not installed', () => { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 14974e2d..1e76c642 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -24,7 +24,6 @@ import { devProxyRouter } from './api/dev-proxy/router'; import { pluginsRouter } from './api/plugins/router'; import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths'; import { agentHandoffRouter } from './api/agent-handoff/router'; -// import { invoiceshelfRouter } from './api/invoiceshelf/router'; // import { walletRouter } from './api/wallet/router'; import { terminalRouter } from './api/terminal/sidecar-server'; // import { caldavRouter } from './api/dav/sidecar-server'; @@ -147,7 +146,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13 // ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 - // ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13 // ['/photos', photosRouter], // plugin — switched off 2026-08-13 // ['/wallet', walletRouter], // plugin — switched off 2026-08-13 ['/system-monitor', systemMonitorRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index d89e42d0..ae3058d4 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -191,15 +191,6 @@ const CORE_REGISTRY: Permission[] = [ // Device registration is the caller's own — a phone subscribing to its own push channel. personal: ['/devices', '/subscriptions'], }, - { - key: 'invoices', - label: 'Invoices', - description: 'InvoiceShelf books', - kind: 'app', - // api: ['/invoiceshelf'], // plugin — switched off 2026-08-13 - api: [], - routes: ['/invoices'], - }, // `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale, // minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone // OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 7759937a..322c9f8f 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -191,6 +191,27 @@ const BUNDLED: CatalogueEntry[] = [ // than one Jellyfin is a thing someone has. has: { api: true, schema: true, sidecar: true, web: true }, }, + { + appName: 'invoices', + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: 'Invoices', + summary: 'InvoiceShelf books — invoices, estimates, customers and payments', + color: '#0ea5e9', + icon: 'ReceiptText', + source: 'https://gitea.officer.dev/plugins/invoices.git', + permissions: [ + { + key: 'invoices', + label: 'Invoices', + description: 'InvoiceShelf books', + }, + ], + // `invoices`, not `invoiceshelf` — the appName drives the URL, and it should name what the plugin + // shows rather than the daemon behind it. See its PLUGIN.md for what that cost. + has: { api: true, schema: true, sidecar: true, web: true }, + }, { appName: 'vault', publisher: 'officerdev', diff --git a/src/servers/sidecar/invoiceshelf/config.ts b/src/servers/sidecar/invoiceshelf/config.ts deleted file mode 100644 index 5313d468..00000000 --- a/src/servers/sidecar/invoiceshelf/config.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { Company, UpstreamTarget } from './upstream'; -import { - createInvoiceshelfAccount, - deleteInvoiceshelfAccount, - getInvoiceshelfCredentials, - listInvoiceshelfAccounts, - recordInvoiceshelfProbe, - setActiveInvoiceshelfAccount, - updateInvoiceshelfAccount, -} from 'officerdb'; -import { UpstreamError, callUpstream, invalidateConfig, listCompanies, mintToken, normalizeBase } from './upstream'; - -// `/_config` — the InvoiceShelf account registry, driven from the app rather than from a shell on the server. -// -// The Sanctum token is WRITE-ONLY across this boundary. The list reports each account's label, URL, pinned -// company and whether it is selected; it has no field that could carry a token, masked or otherwise. The -// only way to change one is to send a new one, which is the same shape photos and offscale use. -// -// A save is validated before it is stored, and the validation does something the photos one does not: it -// lists the companies the token can act for and PINS one. The `company` header is the sharpest edge in this -// API — a wrong or missing value silently returns another company's books instead of erroring — so the -// choice is made once, here, and stored, rather than falling back per request. -// -// Two accounts on the same URL with the same token but different companies is therefore an ordinary thing -// to have, and is exactly why the label is what has to be unique. - -export type ProbeResult = - | { ok: true; version: string | null; companies: Company[] } - | { ok: false; version: string | null; error: string }; - -/** 401/419, or a 3xx that is Laravel redirecting to its HTML login — all three mean the token was refused. */ -const rejected = (status: number): boolean => status === 401 || status === 419 || (status >= 300 && status < 400); - -const REJECTED_MESSAGE = - 'the API token was rejected — mint a new one and paste the whole value, including the leading "1|"'; - -/** - * Ask an instance whether it is really there and whether the token works. - * - * Two calls, because they answer different questions, but NOT the two you would expect: `/app/version` is an - * UNAUTHENTICATED route on 2.4.2, so it answers 200 to a revoked, truncated or empty token. It proves only - * that the URL points at an InvoiceShelf. The companies lookup is the first call that exercises the token at - * all, which is why its 401 has to be reported as a credential problem and not as "companies lookup failed". - */ -export async function probe(cfg: UpstreamTarget): Promise { - let version: string | null = null; - - try { - const res = await callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }); - if (!res.ok) { - return { ok: false, version: null, error: `that URL does not look like an InvoiceShelf (${res.status})` }; - } - const payload = (await res.json()) as { version?: string }; - version = payload.version ?? null; - } catch (err) { - return { ok: false, version: null, error: `could not reach the instance (${String(err)})` }; - } - - try { - const companies = await listCompanies(cfg); - if (!companies.length) return { ok: false, version, error: 'no company is visible to this token' }; - return { ok: true, version, companies }; - } catch (err) { - const status = err instanceof UpstreamError ? err.status : 0; - if (rejected(status)) return { ok: false, version, error: REJECTED_MESSAGE }; - return { ok: false, version, error: String(err) }; - } -} - -/** What the browser is allowed to know about the registry. Never includes a token. */ -async function accountList(userId: number): Promise { - const accounts = await listInvoiceshelfAccounts(userId); - const active = accounts.find((account) => account.isActive) ?? null; - return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts }); -} - -/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */ -export async function noteProbe(userId: number, id: number, version: string | null): Promise { - await recordInvoiceshelfProbe(userId, id, version).catch(() => { - /* a stale lastSeenAt is not worth failing a request over */ - }); -} - -const bad = (error: string, status = 400) => Response.json({ error }, { status }); - -type AccountBody = { - label?: unknown; - url?: unknown; - token?: unknown; - companyId?: unknown; - email?: unknown; - password?: unknown; -}; - -const readBody = async (req: Request): Promise => - ((await req.json().catch(() => null)) as AccountBody | null) ?? {}; - -const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : ''); -const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : ''); -const readToken = (body: AccountBody): string => (typeof body.token === 'string' ? body.token.trim() : ''); - -const readCompanyId = (body: AccountBody): number | null => { - const n = Number(body.companyId); - return Number.isInteger(n) && n > 0 ? n : null; -}; - -const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : ''); -const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : ''); - -const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); - -/** - * The token to store, however the owner chose to supply it. - * - * A pasted token wins; otherwise an email and password are traded for a fresh one and the password is - * discarded with this function's stack frame. Returns '' when neither was given, which for an edit means - * "keep what is stored" and for an add is an error. - */ -async function resolveToken(body: AccountBody, base: string): Promise { - const token = readToken(body); - if (token) return token; - - const email = readEmail(body); - const password = readPassword(body); - if (!email || !password) return ''; - - return mintToken(base, email, password); -} - -/** Sign-in failures, in the words of what the owner actually did. 422 is Laravel's validation refusal. */ -function signInError(err: unknown): string { - if (!(err instanceof UpstreamError)) return `could not reach the instance (${String(err)})`; - if (err.status === 422 || err.status === 401) return 'that email and password were rejected by InvoiceShelf'; - if (err.status === 429) return 'too many sign-in attempts — wait a moment and try again'; - return err.message; -} - -const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_invoiceshelf_accounts_user_label'); - -/** - * Pick the company to pin. - * - * An explicit choice is honoured only if the token can actually see it — pinning an invisible company is the - * silent-wrong-books failure this whole mechanism exists to prevent. Otherwise the sole company is taken - * without asking, and with several the caller must choose: `needsChoice` is the 409 that makes the UI show - * the picker instead of guessing. - */ -function pickCompany(companies: Company[], requested: number | null): Company | null { - if (requested != null) return companies.find((c) => c.id === requested) ?? null; - return companies.length === 1 ? companies[0]! : null; -} - -/** Add an account: validated against the live instance, company pinned, then stored encrypted. */ -async function addAccount(req: Request, userId: number): Promise { - const body = await readBody(req); - const url = readUrl(body); - const requested = readCompanyId(body); - let label = readLabel(body); - - if (!url) return bad('url is required'); - if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); - - let token: string; - try { - token = await resolveToken(body, url); - } catch (err) { - return bad(signInError(err)); - } - if (!token) return bad('an API token, or an email and password to mint one, is required'); - - const result = await probe({ base: url, token, companyId: null }); - if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); - - const company = pickCompany(result.companies, requested); - if (!company) { - // 409, not 400: nothing is wrong with what was sent, it is just not enough to decide. The UI answers - // this by showing the returned companies and asking, then posting again with companyId. - return Response.json( - { - error: requested ? 'that company is not visible to this token' : 'choose which company to use', - needsChoice: true, - companies: result.companies, - version: result.version, - }, - { status: 409 }, - ); - } - - // An unlabelled account takes the company's own name, which is nearly always what the owner would type. - // Falling back to the host keeps the switcher readable when the company is unnamed. - if (!label) label = company.name ?? new URL(url).host; - - // The first account wins the selection: a registry with rows but nothing selected reads as "not connected". - const existing = await listInvoiceshelfAccounts(userId); - const activate = existing.length === 0; - - try { - const account = await createInvoiceshelfAccount({ - userId, - label, - url, - token, - companyId: company.id, - companyName: company.name, - version: result.version, - activate, - }); - invalidateConfig(userId); - return Response.json({ account }); - } catch (err) { - if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); - throw err; - } -} - -/** Edit one account. No credential means "keep the stored one", so a rename never needs one re-typed. */ -async function editAccount(req: Request, userId: number, id: number): Promise { - const body = await readBody(req); - const label = readLabel(body); - const url = readUrl(body); - const requested = readCompanyId(body); - - const current = await getInvoiceshelfCredentials(userId, id); - if (!current) return bad('no such account', 404); - if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://'); - - let token: string; - try { - token = await resolveToken(body, url || current.url); - } catch (err) { - return bad(signInError(err)); - } - - // Re-validate whenever what we would talk to changes. A rename on its own never touches the instance. - let version: string | null | undefined; - let companyId: number | undefined; - let companyName: string | null | undefined; - - if ((url && url !== current.url) || token || requested != null) { - const target = { base: url || current.url, token: token || current.token, companyId: null }; - const result = await probe(target); - if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 }); - version = result.version; - - // Keep the pinned company across a token rotation, but only if the new token can still see it. - const company = pickCompany(result.companies, requested ?? current.companyId); - if (!company) { - return Response.json( - { error: 'choose which company to use', needsChoice: true, companies: result.companies }, - { status: 409 }, - ); - } - companyId = company.id; - companyName = company.name; - } - - try { - const account = await updateInvoiceshelfAccount(userId, id, { - label: label || undefined, - url: url || undefined, - token: token || undefined, - companyId, - companyName, - version, - }); - if (!account) return bad('no such account', 404); - invalidateConfig(userId); - return Response.json({ account }); - } catch (err) { - if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`); - throw err; - } -} - -/** The companies a stored account's token can act for — what the company picker is populated from. */ -async function accountCompanies(userId: number, id: number): Promise { - const creds = await getInvoiceshelfCredentials(userId, id); - if (!creds) return bad('no such account', 404); - try { - const companies = await listCompanies({ base: normalizeBase(creds.url), token: creds.token, companyId: null }); - return Response.json({ companies, companyId: creds.companyId }); - } catch (err) { - const status = err instanceof UpstreamError ? err.status : 0; - return Response.json({ error: rejected(status) ? REJECTED_MESSAGE : String(err) }, { status: 502 }); - } -} - -/** `subpath` is '' for /_config, or '/', '//activate', '//companies'. */ -export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { - const [, rawId, action] = subpath.split('/'); - - if (!rawId) { - if (req.method === 'GET') return accountList(userId); - if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId); - return bad('method not allowed', 405); - } - - const id = Number(rawId); - if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404); - - if (action === 'activate') { - if (req.method !== 'POST') return bad('method not allowed', 405); - const account = await setActiveInvoiceshelfAccount(userId, id); - if (!account) return bad('no such account', 404); - invalidateConfig(userId); - return accountList(userId); - } - - if (action === 'companies') { - if (req.method !== 'GET') return bad('method not allowed', 405); - return accountCompanies(userId, id); - } - - if (action) return bad('not found', 404); - - if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id); - - if (req.method === 'DELETE') { - const removed = await deleteInvoiceshelfAccount(userId, id); - if (!removed) return bad('no such account', 404); - invalidateConfig(userId); - return accountList(userId); - } - - return bad('method not allowed', 405); -} diff --git a/src/servers/sidecar/invoiceshelf/index.ts b/src/servers/sidecar/invoiceshelf/index.ts deleted file mode 100644 index 97b1c4a7..00000000 --- a/src/servers/sidecar/invoiceshelf/index.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import { createSidecarConnector } from '../connect'; -import { handleConfigRoute, noteProbe, probe } from './config'; -import { handleOfficerRoute } from './routes'; -import { getConfig } from './upstream'; -import { API_URL } from '../../officer-url.mjs'; - -// The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the -// Sanctum API token, and the `company` header that scopes every request. The platform API is a thin -// auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials. -// -// The connection is the OWNER'S to set, from the UI — accounts are stored encrypted in -// `invoiceshelf_accounts` and no longer read from the environment. See upstream.ts for why that mattered. -// -// Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in -// _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate -// and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the -// running container. -// -// ───────────────────────────────────────────────────────────────────────────────────────────────── -// HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding. -// -// GET /_health ours. Confirms the token is live and reports the pinned company. -// GET /_config the account registry MINUS every token -// POST /_config { label?, url, companyId?, and EITHER token OR email+password } -// — the password mints a token and is never stored. -// 409 { needsChoice, companies } when the company is ambiguous. -// PATCH /_config/:id same fields, all optional — no credential keeps the stored token -// GET /_config/:id/companies the companies that account's token can act for -// POST /_config/:id/activate switch to that account -// DEL /_config/:id remove it; the newest survivor is promoted if it was active -// GET /_officer/summary me + current company + dashboard totals, one call -// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies -// GET /_officer/bootstrap the upstream's own bootstrap blob -// GET /_officer/next-number?key=invoice the number the next document would take -// GET /_officer/search?search= cross-entity search -// GET /_officer/reports/:kind sales-customers|sales-items|tax-summary|profit-loss|expenses → PDF -// -// GET|POST /_officer/:resource list / create -// GET|PUT|DELETE /_officer/:resource/:id read / update / delete -// POST /_officer/:resource/delete bulk delete {ids:[…]} -// GET /_officer/:resource/templates invoices|estimates only -// GET /_officer/:resource/:id/pdf invoices|estimates|payments — magic-byte repaired -// GET /_officer/:resource/:id/preview rendered email HTML, sends nothing. -// Requires ?subject=&body=&from=&to= — the controller -// validates them and 422s otherwise. -// GET /_officer/customers/:id/stats -// POST /_officer/:resource/:id/:action status|clone|send|convert-to-invoice|duplicate -// anything else 404 -// -// `:resource` is an allow-list (routes.ts). The administrative half of the API — backups, disks, modules, -// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable. -// ───────────────────────────────────────────────────────────────────────────────────────────────── - -/** Grab an ephemeral free port by briefly binding one and releasing it. */ -function getFreePort(): number { - const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); - const p = probeServer.port; - probeServer.stop(true); - if (p == null) throw new Error('failed to acquire a free port'); - return p; -} - -const port = getFreePort(); - -const server = Bun.serve({ - port, - hostname: '127.0.0.1', - // Expense receipts and company logos are uploaded as multipart bodies through this proxy. - maxRequestBodySize: 64 * 1024 * 1024, - async fetch(req) { - const url = new URL(req.url); - - const officerUser = req.headers.get('X-Officer-User'); - const userId = Number(officerUser); - if (!officerUser || !Number.isInteger(userId) || userId <= 0) { - return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); - } - - if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { - try { - return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); - } catch (err) { - console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - const cfg = await getConfig(userId); - - // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a - // configured-but-broken instance is the whole reason the flag is on the response. - if (url.pathname === '/_health') { - if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 }); - const started = Date.now(); - const result = await probe(cfg); - const ms = Date.now() - started; - - if (!result.ok) { - return Response.json( - { ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms }, - { status: 502 }, - ); - } - await noteProbe(userId, cfg.id, result.version); - return Response.json({ - ok: true, - configured: true, - account: cfg.label, - version: result.version, - company: cfg.companyId, - ms, - }); - } - - if (url.pathname.startsWith('/_officer/')) { - if (!cfg) return Response.json({ error: 'invoiceshelf not connected', configured: false }, { status: 503 }); - try { - const res = await handleOfficerRoute(cfg, req, url); - if (res) return res; - return Response.json({ error: 'not found' }, { status: 404 }); - } catch (err) { - console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err); - return Response.json({ error: 'internal error' }, { status: 500 }); - } - } - - return Response.json({ error: 'not found' }, { status: 404 }); - }, -}); - -console.log( - `[invoiceshelf] listening on 127.0.0.1:${port} (instance configured from the UI, stored in invoiceshelf_accounts)`, -); - -type ReplyFn = (msg: SidecarEvent) => void; - -function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'invoiceshelf', - handles: ['invoiceshelf'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, - onConnected() { - connection.send({ type: 'invoiceshelf:server', port }); - console.log(`[invoiceshelf] reported server port ${port} to API`); - }, -}); - -function shutdown(signal: string) { - console.log(`[invoiceshelf] ${signal} received, shutting down...`); - try { - server.stop(true); - } catch { - /* already stopped */ - } - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/invoiceshelf/routes.ts b/src/servers/sidecar/invoiceshelf/routes.ts deleted file mode 100644 index 9a52dc4b..00000000 --- a/src/servers/sidecar/invoiceshelf/routes.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { UpstreamConfig } from './upstream'; -import { callUpstream, resolveCompanyHash } from './upstream'; - -// The Officer-owned route surface. Every path here is deliberate: this is an allow-list, not a passthrough. -// -// InvoiceShelf's API is already REST, so most of these are shaped proxies rather than translations — the -// value the sidecar adds is (a) holding the credential, (b) pinning the company, (c) repairing the PDF -// responses, and (d) refusing to expose the administrative half of the API at all. -// -// DELIBERATELY NOT EXPOSED: backups, disks, modules, update/*, installation/*, mail config and test-send, -// settings writes, and transfer/ownership. Those either mutate the deployment or can lock the owner out of -// it, and none of them belong behind a dashboard proxy. Adding one should be a decision, not an accident. - -type Capabilities = { write: boolean; bulkDelete: boolean }; - -const RESOURCES: Record = { - invoices: { write: true, bulkDelete: true }, - estimates: { write: true, bulkDelete: true }, - 'recurring-invoices': { write: true, bulkDelete: true }, - payments: { write: true, bulkDelete: true }, - expenses: { write: true, bulkDelete: true }, - customers: { write: true, bulkDelete: true }, - items: { write: true, bulkDelete: true }, - units: { write: true, bulkDelete: false }, - categories: { write: true, bulkDelete: false }, - 'tax-types': { write: true, bulkDelete: false }, - 'payment-methods': { write: true, bulkDelete: false }, - notes: { write: true, bulkDelete: false }, - 'custom-fields': { write: true, bulkDelete: false }, - currencies: { write: false, bulkDelete: false }, - countries: { write: false, bulkDelete: false }, - users: { write: false, bulkDelete: false }, - roles: { write: false, bulkDelete: false }, -}; - -// Non-REST sub-actions, per resource. Verified against `artisan route:list` on the LIVE 2.4.2 instance -// rather than the 3.0.0-alpha checkout in _references — they differ. Notably 2.4.2 has -// estimates/{id}/convert-to-invoice but NO invoices/{id}/convert-to-estimate, which 3.0 added. -const ACTIONS: Record = { - invoices: ['status', 'clone', 'send'], - estimates: ['status', 'clone', 'send', 'convert-to-invoice'], - payments: ['send'], - expenses: ['duplicate'], -}; - -// Resources whose PDF lives on a web route keyed by `unique_hash`. -const PDF_RESOURCES = new Set(['invoices', 'estimates', 'payments']); - -const REPORTS: Record = { - 'sales-customers': 'reports/sales/customers', - 'sales-items': 'reports/sales/items', - 'tax-summary': 'reports/tax-summary', - 'profit-loss': 'reports/profit-loss', - expenses: 'reports/expenses', -}; - -const notFound = () => Response.json({ error: 'not found' }, { status: 404 }); -const badRequest = (error: string) => Response.json({ error }, { status: 400 }); - -/** Pass an upstream JSON response through untouched — including Laravel's 422 `errors{}` envelope. */ -async function passthrough(res: Response): Promise { - const body = await res.arrayBuffer(); - const headers = new Headers(); - const type = res.headers.get('content-type'); - if (type) headers.set('Content-Type', type); - return new Response(body, { status: res.status, headers }); -} - -/** - * InvoiceShelf 2.4.2 prefixes the document PDFs with a literal serialised HTTP response — the body of - * `/invoices/pdf/{hash}` starts `HTTP/1.0 200 OK\r\nCache-Control: …\r\n\r\n%PDF-1.7`, 201 bytes of it, - * inside a response whose own Content-Type is already application/pdf. Every PDF reader rejects that. - * - * So: find the `%PDF-` magic and slice from there. Verified on this instance — the document routes carry the - * prefix, the report routes do not, and slicing is a no-op when the magic is already at offset 0. - */ -function repairPdf(bytes: Uint8Array): Uint8Array | null { - const magic = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF- - const limit = Math.min(bytes.length - magic.length, 4096); - for (let i = 0; i <= limit; i++) { - let hit = true; - for (let j = 0; j < magic.length; j++) { - if (bytes[i + j] !== magic[j]) { - hit = false; - break; - } - } - if (hit) return i === 0 ? bytes : bytes.subarray(i); - } - return null; -} - -type PdfParams = { cfg: UpstreamConfig; path: string; query: string; filename: string }; - -async function servePdf({ cfg, path, query, filename }: PdfParams): Promise { - const res = await callUpstream(cfg, { path, query, accept: 'application/pdf' }); - if (!res.ok) return passthrough(res); - - const repaired = repairPdf(new Uint8Array(await res.arrayBuffer())); - if (!repaired) return Response.json({ error: 'upstream did not return a PDF' }, { status: 502 }); - - return new Response(new Blob([repaired]), { - status: 200, - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `inline; filename="${filename}"`, - 'Cache-Control': 'no-store', - }, - }); -} - -/** Look up a document's `unique_hash`, which its PDF web route is keyed by. */ -async function fetchUniqueHash(cfg: UpstreamConfig, resource: string, id: string): Promise { - const res = await callUpstream(cfg, { path: `/api/v1/${resource}/${id}` }); - if (!res.ok) return null; - const payload = (await res.json()) as { data?: { unique_hash?: string } }; - return payload.data?.unique_hash ?? null; -} - -async function readJson(req: Request): Promise { - try { - return await req.json(); - } catch { - return null; - } -} - -type Bootstrap = { - current_user?: unknown; - current_company?: unknown; - current_company_currency?: unknown; - current_company_settings?: unknown; -}; - -/** - * Everything a dashboard needs on first paint, in one round trip: who we are, which company we are pinned to, - * how to format its money, and the totals. - * - * The currency matters more than it looks. Every amount in this API is an integer in minor units, and - * InvoiceShelf's own frontend just divides by 100 — but `current_company_currency` carries the real - * precision, separators and `swap_currency_symbol` (this instance is EUR: "1.234,56 €"). A client that - * formats from these fields is right for currencies the upstream's own UI gets wrong. - */ -async function summary(cfg: UpstreamConfig): Promise { - const [bootRes, dashRes] = await Promise.all([ - callUpstream(cfg, { path: '/api/v1/bootstrap' }), - callUpstream(cfg, { path: '/api/v1/dashboard' }), - ]); - - if (!dashRes.ok) return passthrough(dashRes); - - const boot = bootRes.ok ? ((await bootRes.json()) as Bootstrap) : null; - const dashboard = await dashRes.json(); - - return Response.json({ - me: boot?.current_user ?? null, - company: boot?.current_company ?? null, - currency: boot?.current_company_currency ?? null, - settings: boot?.current_company_settings ?? null, - dashboard, - }); -} - -/** - * The reference-data a form needs, fetched in parallel. All of these are small and change rarely, so one - * round trip beats seven — and `limit=all` keeps customers and items out of pagination. - */ -async function lookups(cfg: UpstreamConfig): Promise { - const wanted = [ - ['customers', '/api/v1/customers?limit=all'], - ['items', '/api/v1/items?limit=all'], - ['units', '/api/v1/units?limit=all'], - ['taxTypes', '/api/v1/tax-types?limit=all'], - ['categories', '/api/v1/categories?limit=all'], - ['paymentMethods', '/api/v1/payment-methods?limit=all'], - ['currencies', '/api/v1/currencies'], - ] as const; - - const entries = await Promise.all( - wanted.map(async ([key, path]) => { - const res = await callUpstream(cfg, { path }); - if (!res.ok) return [key, []] as const; - const payload = (await res.json()) as { data?: unknown }; - return [key, payload.data ?? []] as const; - }), - ); - - return Response.json(Object.fromEntries(entries)); -} - -export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise { - const segments = url.pathname - .replace(/^\/_officer\/?/, '') - .split('/') - .filter(Boolean); - const query = url.search; - const method = req.method; - - const head = segments[0]; - if (!head) return notFound(); - const second = segments[1]; - const third = segments[2]; - - // ── Officer-composed reads ──────────────────────────────────────────────────────────────────── - if (head === 'summary' && method === 'GET') return summary(cfg); - if (head === 'lookups' && method === 'GET') return lookups(cfg); - - if (head === 'bootstrap' && method === 'GET') { - return passthrough(await callUpstream(cfg, { path: '/api/v1/bootstrap', query })); - } - - // `?key=invoice|estimate|payment` — the number the next document would get. - if (head === 'next-number' && method === 'GET') { - return passthrough(await callUpstream(cfg, { path: '/api/v1/next-number', query })); - } - - if (head === 'search' && method === 'GET') { - return passthrough(await callUpstream(cfg, { path: '/api/v1/search', query })); - } - - // ── Reports: PDFs on web routes keyed by the company hash ───────────────────────────────────── - if (head === 'reports') { - if (method !== 'GET' || !second) return notFound(); - const route = REPORTS[second]; - if (!route) return notFound(); - const hash = await resolveCompanyHash(cfg); - return servePdf({ cfg, path: `/${route}/${hash}`, query, filename: `${second}.pdf` }); - } - - // ── Allow-listed resources ──────────────────────────────────────────────────────────────────── - const caps = RESOURCES[head]; - if (!caps) return notFound(); - - const base = `/api/v1/${head}`; - const contentType = req.headers.get('content-type') ?? 'application/json'; - - // Collection: list and create. - if (!second) { - if (method === 'GET') return passthrough(await callUpstream(cfg, { path: base, query })); - if (method === 'POST') { - if (!caps.write) return badRequest(`${head} is read-only`); - return passthrough( - await callUpstream(cfg, { path: base, method: 'POST', body: await req.arrayBuffer(), contentType }), - ); - } - return notFound(); - } - - // Bulk delete — InvoiceShelf models it as POST /{resource}/delete with {"ids":[…]}, not DELETE. - if (second === 'delete' && method === 'POST') { - if (!caps.bulkDelete) return badRequest(`${head} has no bulk delete`); - const body = await readJson(req); - const ids = (body as { ids?: unknown })?.ids; - if (!Array.isArray(ids) || ids.length === 0) return badRequest('ids must be a non-empty array'); - return passthrough( - await callUpstream(cfg, { - path: `${base}/delete`, - method: 'POST', - body: JSON.stringify({ ids }), - contentType: 'application/json', - }), - ); - } - - // The template galleries, which sit alongside the collection rather than under an id. - if (second === 'templates' && method === 'GET' && (head === 'invoices' || head === 'estimates')) { - return passthrough(await callUpstream(cfg, { path: `${base}/templates` })); - } - - const id = second; - - // Single resource. - if (!third) { - if (method === 'GET') return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, query })); - if (method === 'PUT' || method === 'PATCH') { - if (!caps.write) return badRequest(`${head} is read-only`); - return passthrough( - await callUpstream(cfg, { path: `${base}/${id}`, method: 'PUT', body: await req.arrayBuffer(), contentType }), - ); - } - if (method === 'DELETE') { - if (!caps.write) return badRequest(`${head} is read-only`); - return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, method: 'DELETE' })); - } - return notFound(); - } - - // ── Sub-resources ───────────────────────────────────────────────────────────────────────────── - if (third === 'pdf' && method === 'GET') { - if (!PDF_RESOURCES.has(head)) return notFound(); - const hash = await fetchUniqueHash(cfg, head, id); - if (!hash) return Response.json({ error: `${head}/${id} not found` }, { status: 404 }); - // Built from OUR base rather than the upstream's `*_pdf_url`, which is rendered from the instance's - // APP_URL and can point somewhere this process cannot reach. - return servePdf({ cfg, path: `/${head}/pdf/${hash}`, query, filename: `${head.slice(0, -1)}-${id}.pdf` }); - } - - // The rendered email body, as HTML, without sending anything. - if (third === 'preview' && method === 'GET') { - if (!ACTIONS[head]?.includes('send')) return notFound(); - return passthrough(await callUpstream(cfg, { path: `${base}/${id}/send/preview`, query })); - } - - if (head === 'customers' && third === 'stats' && method === 'GET') { - return passthrough(await callUpstream(cfg, { path: `${base}/${id}/stats`, query })); - } - - // Named actions. `send` really does email the customer, which is why actions are an explicit allow-list - // per resource rather than a wildcard under the id. - if (method === 'POST' && ACTIONS[head]?.includes(third)) { - return passthrough( - await callUpstream(cfg, { - path: `${base}/${id}/${third}`, - method: 'POST', - body: await req.arrayBuffer(), - contentType, - }), - ); - } - - return notFound(); -} diff --git a/src/servers/sidecar/invoiceshelf/upstream.ts b/src/servers/sidecar/invoiceshelf/upstream.ts deleted file mode 100644 index f746611f..00000000 --- a/src/servers/sidecar/invoiceshelf/upstream.ts +++ /dev/null @@ -1,202 +0,0 @@ -// InvoiceShelf upstream config + the one function that talks to it. -// -// All knowledge of the InvoiceShelf instance — its URL, its API token and which company the token acts on -// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin -// auth+forward proxy and holds NO InvoiceShelf credentials. -// -// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see -// databases/officer_db/src/invoiceshelf/queries.ts). It is deliberately no longer read from the environment: -// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN` -// there was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no -// code to use it and the largest attack surface in the system. Nothing in this file reads process.env. -// -// Three things about InvoiceShelf's API are load-bearing and easy to get wrong: -// -// 1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an -// unauthenticated request with a 302 to an HTML login page instead of a 401, so every error turns into -// an unparseable redirect. -// 2. NEVER send Origin or Referer. InvoiceShelf's `statefulApi()` middleware treats a request carrying -// either as a first-party browser call and switches from token auth to session+CSRF, which then fails -// 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts -// deliberately forwards neither. Don't add them. -// 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or -// missing value does NOT error. It silently falls back to another company's data. That is why the -// company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved -// per request from whatever the token happens to see first. - -import { getActiveInvoiceshelfCredentials } from 'officerdb'; - -/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */ -export type UpstreamTarget = { base: string; token: string; companyId: number | null }; - -/** - * A stored account, which is where every real call goes. - * - * `id` and `label` ride along because the owner can have several accounts registered and only one selected: - * a probe has to be recorded against the row it actually reached, and a log line saying which books answered - * is the difference between "invoices is broken" and "you are looking at the other company". - */ -export type UpstreamConfig = UpstreamTarget & { id: number; label: string }; - -/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */ -export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, ''); - -// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read -// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which -// then takes effect within a minute instead of needing a restart. -const TTL_MS = 60_000; -const cache = new Map(); - -/** - * The owner's SELECTED account, or null when /invoices has no account yet — the sidecar then answers 503, - * and the UI turns that into the setup form rather than a screen of empty tables. - */ -export async function getConfig(userId: number): Promise { - const hit = cache.get(userId); - if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; - - const creds = await getActiveInvoiceshelfCredentials(userId); - const cfg = creds - ? { - id: creds.id, - label: creds.label, - base: normalizeBase(creds.url), - token: creds.token, - companyId: creds.companyId, - } - : null; - cache.set(userId, { cfg, at: Date.now() }); - return cfg; -} - -/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */ -export function invalidateConfig(userId: number): void { - cache.delete(userId); - hashCache.clear(); -} - -export type Company = { id: number; name: string | null; unique_hash?: string | null }; - -/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */ -export class UpstreamError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - } -} - -/** - * Trade an email and password for a Sanctum token. - * - * InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the - * controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer - * required a curl command. Doing it here instead is the whole point: the password is used for this one call - * and never stored, and only the token it returns is persisted. - * - * Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot - * know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here. - */ -export async function mintToken(base: string, email: string, password: string): Promise { - const res = await fetch(`${base}/api/v1/auth/login`, { - method: 'POST', - // No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list. - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: email, password, device_name: 'officer' }), - redirect: 'manual', - }); - if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status); - const payload = (await res.json()) as { token?: unknown }; - if (typeof payload.token !== 'string' || !payload.token) { - throw new UpstreamError('sign-in returned no token', res.status); - } - return payload.token; -} - -/** - * Every company this token can act for. Asked once when an account is added, so the owner picks explicitly - * instead of inheriting whichever one the API happens to list first. - * - * This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route, - * so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL. - */ -export async function listCompanies(cfg: UpstreamTarget): Promise { - const res = await fetch(`${cfg.base}/api/v1/companies`, { - headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' }, - redirect: 'manual', - }); - if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status); - const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> }; - return (payload.data ?? []) - .filter((c): c is { id: number; name?: string } => typeof c.id === 'number') - .map((c) => ({ id: c.id, name: c.name ?? null })); -} - -// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company. -const hashCache = new Map>(); - -/** - * The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it - * (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching. - */ -export async function resolveCompanyHash(cfg: UpstreamConfig): Promise { - const cached = hashCache.get(cfg.id); - if (cached) return cached; - - const pending = (async () => { - const res = await callUpstream(cfg, { path: '/api/v1/current-company' }); - if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`); - const payload = (await res.json()) as { data?: { unique_hash?: string } }; - const hash = payload.data?.unique_hash; - if (!hash) throw new Error('current-company returned no unique_hash'); - return hash; - })().catch((err) => { - // Clear on failure so the next request retries instead of latching a transient network error forever. - hashCache.delete(cfg.id); - throw err; - }); - - hashCache.set(cfg.id, pending); - return pending; -} - -type CallOptions = { - /** Absolute path on the InvoiceShelf host, e.g. `/api/v1/invoices`. */ - path: string; - method?: string; - /** Raw search string including the leading `?`, or empty. */ - query?: string; - body?: BodyInit | null; - contentType?: string | null; - /** Defaults to `application/json`; PDF routes ask for the bytes instead. */ - accept?: string; - /** Set false for the handful of routes scoped to the user rather than a company. */ - withCompany?: boolean; -}; - -/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */ -export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise { - const headers: Record = { - Authorization: `Bearer ${cfg.token}`, - Accept: opts.accept ?? 'application/json', - }; - - if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId); - if (opts.contentType) headers['Content-Type'] = opts.contentType; - - const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, { - method: opts.method ?? 'GET', - headers, - body: opts.body ?? undefined, - // A 302 here means the token was rejected and Laravel is redirecting to the HTML login. Following it - // would turn a clean 401 into a 200 full of HTML, so we surface the redirect instead. - redirect: 'manual', - }); - - if (res.status === 401 || res.status === 419 || (res.status >= 300 && res.status < 400)) { - console.warn(`[invoiceshelf] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (token rejected or stateful)`); - } - - return res; -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 001f48a4..2d9f86f8 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as giteaMetas } from '../apps/Gitea'; -import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; @@ -32,7 +31,6 @@ export const apps = [ ...widgetMetas, ...desktopMetas, ...giteaMetas, - ...invoicesMetas, ...walletMetas, ...monitorMetas, ...qrTransferMetas, diff --git a/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx b/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx deleted file mode 100644 index 8cba76ba..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/AccountSwitcher.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Link } from 'react-router'; -import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { invoicesSectionPath } from './shared'; -import { useInvoiceShelfAccountActions, useInvoiceShelfAccounts, useInvoiceShelfHealth } from './useInvoiceShelfData'; - -// Which books you are looking at, and how to change them. -// -// The nav header's subtitle was already the connection state, so the switcher takes that line rather than -// adding a second control saying nearly the same thing. With one account it stays exactly what it was: a -// line of text plus a way into the settings screen. -// -// Each entry shows its company under the label, because two accounts on the same instance differing only by -// company is the normal case here — the label alone would not tell them apart. -// -// Switching invalidates the whole ['invoiceshelf'] key: it changes the answer to every query in the -// workspace without changing any of their inputs. - -export const AccountSwitcher = () => { - const { data } = useInvoiceShelfAccounts(); - const { data: health } = useInvoiceShelfHealth(); - const { activate } = useInvoiceShelfAccountActions(); - - const accounts = data?.accounts ?? []; - const active = accounts.find((account) => account.isActive) ?? null; - - // Before the registry answers, fall back to health — it is the query that was already driving this line. - const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected'); - - if (accounts.length < 2) { - return ( - - {status} - - ); - } - - return ( - - - {status} - {activate.isPending ? ( - - ) : ( - - )} - - - - InvoiceShelf accounts - - {accounts.map((account) => ( - void activate.mutateAsync(account.id).catch(() => undefined)} - className="gap-2" - > - - - {account.label} - - {account.companyName ?? account.url} - - - - ))} - - - - - Manage accounts - - - - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx b/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx deleted file mode 100644 index c8ddbc70..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/ConfirmDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { ReactNode } from 'react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -// Confirmation for the destructive and the irreversible-ish. Upstream routes every one of these through a -// single dialog store with the same "are you sure" copy; this keeps the single gate but says what will -// actually happen, because "are you sure?" on its own never told anyone anything. - -export type ConfirmState = { - title: string; - description: ReactNode; - confirmLabel?: string; - destructive?: boolean; - onConfirm: () => void; -} | null; - -export const ConfirmDialog = ({ state, onClose }: { state: ConfirmState; onClose: () => void }) => { - if (!state) return null; - - return ( - !open && onClose()}> - - - {state.title} - {state.description} - - - Cancel - { - state.onConfirm(); - onClose(); - }} - className={state.destructive ? 'bg-red-600 text-white hover:bg-red-700' : ''} - > - {state.confirmLabel ?? 'Confirm'} - - - - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx b/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx deleted file mode 100644 index 766db6fa..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/ConnectionSection.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import type { CompanyOption, Credential, InvoiceShelfAccount } from './useInvoiceShelfData'; -import { useState } from 'react'; -import { Building2, Check, CheckCircle2, Loader2, Trash2, TriangleAlert } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - parseAccountError, - useAccountCompanies, - useInvoiceShelfAccountActions, - useInvoiceShelfAccounts, - useInvoiceShelfHealth, -} from './useInvoiceShelfData'; - -// Connecting InvoiceShelf instances to Officer, from the app. -// -// This screen is BOTH the setup wizard and the permanent settings page: InvoicesView renders it in place of -// whatever section the URL asks for while nothing is connected, and /invoices/settings renders it for good. -// -// The company picker is the part that has no equivalent in the photos version, and it is not a nicety: the -// `company` header scopes almost every InvoiceShelf route, and a wrong or missing value does NOT error — it -// silently returns another company's books. So the account pins one, chosen once, visibly. Two accounts on -// the same URL with the same token and different companies is an ordinary thing to have, which is why the -// LABEL is what has to be unique. -// -// The credential is never displayed, because Officer cannot display it: the token is encrypted at rest and -// the sidecar's GET has no field that could carry it back, and the password is not stored at all. - -const HINT = 'text-[11px] leading-relaxed text-muted-foreground'; - -/** InvoiceShelf's own default — its production compose publishes 8090, and the sidecar dials from this machine. */ -const DEFAULT_URL = 'http://localhost:8090'; - -const URL_HINT = - "The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " + - 'localhost here means the machine Officer runs on, and a local instance needs no TLS.'; - -const SIGN_IN_HINT = - 'Your InvoiceShelf login. It is used once, by the sidecar, to mint an API token — the password is never ' + - 'stored and never reaches the browser again. Each sign-in creates a new token in InvoiceShelf; revoke old ' + - 'ones there.'; - -const TOKEN_HINT = - 'A Sanctum token you already have. Paste it whole, including the leading "1|". It can read and write the ' + - 'entire books, so Officer stores it encrypted and never shows it again.'; - -type FieldProps = { - label: string; - hint?: string; - value: string; - onChange: (value: string) => void; - placeholder: string; - type?: string; - autoFocus?: boolean; -}; - -const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => ( - -); - -// ── credentials ────────────────────────────────────────────────────────────────────────────────── -// -// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the -// default because InvoiceShelf 2.4.2 ships no screen that issues tokens — before this, connecting Officer -// meant running a curl command against /auth/login by hand, which is exactly what the sidecar now does. - -type CredentialMode = 'login' | 'token'; - -function useCredential() { - const [mode, setMode] = useState('login'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [token, setToken] = useState(''); - - const filled = mode === 'token' ? !!token.trim() : !!email.trim() && !!password; - const payload: Credential = mode === 'token' ? { token: token.trim() } : { email: email.trim(), password }; - - const reset = () => { - setEmail(''); - setPassword(''); - setToken(''); - }; - - return { mode, setMode, email, setEmail, password, setPassword, token, setToken, filled, payload, reset }; -} - -type CredentialState = ReturnType; - -const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors'; - -const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => ( -
-
- Credential -
- - -
-
- - {cred.mode === 'login' ? ( - <> - - - - ) : ( - - )} -
-); - -type CompanyPickerProps = { - companies: CompanyOption[]; - value: number | null; - onChange: (id: number) => void; - disabled?: boolean; -}; - -/** A plain select — the list is short, and a combobox would be machinery for four options. */ -const CompanyPicker = ({ companies, value, onChange, disabled }: CompanyPickerProps) => ( - -); - -type AccountRowProps = { account: InvoiceShelfAccount }; - -/** - * One stored account. The active one carries the live health line, because health only ever describes the - * account actually being used — showing a status next to the others would be inventing one. - */ -const AccountRow = ({ account }: AccountRowProps) => { - const { data: health, refetch: recheck, isFetching: checking } = useInvoiceShelfHealth(); - const { edit, activate, remove } = useInvoiceShelfAccountActions(); - - const cred = useCredential(); - const [error, setError] = useState(null); - const [replacing, setReplacing] = useState(false); - const [changingCompany, setChangingCompany] = useState(false); - // Removing an account throws away a token Officer can never show again, so the bin asks once. - const [confirming, setConfirming] = useState(false); - - const companies = useAccountCompanies(changingCompany ? account.id : null); - - const run = async (action: Promise) => { - setError(null); - try { - await action; - cred.reset(); - setReplacing(false); - setChangingCompany(false); - } catch (err) { - setError(parseAccountError(err).message); - } - }; - - return ( -
-
- {account.isActive ? : } - {account.label} - {account.isActive && ( - in use - )} -
- {!account.isActive && ( - - )} - {account.isActive && ( - - )} - -
-
- -

{account.url}

- -
- - {changingCompany ? ( - <> - {companies.isLoading ? ( - - ) : ( - void run(edit.mutateAsync({ id: account.id, companyId }))} - /> - )} - - - ) : ( - <> - {account.companyName ?? `Company ${account.companyId ?? '?'}`} - - - )} -
- - {account.isActive && ( -
- {health?.ok ? ( - - ) : ( - - )} - {health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')} -
- )} - - {replacing ? ( -
- -
- - -
-
- ) : ( - - )} - - {error &&

{error}

} -
- ); -}; - -export const ConnectionSection = () => { - const { data, isLoading } = useInvoiceShelfAccounts(); - const { add } = useInvoiceShelfAccountActions(); - - const cred = useCredential(); - const [label, setLabel] = useState(''); - const [url, setUrl] = useState(DEFAULT_URL); - const [error, setError] = useState(null); - // Populated only when the instance answers with more than one company — see parseAccountError. - const [choices, setChoices] = useState([]); - const [companyId, setCompanyId] = useState(null); - - const accounts = data?.accounts ?? []; - const hasAccounts = accounts.length > 0; - - const submit = async (withCompany: number | null) => { - setError(null); - if (!url.trim()) return setError('The InvoiceShelf URL is required'); - if (!cred.filled) { - return setError(cred.mode === 'token' ? 'An API token is required' : 'An email and password are required'); - } - - try { - await add.mutateAsync({ label: label.trim(), url: url.trim(), companyId: withCompany, ...cred.payload }); - setLabel(''); - setUrl(DEFAULT_URL); - cred.reset(); - setChoices([]); - setCompanyId(null); - } catch (err) { - const parsed = parseAccountError(err); - // A 409 is not a failure — the credential can act for several companies and one has to be picked. Keep - // the form filled in and show the list rather than making them type it all again. - if (parsed.needsChoice && parsed.companies.length) { - setChoices(parsed.companies); - setCompanyId(parsed.companies[0]?.id ?? null); - setError(null); - return; - } - setError(parsed.message); - } - }; - - return ( -
-
-
-
- -
-
-

{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}

-

- {hasAccounts - ? 'Officer stores each instance and its token encrypted, for this account only. One is in use at a time.' - : 'Invoices needs an InvoiceShelf instance and a login before it can show anything.'} -

-
-
- - {!isLoading && accounts.map((account) => )} - -
{ - ev.preventDefault(); - void submit(companyId); - }} - className="flex flex-col gap-3 rounded-lg border p-4" - > -

{hasAccounts ? 'Add another account' : 'Add an account'}

- - - - - {choices.length > 0 && ( - - )} - - {error &&

{error}

} - -
- - {add.isPending && Checking the instance and the credential…} -
- -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx b/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx deleted file mode 100644 index 36f79f46..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/CustomersListView.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import type { Column } from './components'; -import type { ConfirmState } from './ConfirmDialog'; -import type { Address, Customer } from './shared'; -import { useState } from 'react'; -import { Link } from 'react-router'; -import { Filter, MoreHorizontal, Plus, Trash2, Users, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components'; -import { ConfirmDialog } from './ConfirmDialog'; -import { formatDate, formatMoney } from './format'; -import { invoicesCustomerPath, PAGE_SIZE } from './shared'; -import { useListFilters } from './useInvoicesSection'; -import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData'; - -// Customers. Upstream splits the search into three separate fields rather than one box — display name, -// contact name and phone are distinct API filters — so the toolbar search maps to display name and the -// other two live behind the filter row, which is where upstream puts them too. - -type Props = { onEdit: (id: number | 'new') => void }; - -export const CustomersListView = ({ onEdit }: Props) => { - const { filters, patch, select, setPage, clearFilters } = useListFilters(); - const currency = useCurrency(); - const [showFilters, setShowFilters] = useState(false); - const [selection, setSelection] = useState>(new Set()); - const [confirm, setConfirm] = useState(null); - - const { rows, meta, isLoading, error } = useResourceList('customers', { - page: filters.page, - limit: PAGE_SIZE, - display_name: filters.search || undefined, - // `status` and `from` carry contact name and phone here — one query-string slot per filter, and - // customers have no status or date range to compete for them. - contact_name: filters.status || undefined, - phone: filters.fromDate || undefined, - orderByField: 'created_at', - orderBy: 'desc', - }); - - const { remove, removeMany } = useResourceMutations('customers'); - const selected = rows.find((r) => r.id === filters.selected) ?? null; - - const toggleRow = (id: number) => - setSelection((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - - const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id)); - - const columns: Column[] = [ - { - key: 'check', - label: '', - width: '36px', - render: (row) => ( - e.stopPropagation()} className="flex items-center"> - toggleRow(row.id)} /> - - ), - }, - { - key: 'name', - label: 'Name', - width: 'minmax(150px, 2fr)', - render: (row) => ( -
-
{row.name}
- {row.contact_name &&
{row.contact_name}
} -
- ), - }, - { - key: 'email', - label: 'Email', - width: 'minmax(120px, 1.4fr)', - render: (row) => {row.email ?? '—'}, - }, - { - key: 'phone', - label: 'Phone', - width: 'minmax(100px, 1fr)', - render: (row) => row.phone ?? '—', - }, - { - key: 'due_amount', - label: 'Amount due', - width: 'minmax(100px, 1fr)', - align: 'right', - render: (row) => formatMoney(row.due_amount, currency), - }, - { - key: 'created_at', - label: 'Added', - width: 'minmax(90px, 0.9fr)', - render: (row) => formatDate(row.created_at, row.formatted_created_at), - }, - ]; - - const rowMenu = (row: Customer) => ( - - - - - - onEdit(row.id)}>Edit - select(row.id)}>View - - {/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands - Radix's item semantics to the `` rather than nesting one inside the other. Middle-click - opens the filtered list in a tab, which is the point of the filter living in the URL. */} - - Their invoices - - - Their payments - - - - setConfirm({ - title: `Delete ${row.name}?`, - description: - 'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.', - confirmLabel: 'Delete', - destructive: true, - onConfirm: () => { - remove.mutate(row.id); - if (filters.selected === row.id) select(null); - }, - }) - } - > - - Delete - - - - ); - - if (error) return ; - - const toolbar = ( - <> - setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))} - /> - patch({ q: v })} placeholder="Display name…" /> - -
- {selection.size > 0 && ( - - )} - -
- - ); - - return ( - <> -
-
- }> - {showFilters && ( -
- - - {filters.isFiltered && ( - - )} -
- )} - - r.id} - selectedKey={filters.selected} - onRowClick={(r) => select(r.id === filters.selected ? null : r.id)} - rowMenu={rowMenu} - empty={ - isLoading ? ( - - ) : ( - - ) - } - /> -
-
- - {selected && ( -
- onEdit(selected.id)} onClose={() => select(null)} /> -
- )} -
- - setConfirm(null)} /> - - ); -}; - -const CustomerDetail = ({ - customer, - onEdit, - onClose, -}: { - customer: Customer; - onEdit: () => void; - onClose: () => void; -}) => { - const currency = useCurrency(); - const { stats } = useCustomerStats(customer.id); - const chart = stats?.meta?.chartData; - - return ( -
-
-
-
{customer.name}
- {customer.email &&
{customer.email}
} -
- - -
- -
- {chart && ( -
- - - - -
- )} - -
- {formatMoney(customer.due_amount, currency)} - {customer.contact_name && {customer.contact_name}} - {customer.phone && {customer.phone}} - {customer.website && {customer.website}} - {customer.tax_id && {customer.tax_id}} - {formatDate(customer.created_at, customer.formatted_created_at)} -
- - - -
-
- ); -}; - -const Totals = ({ label, value }: { label: string; value: string }) => ( -
-
{label}
-
- {value} -
-
-); - -const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => { - const lines = [ - address?.address_street_1, - address?.address_street_2, - [address?.city, address?.state, address?.zip].filter(Boolean).join(' '), - address?.country?.name, - ].filter((l): l is string => Boolean(l && l.trim())); - - if (!lines.length) return null; - - return ( -
-
{title}
-
{lines.join('\n')}
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx b/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx deleted file mode 100644 index 7994b622..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/DashboardView.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import type { Estimate, Invoice } from './shared'; -import { useMemo } from 'react'; -import { Link } from 'react-router'; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react'; -import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format'; -import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components'; -import { invoicesRecordPath, invoicesSectionPath } from './shared'; -import { useSummary } from './useInvoiceShelfData'; - -// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt. -// -// Every figure here comes from the single /summary call the nav already makes, so arriving on this section -// costs no extra request. Upstream hands the chart over as five parallel arrays (months + four series) -// rather than as rows, so they are zipped into recharts' row shape below. - -export const DashboardView = () => { - const { dashboard, currency, isLoading, error } = useSummary(); - - const chartRows = useMemo(() => { - const chart = dashboard?.chart_data; - if (!chart?.months?.length) return []; - return chart.months.map((month, i) => ({ - month, - // recharts needs major units or the axis reads in cents; the tooltip re-formats from these. - // toMajor divides by 100, matching formatMoney — see the note on MINOR_PER_MAJOR in format.ts. - sales: toMajor(chart.invoice_totals?.[i]), - receipts: toMajor(chart.receipt_totals?.[i]), - expenses: toMajor(chart.expense_totals?.[i]), - net: toMajor(chart.net_income_totals?.[i]), - })); - }, [dashboard]); - - if (error) return ; - if (isLoading) return ; - if (!dashboard) return ; - - const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net); - - return ( -
-
- - - - -
- - {hasChart && ( -
-
- Last 12 months -
- - - -
-
-
- - - - - - - - - - compact(v)} - /> - [ - formatAmount(Math.round((value ?? 0) * 100), currency), - name ?? '', - ]} - /> - - - - - -
-
- )} - -
- invoicesRecordPath('invoices', row.id)} - render={(row: Invoice) => ({ - primary: row.invoice_number, - secondary: row.customer?.name ?? '—', - amount: formatMoney(row.due_amount, currency), - status: row.overdue ? 'OVERDUE' : row.paid_status, - note: relativeDue(row.due_date), - })} - /> - invoicesRecordPath('estimates', row.id)} - render={(row: Estimate) => ({ - primary: row.estimate_number, - secondary: row.customer?.name ?? '—', - amount: formatMoney(row.total, currency), - status: row.status, - note: formatDate(row.estimate_date, row.formatted_estimate_date), - })} - /> -
-
- ); -}; - -const Gradient = ({ id, color }: { id: string; color: string }) => ( - - - - -); - -const LegendDot = ({ className, label }: { className: string; label: string }) => ( - - - {label} - -); - -/** Axis labels only — a full currency render would not fit and the tooltip carries the exact figure. */ -const compact = (v: number): string => { - const abs = Math.abs(v); - if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; - if (abs >= 1_000) return `${(v / 1_000).toFixed(0)}k`; - return String(v); -}; - -// Every tile here only ever *goes somewhere* — no tile mutates anything — so they are anchors, not -// buttons. That is what makes the figures middle-clickable into a new tab and copyable as a link, -// which is the whole point of the section reading its filter out of the query string. -const MetricCard = ({ - icon: Icon, - tone, - label, - value, - hint, - to, -}: { - icon: typeof Wallet; - tone: string; - label: string; - value: string; - hint?: string; - to: string; -}) => ( - -
- - - - {label} -
-
- {value} -
- {hint &&
{hint}
} - -); - -type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string }; - -const RecentPanel = ({ - title, - rows, - render, - href, - empty, -}: { - title: string; - rows: T[]; - render: (row: T) => RecentRender; - href: (row: T) => string; - empty: string; -}) => ( -
-
{title}
- {rows.length === 0 ? ( -
{empty}
- ) : ( -
- {rows.map((row) => { - const r = render(row); - return ( - -
-
- {r.primary} - -
-
- {r.secondary} - {r.note && ` · ${r.note}`} -
-
- {r.amount} - - ); - })} -
- )} -
-); - -export { ArrowDownRight, ArrowUpRight }; diff --git a/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx b/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx deleted file mode 100644 index a4177289..00000000 --- a/src/workspaces/officerdev/src/apps/Invoices/DocumentEditor.tsx +++ /dev/null @@ -1,732 +0,0 @@ -import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared'; -import { useEffect, useMemo, useState } from 'react'; -import { Plus, Trash2, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { ErrorState, LoadingState } from './components'; -import { formatMoney, fromMajor, toInputDate, toMajor } from './format'; -import { FREQUENCY_PRESETS } from './shared'; -import { - useCurrency, - useLookups, - useNextNumber, - useResourceDetail, - useResourceMutations, - useTemplates, -} from './useInvoiceShelfData'; - -// The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part -// — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip. -// An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice -// has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates. -// -// ── the arithmetic ─────────────────────────────────────────────────────────────────────────────── -// Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because -// the server re-validates the totals we send: -// -// line sub_total = round(price × quantity) (price already in minor units) -// line discount = percentage → round(|sub_total| × d / 100) -// fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units -// line total = sub_total − discount -// document sub_total = Σ line totals -// tax base = document sub_total − document discount -// compound tax base = tax base + simple taxes -// total = tax_included ? tax base : tax base + total tax -// -// Every value posted is an integer of minor units. Amounts are shown to the user in major units and -// converted at the edges only — a float never survives past a keystroke. - -type Resource = 'invoices' | 'estimates' | 'recurring-invoices'; - -type Line = { - key: string; - item_id: number | null; - name: string; - description: string; - quantity: number; - /** Minor units. */ - price: number; - discount: number; - discount_type: 'fixed' | 'percentage'; - tax_type_ids: number[]; -}; - -type Props = { resource: Resource; id: number | 'new'; onClose: () => void }; - -const KIND_NOUN: Record = { - invoices: 'invoice', - estimates: 'estimate', - 'recurring-invoices': 'recurring invoice', -}; - -const newLine = (index: number): Line => ({ - key: `new-${index}`, - item_id: null, - name: '', - description: '', - quantity: 1, - price: 0, - discount: 0, - discount_type: 'fixed', - tax_type_ids: [], -}); - -export const DocumentEditor = ({ resource, id, onClose }: Props) => { - const isNew = id === 'new'; - const isRecurring = resource === 'recurring-invoices'; - const isInvoice = resource === 'invoices'; - const currency = useCurrency(); - const { customers, items, taxTypes } = useLookups(); - // A recurring invoice renders through the invoice templates — it has no gallery of its own. - const templates = useTemplates(isRecurring ? 'invoices' : resource); - const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring); - const { record, isLoading, error } = useResourceDetail( - resource, - isNew ? null : (id as number), - ); - const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource); - - const [customerId, setCustomerId] = useState(null); - const [number, setNumber] = useState(''); - const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString())); - const [secondaryDate, setSecondaryDate] = useState(''); - const [reference, setReference] = useState(''); - const [template, setTemplate] = useState(''); - const [notes, setNotes] = useState(''); - const [lines, setLines] = useState([newLine(0)]); - const [discount, setDiscount] = useState(0); - const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed'); - const [docTaxIds, setDocTaxIds] = useState([]); - const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO'); - const [taxIncluded, setTaxIncluded] = useState(false); - const [loaded, setLoaded] = useState(false); - - // Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset - // list keeps its own expression and is offered back as an extra option rather than silently rewritten. - const [frequency, setFrequency] = useState(FREQUENCY_PRESETS[2].cron); - const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE'); - const [limitCount, setLimitCount] = useState(1); - const [limitDate, setLimitDate] = useState(''); - const [sendAutomatically, setSendAutomatically] = useState(false); - const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE'); - - // Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded` - // rather than by a dependency list: react-query re-delivers `record` on every background refetch, and - // without the guard a refetch mid-edit would silently discard what the user had typed. - useEffect(() => { - if (loaded) return; - - if (isNew) { - // A recurring invoice has no number of its own, so it does not wait on one. - if (!isRecurring && !nextNumber) return; - if (nextNumber) setNumber(nextNumber); - setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1')); - setLoaded(true); - return; - } - - if (!record) return; - setCustomerId(record.customer_id ?? null); - setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? '')); - if (isRecurring) { - setPrimaryDate(toInputDate(record.starts_at)); - setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron); - setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE'); - setLimitCount(record.limit_count ?? 1); - setLimitDate(toInputDate(record.limit_date)); - setSendAutomatically(Boolean(record.send_automatically)); - setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE'); - } else { - setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date)); - setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date)); - } - setReference(record.reference_number ?? ''); - setTemplate(record.template_name ?? templates[0]?.name ?? ''); - setNotes(record.notes ?? ''); - setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO'); - setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included)); - setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed'); - // A fixed document discount is stored in minor units but entered in major ones; a percentage is a - // plain number either way. - setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val)); - setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null)); - setLines( - (record.items ?? []).map((line, i) => ({ - key: `row-${line.id ?? i}`, - item_id: line.item_id ?? null, - name: line.name, - description: line.description ?? '', - quantity: Number(line.quantity) || 0, - price: Number(line.price) || 0, - discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val), - discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed', - tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null), - })), - ); - setLoaded(true); - }, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]); - - const totals = useMemo( - () => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }), - [lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes], - ); - - if (error) return ; - if (!isNew && isLoading) return ; - - const patchLine = (key: string, changes: Partial) => - setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l))); - - const pickItem = (key: string, itemId: number) => { - const source = items.find((i) => i.id === itemId); - if (!source) return; - patchLine(key, { - item_id: source.id, - name: source.name, - description: source.description ?? '', - price: Number(source.price) || 0, - }); - }; - - const canSave = - customerId != null && - (isRecurring || number.trim() !== '') && - primaryDate !== '' && - template !== '' && - lines.some((l) => l.name.trim()); - - const save = () => { - // The three resources agree on everything below the header, and disagree on nothing but it. - const header = isRecurring - ? { - starts_at: primaryDate, - frequency, - limit_by: limitBy, - limit_count: limitBy === 'COUNT' ? limitCount : null, - limit_date: limitBy === 'DATE' ? limitDate : null, - send_automatically: sendAutomatically, - status: recurringStatus, - } - : { - [isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate, - [isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null, - [isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(), - }; - - const payload = { - ...header, - customer_id: customerId, - reference_number: reference || null, - template_name: template, - notes: notes || null, - currency_id: record?.currency_id ?? currency?.id ?? null, - exchange_rate: 1, - tax_per_item: taxPerItem, - discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO', - // The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount. - discount, - discount_type: discountType, - discount_val: totals.documentDiscount, - sub_total: totals.subTotal, - total: totals.total, - tax: totals.totalTax, - taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [], - items: lines - .filter((l) => l.name.trim()) - .map((l) => { - const line = computeLine(l, taxTypes, taxPerItem); - return { - item_id: l.item_id, - name: l.name.trim(), - description: l.description || null, - quantity: l.quantity, - price: l.price, - discount: l.discount, - discount_type: l.discount_type, - discount_val: line.discountVal, - tax: line.tax, - total: line.total, - taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [], - }; - }), - }; - - if (isNew) create.mutate(payload, { onSuccess: onClose }); - else update.mutate({ id: id as number, payload }, { onSuccess: onClose }); - }; - - const busy = create.isPending || update.isPending; - - return ( -
-
-
- {isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`} -
- - - -
- -
-
- - - - {!isRecurring && ( - - setNumber(ev.target.value)} className="h-8 text-xs" /> - - )} - - setPrimaryDate(ev.target.value)} - className="h-8 text-xs" - /> - - {!isRecurring && ( - - setSecondaryDate(ev.target.value)} - className="h-8 text-xs" - /> - - )} - - {isRecurring && ( - <> - - - - - - - {limitBy === 'COUNT' && ( - - setLimitCount(Number(ev.target.value))} - className="h-8 text-xs" - /> - - )} - {limitBy === 'DATE' && ( - - setLimitDate(ev.target.value)} - className="h-8 text-xs" - /> - - )} - - - - - )} - - setReference(ev.target.value)} className="h-8 text-xs" /> - - - {/* Only the names: each template also carries a `path` that is a full base64-encoded PNG - preview, several hundred KB apiece, and nothing here needs to render one. */} - - - - - - - - -
- - {isRecurring && ( - - )} - -
-
- Item - Qty - Price - Discount - Total - -
- - {lines.map((line) => { - const computed = computeLine(line, taxTypes, taxPerItem); - return ( -
-
-
-
- patchLine(line.key, { name: ev.target.value, item_id: null })} - placeholder="Description of work" - className="h-8 text-xs" - /> - -
- patchLine(line.key, { description: ev.target.value })} - placeholder="Notes (optional)" - className="h-7 text-[11px]" - /> - {taxPerItem === 'YES' && ( - patchLine(line.key, { tax_type_ids: ids })} - /> - )} -
- - patchLine(line.key, { quantity: Number(ev.target.value) })} - className="h-8 text-right text-xs" - /> - patchLine(line.key, { price: fromMajor(ev.target.value) })} - className="h-8 text-right text-xs" - /> -
- patchLine(line.key, { discount: Number(ev.target.value) })} - className="h-8 text-right text-xs" - /> - -
-
{formatMoney(computed.total, currency)}
- -
-
- ); - })} - -
- -
-
- -
-
- -