el-contador 1.0.2 → 1.0.4

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/.env.example CHANGED
@@ -1,5 +1,6 @@
1
- # Used by docker-compose for Postgres and the backend.
2
1
  # Copy to .env and set values: cp .env.example .env
2
+ # These are used by Docker: the PostgreSQL container creates this user and database
3
+ # automatically on first run. You do not need to install PostgreSQL or create them manually.
3
4
 
4
5
  DB_USER=el_contador
5
6
  DB_PASSWORD=
package/README.md CHANGED
@@ -4,6 +4,8 @@ Bookkeeping and expense management – expenses, sales, bank transactions, and r
4
4
 
5
5
  ## Install (first time)
6
6
 
7
+ You do **not** need to install PostgreSQL on the host or create a database/user manually. The first run creates everything from your `.env`: PostgreSQL runs in a container and creates the user and database automatically; the app then applies the schema and creates the admin user.
8
+
7
9
  1. Create a project directory and add the package:
8
10
 
9
11
  ```bash
@@ -12,16 +14,22 @@ Bookkeeping and expense management – expenses, sales, bank transactions, and r
12
14
  npm install el-contador
13
15
  ```
14
16
 
15
- 2. Copy the env template and set at least `DB_PASSWORD` and `SESSION_SECRET`:
17
+ 2. Create `.env` with the credentials you want (these will be used to create the PostgreSQL user and database on first run):
16
18
 
17
19
  ```bash
18
20
  cp node_modules/el-contador/.env.example .env
19
- # Edit .env: set DB_PASSWORD and SESSION_SECRET
20
21
  ```
21
22
 
22
- `.env` is never published with the package (it is gitignored and not in the published files). Each installation uses its own `.env`; you can also set the Gemini API key later in the admin under **Invoice settings** (see below).
23
+ Edit `.env` and set at least:
24
+
25
+ - **DB_PASSWORD** – password for the database (required).
26
+ - **SESSION_SECRET** – long random string for session cookies.
27
+
28
+ Optional: **DB_USER** (default `el_contador`), **DB_NAME** (default `el_contador_finance`). The first run will create this PostgreSQL user and database inside the container; no separate setup needed.
23
29
 
24
- 3. Start the app:
30
+ Run `npx el-contador` (or `docker compose ...`) from the **same directory** that contains `.env`. If you see `Set DB_PASSWORD in .env`, create or fix `.env` in that directory.
31
+
32
+ 3. Start the app (this starts PostgreSQL and the backend; on first run the DB and admin user are created):
25
33
 
26
34
  ```bash
27
35
  npx el-contador
@@ -33,7 +41,7 @@ Bookkeeping and expense management – expenses, sales, bank transactions, and r
33
41
  docker compose -f node_modules/el-contador/docker-compose.yml --env-file .env up -d
34
42
  ```
35
43
 
36
- 4. Open the admin UI at `http://localhost:3080` (or the port in `ADMIN_PORT`). Log in with the admin user (see `.env`: `INIT_ADMIN_EMAIL` / `INIT_ADMIN_PASSWORD`, or `DB_PASSWORD` if `INIT_ADMIN_PASSWORD` is not set).
44
+ 4. Open the admin UI at `http://localhost:3080` (or the port in `ADMIN_PORT`). Log in with the admin user: **INIT_ADMIN_EMAIL** from `.env` (default `admin@example.com`), and **INIT_ADMIN_PASSWORD** or **DB_PASSWORD** if not set.
37
45
 
38
46
  ## Deploy on another server
39
47
 
@@ -98,6 +106,14 @@ Your data (database and uploads) lives in Docker volumes and is not overwritten
98
106
  - `el-contador down` or `el-contador stop` – stop containers.
99
107
  - `el-contador update` – run `npm update el-contador` then rebuild and start.
100
108
 
109
+ If `npx el-contador` fails (e.g. "unknown shorthand flag: 'f'"), your host may have Docker but not the Compose plugin in the expected form. Install **docker-compose** (standalone) or run Compose manually from the same directory as `.env`:
110
+
111
+ ```bash
112
+ docker-compose -f node_modules/el-contador/docker-compose.yml --env-file .env up -d
113
+ ```
114
+
115
+ On Ubuntu: `sudo apt install docker-compose-plugin` (V2) or the standalone `docker-compose` package.
116
+
101
117
  ## Publishing to npm (maintainers)
102
118
 
