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 +2 -1
- package/README.md +21 -5
- package/admin/app.html +257 -35
- package/bin/cli.js +29 -10
- package/package.json +1 -1
- package/server/db/init.js +13 -0
- package/server/db/schema.sql +5 -3
- package/server/routes/bank.js +44 -1
- package/server/routes/reconciliation.js +36 -2
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.
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
|
1214
|
-
<th>
|
|
1215
|
-
<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 ? "↑" : "↓";
|
|
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
|
-
|
|
2332
|
+
pageTx.forEach((tx) => {
|
|
2202
2333
|
const tr = document.createElement("tr");
|
|
2203
|
-
let
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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(", ")} – ${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
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
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)} – ${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 ? "↑" : "↓";
|
|
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
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
34
|
-
if (cmd === 'update')
|
|
35
|
-
|
|
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
|
-
|
|
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
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';
|
package/server/db/schema.sql
CHANGED
|
@@ -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);
|
package/server/routes/bank.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|