Skip to content

Commit 01073cc

Browse files
Merge pull request #127 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Recurring EMI: describe the loan once
2 parents 7d57617 + 61c884f commit 01073cc

8 files changed

Lines changed: 521 additions & 5 deletions

File tree

‎admin/admin.js‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ function openCarModal(car) {
235235
document.getElementById('carOwnerName').value = car ? (car.ownerName || '') : '';
236236
document.getElementById('carOwnerPhone').value = car ? (car.ownerPhone || '') : '';
237237
document.getElementById('carTemporary').checked = Boolean(car && car.isTemporary);
238+
for (const [id, key] of EMI_FIELDS) {
239+
document.getElementById(id).value = car ? (car[key] ?? '') : '';
240+
}
238241
for (const [id, key] of UPKEEP_FIELDS) {
239242
document.getElementById(id).value = car ? (car[key] ?? '') : '';
240243
}
@@ -438,6 +441,22 @@ function closeCarModal() {
438441
// The papers, the service markers and the tracker. One list, because these are
439442
// nine plain fields that all travel the same way and nine hand-written lines
440443
// each way is eighteen chances to forget one.
444+
/**
445+
* The loan boxes in the vehicle form, paired with the keys car-data.js uses.
446+
*
447+
* A list rather than six lines in each of two places, the same as
448+
* UPKEEP_FIELDS below -- the pair that got added to one and not the other is
449+
* the bug this shape prevents.
450+
*/
451+
const EMI_FIELDS = [
452+
['carEmiAmount', 'emiAmount'],
453+
['carEmiDay', 'emiDay'],
454+
['carEmiCount', 'emiCount'],
455+
['carEmiFirstDue', 'emiFirstDue'],
456+
['carEmiLender', 'emiLender'],
457+
['carEmiEnded', 'emiEndedOn'],
458+
];
459+
441460
const UPKEEP_FIELDS = [
442461
['carPurchaseDate', 'purchase_date'],
443462
['carInsuranceExpiry', 'insurance_expiry'],
@@ -486,6 +505,7 @@ carForm.addEventListener('submit', async (e) => {
486505
ownerName: document.getElementById('carOwnerName').value.trim(),
487506
ownerPhone: document.getElementById('carOwnerPhone').value.trim(),
488507
isTemporary: document.getElementById('carTemporary').checked,
508+
...Object.fromEntries(EMI_FIELDS.map(([id, key]) => [key, document.getElementById(id).value.trim()])),
489509
...Object.fromEntries(UPKEEP_FIELDS.map(([id, key]) => [key, document.getElementById(id).value])),
490510
price: Number(document.getElementById('carPrice').value),
491511
priceMax: document.getElementById('carPriceMax').value.trim() === ''

‎admin/api/vehicles.php‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require_once __DIR__ . '/../src/vocab.php';
66
require_once __DIR__ . '/../src/vehicle-photos.php';
77
require_once __DIR__ . '/../src/fleet-upkeep.php';
8+
require_once __DIR__ . '/../src/loans.php';
89

910
/**
1011
* Vehicles.
@@ -193,8 +194,19 @@ function rate_columns(): string
193194
: ', ' . implode(', ', array_map(static fn(string $c): string => "{$c} = ?", array_keys($optional)));
194195
$ownerValues = array_values($optional);
195196

197+
// Read here rather than inside the closure, so the transaction is given
198+
// its inputs rather than reaching out for them.
199+
$loanInput = [
200+
'amount' => $input['emi_amount'] ?? null,
201+
'due_day' => $input['emi_day'] ?? null,
202+
'instalments' => $input['emi_count'] ?? null,
203+
'first_due_on' => $input['emi_first_due'] ?? null,
204+
'lender' => $input['emi_lender'] ?? null,
205+
'ended_on' => $input['emi_ended_on'] ?? null,
206+
];
207+
196208
$savedId = transaction(function () use (
197-
$id, $data, $reg, $before, $user,
209+
$id, $data, $reg, $before, $user, $loanInput,
198210
$ownerCols, $ownerPlaceholders, $ownerSets, $ownerValues
199211
) {
200212
if ($id === null) {
@@ -305,6 +317,14 @@ function rate_columns(): string
305317
$incoming, null, (int) $user['id'], $user['name'], null, null, $vehicleId);
306318
}
307319

320+
// The loan, which only has somewhere to live once the vehicle has
321+
// an id. One open loan per car: the form describes the car's
322+
// finance, not a history of it, and a car with two live loans is
323+
// not a thing this business has.
324+
if (table_exists('vehicle_loans')) {
325+
save_vehicle_loan($vehicleId, $loanInput, (int) $user['id']);
326+
}
327+
308328
return $vehicleId;
309329
});
310330

@@ -320,6 +340,13 @@ function rate_columns(): string
320340
[$savedId]
321341
);
322342

343+
// Posted straight away rather than on the next dashboard load, so a
344+
// loan entered with a first instalment months ago shows its back
345+
// instalments while the person who typed it is still looking.
346+
if (table_exists('vehicle_loans')) {
347+
loans_post_due();
348+
}
349+
323350
json_out(['vehicle' => present_vehicle($row ?? [])]);
324351
}
325352

@@ -365,7 +392,20 @@ function present_vehicle(array $row): array
365392
if ($row === []) {
366393
return [];
367394
}
395+
396+
// The loan, so the form can show what is already there rather than an
397+
// empty set of boxes over a car that is on finance.
398+
$loan = vehicle_open_loan((int) $row['id']);
399+
368400
return [
401+
'emi' => $loan === null ? null : [
402+
'amount' => $loan['amount'],
403+
'due_day' => (int) $loan['due_day'],
404+
'first_due_on' => $loan['first_due_on'],
405+
'instalments' => (int) $loan['instalments'],
406+
'lender' => $loan['lender'],
407+
'ended_on' => $loan['ended_on'],
408+
],
369409
'id' => (int) $row['id'],
370410
'name' => $row['name'],
371411
'brand' => $row['brand'],

‎admin/car-data.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ function toPanelShape(v) {
3535
ownerName: v.owner_name || '',
3636
ownerPhone: v.owner_phone || '',
3737
isTemporary: Boolean(v.is_temporary),
38+
// The loan on this car, flattened so the form reads one field per box.
39+
// Null when there is none, and null on a database that has not run the
40+
// migration yet -- which reads the same as "no loan", correctly.
41+
emiAmount: v.emi ? v.emi.amount : '',
42+
emiDay: v.emi ? v.emi.due_day : '',
43+
emiCount: v.emi ? v.emi.instalments : '',
44+
emiFirstDue: v.emi ? (v.emi.first_due_on || '') : '',
45+
emiLender: v.emi ? (v.emi.lender || '') : '',
46+
emiEndedOn: v.emi ? (v.emi.ended_on || '') : '',
3847
purchase_date: v.purchase_date || '',
3948
insurance_expiry: v.insurance_expiry || '',
4049
pollution_expiry: v.pollution_expiry || '',
@@ -78,6 +87,12 @@ function toApiShape(car) {
7887
owner_name: car.ownerName || '',
7988
owner_phone: car.ownerPhone || '',
8089
is_temporary: car.isTemporary ? 1 : 0,
90+
emi_amount: car.emiAmount ?? '',
91+
emi_day: car.emiDay ?? '',
92+
emi_count: car.emiCount ?? '',
93+
emi_first_due: car.emiFirstDue || '',
94+
emi_lender: car.emiLender || '',
95+
emi_ended_on: car.emiEndedOn || '',
8196
purchase_date: car.purchase_date || '',
8297
insurance_expiry: car.insurance_expiry || '',
8398
pollution_expiry: car.pollution_expiry || '',

‎admin/dashboard.php‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_once __DIR__ . '/src/vocab.php';
99
require_once __DIR__ . '/src/booking.php';
1010
require_once __DIR__ . '/src/migrate.php';
11+
require_once __DIR__ . '/src/loans.php';
1112
require_once __DIR__ . '/src/shell.php';
1213

1314
// Anyone reaching this page must already be signed in; require_login sends
@@ -21,6 +22,14 @@
2122
// can show a failure, rather than turning it into a failed API call.
2223
$migrationError = migrate_if_needed();
2324

25+
// Any car-loan instalment that has fallen due since somebody last looked, as
26+
// an expense dated the day it fell. Once a day per session and after the
27+
// migration, so the first load on a fresh database finds the table it needs.
28+
// There is no cron on this host the business controls, so the dashboard being
29+
// opened is what drives it -- and nothing is lost by not opening it, because
30+
// the dates are worked out from the loan rather than from today.
31+
loans_post_due_once();
32+
2433
header('X-Frame-Options: DENY');
2534
header('X-Content-Type-Options: nosniff');
2635
header('Referrer-Policy: same-origin');
@@ -493,6 +502,45 @@
493502
</div>
494503
</div>
495504

505+
<p class="modal-section-label">Loan / EMI</p>
506+
<p class="field-hint">For a car bought on finance. Describe the loan once and the
507+
instalments are written into Expenses on the day each one falls &mdash; nothing to
508+
remember and nothing to type each month. Leave the amount empty if there is no loan.</p>
509+
<div class="modal-row modal-row-3">
510+
<div class="field-group">
511+
<label for="carEmiAmount">Monthly instalment (&#8377;)</label>
512+
<input type="number" id="carEmiAmount" min="0" step="1" placeholder="e.g. 18500" />
513+
</div>
514+
<div class="field-group">
515+
<label for="carEmiDay">Falls on day</label>
516+
<input type="number" id="carEmiDay" min="1" max="31" placeholder="e.g. 5" />
517+
<p class="field-hint">31 means the end of the month, as a bank means it:
518+
a short month takes its last day.</p>
519+
</div>
520+
<div class="field-group">
521+
<label for="carEmiCount">How many instalments</label>
522+
<input type="number" id="carEmiCount" min="1" max="600" placeholder="e.g. 36" />
523+
<p class="field-hint">It stops on its own after the last one.</p>
524+
</div>
525+
</div>
526+
<div class="modal-row modal-row-3">
527+
<div class="field-group">
528+
<label for="carEmiFirstDue">First instalment paid on</label>
529+
<input type="date" id="carEmiFirstDue" />
530+
<p class="field-hint">Anything already due is written at once, dated correctly.</p>
531+
</div>
532+
<div class="field-group">
533+
<label for="carEmiLender">Lender</label>
534+
<input type="text" id="carEmiLender" maxlength="120" placeholder="e.g. HDFC Bank" />
535+
</div>
536+
<div class="field-group">
537+
<label for="carEmiEnded">Loan closed on</label>
538+
<input type="date" id="carEmiEnded" />
539+
<p class="field-hint">Set it if the loan is settled or the car is sold. Instalments
540+
already written stay &mdash; they were really paid.</p>
541+
</div>
542+
</div>
543+
496544
<p class="modal-section-label">Papers and service</p>
497545
<p class="field-hint">Leave a date empty and it is simply not watched.
498546
Anything filled in appears under &ldquo;Needs attention&rdquo; a month before it runs out.</p>

‎admin/sql/016_vehicle_emi.sql‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
-- A car bought on finance, and the instalments it owes.
2+
--
3+
-- EMI became an expense category in 015, which made the cost recordable. It
4+
-- did not make it automatic: the same figure had to be typed on the same day
5+
-- every month for the length of the loan, and the month it was forgotten is
6+
-- the month the books stop matching the bank.
7+
--
8+
-- So the loan is described once -- what it costs, which day it falls, when it
9+
-- started and how many instalments there are -- and the expenses are written
10+
-- from it.
11+
CREATE TABLE IF NOT EXISTS vehicle_loans (
12+
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
13+
vehicle_id INT UNSIGNED NOT NULL,
14+
15+
-- Written onto each expense as the vendor, so a generated row reads like a
16+
-- typed one: "paid to HDFC" rather than a blank where the payee goes.
17+
lender VARCHAR(120) NULL,
18+
19+
amount DECIMAL(12,2) NOT NULL,
20+
21+
-- Which day of the month it falls on. Kept separately from first_due_on
22+
-- because the two can disagree honestly: a loan whose first instalment was
23+
-- taken on the 3rd because that was the day the money cleared may still be
24+
-- due on the 5th every month after.
25+
--
26+
-- A day past the end of a short month is clamped to that month's last day
27+
-- when the dates are worked out. 31 means "the end of the month", which is
28+
-- what a bank means by it too.
29+
due_day TINYINT UNSIGNED NOT NULL,
30+
31+
first_due_on DATE NOT NULL,
32+
33+
-- How many there are in total, so the posting stops on its own. A loan that
34+
-- runs out and keeps writing expenses is worse than one that never started.
35+
instalments SMALLINT UNSIGNED NOT NULL,
36+
37+
method ENUM('Cash','UPI','Card','Bank Transfer','Other') NOT NULL DEFAULT 'Bank Transfer',
38+
note VARCHAR(255) NULL,
39+
40+
-- Closed early: the car was sold, or the loan was settled. Set rather than
41+
-- deleted, because the instalments already posted are real money that was
42+
-- really paid and the loan is the only explanation of them.
43+
ended_on DATE NULL,
44+
45+
created_by INT UNSIGNED NULL,
46+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
47+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
48+
49+
PRIMARY KEY (id),
50+
KEY ix_vehicle_loans_vehicle (vehicle_id),
51+
KEY ix_vehicle_loans_open (ended_on),
52+
CONSTRAINT fk_vehicle_loans_vehicle FOREIGN KEY (vehicle_id) REFERENCES vehicles (id)
53+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
54+
55+
-- Which loan an expense came from, and which instalment of it.
56+
--
57+
-- loan_seq is 1 for the first instalment, 2 for the second, and so on. The
58+
-- unique key across the pair is the whole of why this can be run as often as
59+
-- anyone likes: posting the same instalment twice is not a thing the code has
60+
-- to be careful about, it is a thing the database refuses. Two people opening
61+
-- the dashboard in the same second cannot produce two rows.
62+
--
63+
-- Both NULL on a typed expense, and the unique key ignores those -- MySQL
64+
-- allows any number of rows where part of a unique key is NULL, which is
65+
-- exactly the behaviour wanted here.
66+
ALTER TABLE expenses
67+
ADD COLUMN loan_id INT UNSIGNED NULL AFTER booking_id;
68+
69+
ALTER TABLE expenses
70+
ADD COLUMN loan_seq SMALLINT UNSIGNED NULL AFTER loan_id;
71+
72+
ALTER TABLE expenses
73+
ADD UNIQUE KEY uq_expenses_loan_instalment (loan_id, loan_seq);
74+
75+
ALTER TABLE expenses
76+
ADD CONSTRAINT fk_expenses_loan FOREIGN KEY (loan_id) REFERENCES vehicle_loans (id);

‎admin/src/db.php‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,41 @@ function next_number(string $prefix, ?int $year = null): string
308308
* Asked once per request and remembered, so a page reading several vehicles
309309
* does not ask several times.
310310
*/
311+
/**
312+
* Whether a table is there at all.
313+
*
314+
* The same forward-compatibility problem table_has_column solves, one level
315+
* up: a release that adds a table runs against a database that has not been
316+
* migrated yet, for exactly as long as it takes somebody to open the panel.
317+
* Asked the same way and cached the same way, and a failure is logged rather
318+
* than turned into a confident "no" -- see the note below for why that
319+
* distinction cost a round of debugging once already.
320+
*/
321+
function table_exists(string $table): bool
322+
{
323+
static $cache = [];
324+
if (array_key_exists($table, $cache)) {
325+
return $cache[$table];
326+
}
327+
328+
try {
329+
$row = fetch_one(
330+
'SELECT 1 AS present
331+
FROM information_schema.tables
332+
WHERE table_schema = DATABASE()
333+
AND table_name = ?
334+
LIMIT 1',
335+
[$table]
336+
);
337+
$cache[$table] = $row !== null;
338+
} catch (Throwable $e) {
339+
error_log("table_exists($table) failed: " . $e->getMessage());
340+
$cache[$table] = false;
341+
}
342+
343+
return $cache[$table];
344+
}
345+
311346
function table_has_column(string $table, string $column): bool
312347
{
313348
static $cache = [];

0 commit comments

Comments
 (0)