diff --git a/public/index.html b/public/index.html
index b052568..c22accb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -28,6 +28,7 @@
+
@@ -111,6 +112,26 @@
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 7da3034..b6d8ed1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -24,6 +24,7 @@ import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
import './modals/payment-modal.js';
import './modals/email-modal.js';
import { setDefaultDate } from './utils/helpers.js';
+import { renderAccountingView } from './views/accounting-view.js';
// ============================================================
// Tab Management
@@ -48,7 +49,9 @@ function showTab(tabName) {
renderCustomerView();
} else if (tabName === 'settings') {
checkCurrentLogo();
- }
+ } else if (tabName === 'accounting') {
+ renderAccountingView();
+ }
}
// ============================================================
@@ -73,7 +76,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Hash-based navigation (e.g. after OAuth redirect /#settings)
if (window.location.hash) {
const hashTab = window.location.hash.replace('#', '');
- if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
+ if (['quotes', 'invoices', 'customers', 'accounting', 'settings'].includes(hashTab)) {
showTab(hashTab);
}
}
diff --git a/public/js/utils/api.js b/public/js/utils/api.js
index 47235a4..f9fd953 100644
--- a/public/js/utils/api.js
+++ b/public/js/utils/api.js
@@ -115,6 +115,32 @@ const API = {
syncPayments: () => fetch('/api/qbo/sync-payments', { method: 'POST' }).then(r => r.json()),
auth: () => window.location.href = '/auth/qbo'
},
+
+ // Accounting API (Phase 1 — read-only)
+ accounting: {
+ getAccounts: (type = null, activeOnly = true) => {
+ const params = new URLSearchParams();
+ if (type) params.set('type', type);
+ if (!activeOnly) params.set('activeOnly', 'false');
+ const qs = params.toString();
+ return fetch('/api/accounting/accounts' + (qs ? '?' + qs : '')).then(r => r.json());
+ },
+ syncAccounts: () => fetch('/api/accounting/sync-accounts', { method: 'POST' }).then(r => r.json()),
+ getRegister: (accountId, startDate, endDate) => {
+ const params = new URLSearchParams({ accountId });
+ if (startDate) params.set('startDate', startDate);
+ if (endDate) params.set('endDate', endDate);
+ return fetch('/api/accounting/register?' + params.toString()).then(r => r.json());
+ },
+ getProfitAndLoss: (startDate, endDate, accountingMethod = 'Accrual') => {
+ const params = new URLSearchParams({ startDate, endDate, accountingMethod });
+ return fetch('/api/accounting/reports/profit-loss?' + params.toString()).then(r => r.json());
+ },
+ getBalanceSheet: (asOfDate, accountingMethod = 'Accrual') => {
+ const params = new URLSearchParams({ asOfDate, accountingMethod });
+ return fetch('/api/accounting/reports/balance-sheet?' + params.toString()).then(r => r.json());
+ }
+ },
// Settings API
settings: {
diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js
new file mode 100644
index 0000000..6a60f0a
--- /dev/null
+++ b/public/js/views/accounting-view.js
@@ -0,0 +1,551 @@
+/**
+ * accounting-view.js — Phase 1, read-only
+ *
+ * Drei Bereiche:
+ * 1) Accounts Overview — Cards mit Bank- und Credit-Card-Balances
+ * 2) Register — read-only Liste der Transaktionen für ein Konto
+ * 3) Reports — Profit & Loss + Balance Sheet
+ */
+
+import { formatDate } from '../utils/helpers.js';
+
+// ────────────────────────────────────────────────────────────────────
+// State (modul-lokal)
+// ────────────────────────────────────────────────────────────────────
+
+let allAccounts = []; // alle aktiven Accounts (gesamt, für Dropdown)
+let registerAccountId = null; // aktuell ausgewählter Account fürs Register
+let registerStartDate = null;
+let registerEndDate = null;
+
+// Reports
+let plStartDate = null;
+let plEndDate = null;
+let plAccountingMethod = 'Accrual';
+
+let bsAsOfDate = null;
+let bsAccountingMethod = 'Accrual';
+
+// ────────────────────────────────────────────────────────────────────
+// Helpers
+// ────────────────────────────────────────────────────────────────────
+
+function fmtMoney(n) {
+ if (n == null || isNaN(n)) return '';
+ return n.toLocaleString('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ });
+}
+
+function todayISO() {
+ return new Date().toISOString().split('T')[0];
+}
+
+function firstOfMonthISO() {
+ const d = new Date();
+ return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0];
+}
+
+function firstOfYearISO() {
+ const d = new Date();
+ return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0];
+}
+
+function escapeHtml(s) {
+ if (s == null) return '';
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function showError(slotId, message) {
+ const el = document.getElementById(slotId);
+ if (!el) return;
+ el.innerHTML = `
+
+
QBO Error
+
${escapeHtml(message)}
+
`;
+}
+
+function showLoading(slotId, message = 'Loading…') {
+ const el = document.getElementById(slotId);
+ if (!el) return;
+ el.innerHTML = `
+
+
+
${escapeHtml(message)}
+
`;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Toolbar (top-of-tab heading + sync button)
+// ────────────────────────────────────────────────────────────────────
+
+export function injectToolbar() {
+ const c = document.getElementById('accounting-toolbar');
+ if (!c) return;
+ c.innerHTML = `
+
+
Accounting
+
read-only
+
+
+
+
`;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Accounts Overview
+// ────────────────────────────────────────────────────────────────────
+
+export async function loadAccountsOverview() {
+ const slot = 'accounting-accounts';
+ showLoading(slot, 'Loading accounts from QBO…');
+
+ try {
+ // Wir laden alle aktiven Accounts in einem Rutsch und filtern client-seitig.
+ const accounts = await window.API.accounting.getAccounts(null, true);
+
+ if (accounts.error) {
+ showError(slot, accounts.error);
+ return;
+ }
+
+ allAccounts = accounts;
+
+ // Bank/Credit Card Cards rendern
+ const cards = accounts.filter(a =>
+ a.accountType === 'Bank' || a.accountType === 'Credit Card'
+ );
+
+ const el = document.getElementById(slot);
+ if (!cards.length) {
+ el.innerHTML = `
+
+ No bank or credit card accounts found in QBO.
+
`;
+ } else {
+ el.innerHTML = `
+
+ ${cards.map(a => renderAccountCard(a)).join('')}
+
`;
+ }
+
+ // Register-Dropdown füttern (nur Bank + Credit Card)
+ populateRegisterAccountDropdown(cards);
+
+ } catch (err) {
+ console.error('Accounts load failed:', err);
+ showError(slot, err.message || 'Failed to load accounts');
+ }
+}
+
+function renderAccountCard(a) {
+ const isBank = a.accountType === 'Bank';
+ const accent = isBank ? 'border-blue-200 bg-blue-50' : 'border-purple-200 bg-purple-50';
+ const label = isBank ? 'Bank' : 'Credit Card';
+ const labelColor = isBank ? 'text-blue-700' : 'text-purple-700';
+ const bal = a.currentBalance;
+ const balText = bal != null ? fmtMoney(bal) : '—';
+ // Bei Credit Cards ist ein positiver CurrentBalance i.d.R. eine Schuld; nur Hinweis, keine Vorzeichenakrobatik.
+ return `
+
+
+ ${label}
+ #${a.id}
+
+
${escapeHtml(a.name)}
+
${balText}
+ ${a.accountSubType ? `
${escapeHtml(a.accountSubType)}
` : ''}
+
+ `;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Register
+// ────────────────────────────────────────────────────────────────────
+
+function populateRegisterAccountDropdown(bankCardAccounts) {
+ const sel = document.getElementById('reg-account');
+ if (!sel) return;
+
+ const current = registerAccountId || sel.value;
+ sel.innerHTML = `
` +
+ bankCardAccounts.map(a =>
+ `
`
+ ).join('');
+
+ if (current && bankCardAccounts.find(a => a.id === current)) {
+ sel.value = current;
+ }
+}
+
+export function injectRegisterControls() {
+ const c = document.getElementById('accounting-register-controls');
+ if (!c) return;
+
+ if (!registerStartDate) registerStartDate = firstOfMonthISO();
+ if (!registerEndDate) registerEndDate = todayISO();
+
+ c.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+export function selectRegisterAccount(accountId) {
+ registerAccountId = accountId;
+ const sel = document.getElementById('reg-account');
+ if (sel) sel.value = accountId;
+ loadRegister();
+}
+
+export async function loadRegister() {
+ const sel = document.getElementById('reg-account');
+ const start = document.getElementById('reg-start');
+ const end = document.getElementById('reg-end');
+
+ if (!sel || !sel.value) {
+ const slot = document.getElementById('accounting-register-table');
+ if (slot) slot.innerHTML = `
Select an account to view the register.
`;
+ return;
+ }
+
+ registerAccountId = sel.value;
+ registerStartDate = start.value;
+ registerEndDate = end.value;
+
+ const slot = 'accounting-register-table';
+ showLoading(slot, 'Loading register from QBO…');
+
+ try {
+ const result = await window.API.accounting.getRegister(
+ registerAccountId, registerStartDate, registerEndDate
+ );
+
+ if (result.error) {
+ showError(slot, result.error);
+ return;
+ }
+
+ renderRegisterTable(result);
+ } catch (err) {
+ console.error('Register load failed:', err);
+ showError(slot, err.message || 'Failed to load register');
+ }
+}
+
+function renderRegisterTable(result) {
+ const el = document.getElementById('accounting-register-table');
+ if (!el) return;
+
+ const rows = result.rows || [];
+ const meta = result.meta || {};
+
+ if (!rows.length) {
+ el.innerHTML = `
+
+ No transactions in selected range.
+
`;
+ return;
+ }
+
+ const tbody = rows.map(r => `
+
+ | ${escapeHtml(r.date || '')} |
+ ${escapeHtml(r.type || '')} |
+ ${escapeHtml(r.docNum || '')} |
+ ${escapeHtml(r.payee || '')} |
+ ${escapeHtml(r.splitAccount || '')} |
+ ${escapeHtml((r.memo || '').slice(0, 60))} |
+
+ ${r.amount != null ? fmtMoney(r.amount) : ''}
+ |
+
+ `).join('');
+
+ el.innerHTML = `
+
+
+ ${escapeHtml(meta.reportName || 'Transaction List')}
+ ${meta.startPeriod ? '— ' + escapeHtml(meta.startPeriod) : ''}
+ ${meta.endPeriod ? ' to ' + escapeHtml(meta.endPeriod) : ''}
+ · ${rows.length} rows
+
+
+
+
+
+ | Date |
+ Type |
+ No. |
+ Payee |
+ Split / Category |
+ Memo |
+ Amount |
+
+
+ ${tbody}
+
+
+
`;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Reports — P&L + Balance Sheet
+// ────────────────────────────────────────────────────────────────────
+
+export function injectReportsControls() {
+ const c = document.getElementById('accounting-reports');
+ if (!c) return;
+
+ if (!plStartDate) plStartDate = firstOfYearISO();
+ if (!plEndDate) plEndDate = todayISO();
+ if (!bsAsOfDate) bsAsOfDate = todayISO();
+
+ c.innerHTML = `
+
+
+
+
+
Profit & Loss
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Balance Sheet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+export async function loadProfitLoss() {
+ plStartDate = document.getElementById('pl-start').value;
+ plEndDate = document.getElementById('pl-end').value;
+ plAccountingMethod = document.getElementById('pl-method').value;
+
+ showLoading('pl-result', 'Loading P&L from QBO…');
+ try {
+ const data = await window.API.accounting.getProfitAndLoss(
+ plStartDate, plEndDate, plAccountingMethod
+ );
+ if (data.error) return showError('pl-result', data.error);
+ document.getElementById('pl-result').innerHTML = renderQboReport(data);
+ } catch (err) {
+ showError('pl-result', err.message || 'Failed to load P&L');
+ }
+}
+
+export async function loadBalanceSheet() {
+ bsAsOfDate = document.getElementById('bs-asof').value;
+ bsAccountingMethod = document.getElementById('bs-method').value;
+
+ showLoading('bs-result', 'Loading Balance Sheet from QBO…');
+ try {
+ const data = await window.API.accounting.getBalanceSheet(
+ bsAsOfDate, bsAccountingMethod
+ );
+ if (data.error) return showError('bs-result', data.error);
+ document.getElementById('bs-result').innerHTML = renderQboReport(data);
+ } catch (err) {
+ showError('bs-result', err.message || 'Failed to load Balance Sheet');
+ }
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Generic QBO Report Renderer (P&L + Balance Sheet)
+// QBO Reports haben rekursive Section/Row-Bäume mit Summary-Zeilen.
+// Wir rendern sie als verschachtelte HTML-Tabelle.
+// ────────────────────────────────────────────────────────────────────
+
+function renderQboReport(report) {
+ if (!report || !report.Header) {
+ return `
No report data.
`;
+ }
+
+ const cols = (report.Columns && report.Columns.Column) || [];
+ const colCount = cols.length;
+
+ let body = '';
+ if (report.Rows && report.Rows.Row) {
+ body = renderReportRows(report.Rows.Row, 0, colCount);
+ }
+
+ const headerRow = cols.map(c =>
+ `
${escapeHtml(c.ColTitle || '')} | `
+ ).join('');
+
+ return `
+
+ ${escapeHtml(report.Header.ReportName || '')}
+ ${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''}
+ ${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
+
+
+
+
+ ${headerRow}
+
+ ${body}
+
+
`;
+}
+
+function renderReportRows(rows, depth, colCount) {
+ if (!rows) return '';
+ const arr = Array.isArray(rows) ? rows : [rows];
+ let html = '';
+
+ for (const row of arr) {
+ const isSection = row.type === 'Section' || row.Rows || row.Summary;
+ const indentPx = depth * 16;
+
+ if (row.Header && row.Header.ColData) {
+ // Section header row
+ const headerCells = row.Header.ColData.map((c, i) => {
+ if (i === 0) {
+ return `
${escapeHtml(c.value || '')} | `;
+ }
+ return `
| `;
+ }).join('');
+ html += `
${headerCells}
`;
+ }
+
+ if (isSection && row.Rows && row.Rows.Row) {
+ html += renderReportRows(row.Rows.Row, depth + 1, colCount);
+ }
+
+ if (row.Summary && row.Summary.ColData) {
+ const sumCells = row.Summary.ColData.map((c, i) => {
+ if (i === 0) {
+ return `
${escapeHtml(c.value || '')} | `;
+ }
+ return `
${escapeHtml(c.value || '')} | `;
+ }).join('');
+ html += `
${sumCells}
`;
+ }
+
+ if (!isSection && row.ColData) {
+ // Plain data row
+ const cells = row.ColData.map((c, i) => {
+ if (i === 0) {
+ return `
${escapeHtml(c.value || '')} | `;
+ }
+ return `
${escapeHtml(c.value || '')} | `;
+ }).join('');
+ html += `
${cells}
`;
+ }
+ }
+
+ return html;
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Init / Public Entry Points
+// ────────────────────────────────────────────────────────────────────
+
+export function renderAccountingView() {
+ injectToolbar();
+ injectRegisterControls();
+ injectReportsControls();
+ loadAccountsOverview();
+}
+
+export function refreshAll() {
+ loadAccountsOverview();
+ if (registerAccountId) loadRegister();
+}
+
+// Expose for onclick handlers
+window.accountingView = {
+ renderAccountingView,
+ refreshAll,
+ loadAccountsOverview,
+ loadRegister,
+ selectRegisterAccount,
+ loadProfitLoss,
+ loadBalanceSheet
+};
diff --git a/schema.sql b/schema.sql
index 6014d6a..caf2c73 100644
--- a/schema.sql
+++ b/schema.sql
@@ -2,10 +2,10 @@
-- PostgreSQL database dump
--
-\restrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
+\restrict dcppwhgnHJoNOBlNPc2moWihaP892wdvcafOsrY89xMPWDOJABsPkfufznphBjh
--- Dumped from database version 17.6
--- Dumped by pg_dump version 17.6
+-- Dumped from database version 17.7
+-- Dumped by pg_dump version 17.7
SET statement_timeout = 0;
SET lock_timeout = 0;
@@ -45,7 +45,9 @@ CREATE TABLE public.customers (
line3 character varying(255),
line4 character varying(255),
qbo_id character varying(50),
- qbo_sync_token character varying(50)
+ qbo_sync_token character varying(50),
+ contact character varying(255),
+ remarks text
);
@@ -120,7 +122,7 @@ ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
CREATE TABLE public.invoices (
id integer NOT NULL,
- invoice_number character varying(50) NOT NULL,
+ invoice_number character varying(50) DEFAULT NULL::character varying,
customer_id integer,
invoice_date date NOT NULL,
terms character varying(100) DEFAULT 'Net 30'::character varying,
@@ -135,7 +137,20 @@ CREATE TABLE public.invoices (
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
qbo_id character varying(50),
qbo_sync_token character varying(50),
- qbo_doc_number character varying(50)
+ qbo_doc_number character varying(50),
+ paid_date date,
+ scheduled_send_date date,
+ payment_status character varying(20),
+ email_status character varying(20) DEFAULT 'open'::character varying,
+ bill_to_name character varying(255),
+ is_recurring boolean DEFAULT false,
+ recurring_interval character varying(20),
+ next_recurring_date date,
+ recurring_source_id integer,
+ stripe_payment_link_id character varying(255),
+ stripe_payment_link_url text,
+ stripe_payment_status character varying(50) DEFAULT 'pending'::character varying,
+ sent_dates date[] DEFAULT '{}'::date[]
);
@@ -163,6 +178,84 @@ ALTER SEQUENCE public.invoices_id_seq OWNER TO quoteuser;
ALTER SEQUENCE public.invoices_id_seq OWNED BY public.invoices.id;
+--
+-- Name: payment_invoices; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.payment_invoices (
+ id integer NOT NULL,
+ payment_id integer,
+ invoice_id integer,
+ amount numeric(12,2) NOT NULL
+);
+
+
+ALTER TABLE public.payment_invoices OWNER TO quoteuser;
+
+--
+-- Name: payment_invoices_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
+--
+
+CREATE SEQUENCE public.payment_invoices_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.payment_invoices_id_seq OWNER TO quoteuser;
+
+--
+-- Name: payment_invoices_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
+--
+
+ALTER SEQUENCE public.payment_invoices_id_seq OWNED BY public.payment_invoices.id;
+
+
+--
+-- Name: payments; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.payments (
+ id integer NOT NULL,
+ payment_date date NOT NULL,
+ reference_number character varying(100),
+ payment_method character varying(50),
+ deposit_to_account character varying(200),
+ total_amount numeric(12,2) NOT NULL,
+ customer_id integer,
+ qbo_payment_id character varying(50),
+ notes text,
+ created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+
+ALTER TABLE public.payments OWNER TO quoteuser;
+
+--
+-- Name: payments_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
+--
+
+CREATE SEQUENCE public.payments_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER SEQUENCE public.payments_id_seq OWNER TO quoteuser;
+
+--
+-- Name: payments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
+--
+
+ALTER SEQUENCE public.payments_id_seq OWNED BY public.payments.id;
+
+
--
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
--
@@ -250,6 +343,19 @@ ALTER SEQUENCE public.quotes_id_seq OWNER TO quoteuser;
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
+--
+-- Name: settings; Type: TABLE; Schema: public; Owner: quoteuser
+--
+
+CREATE TABLE public.settings (
+ key character varying(100) NOT NULL,
+ value text,
+ updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
+);
+
+
+ALTER TABLE public.settings OWNER TO quoteuser;
+
--
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
--
@@ -271,6 +377,20 @@ ALTER TABLE ONLY public.invoice_items ALTER COLUMN id SET DEFAULT nextval('publi
ALTER TABLE ONLY public.invoices ALTER COLUMN id SET DEFAULT nextval('public.invoices_id_seq'::regclass);
+--
+-- Name: payment_invoices id; Type: DEFAULT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payment_invoices ALTER COLUMN id SET DEFAULT nextval('public.payment_invoices_id_seq'::regclass);
+
+
+--
+-- Name: payments id; Type: DEFAULT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payments ALTER COLUMN id SET DEFAULT nextval('public.payments_id_seq'::regclass);
+
+
--
-- Name: quote_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
--
@@ -325,6 +445,30 @@ ALTER TABLE ONLY public.invoices
ADD CONSTRAINT invoices_pkey PRIMARY KEY (id);
+--
+-- Name: payment_invoices payment_invoices_payment_id_invoice_id_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payment_invoices
+ ADD CONSTRAINT payment_invoices_payment_id_invoice_id_key UNIQUE (payment_id, invoice_id);
+
+
+--
+-- Name: payment_invoices payment_invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payment_invoices
+ ADD CONSTRAINT payment_invoices_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: payments payments_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_pkey PRIMARY KEY (id);
+
+
--
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -349,6 +493,14 @@ ALTER TABLE ONLY public.quotes
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
+--
+-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.settings
+ ADD CONSTRAINT settings_pkey PRIMARY KEY (key);
+
+
--
-- Name: idx_customers_qbo_id; Type: INDEX; Schema: public; Owner: quoteuser
--
@@ -384,6 +536,55 @@ CREATE INDEX idx_invoices_customer_id ON public.invoices USING btree (customer_i
CREATE INDEX idx_invoices_invoice_number ON public.invoices USING btree (invoice_number);
+--
+-- Name: idx_invoices_paid_date; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_invoices_paid_date ON public.invoices USING btree (paid_date);
+
+
+--
+-- Name: idx_invoices_recurring; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_invoices_recurring ON public.invoices USING btree (is_recurring, next_recurring_date) WHERE (is_recurring = true);
+
+
+--
+-- Name: idx_invoices_scheduled_send_date; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_invoices_scheduled_send_date ON public.invoices USING btree (scheduled_send_date);
+
+
+--
+-- Name: idx_payment_invoices_invoice; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_payment_invoices_invoice ON public.payment_invoices USING btree (invoice_id);
+
+
+--
+-- Name: idx_payment_invoices_payment; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_payment_invoices_payment ON public.payment_invoices USING btree (payment_id);
+
+
+--
+-- Name: idx_payments_customer; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_payments_customer ON public.payments USING btree (customer_id);
+
+
+--
+-- Name: idx_payments_date; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE INDEX idx_payments_date ON public.payments USING btree (payment_date);
+
+
--
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
--
@@ -405,6 +606,13 @@ CREATE INDEX idx_quotes_customer_id ON public.quotes USING btree (customer_id);
CREATE INDEX idx_quotes_quote_number ON public.quotes USING btree (quote_number);
+--
+-- Name: uniq_recurring_source_invoice_date; Type: INDEX; Schema: public; Owner: quoteuser
+--
+
+CREATE UNIQUE INDEX uniq_recurring_source_invoice_date ON public.invoices USING btree (recurring_source_id, invoice_date) WHERE (recurring_source_id IS NOT NULL);
+
+
--
-- Name: invoice_items invoice_items_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -429,6 +637,38 @@ ALTER TABLE ONLY public.invoices
ADD CONSTRAINT invoices_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
+--
+-- Name: invoices invoices_recurring_source_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.invoices
+ ADD CONSTRAINT invoices_recurring_source_id_fkey FOREIGN KEY (recurring_source_id) REFERENCES public.invoices(id);
+
+
+--
+-- Name: payment_invoices payment_invoices_invoice_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payment_invoices
+ ADD CONSTRAINT payment_invoices_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE CASCADE;
+
+
+--
+-- Name: payment_invoices payment_invoices_payment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payment_invoices
+ ADD CONSTRAINT payment_invoices_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES public.payments(id) ON DELETE CASCADE;
+
+
+--
+-- Name: payments payments_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
+--
+
+ALTER TABLE ONLY public.payments
+ ADD CONSTRAINT payments_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
+
+
--
-- Name: quote_items quote_items_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
--
@@ -449,5 +689,5 @@ ALTER TABLE ONLY public.quotes
-- PostgreSQL database dump complete
--
-\unrestrict XHJaQEVNwjEtL1FZTBb0Sf7ooBX1Ld95BOqQlHUgJxKe87sxBoQbgpWG7aympDU
+\unrestrict dcppwhgnHJoNOBlNPc2moWihaP892wdvcafOsrY89xMPWDOJABsPkfufznphBjh
diff --git a/src/index.js b/src/index.js
index bdadbe9..d5968ef 100644
--- a/src/index.js
+++ b/src/index.js
@@ -25,6 +25,7 @@ const invoiceRoutes = require('./routes/invoices');
const paymentRoutes = require('./routes/payments');
const qboRoutes = require('./routes/qbo');
const settingsRoutes = require('./routes/settings');
+const accountingRoutes = require('./routes/accounting');
// Import PDF service for browser initialization
const { setBrowser } = require('./services/pdf-service');
@@ -120,6 +121,7 @@ app.use('/api/quotes', quoteRoutes);
app.use('/api/invoices', invoiceRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/qbo', qboRoutes);
+app.use('/api/accounting', accountingRoutes);
app.use('/api', settingsRoutes);
// Start server
diff --git a/src/routes/accounting.js b/src/routes/accounting.js
new file mode 100644
index 0000000..7787e2b
--- /dev/null
+++ b/src/routes/accounting.js
@@ -0,0 +1,115 @@
+/**
+ * Accounting Routes
+ * /api/accounting/*
+ *
+ * Phase 1 — read-only:
+ * GET /accounts
+ * POST /sync-accounts (Phase-1 Stub)
+ * GET /register
+ * GET /reports/profit-loss
+ * GET /reports/balance-sheet
+ */
+const express = require('express');
+const router = express.Router();
+
+const accountingService = require('../services/accounting-service');
+
+/**
+ * Helper: einheitliches Error-Mapping QBO → HTTP
+ * Fault-Details landen im Server-Log, der User sieht die kurze Message.
+ */
+function handleQboError(err, res, context) {
+ console.error(`❌ Accounting/${context} error:`, err.message);
+ if (err.qboFault) {
+ console.error(' QBO Fault detail:', JSON.stringify(err.qboFault));
+ }
+ if (err.stack) console.error(err.stack);
+
+ res.status(500).json({
+ error: err.message || 'QBO request failed',
+ context
+ });
+}
+
+// ─── GET /api/accounting/accounts ───────────────────────────────────
+// Optional ?type=Bank|CreditCard|Expense|Income|... ?activeOnly=false
+router.get('/accounts', async (req, res) => {
+ try {
+ const type = req.query.type || null;
+ const activeOnly = req.query.activeOnly === 'false' ? false : true;
+
+ const accounts = await accountingService.listAccounts({ type, activeOnly });
+ res.json(accounts);
+ } catch (err) {
+ handleQboError(err, res, 'accounts');
+ }
+});
+
+// ─── POST /api/accounting/sync-accounts ─────────────────────────────
+// Phase-1 Stub. Voll implementiert in Phase 2 mit qbo_account_cache.
+router.post('/sync-accounts', (req, res) => {
+ res.json({
+ success: true,
+ synced: 0,
+ cached: false,
+ message:
+ 'Account-Sync wird in Phase 2 aktiviert (qbo_account_cache). ' +
+ 'In Phase 1 werden Accounts direkt live aus QBO geladen.'
+ });
+});
+
+// ─── GET /api/accounting/register ───────────────────────────────────
+// ?accountId=
&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
+router.get('/register', async (req, res) => {
+ const { accountId, startDate, endDate } = req.query;
+
+ if (!accountId) {
+ return res.status(400).json({ error: 'accountId is required' });
+ }
+
+ try {
+ const result = await accountingService.getRegister({
+ accountId,
+ startDate,
+ endDate
+ });
+ res.json(result);
+ } catch (err) {
+ handleQboError(err, res, 'register');
+ }
+});
+
+// ─── GET /api/accounting/reports/profit-loss ────────────────────────
+// ?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&accountingMethod=Accrual|Cash
+router.get('/reports/profit-loss', async (req, res) => {
+ const { startDate, endDate, accountingMethod } = req.query;
+
+ try {
+ const data = await accountingService.getProfitAndLoss({
+ startDate,
+ endDate,
+ accountingMethod: accountingMethod || 'Accrual'
+ });
+ res.json(data);
+ } catch (err) {
+ handleQboError(err, res, 'profit-loss');
+ }
+});
+
+// ─── GET /api/accounting/reports/balance-sheet ──────────────────────
+// ?asOfDate=YYYY-MM-DD&accountingMethod=Accrual|Cash
+router.get('/reports/balance-sheet', async (req, res) => {
+ const { asOfDate, accountingMethod } = req.query;
+
+ try {
+ const data = await accountingService.getBalanceSheet({
+ asOfDate,
+ accountingMethod: accountingMethod || 'Accrual'
+ });
+ res.json(data);
+ } catch (err) {
+ handleQboError(err, res, 'balance-sheet');
+ }
+});
+
+module.exports = router;
diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js
new file mode 100644
index 0000000..3da165c
--- /dev/null
+++ b/src/services/accounting-service.js
@@ -0,0 +1,274 @@
+// src/services/accounting-service.js
+/**
+ * Accounting Service
+ * Read-only wrappers around QBO Accounts, TransactionList Register
+ * and the P&L / Balance Sheet reports.
+ *
+ * Phase 1 — read-only. Keine lokale Cache-Tabelle, alles live aus QBO.
+ */
+const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
+
+// QBO minor version — fixiert für stabilen Field-Support
+const QBO_MINOR_VERSION = '75';
+
+function getClientInfo() {
+ const oauthClient = getOAuthClient();
+ const companyId = oauthClient.getToken().realmId;
+ const baseUrl = getQboBaseUrl();
+ return { oauthClient, companyId, baseUrl };
+}
+
+/**
+ * Helper: extrahiert .json() aus QBO Response (kompatibel zu intuit-oauth)
+ */
+function getJson(response) {
+ return response.getJson ? response.getJson() : response.json;
+}
+
+/**
+ * Helper: hängt minorversion an URLs an, ohne bestehende Query-Strings zu zerschießen
+ */
+function withMinorVersion(url) {
+ return url + (url.includes('?') ? '&' : '?') + 'minorversion=' + QBO_MINOR_VERSION;
+}
+
+/**
+ * Wirft einen lesbaren Fehler bei QBO Faults
+ */
+function throwIfFault(data, context) {
+ if (data && data.Fault && data.Fault.Error) {
+ const msg = data.Fault.Error.map(e =>
+ `${e.code}: ${e.Message}${e.Detail ? ' - ' + e.Detail : ''}`
+ ).join('; ');
+ const err = new Error(`QBO ${context} failed: ${msg}`);
+ err.qboFault = data.Fault;
+ throw err;
+ }
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Accounts
+// ────────────────────────────────────────────────────────────────────
+
+/**
+ * Lädt Accounts aus QBO. Optional gefiltert nach AccountType.
+ *
+ * @param {Object} opts
+ * @param {string|null} opts.type - z.B. 'Bank', 'Credit Card', 'Expense', 'Income'
+ * (akzeptiert auch 'CreditCard' für URL-Bequemlichkeit)
+ * @param {boolean} opts.activeOnly - default true
+ */
+async function listAccounts({ type = null, activeOnly = true } = {}) {
+ const { companyId, baseUrl } = getClientInfo();
+
+ let where = [];
+ if (activeOnly) where.push("Active = true");
+ if (type) {
+ // Erlaube 'CreditCard' als URL-freundliche Variante
+ const normalizedType = type === 'CreditCard' ? 'Credit Card' : type;
+ // Apostrophe in QBO-Strings escaped man durch Verdoppelung
+ const safe = normalizedType.replace(/'/g, "''");
+ where.push(`AccountType = '${safe}'`);
+ }
+
+ const whereClause = where.length ? ' WHERE ' + where.join(' AND ') : '';
+ const query = `SELECT * FROM Account${whereClause} ORDERBY Name ASC MAXRESULTS 1000`;
+
+ const url = withMinorVersion(
+ `${baseUrl}/v3/company/${companyId}/query?query=${encodeURIComponent(query)}`
+ );
+
+ const response = await makeQboApiCall({ url, method: 'GET' });
+ const data = getJson(response);
+ throwIfFault(data, 'Account query');
+
+ const accounts = (data.QueryResponse && data.QueryResponse.Account) || [];
+
+ // Schlanke, frontend-freundliche Form
+ return accounts.map(a => ({
+ id: a.Id,
+ name: a.Name,
+ fullyQualifiedName: a.FullyQualifiedName,
+ accountType: a.AccountType,
+ accountSubType: a.AccountSubType,
+ classification: a.Classification, // Asset, Liability, Equity, Revenue, Expense
+ currentBalance: a.CurrentBalance != null ? Number(a.CurrentBalance) : null,
+ currency: a.CurrencyRef ? a.CurrencyRef.value : null,
+ active: a.Active === true,
+ syncToken: a.SyncToken
+ }));
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Register (TransactionList Report)
+// ────────────────────────────────────────────────────────────────────
+
+/**
+ * Liefert den Register eines Accounts (read-only).
+ * Verwendet QBOs TransactionList Report — der ist für genau diesen Zweck gedacht
+ * und liefert Date / TxnType / DocNum / Name / Account / Amount sauber zurück.
+ *
+ * @param {Object} opts
+ * @param {string} opts.accountId - QBO Account Id (Pflicht)
+ * @param {string} opts.startDate - YYYY-MM-DD
+ * @param {string} opts.endDate - YYYY-MM-DD
+ */
+async function getRegister({ accountId, startDate, endDate }) {
+ if (!accountId) throw new Error('accountId is required');
+
+ const { companyId, baseUrl } = getClientInfo();
+
+ const params = new URLSearchParams();
+ if (startDate) params.set('start_date', startDate);
+ if (endDate) params.set('end_date', endDate);
+ // account filter: TransactionList akzeptiert eine kommaseparierte Liste
+ params.set('account', String(accountId));
+ params.set('minorversion', QBO_MINOR_VERSION);
+
+ const url = `${baseUrl}/v3/company/${companyId}/reports/TransactionList?${params.toString()}`;
+
+ const response = await makeQboApiCall({ url, method: 'GET' });
+ const data = getJson(response);
+ throwIfFault(data, 'TransactionList report');
+
+ return normalizeTransactionListReport(data);
+}
+
+/**
+ * Normalisiert die QBO TransactionList Report Antwort in eine flache
+ * Liste mit { date, type, docNum, payee, account, memo, amount, qboId }.
+ *
+ * Der Report liefert Columns dynamisch — wir bauen eine Index-Map und
+ * lesen die Zellen darüber aus.
+ */
+function normalizeTransactionListReport(report) {
+ const columns = (report.Columns && report.Columns.Column) || [];
+ const colIndex = {};
+ columns.forEach((c, i) => {
+ // ColType ist z.B. "tx_date", "txn_type", "doc_num", "name", "account_name",
+ // "memo", "subt_nat_amount", "split_acc"
+ if (c.ColType) colIndex[c.ColType] = i;
+ });
+
+ const rows = [];
+
+ function walk(rowGroup) {
+ if (!rowGroup) return;
+ const items = Array.isArray(rowGroup) ? rowGroup : (rowGroup.Row || []);
+ for (const r of items) {
+ if (r.type === 'Section' || r.Rows) {
+ walk(r.Rows && r.Rows.Row);
+ continue;
+ }
+ // Data row
+ if (!r.ColData) continue;
+ const cell = (key) => {
+ const idx = colIndex[key];
+ if (idx == null) return null;
+ const c = r.ColData[idx];
+ return c ? c : null;
+ };
+ const dateCell = cell('tx_date');
+ const typeCell = cell('txn_type');
+ const docCell = cell('doc_num');
+ const payeeCell = cell('name');
+ const acctCell = cell('account_name');
+ const memoCell = cell('memo');
+ const amtCell = cell('subt_nat_amount');
+ const splitCell = cell('split_acc');
+
+ // QBO setzt die qbo Txn Id meistens als value im Date-Cell oder DocNum-Cell.
+ // Wir greifen sicherheitshalber an mehreren Stellen.
+ const qboId =
+ (dateCell && dateCell.id) ||
+ (docCell && docCell.id) ||
+ (typeCell && typeCell.id) ||
+ null;
+
+ rows.push({
+ date: dateCell ? dateCell.value : null,
+ type: typeCell ? typeCell.value : null,
+ docNum: docCell ? docCell.value : null,
+ payee: payeeCell ? payeeCell.value : null,
+ account: acctCell ? acctCell.value : null,
+ memo: memoCell ? memoCell.value : null,
+ amount: amtCell && amtCell.value !== '' ? Number(amtCell.value) : null,
+ splitAccount: splitCell ? splitCell.value : null,
+ qboId
+ });
+ }
+ }
+
+ walk(report.Rows && report.Rows.Row);
+
+ return {
+ meta: {
+ reportName: report.Header && report.Header.ReportName,
+ startPeriod: report.Header && report.Header.StartPeriod,
+ endPeriod: report.Header && report.Header.EndPeriod,
+ currency: report.Header && report.Header.Currency,
+ time: report.Header && report.Header.Time
+ },
+ columns: columns.map(c => ({ title: c.ColTitle, type: c.ColType })),
+ rows
+ };
+}
+
+// ────────────────────────────────────────────────────────────────────
+// Reports — Profit & Loss, Balance Sheet
+// ────────────────────────────────────────────────────────────────────
+
+function buildReportUrl(reportName, params) {
+ const { companyId, baseUrl } = getClientInfo();
+ const usp = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v != null && v !== '') usp.set(k, v);
+ });
+ usp.set('minorversion', QBO_MINOR_VERSION);
+ return `${baseUrl}/v3/company/${companyId}/reports/${reportName}?${usp.toString()}`;
+}
+
+/**
+ * Profit & Loss Report
+ * @param {Object} opts
+ * @param {string} opts.startDate - YYYY-MM-DD
+ * @param {string} opts.endDate - YYYY-MM-DD
+ * @param {string} opts.accountingMethod - 'Accrual' | 'Cash' (default 'Accrual')
+ */
+async function getProfitAndLoss({ startDate, endDate, accountingMethod = 'Accrual' } = {}) {
+ const url = buildReportUrl('ProfitAndLoss', {
+ start_date: startDate,
+ end_date: endDate,
+ accounting_method: accountingMethod
+ });
+ const response = await makeQboApiCall({ url, method: 'GET' });
+ const data = getJson(response);
+ throwIfFault(data, 'ProfitAndLoss report');
+ return data;
+}
+
+/**
+ * Balance Sheet Report
+ * @param {Object} opts
+ * @param {string} opts.asOfDate - YYYY-MM-DD (mapped to end_date)
+ * @param {string} opts.accountingMethod - 'Accrual' | 'Cash' (default 'Accrual')
+ */
+async function getBalanceSheet({ asOfDate, accountingMethod = 'Accrual' } = {}) {
+ const url = buildReportUrl('BalanceSheet', {
+ end_date: asOfDate,
+ accounting_method: accountingMethod
+ });
+ const response = await makeQboApiCall({ url, method: 'GET' });
+ const data = getJson(response);
+ throwIfFault(data, 'BalanceSheet report');
+ return data;
+}
+
+module.exports = {
+ listAccounts,
+ getRegister,
+ getProfitAndLoss,
+ getBalanceSheet,
+ // exposed for testing/debugging
+ normalizeTransactionListReport
+};