103
119
  1. Create an npm account at [npmjs.com/signup](https://www.npmjs.com/signup) if needed.
package/admin/app.html CHANGED
@@ -157,6 +157,10 @@
157
157
  .btn-secondary:hover {
158
158
  background: #f8fafc;
159
159
  }
160
+ .btn-secondary.btn-sm {
161
+ padding: 4px 10px;
162
+ font-size: 13px;
163
+ }
160
164
 
161
165
  .filter-toggles {
162
166
  display: inline-flex;
@@ -286,6 +290,24 @@
286
290
  @media (max-width: 800px) {
287
291
  #reconTwoCol { grid-template-columns: 1fr; }
288
292
  }
293
+
294
+ .recon-paired-table { width: 100%; }
295
+ .recon-paired-table td.recon-paired-cell {
296
+ padding: 6px 10px;
297
+ vertical-align: middle;
298
+ font-size: 14px;
299
+ }
300
+ .recon-pagination {
301
+ display: flex;
302
+ align-items: center;
303
+ justify-content: space-between;
304
+ flex-wrap: wrap;
305
+ gap: 10px;
306
+ margin-top: 10px;
307
+ padding-top: 10px;
308
+ border-top: 1px solid var(--line);
309
+ }
310
+ .recon-pagination button:not(:last-child) { margin-right: 8px; }
289
311
  </style>
290
312
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
291
313
  <script src="https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
@@ -307,6 +329,7 @@
307
329
  <button class="tab-btn" data-tab="sales">Sales</button>
308
330
  <button class="tab-btn" data-tab="bank">Bank Transactions</button>
309
331
  <button class="tab-btn" data-tab="reconciliation">Reconciliation</button>
332
+ <button class="tab-btn" data-tab="accounts">Accounts</button>
310
333
  <button class="tab-btn" data-tab="customers">Customers</button>
311
334
  <button class="tab-btn" data-tab="suppliers">Suppliers</button>
312
335
  <button class="tab-btn" data-tab="vat-reports">VAT Reports</button>
@@ -1105,6 +1128,7 @@
1105
1128
  </div>
1106
1129
  <div class="panel">
1107
1130
  <h2>Bank Transactions</h2>
1131
+ <p class="small" style="color: var(--muted);">Delete a line to remove duplicated or mistaken imports.</p>
1108
1132
  <table>
1109
1133
  <thead>
1110
1134
  <tr>
@@ -1113,6 +1137,7 @@
1113
1137
  <th>Description</th>
1114
1138
  <th>Amount</th>
1115
1139
  <th>Status</th>
1140
+ <th>Actions</th>
1116
1141
  </tr>
1117
1142
  </thead>
1118
1143
  <tbody id="bankRows"></tbody>
@@ -1151,6 +1176,7 @@
1151
1176
  <div style="display: flex; align-items: center; flex-wrap: wrap; gap: 12px;">
1152
1177
  <button type="button" id="reconMatchSelected" class="btn-primary" disabled>Match selected</button>
1153
1178
  <button type="button" id="reconCreateAdjustment" class="btn-secondary hidden" title="Create expense to balance">+ Add expense to balance</button>
1179
+ <button type="button" id="reconReconcileToAccount" class="btn-secondary hidden">Reconcile to account</button>
1154
1180
  <span id="reconSelectionStatus" class="small" style="color: var(--muted);"></span>
1155
1181
  </div>
1156
1182
  </div>
@@ -1207,16 +1233,86 @@
1207
1233
  </div>
1208
1234
  </div>
1209
1235
  <div id="reconPairedPanel" class="panel hidden">
1210
- <table>
1236
+ <table class="recon-paired-table">
1211
1237
  <thead>
1212
1238
  <tr>
1213
- <th>Bank Transaction</th>
1214
- <th>Match Info</th>
1215
- <th>Action</th>
1239
+ <th>Bank</th>
1240
+ <th>Paired with</th>
1241
+ <th></th>
1216
1242
  </tr>
1217
1243
  </thead>
1218
1244
  <tbody id="reconPairedRows"></tbody>
1219
1245
  </table>
1246
+ <div id="reconPairedPagination" class="recon-pagination hidden">
1247
+ <span id="reconPairedPaginationInfo" class="small" style="color: var(--muted);"></span>
1248
+ <div>
1249
+ <button type="button" id="reconPairedPrev" class="btn-secondary" disabled>Previous</button>
1250
+ <button type="button" id="reconPairedNext" class="btn-secondary" disabled>Next</button>
1251
+ </div>
1252
+ </div>
1253
+ </div>
1254
+
1255
+ <div id="reconToAccountModal" class="modal-overlay hidden">
1256
+ <div class="panel" style="max-width: 400px; width: 100%; margin: 16px;">
1257
+ <h2>Reconcile to account</h2>
1258
+ <p class="small" style="color: var(--muted); margin: 0 0 12px 0;">Assign this bank transaction to a ledger account (no expense or invoice).</p>
1259
+ <div class="field">
1260
+ <label for="reconAccountType">Account type *</label>
1261
+ <select id="reconAccountType" class="field" required>
1262
+ <option value="loan">Loan</option>
1263
+ <option value="accrual">Accrual</option>
1264
+ <option value="retro">Retro</option>
1265
+ <option value="other">Other</option>
1266
+ </select>
1267
+ </div>
1268
+ <div class="field">
1269
+ <label for="reconAccountNote">Note (optional)</label>
1270
+ <textarea id="reconAccountNote" class="field" rows="2" placeholder="Short note for this ledger entry"></textarea>
1271
+ </div>
1272
+ <div id="reconToAccountError" class="small" style="color: var(--danger); margin-top: 8px; display: none;"></div>
1273
+ <div class="actions" style="margin-top: 12px;">
1274
+ <button type="button" id="reconToAccountSubmit" class="btn-primary">Submit</button>
1275
+ <button type="button" id="reconToAccountCancel" class="btn-secondary">Cancel</button>
1276
+ </div>
1277
+ </div>
1278
+ </div>
1279
+ </section>
1280
+
1281
+ <section id="tab-accounts" class="tab-content hidden">
1282
+ <div class="panel">
1283
+ <h2>Accounts</h2>
1284
+ <p class="small" style="color: var(--muted);">Ledger reconciliation for bank transactions that are not matched to an expense or invoice (e.g. loans, accruals, retros).</p>
1285
+ <div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px;">
1286
+ <label class="small">Account type:</label>
1287
+ <select id="accountsFilterType" class="field" style="width: auto;">
1288
+ <option value="">All</option>
1289
+ <option value="loan">Loan</option>
1290
+ <option value="accrual">Accrual</option>
1291
+ <option value="retro">Retro</option>
1292
+ <option value="other">Other</option>
1293
+ </select>
1294
+ <label class="small">From:</label>
1295
+ <input type="date" id="accountsFilterFrom" class="field" style="width: auto;">
1296
+ <label class="small">To:</label>
1297
+ <input type="date" id="accountsFilterTo" class="field" style="width: auto;">
1298
+ </div>
1299
+ <div style="border: 1px solid var(--line); border-radius: 8px; overflow: hidden;">
1300
+ <table>
1301
+ <thead>
1302
+ <tr>
1303
+ <th>Date</th>
1304
+ <th>Type</th>
1305
+ <th>Description</th>
1306
+ <th style="text-align: right;">Amount</th>
1307
+ <th>Account type</th>
1308
+ <th>Note</th>
1309
+ <th>Actions</th>
1310
+ </tr>
1311
+ </thead>
1312
+ <tbody id="accountsRows"></tbody>
1313
+ </table>
1314
+ </div>
1315
+ <p id="accountsEmpty" class="small" style="color: var(--muted); margin-top: 8px; display: none;">No transactions reconciled to account. Use Reconciliation tab and choose "Reconcile to account" for a bank transaction.</p>
1220
1316
  </div>
1221
1317
  </section>
1222
1318
 
@@ -1833,6 +1929,7 @@
1833
1929
  const bankArrow = bankIsIn ? "&#8593;" : "&#8595;";
1834
1930
  const bankColor = bankIsIn ? "var(--recon-in)" : "var(--recon-out)";
1835
1931
  const bankLabel = bankIsIn ? "Money In" : "Money Out";
1932
+ const actionsCell = tx.reconciled ? "<span class=\"small\" style=\"color: var(--muted);\">Paired</span>" : `<button type="button" data-action="delete-bank" data-id="${tx.id}" class="btn-secondary">Delete</button>`;
1836
1933
  tr.innerHTML = `
1837
1934
  <td>${tx.date}</td>
1838
1935
  <td><span style="color: ${bankColor}; font-weight: 600;"><span style="font-weight: bold;">${bankArrow}</span> ${bankLabel}</span></td>
@@ -1842,6 +1939,7 @@
1842
1939
  </td>
1843
1940
  <td>${money(tx.amount)}</td>
1844
1941
  <td>${statusHtml}</td>
1942
+ <td>${actionsCell}</td>
1845
1943
  `;
1846
1944
  rows.appendChild(tr);
1847
1945
  });
