Standalone expenses (date, description, category, amount) with the same equal/own/payer split machinery as entry costs, merged into the costs panel and settle-up as a single expense bucket. Self-fetching card with per-day grouping, client-side sorting, and quick-add. CSV export interleaves expenses with priced entries, one share column per member (UTF-8 BOM, RFC 4180, formula-injection guard on text cells). Also fixes trip deletion, which hit a foreign-key violation and rolled back for any trip with checklist items.
457 lines
20 KiB
JavaScript
457 lines
20 KiB
JavaScript
import { test, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import request from 'supertest';
|
|
import { createApp } from '../src/server/app.js';
|
|
|
|
let tmpDir;
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expenses-'));
|
|
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
if (app.locals.db) app.locals.db.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function createAccount() {
|
|
const agent = request.agent(app);
|
|
const res = await agent.post('/api/auth/account').send({});
|
|
assert.equal(res.status, 201);
|
|
return { agent, user: res.body.user };
|
|
}
|
|
|
|
// Trip owned by `owner`; every account in `members` joins via the join code.
|
|
async function makeTrip(owner, members = []) {
|
|
const trip = (await owner.agent.post('/api/trips').send({
|
|
name: 'Spending', start_date: '2026-08-01', end_date: '2026-08-10', currency: 'THB',
|
|
})).body.trip;
|
|
for (const m of members) {
|
|
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
|
}
|
|
return trip;
|
|
}
|
|
|
|
async function addExpense(account, trip, body) {
|
|
const res = await account.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
|
date: '2026-08-02', description: 'Lunch', amount: 100, ...body,
|
|
});
|
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
|
|
return res.body.expense;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('POST expense: defaults applied, created_by is the caller', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
|
|
const res = await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
|
date: '2026-08-02', description: ' Street food dinner ', amount: 380,
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const e = res.body.expense;
|
|
assert.equal(e.trip_id, trip.id);
|
|
assert.equal(e.date, '2026-08-02');
|
|
assert.equal(e.description, 'Street food dinner', 'description is trimmed');
|
|
assert.equal(e.category, 'other');
|
|
assert.equal(e.amount, 380);
|
|
assert.equal(e.paid_by, null);
|
|
assert.equal(e.split_mode, 'equal');
|
|
assert.deepEqual(e.participants, []);
|
|
assert.equal(e.created_by, anna.user.id);
|
|
assert.ok(Number.isInteger(e.id));
|
|
});
|
|
|
|
test('POST expense: every field round-trips, participants come back sorted', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
|
|
const e = await addExpense(anna, trip, {
|
|
date: '2026-08-03', description: 'Songthaew', amount: 60.5, category: 'transport',
|
|
paid_by: ben.user.id, split_mode: 'equal', participants: [ben.user.id, anna.user.id],
|
|
});
|
|
assert.equal(e.category, 'transport');
|
|
assert.equal(e.amount, 60.5);
|
|
assert.equal(e.paid_by, ben.user.id);
|
|
assert.deepEqual(e.participants, [anna.user.id, ben.user.id].sort((a, b) => a - b));
|
|
});
|
|
|
|
test('POST expense: amount 0 is allowed; every category in the enum is accepted', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
|
|
const zero = await addExpense(anna, trip, { amount: 0 });
|
|
assert.equal(zero.amount, 0);
|
|
|
|
for (const category of ['food', 'drinks', 'transport', 'activities', 'shopping', 'accommodation', 'other']) {
|
|
const e = await addExpense(anna, trip, { category });
|
|
assert.equal(e.category, category);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List, ordering & summary
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('GET expenses: ordered by (date, id)', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
|
|
// Created deliberately out of date order.
|
|
const late = await addExpense(anna, trip, { date: '2026-08-05', description: 'late' });
|
|
const earlyA = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-a' });
|
|
const mid = await addExpense(anna, trip, { date: '2026-08-03', description: 'mid' });
|
|
const earlyB = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-b' });
|
|
|
|
const res = await anna.agent.get(`/api/trips/${trip.id}/expenses`);
|
|
assert.equal(res.status, 200);
|
|
assert.deepEqual(
|
|
res.body.expenses.map((e) => e.id),
|
|
[earlyA.id, earlyB.id, mid.id, late.id],
|
|
'same-date rows fall back to id order'
|
|
);
|
|
});
|
|
|
|
test('GET expenses: summary total/byCategory/byDay; `own` counts amount x participants', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
|
|
// equal: effective total = amount (100)
|
|
await addExpense(anna, trip, { date: '2026-08-01', description: 'Dinner', amount: 100, category: 'food', paid_by: anna.user.id });
|
|
// own with no participants rows = both members: effective total = 50 x 2 = 100
|
|
await addExpense(anna, trip, { date: '2026-08-01', description: 'Bus tickets', amount: 50, category: 'transport', split_mode: 'own' });
|
|
// payer: effective total = amount (30)
|
|
await addExpense(anna, trip, { date: '2026-08-02', description: 'Souvenir', amount: 30, category: 'shopping', split_mode: 'payer', paid_by: ben.user.id });
|
|
|
|
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
|
assert.equal(s.total, 230);
|
|
assert.deepEqual(s.byCategory, { food: 100, transport: 100, shopping: 30 });
|
|
assert.deepEqual(s.byDay, [
|
|
{ date: '2026-08-01', total: 200 },
|
|
{ date: '2026-08-02', total: 30 },
|
|
]);
|
|
});
|
|
|
|
test('GET expenses: `own` with a participants subset scales by that subset only', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const carol = await createAccount();
|
|
const trip = await makeTrip(anna, [ben, carol]);
|
|
|
|
// 3 members, but only 2 participate -> 40 x 2 = 80 (not 120).
|
|
await addExpense(anna, trip, {
|
|
date: '2026-08-04', description: 'Cable car', amount: 40, category: 'activities',
|
|
split_mode: 'own', participants: [anna.user.id, carol.user.id],
|
|
});
|
|
|
|
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
|
assert.equal(s.total, 80);
|
|
assert.deepEqual(s.byCategory, { activities: 80 });
|
|
assert.deepEqual(s.byDay, [{ date: '2026-08-04', total: 80 }]);
|
|
});
|
|
|
|
test('GET expenses: empty trip yields an empty list and a zeroed summary', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
|
|
const body = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body;
|
|
assert.deepEqual(body.expenses, []);
|
|
assert.equal(body.summary.total, 0);
|
|
assert.deepEqual(body.summary.byCategory, {});
|
|
assert.deepEqual(body.summary.byDay, []);
|
|
});
|
|
|
|
test('GET expenses: all members see all expenses (no personal/hidden rows)', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
|
|
const byAnna = await addExpense(anna, trip, { description: 'Anna paid' });
|
|
const byBen = await addExpense(ben, trip, { description: 'Ben paid' });
|
|
|
|
const forBen = (await ben.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
|
assert.deepEqual(forBen.map((e) => e.id).sort((a, b) => a - b), [byAnna.id, byBen.id].sort((a, b) => a - b));
|
|
assert.equal(forBen.find((e) => e.id === byAnna.id).created_by, anna.user.id);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('PATCH expense: each field individually', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
const e = await addExpense(anna, trip, {});
|
|
const url = `/api/expenses/${e.id}`;
|
|
|
|
const date = await anna.agent.patch(url).send({ date: '2026-08-07' });
|
|
assert.equal(date.status, 200);
|
|
assert.equal(date.body.expense.date, '2026-08-07');
|
|
|
|
assert.equal((await anna.agent.patch(url).send({ description: 'Brunch' })).body.expense.description, 'Brunch');
|
|
assert.equal((await anna.agent.patch(url).send({ amount: 12.75 })).body.expense.amount, 12.75);
|
|
assert.equal((await anna.agent.patch(url).send({ category: 'drinks' })).body.expense.category, 'drinks');
|
|
|
|
const paid = await anna.agent.patch(url).send({ paid_by: ben.user.id });
|
|
assert.equal(paid.body.expense.paid_by, ben.user.id);
|
|
assert.equal((await anna.agent.patch(url).send({ paid_by: null })).body.expense.paid_by, null);
|
|
|
|
assert.equal((await anna.agent.patch(url).send({ split_mode: 'own' })).body.expense.split_mode, 'own');
|
|
});
|
|
|
|
test('PATCH expense: participants replaces the whole set; [] means all members', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const carol = await createAccount();
|
|
const trip = await makeTrip(anna, [ben, carol]);
|
|
const e = await addExpense(anna, trip, { participants: [anna.user.id, ben.user.id] });
|
|
const url = `/api/expenses/${e.id}`;
|
|
|
|
const swapped = await anna.agent.patch(url).send({ participants: [carol.user.id] });
|
|
assert.equal(swapped.status, 200);
|
|
assert.deepEqual(swapped.body.expense.participants, [carol.user.id], 'replaces, does not merge');
|
|
|
|
const cleared = await anna.agent.patch(url).send({ participants: [] });
|
|
assert.deepEqual(cleared.body.expense.participants, [], '[] = all members');
|
|
|
|
const nulled = await anna.agent.patch(url).send({ participants: [anna.user.id] });
|
|
assert.deepEqual(nulled.body.expense.participants, [anna.user.id]);
|
|
assert.deepEqual((await anna.agent.patch(url).send({ participants: null })).body.expense.participants, []);
|
|
});
|
|
|
|
test('PATCH expense: any member may edit another member\'s expense; created_by never changes', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
const e = await addExpense(anna, trip, { description: 'Anna logged this' });
|
|
|
|
const res = await ben.agent.patch(`/api/expenses/${e.id}`).send({ description: 'Ben corrected it' });
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.expense.description, 'Ben corrected it');
|
|
assert.equal(res.body.expense.created_by, anna.user.id, 'created_by is informational and stays put');
|
|
});
|
|
|
|
test('created_by is server-set and ignored in POST/PATCH bodies', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
|
|
const created = await addExpense(anna, trip, { created_by: ben.user.id });
|
|
assert.equal(created.created_by, anna.user.id, 'POST body created_by is ignored');
|
|
|
|
const patched = await anna.agent.patch(`/api/expenses/${created.id}`).send({ created_by: ben.user.id });
|
|
assert.equal(patched.status, 200);
|
|
assert.equal(patched.body.expense.created_by, anna.user.id, 'PATCH body created_by is ignored');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delete
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('DELETE expense -> 204, gone from the list, participant rows cleaned up', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
const e = await addExpense(anna, trip, { participants: [anna.user.id] });
|
|
|
|
const before = app.locals.db
|
|
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
|
.get(e.id).c;
|
|
assert.equal(before, 1);
|
|
|
|
assert.equal((await ben.agent.delete(`/api/expenses/${e.id}`)).status, 204, 'any member may delete');
|
|
|
|
const list = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
|
assert.ok(!list.some((x) => x.id === e.id));
|
|
|
|
const after = app.locals.db
|
|
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
|
.get(e.id).c;
|
|
assert.equal(after, 0, 'expense_participants rows are deleted too');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('validation: bad date, description, amount and category -> 400', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
const base = `/api/trips/${trip.id}/expenses`;
|
|
const ok = { date: '2026-08-02', description: 'Lunch', amount: 100 };
|
|
const post = (body) => anna.agent.post(base).send({ ...ok, ...body });
|
|
|
|
// date
|
|
assert.equal((await anna.agent.post(base).send({ description: 'x', amount: 1 })).status, 400, 'date required');
|
|
assert.equal((await post({ date: 'not-a-date' })).status, 400);
|
|
assert.equal((await post({ date: '2026-13-01' })).status, 400, 'month 13');
|
|
assert.equal((await post({ date: '2026-02-30' })).status, 400, 'impossible day');
|
|
assert.equal((await post({ date: '2026-8-2' })).status, 400, 'unpadded');
|
|
assert.equal((await post({ date: 20260802 })).status, 400, 'non-string');
|
|
|
|
// description
|
|
assert.equal((await anna.agent.post(base).send({ date: ok.date, amount: 1 })).status, 400, 'description required');
|
|
assert.equal((await post({ description: '' })).status, 400);
|
|
assert.equal((await post({ description: ' ' })).status, 400, 'whitespace-only');
|
|
assert.equal((await post({ description: 'x'.repeat(121) })).status, 400, '>120 chars');
|
|
assert.equal((await post({ description: 'x'.repeat(120) })).status, 201, '120 chars is the limit, inclusive');
|
|
|
|
// amount
|
|
assert.equal((await anna.agent.post(base).send({ date: ok.date, description: 'x' })).status, 400, 'amount required');
|
|
assert.equal((await post({ amount: -1 })).status, 400, 'negative');
|
|
assert.equal((await post({ amount: '100' })).status, 400, 'string is not a number');
|
|
assert.equal((await post({ amount: null })).status, 400);
|
|
assert.equal((await post({ amount: {} })).status, 400);
|
|
|
|
// category
|
|
assert.equal((await post({ category: 'gadgets' })).status, 400, 'unknown category');
|
|
assert.equal((await post({ category: 'Food' })).status, 400, 'enum is case-sensitive');
|
|
assert.equal((await post({ category: 7 })).status, 400);
|
|
});
|
|
|
|
test('validation: paid_by, split_mode and participants -> 400', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const outsider = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
const base = `/api/trips/${trip.id}/expenses`;
|
|
const post = (body) => anna.agent.post(base).send({ date: '2026-08-02', description: 'Lunch', amount: 100, ...body });
|
|
|
|
assert.equal((await post({ paid_by: outsider.user.id })).status, 400, 'paid_by must be a trip member');
|
|
assert.equal((await post({ paid_by: 999999 })).status, 400, 'paid_by must exist');
|
|
assert.equal((await post({ paid_by: 'anna' })).status, 400);
|
|
|
|
assert.equal((await post({ split_mode: 'payer' })).status, 400, "'payer' requires paid_by");
|
|
assert.equal((await post({ split_mode: 'payer', paid_by: ben.user.id })).status, 201);
|
|
assert.equal((await post({ split_mode: 'sideways' })).status, 400, 'unknown split_mode');
|
|
|
|
assert.equal((await post({ participants: [anna.user.id, outsider.user.id] })).status, 400, 'non-member participant');
|
|
assert.equal((await post({ participants: [999999] })).status, 400);
|
|
assert.equal((await post({ participants: 5 })).status, 400, 'not an array');
|
|
assert.equal((await post({ participants: ['1'] })).status, 400, 'not member ids');
|
|
});
|
|
|
|
test('validation: PATCH applies the same rules and merges for the payer/paid_by rule', async () => {
|
|
const anna = await createAccount();
|
|
const ben = await createAccount();
|
|
const outsider = await createAccount();
|
|
const trip = await makeTrip(anna, [ben]);
|
|
const e = await addExpense(anna, trip, {});
|
|
const url = `/api/expenses/${e.id}`;
|
|
const patch = (body) => anna.agent.patch(url).send(body);
|
|
|
|
assert.equal((await patch({ date: '2026-13-01' })).status, 400);
|
|
assert.equal((await patch({ description: ' ' })).status, 400);
|
|
assert.equal((await patch({ description: 'x'.repeat(121) })).status, 400);
|
|
assert.equal((await patch({ amount: -0.01 })).status, 400);
|
|
assert.equal((await patch({ amount: 'free' })).status, 400);
|
|
assert.equal((await patch({ category: 'gadgets' })).status, 400);
|
|
assert.equal((await patch({ paid_by: outsider.user.id })).status, 400);
|
|
assert.equal((await patch({ participants: [outsider.user.id] })).status, 400);
|
|
|
|
// 'payer' is validated against the EFFECTIVE (merged) values, like entries.
|
|
assert.equal((await patch({ split_mode: 'payer' })).status, 400, 'no existing paid_by to inherit');
|
|
assert.equal((await patch({ split_mode: 'payer', paid_by: ben.user.id })).status, 200);
|
|
assert.equal((await patch({ paid_by: null })).status, 400, 'clearing paid_by would strand a payer split');
|
|
assert.equal((await patch({ split_mode: 'equal', paid_by: null })).status, 200);
|
|
|
|
// An empty PATCH body is harmless.
|
|
assert.equal((await patch({})).status, 200);
|
|
});
|
|
|
|
test('validation: a rejected request leaves the stored row untouched', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
const e = await addExpense(anna, trip, { description: 'Lunch', amount: 100 });
|
|
|
|
assert.equal((await anna.agent.patch(`/api/expenses/${e.id}`).send({
|
|
description: 'Changed', amount: -5,
|
|
})).status, 400);
|
|
|
|
const stored = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses[0];
|
|
assert.equal(stored.description, 'Lunch');
|
|
assert.equal(stored.amount, 100);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Access control
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('access control: non-member gets 404 (never 403) everywhere', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
const e = await addExpense(anna, trip, {});
|
|
const outsider = await createAccount();
|
|
|
|
const list = await outsider.agent.get(`/api/trips/${trip.id}/expenses`);
|
|
assert.equal(list.status, 404);
|
|
assert.deepEqual(list.body, { error: 'not found' }, 'no existence leak');
|
|
|
|
assert.equal((await outsider.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
|
date: '2026-08-02', description: 'Sneaky', amount: 1,
|
|
})).status, 404);
|
|
assert.equal((await outsider.agent.get(`/api/trips/${trip.id}/expenses/export.csv`)).status, 404);
|
|
assert.equal((await outsider.agent.patch(`/api/expenses/${e.id}`).send({ amount: 1 })).status, 404);
|
|
assert.equal((await outsider.agent.delete(`/api/expenses/${e.id}`)).status, 404);
|
|
|
|
// The expense really does still exist — the 404 was about access, not absence.
|
|
assert.equal((await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses.length, 1);
|
|
});
|
|
|
|
test('access control: unknown / malformed ids -> 404', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
|
|
assert.equal((await anna.agent.patch('/api/expenses/999999').send({ amount: 1 })).status, 404);
|
|
assert.equal((await anna.agent.delete('/api/expenses/999999')).status, 404);
|
|
assert.equal((await anna.agent.patch('/api/expenses/not-a-number').send({ amount: 1 })).status, 404);
|
|
assert.equal((await anna.agent.get(`/api/trips/999999/expenses`)).status, 404);
|
|
assert.equal((await anna.agent.get(`/api/trips/${trip.id}x/expenses`)).status, 404);
|
|
});
|
|
|
|
test('access control: unauthenticated -> 401 on every expense route', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
const e = await addExpense(anna, trip, {});
|
|
|
|
for (const res of [
|
|
await request(app).get(`/api/trips/${trip.id}/expenses`),
|
|
await request(app).post(`/api/trips/${trip.id}/expenses`).send({ date: '2026-08-02', description: 'x', amount: 1 }),
|
|
await request(app).get(`/api/trips/${trip.id}/expenses/export.csv`),
|
|
await request(app).patch(`/api/expenses/${e.id}`).send({ amount: 1 }),
|
|
await request(app).delete(`/api/expenses/${e.id}`),
|
|
]) {
|
|
assert.equal(res.status, 401);
|
|
assert.deepEqual(res.body, { error: 'unauthorized' });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Trip lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('deleting a trip removes its expenses', async () => {
|
|
const anna = await createAccount();
|
|
const trip = await makeTrip(anna);
|
|
const e = await addExpense(anna, trip, {});
|
|
|
|
assert.equal((await anna.agent.delete(`/api/trips/${trip.id}`)).status, 204);
|
|
const left = app.locals.db.prepare('SELECT COUNT(*) AS c FROM expenses WHERE id = ?').get(e.id).c;
|
|
assert.equal(left, 0);
|
|
});
|