immortal-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/db.ts
|
|
3
|
+
* @description Invoice Service Pro v3 — SQLite Database Layer (better-sqlite3)
|
|
4
|
+
*
|
|
5
|
+
* Schema:
|
|
6
|
+
* clients — reusable client records
|
|
7
|
+
* invoices — invoice headers
|
|
8
|
+
* line_items — line items per invoice
|
|
9
|
+
* activity — audit log
|
|
10
|
+
*
|
|
11
|
+
* All monetary values stored in CENTS (INTEGER).
|
|
12
|
+
* JSON columns: tags, party JSON (from/to stored as JSON blob).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import type {
|
|
19
|
+
Invoice, InvoiceStatus, InvoiceCurrency, InvoiceLineItem,
|
|
20
|
+
InvoiceParty, InvoiceActivity, Client,
|
|
21
|
+
DashboardStats, InvoiceListQuery, PaginatedResponse,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const DB_PATH = process.env["DB_PATH"] ?? path.join(__dirname, "..", "data", "invoices.db");
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function newId(): string {
|
|
30
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
31
|
+
}
|
|
32
|
+
function newItemId(): string {
|
|
33
|
+
return `li_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
34
|
+
}
|
|
35
|
+
let counter = 1;
|
|
36
|
+
function nextNumber(): string {
|
|
37
|
+
const n = String(counter++).padStart(4, "0");
|
|
38
|
+
return `INV-${new Date().getFullYear()}-${n}`;
|
|
39
|
+
}
|
|
40
|
+
function now(): string { return new Date().toISOString(); }
|
|
41
|
+
function today(): string { return now().split("T")[0] ?? now(); }
|
|
42
|
+
function addDays(d: number): string {
|
|
43
|
+
const dt = new Date(); dt.setDate(dt.getDate() + d);
|
|
44
|
+
return dt.toISOString().split("T")[0] ?? dt.toISOString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── DB Init ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
import { mkdirSync } from "node:fs";
|
|
50
|
+
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
51
|
+
|
|
52
|
+
const db = new Database(DB_PATH);
|
|
53
|
+
db.pragma("journal_mode = WAL");
|
|
54
|
+
db.pragma("foreign_keys = ON");
|
|
55
|
+
|
|
56
|
+
db.exec(`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS clients (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
name TEXT NOT NULL,
|
|
60
|
+
email TEXT NOT NULL,
|
|
61
|
+
phone TEXT,
|
|
62
|
+
address TEXT NOT NULL DEFAULT '',
|
|
63
|
+
city TEXT NOT NULL DEFAULT '',
|
|
64
|
+
country TEXT NOT NULL DEFAULT '',
|
|
65
|
+
tax_id TEXT,
|
|
66
|
+
website TEXT,
|
|
67
|
+
notes TEXT,
|
|
68
|
+
created_at TEXT NOT NULL,
|
|
69
|
+
updated_at TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS invoices (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
number TEXT NOT NULL UNIQUE,
|
|
75
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
76
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
77
|
+
issued_at TEXT NOT NULL,
|
|
78
|
+
due_at TEXT NOT NULL,
|
|
79
|
+
paid_at TEXT,
|
|
80
|
+
from_json TEXT NOT NULL,
|
|
81
|
+
to_json TEXT NOT NULL,
|
|
82
|
+
notes TEXT,
|
|
83
|
+
terms TEXT,
|
|
84
|
+
discount REAL DEFAULT 0,
|
|
85
|
+
reference TEXT,
|
|
86
|
+
tags_json TEXT DEFAULT '[]',
|
|
87
|
+
created_at TEXT NOT NULL,
|
|
88
|
+
updated_at TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS line_items (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
|
94
|
+
description TEXT NOT NULL,
|
|
95
|
+
quantity REAL NOT NULL DEFAULT 1,
|
|
96
|
+
unit_price INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
tax_rate REAL NOT NULL DEFAULT 0,
|
|
98
|
+
discount REAL DEFAULT 0,
|
|
99
|
+
unit TEXT,
|
|
100
|
+
sort_order INTEGER DEFAULT 0
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE IF NOT EXISTS activity (
|
|
104
|
+
id TEXT PRIMARY KEY,
|
|
105
|
+
invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
|
106
|
+
action TEXT NOT NULL,
|
|
107
|
+
note TEXT,
|
|
108
|
+
performed_at TEXT NOT NULL
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_invoices_currency ON invoices(currency);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_invoices_issued ON invoices(issued_at);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_line_items_inv ON line_items(invoice_id);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_activity_inv ON activity(invoice_id);
|
|
116
|
+
`);
|
|
117
|
+
|
|
118
|
+
// ─── Row → Domain ──────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
interface InvoiceRow {
|
|
121
|
+
id: string; number: string; status: string; currency: string;
|
|
122
|
+
issued_at: string; due_at: string; paid_at?: string | null;
|
|
123
|
+
from_json: string; to_json: string; notes?: string | null;
|
|
124
|
+
terms?: string | null; discount?: number | null; reference?: string | null;
|
|
125
|
+
tags_json?: string | null; created_at: string; updated_at: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface LineItemRow {
|
|
129
|
+
id: string; invoice_id: string; description: string;
|
|
130
|
+
quantity: number; unit_price: number; tax_rate: number;
|
|
131
|
+
discount?: number | null; unit?: string | null; sort_order: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ActivityRow {
|
|
135
|
+
id: string; invoice_id: string; action: string; note?: string | null; performed_at: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface ClientRow {
|
|
139
|
+
id: string; name: string; email: string; phone?: string | null;
|
|
140
|
+
address: string; city: string; country: string; tax_id?: string | null;
|
|
141
|
+
website?: string | null; notes?: string | null; created_at: string; updated_at: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rowToInvoice(row: InvoiceRow, items: InvoiceLineItem[]): Invoice {
|
|
145
|
+
const inv: Invoice = {
|
|
146
|
+
id: row.id,
|
|
147
|
+
number: row.number,
|
|
148
|
+
status: row.status as InvoiceStatus,
|
|
149
|
+
currency: row.currency as InvoiceCurrency,
|
|
150
|
+
issuedAt: row.issued_at,
|
|
151
|
+
dueAt: row.due_at,
|
|
152
|
+
from: JSON.parse(row.from_json) as InvoiceParty,
|
|
153
|
+
to: JSON.parse(row.to_json) as InvoiceParty,
|
|
154
|
+
lineItems: items,
|
|
155
|
+
createdAt: row.created_at,
|
|
156
|
+
updatedAt: row.updated_at,
|
|
157
|
+
};
|
|
158
|
+
if (row.paid_at) inv.paidAt = row.paid_at;
|
|
159
|
+
if (row.notes) inv.notes = row.notes;
|
|
160
|
+
if (row.terms) inv.terms = row.terms;
|
|
161
|
+
if (row.discount) inv.discount = row.discount;
|
|
162
|
+
if (row.reference) inv.reference = row.reference;
|
|
163
|
+
const tags = row.tags_json ? JSON.parse(row.tags_json) as string[] : [];
|
|
164
|
+
if (tags.length) inv.tags = tags;
|
|
165
|
+
return inv;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rowToLineItem(row: LineItemRow): InvoiceLineItem {
|
|
169
|
+
const li: InvoiceLineItem = {
|
|
170
|
+
id: row.id, description: row.description,
|
|
171
|
+
quantity: row.quantity, unitPrice: row.unit_price, taxRate: row.tax_rate,
|
|
172
|
+
};
|
|
173
|
+
if (row.discount) li.discount = row.discount;
|
|
174
|
+
if (row.unit) li.unit = row.unit;
|
|
175
|
+
return li;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function rowToClient(row: ClientRow): Client {
|
|
179
|
+
const c: Client = {
|
|
180
|
+
id: row.id, name: row.name, email: row.email,
|
|
181
|
+
address: row.address, city: row.city, country: row.country,
|
|
182
|
+
createdAt: row.created_at, updatedAt: row.updated_at,
|
|
183
|
+
};
|
|
184
|
+
if (row.phone) c.phone = row.phone;
|
|
185
|
+
if (row.tax_id) c.taxId = row.tax_id;
|
|
186
|
+
if (row.website) c.website = row.website;
|
|
187
|
+
if (row.notes) c.notes = row.notes;
|
|
188
|
+
return c;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rowToActivity(row: ActivityRow): InvoiceActivity {
|
|
192
|
+
const a: InvoiceActivity = {
|
|
193
|
+
id: row.id, invoiceId: row.invoice_id,
|
|
194
|
+
action: row.action as InvoiceActivity["action"],
|
|
195
|
+
performedAt: row.performed_at,
|
|
196
|
+
};
|
|
197
|
+
if (row.note) a.note = row.note;
|
|
198
|
+
return a;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Prepared Statements ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
const stmts = {
|
|
204
|
+
getInvoice: db.prepare<[string], InvoiceRow>("SELECT * FROM invoices WHERE id=?"),
|
|
205
|
+
getItems: db.prepare<[string], LineItemRow>("SELECT * FROM line_items WHERE invoice_id=? ORDER BY sort_order"),
|
|
206
|
+
listActivity: db.prepare<[string], ActivityRow>("SELECT * FROM activity WHERE invoice_id=? ORDER BY performed_at DESC"),
|
|
207
|
+
insertInvoice: db.prepare(`INSERT INTO invoices
|
|
208
|
+
(id,number,status,currency,issued_at,due_at,from_json,to_json,notes,terms,discount,reference,tags_json,created_at,updated_at)
|
|
209
|
+
VALUES (@id,@number,@status,@currency,@issued_at,@due_at,@from_json,@to_json,@notes,@terms,@discount,@reference,@tags_json,@created_at,@updated_at)`),
|
|
210
|
+
insertItem: db.prepare(`INSERT INTO line_items
|
|
211
|
+
(id,invoice_id,description,quantity,unit_price,tax_rate,discount,unit,sort_order)
|
|
212
|
+
VALUES (@id,@invoice_id,@description,@quantity,@unit_price,@tax_rate,@discount,@unit,@sort_order)`),
|
|
213
|
+
insertActivity: db.prepare(`INSERT INTO activity (id,invoice_id,action,note,performed_at)
|
|
214
|
+
VALUES (@id,@invoice_id,@action,@note,@performed_at)`),
|
|
215
|
+
updateInvoice: db.prepare(`UPDATE invoices SET
|
|
216
|
+
status=@status, notes=@notes, terms=@terms, reference=@reference,
|
|
217
|
+
paid_at=@paid_at, tags_json=@tags_json, updated_at=@updated_at
|
|
218
|
+
WHERE id=@id`),
|
|
219
|
+
deleteInvoice: db.prepare("DELETE FROM invoices WHERE id=?"),
|
|
220
|
+
// Clients
|
|
221
|
+
getClient: db.prepare<[string], ClientRow>("SELECT * FROM clients WHERE id=?"),
|
|
222
|
+
listClients: db.prepare<[], ClientRow>("SELECT * FROM clients ORDER BY name"),
|
|
223
|
+
insertClient: db.prepare(`INSERT INTO clients
|
|
224
|
+
(id,name,email,phone,address,city,country,tax_id,website,notes,created_at,updated_at)
|
|
225
|
+
VALUES (@id,@name,@email,@phone,@address,@city,@country,@tax_id,@website,@notes,@created_at,@updated_at)`),
|
|
226
|
+
updateClient: db.prepare(`UPDATE clients SET
|
|
227
|
+
name=@name, email=@email, phone=@phone, address=@address, city=@city,
|
|
228
|
+
country=@country, tax_id=@tax_id, website=@website, notes=@notes, updated_at=@updated_at
|
|
229
|
+
WHERE id=@id`),
|
|
230
|
+
deleteClient: db.prepare("DELETE FROM clients WHERE id=?"),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// ─── Invoice Store ─────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export const InvoiceStore = {
|
|
236
|
+
|
|
237
|
+
get(id: string): Invoice | undefined {
|
|
238
|
+
const row = stmts.getInvoice.get(id);
|
|
239
|
+
if (!row) return undefined;
|
|
240
|
+
const items = stmts.getItems.all(id).map(rowToLineItem);
|
|
241
|
+
return rowToInvoice(row, items);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
list(query: InvoiceListQuery = {}): PaginatedResponse<Invoice> {
|
|
245
|
+
const conditions: string[] = [];
|
|
246
|
+
const params: unknown[] = [];
|
|
247
|
+
|
|
248
|
+
if (query.status) { conditions.push("status=?"); params.push(query.status); }
|
|
249
|
+
if (query.currency) { conditions.push("currency=?"); params.push(query.currency); }
|
|
250
|
+
if (query.dateFrom) { conditions.push("issued_at>=?"); params.push(query.dateFrom); }
|
|
251
|
+
if (query.dateTo) { conditions.push("issued_at<=?"); params.push(query.dateTo); }
|
|
252
|
+
if (query.search || query.clientName) {
|
|
253
|
+
const term = `%${query.search ?? query.clientName ?? ""}%`;
|
|
254
|
+
conditions.push("(number LIKE ? OR json_extract(to_json,'$.name') LIKE ? OR notes LIKE ?)");
|
|
255
|
+
params.push(term, term, term);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
259
|
+
const sortCol: Record<string, string> = {
|
|
260
|
+
number: "number", issuedAt: "issued_at", dueAt: "due_at", status: "status", total: "issued_at",
|
|
261
|
+
};
|
|
262
|
+
const col = sortCol[query.sortBy ?? "issuedAt"] ?? "issued_at";
|
|
263
|
+
const dir = query.sortDir === "asc" ? "ASC" : "DESC";
|
|
264
|
+
|
|
265
|
+
const page = Math.max(1, query.page ?? 1);
|
|
266
|
+
const limit = Math.min(100, Math.max(1, query.limit ?? 25));
|
|
267
|
+
const offset = (page - 1) * limit;
|
|
268
|
+
|
|
269
|
+
const countSql = `SELECT COUNT(*) as n FROM invoices ${where}`;
|
|
270
|
+
const dataSql = `SELECT * FROM invoices ${where} ORDER BY ${col} ${dir} LIMIT ${limit} OFFSET ${offset}`;
|
|
271
|
+
|
|
272
|
+
const total = (db.prepare(countSql).get(...params) as { n: number }).n;
|
|
273
|
+
const rows = db.prepare(dataSql).all(...params) as InvoiceRow[];
|
|
274
|
+
|
|
275
|
+
const data = rows.map(row => {
|
|
276
|
+
const items = stmts.getItems.all(row.id).map(rowToLineItem);
|
|
277
|
+
return rowToInvoice(row, items);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
create(inv: Invoice): Invoice {
|
|
284
|
+
const tx = db.transaction(() => {
|
|
285
|
+
stmts.insertInvoice.run({
|
|
286
|
+
id: inv.id, number: inv.number, status: inv.status, currency: inv.currency,
|
|
287
|
+
issued_at: inv.issuedAt, due_at: inv.dueAt,
|
|
288
|
+
from_json: JSON.stringify(inv.from), to_json: JSON.stringify(inv.to),
|
|
289
|
+
notes: inv.notes ?? null, terms: inv.terms ?? null,
|
|
290
|
+
discount: inv.discount ?? null, reference: inv.reference ?? null,
|
|
291
|
+
tags_json: JSON.stringify(inv.tags ?? []),
|
|
292
|
+
created_at: inv.createdAt, updated_at: inv.updatedAt,
|
|
293
|
+
});
|
|
294
|
+
inv.lineItems.forEach((li, i) => {
|
|
295
|
+
stmts.insertItem.run({
|
|
296
|
+
id: li.id, invoice_id: inv.id, description: li.description,
|
|
297
|
+
quantity: li.quantity, unit_price: li.unitPrice, tax_rate: li.taxRate,
|
|
298
|
+
discount: li.discount ?? null, unit: li.unit ?? null, sort_order: i,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
stmts.insertActivity.run({
|
|
302
|
+
id: newId(), invoice_id: inv.id, action: "created",
|
|
303
|
+
note: `Invoice ${inv.number} created`, performed_at: now(),
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
tx();
|
|
307
|
+
return inv;
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
update(id: string, patch: Partial<Invoice> & { status?: InvoiceStatus }): Invoice | undefined {
|
|
311
|
+
const existing = this.get(id);
|
|
312
|
+
if (!existing) return undefined;
|
|
313
|
+
|
|
314
|
+
const updated: Invoice = { ...existing, ...patch, id, updatedAt: now() };
|
|
315
|
+
stmts.updateInvoice.run({
|
|
316
|
+
id,
|
|
317
|
+
status: updated.status,
|
|
318
|
+
notes: updated.notes ?? null,
|
|
319
|
+
terms: updated.terms ?? null,
|
|
320
|
+
reference: updated.reference ?? null,
|
|
321
|
+
paid_at: updated.paidAt ?? null,
|
|
322
|
+
tags_json: JSON.stringify(updated.tags ?? []),
|
|
323
|
+
updated_at: updated.updatedAt,
|
|
324
|
+
});
|
|
325
|
+
if (patch.status && patch.status !== existing.status) {
|
|
326
|
+
stmts.insertActivity.run({
|
|
327
|
+
id: newId(), invoice_id: id,
|
|
328
|
+
action: "status_changed",
|
|
329
|
+
note: `Status changed from ${existing.status} to ${patch.status}`,
|
|
330
|
+
performed_at: now(),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return this.get(id);
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
delete(id: string): boolean {
|
|
337
|
+
const res = stmts.deleteInvoice.run(id);
|
|
338
|
+
return res.changes > 0;
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
getActivity(id: string): InvoiceActivity[] {
|
|
342
|
+
return stmts.listActivity.all(id).map(rowToActivity);
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
addActivity(invoiceId: string, action: InvoiceActivity["action"], note?: string): void {
|
|
346
|
+
stmts.insertActivity.run({ id: newId(), invoice_id: invoiceId, action, note: note ?? null, performed_at: now() });
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
count(): number {
|
|
350
|
+
return (db.prepare("SELECT COUNT(*) as n FROM invoices").get() as { n: number }).n;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
dashboard(): DashboardStats {
|
|
354
|
+
const countByStatus = db.prepare(
|
|
355
|
+
"SELECT status, COUNT(*) as n FROM invoices GROUP BY status"
|
|
356
|
+
).all() as { status: string; n: number }[];
|
|
357
|
+
|
|
358
|
+
const byStatus: Record<string, number> = {};
|
|
359
|
+
for (const r of countByStatus) byStatus[r.status] = r.n;
|
|
360
|
+
|
|
361
|
+
// Revenue from paid invoices (sum unit_price * quantity + tax - discount)
|
|
362
|
+
const revenueRow = db.prepare(`
|
|
363
|
+
SELECT COALESCE(SUM(
|
|
364
|
+
CAST(li.quantity * li.unit_price AS REAL) * (1 + li.tax_rate) * (1 - COALESCE(li.discount,0))
|
|
365
|
+
* (1 - COALESCE(i.discount,0))
|
|
366
|
+
),0) as total
|
|
367
|
+
FROM line_items li
|
|
368
|
+
JOIN invoices i ON i.id = li.invoice_id
|
|
369
|
+
WHERE i.status = 'paid'
|
|
370
|
+
`).get() as { total: number };
|
|
371
|
+
|
|
372
|
+
const outstandingRow = db.prepare(`
|
|
373
|
+
SELECT COALESCE(SUM(
|
|
374
|
+
CAST(li.quantity * li.unit_price AS REAL) * (1 + li.tax_rate) * (1 - COALESCE(li.discount,0))
|
|
375
|
+
* (1 - COALESCE(i.discount,0))
|
|
376
|
+
),0) as total
|
|
377
|
+
FROM line_items li
|
|
378
|
+
JOIN invoices i ON i.id = li.invoice_id
|
|
379
|
+
WHERE i.status = 'sent'
|
|
380
|
+
`).get() as { total: number };
|
|
381
|
+
|
|
382
|
+
const overdueRow = db.prepare(`
|
|
383
|
+
SELECT COALESCE(SUM(
|
|
384
|
+
CAST(li.quantity * li.unit_price AS REAL) * (1 + li.tax_rate) * (1 - COALESCE(li.discount,0))
|
|
385
|
+
* (1 - COALESCE(i.discount,0))
|
|
386
|
+
),0) as total
|
|
387
|
+
FROM line_items li
|
|
388
|
+
JOIN invoices i ON i.id = li.invoice_id
|
|
389
|
+
WHERE i.status = 'overdue'
|
|
390
|
+
`).get() as { total: number };
|
|
391
|
+
|
|
392
|
+
const totalCount = (db.prepare("SELECT COUNT(*) as n FROM invoices").get() as { n: number }).n;
|
|
393
|
+
const clientCount = (db.prepare("SELECT COUNT(*) as n FROM clients").get() as { n: number }).n;
|
|
394
|
+
|
|
395
|
+
const avgRow = db.prepare(`
|
|
396
|
+
SELECT COALESCE(AVG(sub.total), 0) as avg FROM (
|
|
397
|
+
SELECT i.id, SUM(li.quantity * li.unit_price * (1 + li.tax_rate)) as total
|
|
398
|
+
FROM invoices i JOIN line_items li ON li.invoice_id = i.id
|
|
399
|
+
GROUP BY i.id
|
|
400
|
+
) sub
|
|
401
|
+
`).get() as { avg: number };
|
|
402
|
+
|
|
403
|
+
// Monthly revenue (last 12 months)
|
|
404
|
+
const monthlyRows = db.prepare(`
|
|
405
|
+
SELECT strftime('%Y-%m', i.issued_at) as month,
|
|
406
|
+
COALESCE(SUM(li.quantity * li.unit_price * (1 + li.tax_rate)),0) as cents
|
|
407
|
+
FROM invoices i JOIN line_items li ON li.invoice_id = i.id
|
|
408
|
+
WHERE i.status = 'paid' AND i.issued_at >= date('now','-12 months')
|
|
409
|
+
GROUP BY month ORDER BY month
|
|
410
|
+
`).all() as { month: string; cents: number }[];
|
|
411
|
+
|
|
412
|
+
// Top clients
|
|
413
|
+
const topClientsRows = db.prepare(`
|
|
414
|
+
SELECT json_extract(i.to_json, '$.name') as name,
|
|
415
|
+
COUNT(*) as invoice_count,
|
|
416
|
+
COALESCE(SUM(li.quantity * li.unit_price * (1 + li.tax_rate)),0) as total_cents
|
|
417
|
+
FROM invoices i JOIN line_items li ON li.invoice_id = i.id
|
|
418
|
+
WHERE i.status IN ('paid','sent')
|
|
419
|
+
GROUP BY name ORDER BY total_cents DESC LIMIT 5
|
|
420
|
+
`).all() as { name: string; invoice_count: number; total_cents: number }[];
|
|
421
|
+
|
|
422
|
+
// This month vs last month
|
|
423
|
+
const thisMonthRow = db.prepare(`
|
|
424
|
+
SELECT COALESCE(SUM(li.quantity * li.unit_price * (1 + li.tax_rate)),0) as cents
|
|
425
|
+
FROM invoices i JOIN line_items li ON li.invoice_id = i.id
|
|
426
|
+
WHERE i.status = 'paid' AND strftime('%Y-%m', i.issued_at) = strftime('%Y-%m','now')
|
|
427
|
+
`).get() as { cents: number };
|
|
428
|
+
|
|
429
|
+
const lastMonthRow = db.prepare(`
|
|
430
|
+
SELECT COALESCE(SUM(li.quantity * li.unit_price * (1 + li.tax_rate)),0) as cents
|
|
431
|
+
FROM invoices i JOIN line_items li ON li.invoice_id = i.id
|
|
432
|
+
WHERE i.status = 'paid' AND strftime('%Y-%m', i.issued_at) = strftime('%Y-%m','now','-1 month')
|
|
433
|
+
`).get() as { cents: number };
|
|
434
|
+
|
|
435
|
+
const recentActivity = (db.prepare(
|
|
436
|
+
"SELECT * FROM activity ORDER BY performed_at DESC LIMIT 15"
|
|
437
|
+
).all() as ActivityRow[]).map(rowToActivity);
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
totalInvoices: totalCount,
|
|
441
|
+
totalRevenueCents: Math.round(revenueRow.total),
|
|
442
|
+
outstandingCents: Math.round(outstandingRow.total),
|
|
443
|
+
overdueCents: Math.round(overdueRow.total),
|
|
444
|
+
draftCount: byStatus["draft"] ?? 0,
|
|
445
|
+
sentCount: byStatus["sent"] ?? 0,
|
|
446
|
+
paidCount: byStatus["paid"] ?? 0,
|
|
447
|
+
overdueCount: byStatus["overdue"] ?? 0,
|
|
448
|
+
cancelledCount: byStatus["cancelled"] ?? 0,
|
|
449
|
+
totalClients: clientCount,
|
|
450
|
+
avgInvoiceCents: Math.round(avgRow.avg),
|
|
451
|
+
revenueThisMonth: Math.round(thisMonthRow.cents),
|
|
452
|
+
revenueLastMonth: Math.round(lastMonthRow.cents),
|
|
453
|
+
invoicesByStatus: byStatus as Record<InvoiceStatus, number>,
|
|
454
|
+
recentActivity,
|
|
455
|
+
topClients: topClientsRows.map(r => ({ name: r.name ?? "—", totalCents: Math.round(r.total_cents), invoiceCount: r.invoice_count })),
|
|
456
|
+
monthlyRevenue: monthlyRows.map(r => ({ month: r.month, cents: Math.round(r.cents) })),
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// ─── Client Store ──────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
export const ClientStore = {
|
|
464
|
+
get(id: string): Client | undefined {
|
|
465
|
+
const r = stmts.getClient.get(id);
|
|
466
|
+
return r ? rowToClient(r) : undefined;
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
list(): Client[] {
|
|
470
|
+
return stmts.listClients.all().map(rowToClient);
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
create(dto: Omit<Client, "id" | "createdAt" | "updatedAt">): Client {
|
|
474
|
+
const n = now();
|
|
475
|
+
const client: Client = { ...dto, id: newId(), createdAt: n, updatedAt: n };
|
|
476
|
+
stmts.insertClient.run({
|
|
477
|
+
id: client.id, name: client.name, email: client.email,
|
|
478
|
+
phone: client.phone ?? null, address: client.address, city: client.city,
|
|
479
|
+
country: client.country, tax_id: client.taxId ?? null,
|
|
480
|
+
website: client.website ?? null, notes: client.notes ?? null,
|
|
481
|
+
created_at: client.createdAt, updated_at: client.updatedAt,
|
|
482
|
+
});
|
|
483
|
+
return client;
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
update(id: string, patch: Partial<Client>): Client | undefined {
|
|
487
|
+
const existing = this.get(id);
|
|
488
|
+
if (!existing) return undefined;
|
|
489
|
+
const updated = { ...existing, ...patch, id, updatedAt: now() };
|
|
490
|
+
stmts.updateClient.run({
|
|
491
|
+
id, name: updated.name, email: updated.email,
|
|
492
|
+
phone: updated.phone ?? null, address: updated.address, city: updated.city,
|
|
493
|
+
country: updated.country, tax_id: updated.taxId ?? null,
|
|
494
|
+
website: updated.website ?? null, notes: updated.notes ?? null,
|
|
495
|
+
updated_at: updated.updatedAt,
|
|
496
|
+
});
|
|
497
|
+
return this.get(id);
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
delete(id: string): boolean {
|
|
501
|
+
const res = stmts.deleteClient.run(id);
|
|
502
|
+
return res.changes > 0;
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
count(): number {
|
|
506
|
+
return (db.prepare("SELECT COUNT(*) as n FROM clients").get() as { n: number }).n;
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// ─── ID / Number helpers ───────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
export { newId, newItemId, nextNumber, now, today, addDays };
|
|
513
|
+
|
|
514
|
+
// ─── Seed data (runs once if DB is empty) ─────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
function seedIfEmpty(): void {
|
|
517
|
+
const count = InvoiceStore.count();
|
|
518
|
+
if (count > 0) return;
|
|
519
|
+
|
|
520
|
+
// Reset counter to match DB
|
|
521
|
+
counter = 1;
|
|
522
|
+
|
|
523
|
+
// Seed clients
|
|
524
|
+
const clients = [
|
|
525
|
+
ClientStore.create({ name: "Acme Technologies", email: "accounts@acme.com", phone: "+1 415 555 0100", address: "100 Innovation Drive", city: "San Francisco, CA 94105", country: "US", taxId: "US-TAX-88776655" }),
|
|
526
|
+
ClientStore.create({ name: "Munich Innovations GmbH", email: "billing@munich-innovations.de", phone: "+49 89 12345678", address: "Maximilianstrasse 12", city: "Munich 80539", country: "DE", taxId: "DE-TAX-99887766" }),
|
|
527
|
+
ClientStore.create({ name: "London Fintech Ltd", email: "accounts@londonfintech.co.uk", phone: "+44 20 7946 0958", address: "1 Canary Wharf", city: "London E14 5AB", country: "GB", taxId: "GB-TAX-112233" }),
|
|
528
|
+
ClientStore.create({ name: "Dubai Digital Solutions", email: "finance@dubaidigital.ae", phone: "+971 4 555 0234", address: "DIFC Gate Building", city: "Dubai", country: "AE" }),
|
|
529
|
+
ClientStore.create({ name: "Startup Valley Inc", email: "pay@startupvalley.io", phone: "+1 650 555 0199", address: "500 University Ave", city: "Palo Alto, CA 94301", country: "US" }),
|
|
530
|
+
];
|
|
531
|
+
|
|
532
|
+
const sender: { name: string; email: string; address: string; city: string; country: string; taxId: string } = {
|
|
533
|
+
name: "Immortal Corp", email: "billing@immortal.dev",
|
|
534
|
+
address: "1 Resilience Boulevard", city: "San Francisco, CA 94102",
|
|
535
|
+
country: "US", taxId: "US-TAX-99887766",
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const mkInv = (status: string, currency: string, client: Client, items: { desc: string; qty: number; unit: number; tax: number; unit_label?: string }[], notesText?: string): Invoice => {
|
|
539
|
+
const issuedDt = new Date();
|
|
540
|
+
issuedDt.setDate(issuedDt.getDate() - Math.floor(Math.random() * 60));
|
|
541
|
+
const dueDt = new Date(issuedDt);
|
|
542
|
+
dueDt.setDate(dueDt.getDate() + 30);
|
|
543
|
+
|
|
544
|
+
const id = newId();
|
|
545
|
+
const inv: Invoice = {
|
|
546
|
+
id, number: nextNumber(),
|
|
547
|
+
status: status as InvoiceStatus,
|
|
548
|
+
currency: currency as InvoiceCurrency,
|
|
549
|
+
issuedAt: issuedDt.toISOString().split("T")[0] ?? today(),
|
|
550
|
+
dueAt: dueDt.toISOString().split("T")[0] ?? addDays(30),
|
|
551
|
+
from: { ...sender },
|
|
552
|
+
to: { name: client.name, email: client.email, address: client.address, city: client.city, country: client.country, ...(client.taxId && { taxId: client.taxId }), ...(client.phone && { phone: client.phone }) },
|
|
553
|
+
lineItems: items.map(i => ({ id: newItemId(), description: i.desc, quantity: i.qty, unitPrice: i.unit, taxRate: i.tax, ...(i.unit_label && { unit: i.unit_label }) })),
|
|
554
|
+
notes: notesText ?? "Thank you for your business. We appreciate the opportunity to work with you.",
|
|
555
|
+
terms: "Payment due within 30 days of invoice date. Late payments subject to 1.5% monthly interest.",
|
|
556
|
+
createdAt: now(), updatedAt: now(),
|
|
557
|
+
};
|
|
558
|
+
if (status === "paid") {
|
|
559
|
+
const paidDt = new Date(issuedDt);
|
|
560
|
+
paidDt.setDate(paidDt.getDate() + Math.floor(Math.random() * 25) + 5);
|
|
561
|
+
inv.paidAt = paidDt.toISOString().split("T")[0] ?? today();
|
|
562
|
+
}
|
|
563
|
+
return inv;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const [acme, munich, london, dubai, startup] = clients as [Client, Client, Client, Client, Client];
|
|
567
|
+
|
|
568
|
+
const invoices = [
|
|
569
|
+
mkInv("paid", "USD", acme, [
|
|
570
|
+
{ desc: "Enterprise License — Immortal.js Pro (Annual)", qty: 1, unit: 299900, tax: 0.08, unit_label: "yr" },
|
|
571
|
+
{ desc: "Onboarding & Architecture Review (Senior Engineer)", qty: 8, unit: 25000, tax: 0.08, unit_label: "hrs" },
|
|
572
|
+
{ desc: "24/7 Priority Support Package", qty: 1, unit: 120000, tax: 0.08, unit_label: "yr" },
|
|
573
|
+
]),
|
|
574
|
+
mkInv("sent", "EUR", munich, [
|
|
575
|
+
{ desc: "SaaS Monthly Subscription — Team Plan", qty: 3, unit: 49900, tax: 0.19, unit_label: "mo" },
|
|
576
|
+
{ desc: "Custom Plugin Development", qty: 40, unit: 15000, tax: 0.19, unit_label: "hrs" },
|
|
577
|
+
]),
|
|
578
|
+
mkInv("overdue", "GBP", london, [
|
|
579
|
+
{ desc: "API Integration Consulting", qty: 16, unit: 22500, tax: 0.20, unit_label: "hrs" },
|
|
580
|
+
{ desc: "Security Audit & Penetration Testing", qty: 1, unit: 350000, tax: 0.20, unit_label: "proj" },
|
|
581
|
+
]),
|
|
582
|
+
mkInv("draft", "AED", dubai, [
|
|
583
|
+
{ desc: "Cloud Infrastructure Architecture Design", qty: 1, unit: 550000, tax: 0.05, unit_label: "proj" },
|
|
584
|
+
{ desc: "Custom Dashboard Development (React + Node.js)", qty: 1, unit: 280000, tax: 0.05, unit_label: "proj" },
|
|
585
|
+
{ desc: "Technical Training Sessions", qty: 8, unit: 18000, tax: 0.05, unit_label: "hrs" },
|
|
586
|
+
]),
|
|
587
|
+
mkInv("paid", "USD", startup, [
|
|
588
|
+
{ desc: "Immortal.js Starter License", qty: 1, unit: 9900, tax: 0.08, unit_label: "yr" },
|
|
589
|
+
{ desc: "Quick-Start Implementation Pack", qty: 2, unit: 15000, tax: 0.08, unit_label: "hrs" },
|
|
590
|
+
]),
|
|
591
|
+
mkInv("cancelled", "CAD", acme, [
|
|
592
|
+
{ desc: "Custom Analytics Module (Cancelled)", qty: 1, unit: 180000, tax: 0.13, unit_label: "proj" },
|
|
593
|
+
], "Project cancelled due to scope change."),
|
|
594
|
+
mkInv("paid", "USD", munich, [
|
|
595
|
+
{ desc: "Performance Optimization Engagement", qty: 20, unit: 20000, tax: 0.08, unit_label: "hrs" },
|
|
596
|
+
{ desc: "Load Testing & Benchmarking", qty: 1, unit: 45000, tax: 0.08, unit_label: "proj" },
|
|
597
|
+
]),
|
|
598
|
+
mkInv("sent", "GBP", london, [
|
|
599
|
+
{ desc: "Resilience Framework Consultation", qty: 12, unit: 22500, tax: 0.20, unit_label: "hrs" },
|
|
600
|
+
]),
|
|
601
|
+
];
|
|
602
|
+
|
|
603
|
+
for (const inv of invoices) {
|
|
604
|
+
InvoiceStore.create(inv);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
seedIfEmpty();
|