@@ -1944,6 +2042,8 @@
1944
2042
  }
1945
2043
 
1946
2044
  let reconciliationView = 'open'; // 'open' or 'paired'
2045
+ let reconPairedPage = 0;
2046
+ const reconPairedPageSize = 25;
1947
2047
  let reconSelectedBankId = null;
1948
2048
  let reconSuggestedPreSelect = null; // { type: 'expense'|'sale', id } to pre-check when "Use suggested" was clicked
1949
2049
 
@@ -2176,9 +2276,22 @@
2176
2276
  adjBtn.removeAttribute("data-adjustment-amount");
2177
2277
  }
2178
2278
  }
2279
+ const reconAccountBtn = document.getElementById("reconReconcileToAccount");
2280
+ if (reconAccountBtn) {
2281
+ const onlyBankSelected = !!bankTxId && expenseIds.length === 0 && !saleId;
2282
+ if (onlyBankSelected) {
2283
+ reconAccountBtn.classList.remove("hidden");
2284
+ reconAccountBtn.dataset.bankId = bankTxId;
2285
+ } else {
2286
+ reconAccountBtn.classList.add("hidden");
2287
+ reconAccountBtn.removeAttribute("data-bank-id");
2288
+ }
2289
+ }
2179
2290
  } else {
2180
2291
  overviewPanel.classList.add("hidden");
2181
2292
  if (adjBtn) adjBtn.classList.add("hidden");
2293
+ const reconAccountBtn = document.getElementById("reconReconcileToAccount");
2294
+ if (reconAccountBtn) { reconAccountBtn.classList.add("hidden"); reconAccountBtn.removeAttribute("data-bank-id"); }
2182
2295
  }
2183
2296
  }
2184
2297
 
@@ -2190,33 +2303,44 @@
2190
2303
 
2191
2304
  pairedRows.innerHTML = "";
2192
2305
  const filteredTx = data.bankTransactions
2193
- .filter((tx) => tx.reconciled)
2306
+ .filter((tx) => tx.reconciled && tx.reconciliationRefType !== "account")
2194
2307
  .sort((a, b) => new Date(b.date) - new Date(a.date));
2195
2308
 
2309
+ const totalCount = filteredTx.length;
2310
+ const totalPages = Math.max(1, Math.ceil(totalCount / reconPairedPageSize));
2311
+ reconPairedPage = Math.min(reconPairedPage, totalPages - 1);
2312
+ const pageTx = filteredTx.slice(reconPairedPage * reconPairedPageSize, (reconPairedPage + 1) * reconPairedPageSize);
2313
+
2314
+ const paginationEl = document.getElementById("reconPairedPagination");
2315
+ const paginationInfo = document.getElementById("reconPairedPaginationInfo");
2316
+ const prevBtn = document.getElementById("reconPairedPrev");
2317
+ const nextBtn = document.getElementById("reconPairedNext");
2318
+ if (paginationEl) paginationEl.classList.toggle("hidden", totalCount <= reconPairedPageSize);
2319
+ if (paginationInfo) {
2320
+ const from = totalCount === 0 ? 0 : reconPairedPage * reconPairedPageSize + 1;
2321
+ const to = Math.min((reconPairedPage + 1) * reconPairedPageSize, totalCount);
2322
+ paginationInfo.textContent = totalCount === 0 ? "" : `Showing ${from}-${to} of ${totalCount}`;
2323
+ }
2324
+ if (prevBtn) prevBtn.disabled = reconPairedPage <= 0;
2325
+ if (nextBtn) nextBtn.disabled = reconPairedPage >= totalPages - 1;
2326
+
2196
2327
  if (!filteredTx.length) {
2197
2328
  const tr = document.createElement("tr");
2198
2329
  tr.innerHTML = `<td colspan="3" class="small" style="color: var(--muted);">No paired transactions yet.</td>`;
2199
2330
  pairedRows.appendChild(tr);
2200
2331
  } else {
2201
- filteredTx.forEach((tx) => {
2332
+ pageTx.forEach((tx) => {
2202
2333
  const tr = document.createElement("tr");
2203
- let matchInfo = "";
2204
- let matchDetails = "";
2334
+ let matchLine = "";
2205
2335
  let unmatchBtn = "";
2206
2336
 
2207
2337
  if (tx.reconciliationRefType === 'expenses') {
2208
2338
  const matchedExpenses = data.expenses.filter((e) => e.bankTransactionId === tx.id);
2209
2339
  const total = matchedExpenses.reduce((s, e) => s + (Number(e.amount) || 0) + (Number(e.vat) || 0), 0);
2210
2340
  const adj = Number(tx.adjustmentAmount) || 0;
2211
- matchInfo = `<span class="status-pill status-paired">Paired</span>`;
2212
- matchDetails = `
2213
- <div style="margin-top: 4px;">
2214
- <strong>Expenses (${matchedExpenses.length}):</strong> ${matchedExpenses.map((e) => e.vendor).join(", ")}<br>
2215
- <span class="small">Combined total: ${money(total)}</span>
2216
- ${Math.abs(adj) >= 0.01 ? `<br><span class="small">Adjustment: ${adj >= 0 ? "+" : ""}${money(adj)}</span>` : ""}
2217
- </div>
2218
- `;
2219
- unmatchBtn = `<button type="button" data-action="unmatch" data-tx-id="${tx.id}" class="btn-secondary" style="margin-top:6px;">Unmatch</button>`;
2341
+ const adjStr = Math.abs(adj) >= 0.01 ? ` (adj ${adj >= 0 ? "+" : ""}${money(adj)})` : "";
2342
+ matchLine = `<span class="status-pill status-paired">Paired</span> Expenses (${matchedExpenses.length}): ${matchedExpenses.map((e) => escapeHtml(e.vendor)).join(", ")} &ndash; ${money(total)}${adjStr}`;
2343
+ unmatchBtn = `<button type="button" data-action="unmatch" data-tx-id="${tx.id}" class="btn-secondary btn-sm">Unmatch</button>`;
2220
2344
  } else if (tx.reconciliationRefId) {
2221
2345
  let matchedItem = null;
2222
2346
  let matchedType = "";
@@ -2231,15 +2355,9 @@
2231
2355
  const ref = matchedType === "Expense" ? matchedItem.vendor : (matchedItem.invoiceNo || matchedItem.customer);
2232
2356
  const amount = matchedType === "Expense" ? (Number(matchedItem.amount) || 0) + (Number(matchedItem.vat) || 0) : matchedItem.total;
2233
2357
  const adj = Number(tx.adjustmentAmount) || 0;
2234
- matchInfo = `<span class="status-pill status-paired">Paired</span>`;
2235
- matchDetails = `
2236
- <div style="margin-top: 4px;">
2237
- <strong>${matchedType}:</strong> ${ref}<br>
2238
- <span class="small">${matchedType === "Expense" ? "Expense total" : "Invoice total"}: ${money(amount)}</span>
2239
- ${Math.abs(adj) >= 0.01 ? `<br><span class="small">Adjustment: ${adj >= 0 ? "+" : ""}${money(adj)}</span>` : ""}
2240
- </div>
2241
- `;
2242
- unmatchBtn = `<button type="button" data-action="unmatch" data-tx-id="${tx.id}" class="btn-secondary" style="margin-top:6px;">Unmatch</button>`;
2358
+ const adjStr = Math.abs(adj) >= 0.01 ? ` (adj ${adj >= 0 ? "+" : ""}${money(adj)})` : "";
2359
+ matchLine = `<span class="status-pill status-paired">Paired</span> ${matchedType}: ${escapeHtml(ref)} &ndash; ${money(amount)}${adjStr}`;
2360
+ unmatchBtn = `<button type="button" data-action="unmatch" data-tx-id="${tx.id}" class="btn-secondary btn-sm">Unmatch</button>`;
2243
2361
  }
2244
2362
  }
2245
2363
 
@@ -2247,16 +2365,14 @@
2247
2365
  const txArrow = txIsIn ? "&#8593;" : "&#8595;";
2248
2366
  const txColor = txIsIn ? "var(--recon-in)" : "var(--recon-out)";
2249
2367
  const txLabel = txIsIn ? "Money In" : "Money Out";
2368
+ const dateOnly = (tx.date || "").toString().slice(0, 10);
2369
+ const desc = (tx.description || "").slice(0, 45);
2370
+ const descTrunc = (tx.description || "").length > 45 ? desc + "..." : desc;
2371
+ const pairedOn = tx.reconciledAt ? new Date(tx.reconciledAt).toLocaleDateString() : "—";
2250
2372
  tr.innerHTML = `
2251
- <td>
2252
- <strong style="color: ${txColor};"><span style="font-weight: bold;">${txArrow}</span> ${txLabel}</strong><br>
2253
- ${tx.date} - ${money(tx.amount)}<br>
2254
- <span class="small">${tx.description}</span>
2255
- </td>
2256
- <td>${matchInfo}${matchDetails}${unmatchBtn}
2257
- <div class="small" style="margin-top: 4px; color: var(--muted);">Paired on: ${tx.reconciledAt ? new Date(tx.reconciledAt).toLocaleDateString() : "Unknown"}</div>
2258
- </td>
2259
- <td><span class="status-pill status-done">Completed</span></td>
2373
+ <td class="recon-paired-cell"><span style="color:${txColor};font-weight:600;">${txArrow} ${txLabel}</span> ${dateOnly} ${money(tx.amount)} <span class="small" style="color:var(--muted);">${escapeHtml(descTrunc)}</span></td>
2374
+ <td class="recon-paired-cell"><span class="recon-paired-match">${matchLine}</span> <span class="small" style="color:var(--muted);">Paired ${pairedOn}</span> ${unmatchBtn}</td>
2375
+ <td class="recon-paired-cell"><span class="status-pill status-done">Completed</span></td>
2260
2376
  `;
2261
2377
  pairedRows.appendChild(tr);
2262
2378
  });
@@ -2264,6 +2380,39 @@
2264
2380
  }
2265
2381
  }
2266
2382
 
2383
+ function renderAccounts() {
2384
+ const tbody = document.getElementById("accountsRows");
2385
+ const emptyEl = document.getElementById("accountsEmpty");
2386
+ if (!tbody) return;
2387
+ const typeFilter = (document.getElementById("accountsFilterType") || {}).value || "";
2388
+ const fromFilter = (document.getElementById("accountsFilterFrom") || {}).value || "";
2389
+ const toFilter = (document.getElementById("accountsFilterTo") || {}).value || "";
2390
+ let list = (data.bankTransactions || []).filter((tx) => tx.reconciliationRefType === "account");
2391
+ if (typeFilter) list = list.filter((tx) => tx.accountType === typeFilter);
2392
+ if (fromFilter) list = list.filter((tx) => tx.date >= fromFilter);
2393
+ if (toFilter) list = list.filter((tx) => tx.date <= toFilter);
2394
+ list = list.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
2395
+ tbody.innerHTML = "";
2396
+ if (emptyEl) emptyEl.style.display = list.length === 0 ? "block" : "none";
2397
+ const accountTypeLabel = (v) => ({ loan: "Loan", accrual: "Accrual", retro: "Retro", other: "Other" }[v] || v || "");
2398
+ list.forEach((tx) => {
2399
+ const tr = document.createElement("tr");
2400
+ const isIn = tx.type === "in";
2401
+ const color = isIn ? "var(--recon-in)" : "var(--recon-out)";
2402
+ const typeLabel = isIn ? "Money In" : "Money Out";
2403
+ tr.innerHTML = `
2404
+ <td>${tx.date}</td>
2405
+ <td><span style="color:${color};font-weight:600;">${typeLabel}</span></td>
2406
+ <td>${escapeHtml((tx.description || "").slice(0, 60))}${(tx.description || "").length > 60 ? "..." : ""}</td>
2407
+ <td style="text-align:right;"><strong>${money(tx.amount)}</strong></td>
2408
+ <td>${escapeHtml(accountTypeLabel(tx.accountType))}</td>
2409
+ <td>${escapeHtml((tx.accountNote || "").slice(0, 40))}${(tx.accountNote || "").length > 40 ? "..." : ""}</td>
2410
+ <td><button type="button" class="btn-secondary" data-action="accounts-unmatch" data-bank-id="${tx.id}">Unreconcile</button></td>
2411
+ `;
2412
+ tbody.appendChild(tr);
2413
+ });
2414
+ }
2415
+
2267
2416
  function getReconSelection() {
2268
2417
  const bankRow = document.querySelector("#reconBankRows tr.recon-bank-row.selected");
2269
2418
  const bankTxId = bankRow ? bankRow.dataset.txId : null;
@@ -2688,6 +2837,7 @@
2688
2837
  renderSales();
2689
2838
  renderBank();
2690
2839
  renderReconciliation();
2840
+ renderAccounts();
2691
2841
  renderCustomers();
2692
2842
  renderSuppliers();
2693
2843
  updateCustomerSelect();
@@ -2710,6 +2860,7 @@
2710
2860
  if (tabName === "suppliers") updateSupplierCategorySelects();
2711
2861
  if (tabName === "admin") loadAdminData();
2712
2862
  if (tabName === "tasks") loadTasks();
2863
+ if (tabName === "accounts") renderAccounts();
2713
2864
  if (tabName === "expenses") {
2714
2865
  const msgEl = document.getElementById("expenseApprovalMessage");
2715
2866
  if (msgEl) {
@@ -4334,6 +4485,46 @@
4334
4485
  alert(e.message || "Failed to create adjustment expense.");
4335
4486
  }
4336
4487
  });
4488
+ document.getElementById("reconReconcileToAccount").addEventListener("click", function () {
4489
+ const btn = document.getElementById("reconReconcileToAccount");
4490
+ const bankId = btn && btn.dataset.bankId;
4491
+ if (!bankId) return;
4492
+ document.getElementById("reconToAccountModal").dataset.bankId = bankId;
4493
+ document.getElementById("reconAccountType").value = "other";
4494
+ document.getElementById("reconAccountNote").value = "";
4495
+ document.getElementById("reconToAccountError").style.display = "none";
4496
+ document.getElementById("reconToAccountModal").classList.remove("hidden");
4497
+ });
4498
+ document.getElementById("reconToAccountCancel").addEventListener("click", function () {
4499
+ document.getElementById("reconToAccountModal").classList.add("hidden");
4500
+ });
4501
+ document.getElementById("reconToAccountModal").addEventListener("click", function (e) {
4502
+ if (e.target.id === "reconToAccountModal") document.getElementById("reconToAccountModal").classList.add("hidden");
4503
+ });
4504
+ document.getElementById("reconToAccountSubmit").addEventListener("click", async function () {
4505
+ const modal = document.getElementById("reconToAccountModal");
4506
+ const bankId = modal.dataset.bankId;
4507
+ const accountType = document.getElementById("reconAccountType").value;
4508
+ const accountNote = document.getElementById("reconAccountNote").value.trim() || undefined;
4509
+ const errEl = document.getElementById("reconToAccountError");
4510
+ if (!bankId || !accountType) {
4511
+ errEl.textContent = "Account type is required.";
4512
+ errEl.style.display = "block";
4513
+ return;
4514
+ }
4515
+ errEl.style.display = "none";
4516
+ try {
4517
+ await apiPost("/api/reconciliation/match-account", { bankTransactionId: bankId, accountType, accountNote });
4518
+ document.getElementById("reconToAccountModal").classList.add("hidden");
4519
+ reconSelectedBankId = null;
4520
+ await loadAll();
4521
+ renderReconciliation();
4522
+ renderAccounts();
4523
+ } catch (e) {
4524
+ errEl.textContent = e.message || "Failed to reconcile to account.";
4525
+ errEl.style.display = "block";
4526
+ }
4527
+ });
4337
4528
  document.getElementById("reconOpenNewExpenseModal").addEventListener("click", function () {
4338
4529
  const tx = reconSelectedBankId ? data.bankTransactions.find((t) => t.id === reconSelectedBankId) : null;
4339
4530
  if (!tx || tx.type !== "out") return;
@@ -4461,6 +4652,29 @@
4461
4652
  }
4462
4653
  });
4463
4654
 
4655
+ document.getElementById("accountsRows").addEventListener("click", async (event) => {
4656
+ const btn = event.target.closest("button[data-action='accounts-unmatch']");
4657
+ if (btn && btn.dataset.bankId) {
4658
+ await unmatchTransaction(btn.dataset.bankId);
4659
+ }
4660
+ });
4661
+
4662
+ document.getElementById("bankRows").addEventListener("click", async (event) => {
4663
+ const deleteBtn = event.target.closest("button[data-action='delete-bank']");
4664
+ if (deleteBtn && deleteBtn.dataset.id) {
4665
+ if (!confirm("Delete this bank transaction? If it was matched, the invoice or expense will be unmatched.")) return;
4666
+ await apiDelete("/api/bank-transactions/" + encodeURIComponent(deleteBtn.dataset.id));
4667
+ data.bankTransactions = await apiGet("/api/bank-transactions");
4668
+ renderBank();
4669
+ renderReconciliation();
4670
+ renderAccounts();
4671
+ }
4672
+ });
4673
+
4674
+ document.getElementById("accountsFilterType").addEventListener("change", () => renderAccounts());
4675
+ document.getElementById("accountsFilterFrom").addEventListener("change", () => renderAccounts());
4676
+ document.getElementById("accountsFilterTo").addEventListener("change", () => renderAccounts());
4677
+
4464
4678
  // Reconciliation view toggle handlers
4465
4679
  document.getElementById("reconViewOpen").addEventListener("click", () => {
4466
4680
  reconciliationView = 'open';
@@ -4474,6 +4688,14 @@
4474
4688
  document.getElementById("reconViewOpen").classList.remove("active");
4475
4689
  renderReconciliation();
4476
4690
  });
4691
+ document.getElementById("reconPairedPrev").addEventListener("click", () => {
4692
+ reconPairedPage = Math.max(0, reconPairedPage - 1);
4693
+ renderReconciliation();
4694
+ });
4695
+ document.getElementById("reconPairedNext").addEventListener("click", () => {
4696
+ reconPairedPage += 1;
4697
+ renderReconciliation();
4698
+ });
4477
4699
 
4478
4700
  document.getElementById("btnLogout").addEventListener("click", async () => {
4479
4701
  await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
package/bin/cli.js CHANGED
@@ -11,10 +11,31 @@ const hasEnv = fs.existsSync(envFile);
11
11
 
12
12
  const cmd = process.argv[2] || 'start';
13
13
 
14
- function composeArgs(sub) {
15
- const a = ['compose', '-f', composePath];
16
- if (hasEnv) a.push('--env-file', envFile);
17
- return a.concat(sub);
14
+ // Escape for safe use in a shell command (single-quote style)
15
+ function shQuote(s) {
16
+ return "'" + String(s).replace(/'/g, "'\"'\"'") + "'";
17
+ }
18
+
19
+ // Run compose via shell so "docker compose" or "docker-compose" is parsed correctly by the OS
20
+ function runCompose(subargs) {
21
+ const envPart = hasEnv ? ' --env-file ' + shQuote(envFile) : '';
22
+ const base = ' -f ' + shQuote(composePath) + envPart + ' ';
23
+ const full = base + subargs.join(' ');
24
+ const r = spawnSync(
25
+ process.platform === 'win32' ? 'cmd' : 'sh',
26
+ [process.platform === 'win32' ? '/c' : '-c', 'docker-compose' + full],
27
+ { stdio: 'inherit', cwd }
28
+ );
29
+ if (r.status === 0) return;
30
+ const r2 = spawnSync(
31
+ process.platform === 'win32' ? 'cmd' : 'sh',
32
+ [process.platform === 'win32' ? '/c' : '-c', 'docker compose' + full],
33
+ { stdio: 'inherit', cwd }
34
+ );
35
+ if (r2.status !== 0) {
36
+ console.error('el-contador: need "docker-compose" or "docker compose". Install Docker and Docker Compose.');
37
+ process.exit(1);
38
+ }
18
39
  }
19
40
 
20
41
  if (cmd === 'update') {
@@ -30,13 +51,11 @@ if (cmd === 'start' || cmd === 'up' || cmd === 'update') {
30
51
  process.exit(1);
31
52
  }
32
53
  if (!hasEnv) console.warn('el-contador: no .env in current directory; copy from node_modules/el-contador/.env.example');
33
- const args = composeArgs(['up', '-d']);
34
- if (cmd === 'update') args.push('--build');
35
- const r = spawnSync('docker', args, { stdio: 'inherit', cwd });
36
- process.exit(r.status !== null ? r.status : 1);
54
+ const subargs = ['up', '-d'];
55
+ if (cmd === 'update') subargs.push('--build');
56
+ runCompose(subargs);
37
57
  } else if (cmd === 'down' || cmd === 'stop') {
38
- const r = spawnSync('docker', composeArgs(['down']), { stdio: 'inherit', cwd });
39
- process.exit(r.status !== null ? r.status : 1);
58
+ runCompose(['down']);
40
59
  } else {
41
60
  console.log('Usage: el-contador [start|up|down|stop|update]');
42
61
  console.log(' start, up Start the app (default). Requires .env in current directory.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "el-contador",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Bookkeeping and expense management – run with Docker",
5
5
  "keywords": ["bookkeeping", "expenses", "docker", "accounting", "finance"],
6
6
  "license": "MIT",
package/server/db/init.js CHANGED
@@ -13,6 +13,19 @@ async function init() {
13
13
  await pool.query("ALTER TABLE sales ADD COLUMN IF NOT EXISTS customer_address text");
14
14
  await pool.query("ALTER TABLE sales ADD COLUMN IF NOT EXISTS voided boolean NOT NULL DEFAULT false");
15
15
  await pool.query("ALTER TABLE sales ADD COLUMN IF NOT EXISTS voided_at timestamptz");
16
+ await pool.query("ALTER TABLE bank_transactions ADD COLUMN IF NOT EXISTS account_type text");
17
+ await pool.query("ALTER TABLE bank_transactions ADD COLUMN IF NOT EXISTS account_note text");
18
+ await pool.query(`
19
+ DO $$
20
+ BEGIN
21
+ IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'bank_transactions_reconciliation_ref_type_check') THEN
22
+ ALTER TABLE bank_transactions DROP CONSTRAINT bank_transactions_reconciliation_ref_type_check;
23
+ END IF;
24
+ ALTER TABLE bank_transactions ADD CONSTRAINT bank_transactions_reconciliation_ref_type_check
25
+ CHECK (reconciliation_ref_type IN ('expense', 'sale', 'expenses', 'account'));
26
+ EXCEPTION WHEN duplicate_object THEN NULL;
27
+ END $$;
28
+ `);
16
29
  } catch (e) { /* columns may already exist */ }
17
30
  const bcrypt = require('bcrypt');
18
31
  const email = process.env.INIT_ADMIN_EMAIL || 'admin@example.com';
@@ -109,9 +109,11 @@ CREATE TABLE IF NOT EXISTS bank_transactions (
109
109
  reference text,
110
110
  description text NOT NULL,
111
111
  reconciled boolean NOT NULL DEFAULT false,
112
- reconciliation_ref_type text CHECK (reconciliation_ref_type IN ('expense', 'sale', 'expenses')),
112
+ reconciliation_ref_type text CHECK (reconciliation_ref_type IN ('expense', 'sale', 'expenses', 'account')),
113
113
  reconciliation_ref_id uuid,
114
114
  reconciled_at timestamptz,
115
+ account_type text,
116
+ account_note text,
115
117
  created_at timestamptz NOT NULL DEFAULT now()
116
118
  );
117
119
 
@@ -127,14 +129,14 @@ CREATE INDEX IF NOT EXISTS idx_bank_reconciled ON bank_transactions(reconciled);
127
129
  -- Migration: Adjustment amount when reconciliation balance is within 0.50 EUR
128
130
  ALTER TABLE bank_transactions ADD COLUMN IF NOT EXISTS adjustment_amount numeric(12,2) DEFAULT 0;
129
131
 
130
- -- Migration: Allow reconciliation_ref_type 'expenses' for multi-expense match
132
+ -- Migration: Allow reconciliation_ref_type 'expenses' and 'account' for multi-expense match and ledger
131
133
  DO $$
132
134
  BEGIN
133
135
  IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'bank_transactions_reconciliation_ref_type_check') THEN
134
136
  ALTER TABLE bank_transactions DROP CONSTRAINT bank_transactions_reconciliation_ref_type_check;
135
137
  END IF;
136
138
  ALTER TABLE bank_transactions ADD CONSTRAINT bank_transactions_reconciliation_ref_type_check
137
- CHECK (reconciliation_ref_type IN ('expense', 'sale', 'expenses'));
139
+ CHECK (reconciliation_ref_type IN ('expense', 'sale', 'expenses', 'account'));
138
140
  EXCEPTION WHEN duplicate_object THEN NULL;
139
141
  END $$;
140
142
  CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name);
@@ -174,7 +174,7 @@ router.get('/', async (req, res) => {
174
174
  let r;
175
175
  try {
176
176
  r = await pool.query(
177
- 'SELECT id, date, type, amount, reference, description, reconciled, reconciliation_ref_type, reconciliation_ref_id, reconciled_at, adjustment_amount, created_at FROM bank_transactions ORDER BY date DESC'
177
+ 'SELECT id, date, type, amount, reference, description, reconciled, reconciliation_ref_type, reconciliation_ref_id, reconciled_at, adjustment_amount, account_type, account_note, created_at FROM bank_transactions ORDER BY date DESC'
178
178
  );
179
179
  } catch (err) {
180
180
  if (err.code === '42703') {
@@ -195,6 +195,8 @@ router.get('/', async (req, res) => {
195
195
  reconciliationRefId: row.reconciliation_ref_id,
196
196
  reconciledAt: row.reconciled_at,
197
197
  adjustmentAmount: row.adjustment_amount != null ? Number(row.adjustment_amount) : 0,
198
+ accountType: row.account_type ?? null,
199
+ accountNote: row.account_note ?? null,
198
200
  createdAt: row.created_at,
199
201
  })));
200
202
  });
@@ -229,4 +231,45 @@ router.post('/', async (req, res) => {
229
231
  });
230
232
  });
231
233
 
234
+ /** Delete a bank transaction (e.g. to remove duplicated import). Unlinks reconciliation on expense/sale if present. */
235
+ router.delete('/:id', async (req, res) => {
236
+ const id = req.params.id;
237
+ const client = await pool.connect();
238
+ try {
239
+ const r = await client.query(
240
+ 'SELECT id, reconciliation_ref_type, reconciliation_ref_id FROM bank_transactions WHERE id = $1',
241
+ [id]
242
+ );
243
+ if (r.rows.length === 0) {
244
+ return res.status(404).json({ error: 'Bank transaction not found' });
245
+ }
246
+ const row = r.rows[0];
247
+ await client.query('BEGIN');
248
+ if (row.reconciliation_ref_type === 'expense' && row.reconciliation_ref_id) {
249
+ await client.query(
250
+ 'UPDATE expenses SET reconciled = false, reconciled_at = NULL, bank_transaction_id = NULL WHERE id = $1',
251
+ [row.reconciliation_ref_id]
252
+ );
253
+ } else if (row.reconciliation_ref_type === 'expenses') {
254
+ await client.query(
255
+ 'UPDATE expenses SET reconciled = false, reconciled_at = NULL, bank_transaction_id = NULL WHERE bank_transaction_id = $1',
256
+ [id]
257
+ );
258
+ } else if (row.reconciliation_ref_type === 'sale' && row.reconciliation_ref_id) {
259
+ await client.query(
260
+ 'UPDATE sales SET reconciled = false, reconciled_at = NULL WHERE id = $1',
261
+ [row.reconciliation_ref_id]
262
+ );
263
+ }
264
+ await client.query('DELETE FROM bank_transactions WHERE id = $1', [id]);
265
+ await client.query('COMMIT');
266
+ res.json({ ok: true });
267
+ } catch (e) {
268
+ await client.query('ROLLBACK');
269
+ throw e;
270
+ } finally {
271
+ client.release();
272
+ }
273
+ });
274
+
232
275
  module.exports = router;
@@ -123,7 +123,41 @@ router.post('/match-expenses', async (req, res) => {
123
123
  }
124
124
  });
125
125
 
126
- /** Unmatch a bank transaction from its expense(s) or sale */
126
+ const ACCOUNT_TYPES = ['loan', 'accrual', 'retro', 'other'];
127
+
128
+ /** Reconcile a bank transaction to an account (no expense or invoice). For loans, accruals, retros, etc. */
129
+ router.post('/match-account', async (req, res) => {
130
+ const { bankTransactionId, accountType, accountNote } = req.body || {};
131
+ if (!bankTransactionId || !accountType || !ACCOUNT_TYPES.includes(accountType)) {
132
+ return res.status(400).json({
133
+ error: 'bankTransactionId and accountType required; accountType must be one of: loan, accrual, retro, other',
134
+ });
135
+ }
136
+ const now = new Date().toISOString();
137
+ try {
138
+ const txRow = await pool.query(
139
+ 'SELECT id, reconciled FROM bank_transactions WHERE id = $1',
140
+ [bankTransactionId]
141
+ );
142
+ if (txRow.rows.length === 0) {
143
+ return res.status(404).json({ error: 'Bank transaction not found' });
144
+ }
145
+ if (txRow.rows[0].reconciled) {
146
+ return res.status(400).json({ error: 'Bank transaction is already reconciled' });
147
+ }
148
+ const note = accountNote != null ? String(accountNote).trim() : null;
149
+ await pool.query(
150
+ `UPDATE bank_transactions SET reconciled = true, reconciliation_ref_type = 'account', reconciliation_ref_id = NULL,
151
+ reconciled_at = $1, account_type = $2, account_note = $3, adjustment_amount = 0 WHERE id = $4`,
152
+ [now, accountType, note || null, bankTransactionId]
153
+ );
154
+ res.json({ ok: true });
155
+ } catch (e) {
156
+ res.status(500).json({ error: e.message });
157
+ }
158
+ });
159
+
160
+ /** Unmatch a bank transaction from its expense(s), sale, or account */
127
161
  router.post('/unmatch', async (req, res) => {
128
162
  const { bankTransactionId } = req.body || {};
129
163
  if (!bankTransactionId) {
@@ -141,7 +175,7 @@ router.post('/unmatch', async (req, res) => {
141
175
  const row = r.rows[0];
142
176
  await client.query('BEGIN');
143
177
  await client.query(
144
- `UPDATE bank_transactions SET reconciled = false, reconciliation_ref_type = NULL, reconciliation_ref_id = NULL, reconciled_at = NULL, adjustment_amount = 0 WHERE id = $1`,
178
+ `UPDATE bank_transactions SET reconciled = false, reconciliation_ref_type = NULL, reconciliation_ref_id = NULL, reconciled_at = NULL, adjustment_amount = 0, account_type = NULL, account_note = NULL WHERE id = $1`,
145
179
  [bankTransactionId]
146
180
  );
147
181
  if (row.reconciliation_ref_type === 'expense' && row.reconciliation_ref_id) {