Skip to content

Commit 134c20a

Browse files
committed
The records a rental business is asked for when something goes wrong
Most of the nine sections asked for were already here -- payment history with proofs, deposit collection and refund with deductions, pickup and return readings, extra-KM maths, a seven-stage lifecycle. What was missing was the evidence: the licence that was checked, the state the car left in, what came back damaged, and what was charged for it. Those are what a dispute turns on, and they were on somebody's phone or nowhere. --- the customer A WhatsApp number, because the one that answers a call is often not the one that answers a message and WhatsApp is how this business talks. A licence expiry, because a licence that runs out mid-hire is the owner's liability and worth knowing before the keys move. An ID number. A customer type -- new, returning, corporate. An estimated KM, which is useful before a hire for quoting and after it for knowing whose estimates to trust. --- documents Driving licence, Aadhaar or ID, passport, and anything else. PDF as well as photographs, because a licence usually arrives as a PDF -- and a PDF is recognised by its first five bytes rather than by what the file was called, for the same reason the images are. Against the customer rather than the booking. A returning customer's licence is the same licence, and asking for it again every hire is how a file ends up attached to three bookings and missing from the fourth. Uploading a licence replaces the one on file rather than stacking a fourth copy nobody can pick between; "other" is the exception, being where everything else goes. An expiry that has passed is marked, in red, on the booking. These are the most private things the system holds -- a licence carries a name, an address, a date of birth and a photograph -- so the reader requires a signed-in user, sends no-store, and the file names carry nothing. Signed out it answers 302 and zero bytes; a traversal, a .php and a .pdf.php all answer 404. --- the handover check Thirteen items on both legs: exterior, interior, tyres, spare, tools, papers in the car, AC, lights, indicators, horn, mirrors, wipers, toolkit. Stored as JSON on the reading rather than as thirteen columns, because the list changes -- a business that starts hiring bikes wants a different one -- and a schema change per item is how a checklist stops being maintained. Unticked is stored as false rather than left out. "Not checked" and "checked and wrong" are different things and only the first is what a blank should mean. --- charges at return Cleaning, fuel, late return, other -- each its own line with its own amount and note, and each feeding the booking's total. Itemised rather than folded into other_charges, because "₹1,150 of other charges" is not something a customer accepts and not something anyone can explain a month later. An "other" charge with no note is refused, and refusing it does not take the fuel charge down with it. Raised after the reading is saved, not with it, so a rejected charge never costs someone the reading they just took. --- damage What was damaged, what it is thought to cost, which leg it was noticed at, notes and photographs. Deliberately not money in itself: an estimate is not a charge. Some damage is billed, some is held back from the deposit, some is absorbed -- and a record that assumed the first would make the other two wrong. A tick on the return form raises the charge; leaving it unticked records the damage and bills nothing. --- the lifecycle Not changed, and this is the one place I did not do as asked. The statuses already run Enquiry, Confirmed, Ready, Active, Returned, Completed, Cancelled. The extra ones wanted -- Advance Paid, Payment Pending, Deposit Pending -- are not stages a booking is moved through: they are facts about the money, true or false on their own, and the panel already derives and shows them. Making them statuses would mean somebody remembering to move a booking to "Payment Pending", and a status nobody remembers to set is worse than none. Checked: twenty-nine browser assertions over the new fields, the checklist round trip, the charges going up line by line, the refusal that spares the rest, and what the detail screen shows; the document validator against a real multipart upload, where a PHP script renamed .pdf is refused and a genuine PDF is not; the reader's session guard; and the earlier suites still passing -- 35 money tests, 15 commission-split tests, 19 feature tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
1 parent 071a638 commit 134c20a

14 files changed

Lines changed: 1248 additions & 28 deletions

‎admin/admin.css‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,36 @@ label.finance-toggle {
583583
.booking-conflict-error {
584584
color: var(--danger); font-size: 0.85rem; font-weight: 600; margin: -6px 0 14px; min-height: 1.2em;
585585
}
586+
/* The handover checklist. Two columns on anything wider than a phone, because
587+
thirteen checks in one column is a scroll nobody finishes. */
588+
.check-grid {
589+
display: grid; gap: 4px 16px; margin-top: 8px;
590+
/* 165px, not 210: the modal these sit in is about 420px of content wide, and
591+
at 210 the grid silently fell back to one column -- thirteen checks in a
592+
single column is the scroll this was meant to avoid. */
593+
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
594+
}
595+
.check-item {
596+
display: flex; align-items: center; gap: 8px;
597+
font-size: 0.88rem; color: var(--ink-dim);
598+
/* A 44px row, so a thumb hits the label as readily as the box. */
599+
min-height: 34px; cursor: pointer;
600+
}
601+
.check-item input { width: 17px; height: 17px; flex: none; accent-color: var(--primary); }
602+
.check-item:has(input:checked) { color: var(--ink); font-weight: 600; }
603+
604+
/* A document, which may be a PDF and so has no thumbnail to show. */
605+
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
606+
.doc-row {
607+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
608+
border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px;
609+
background: var(--surface);
610+
}
611+
.doc-kind { font-weight: 700; font-size: 0.84rem; }
612+
.doc-meta { color: var(--ink-faint); font-size: 0.8rem; }
613+
.doc-row .doc-actions { margin-left: auto; display: flex; gap: 6px; }
614+
.doc-expired { color: var(--danger); font-weight: 700; }
615+
586616
/* A car that is not ours. Stated on the card rather than only inside the edit
587617
form, because "whose car is this" decides what a booking on it earns and
588618
nobody should have to open a dialog to find out. */

‎admin/admin.js‎

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,11 @@ function openBookingModal(booking) {
11801180
document.getElementById('bkPhone').value = booking.customer_phone || '';
11811181
document.getElementById('bkAddress').value = booking.customer_address || '';
11821182
document.getElementById('bkLicence').value = booking.licence_number || '';
1183+
document.getElementById('bkWhatsapp').value = booking.whatsapp || '';
1184+
document.getElementById('bkLicenceExpiry').value = booking.licence_expiry || '';
1185+
document.getElementById('bkIdNumber').value = booking.id_number || '';
1186+
document.getElementById('bkCustomerType').value = booking.customer_type || 'New';
1187+
document.getElementById('bkEstimatedKm').value = booking.estimated_km ?? '';
11831188
document.getElementById('bkStartDate').value = sd;
11841189
document.getElementById('bkStartTime').value = st;
11851190
document.getElementById('bkReturnDate').value = rd;
@@ -1248,6 +1253,11 @@ bookingForm.addEventListener('submit', async (e) => {
12481253
phone: document.getElementById('bkPhone').value.trim(),
12491254
address: document.getElementById('bkAddress').value.trim(),
12501255
licence_number: document.getElementById('bkLicence').value.trim(),
1256+
whatsapp: document.getElementById('bkWhatsapp').value.trim(),
1257+
licence_expiry: document.getElementById('bkLicenceExpiry').value,
1258+
id_number: document.getElementById('bkIdNumber').value.trim(),
1259+
customer_type: document.getElementById('bkCustomerType').value,
1260+
estimated_km: document.getElementById('bkEstimatedKm').value,
12511261
vehicle_id: Number(bookingVehicleSelect.value),
12521262
start_at: startAt,
12531263
return_at: returnAt,
@@ -1420,6 +1430,8 @@ async function renderBookingDetail(id) {
14201430
<div class="detail-field"><span class="k">Extra KM (${Number(booking.extra_km || 0).toLocaleString('en-IN')} km)</span><span class="v">+ ${formatINR(booking.extra_km_charge)}</span></div>` : ''}
14211431
${Number(charges.other_charges) ? `
14221432
<div class="detail-field"><span class="k">Other Charges</span><span class="v">+ ${formatINR(charges.other_charges)}</span></div>` : ''}
1433+
${(booking.extras || []).map((x) => `
1434+
<div class="detail-field"><span class="k">${escapeHTML(x.label)}${x.note ? ' — ' + escapeHTML(x.note) : ''}</span><span class="v">+ ${formatINR(x.amount)}</span></div>`).join('')}
14231435
${Number(charges.discount) ? `
14241436
<div class="detail-field"><span class="k">Discount</span><span class="v">- ${formatINR(charges.discount)}</span></div>` : ''}
14251437
<div class="detail-field"><span class="k">Rental Amount Due</span><span class="v">${formatINR(booking.total)}</span></div>
@@ -1486,6 +1498,7 @@ async function renderBookingDetail(id) {
14861498
<div class="detail-field"><span class="k">Fuel Level</span><span class="v">${booking.pickup.fuel_level || '—'}</span></div>
14871499
<div class="detail-field"><span class="k">Condition</span><span class="v">${booking.pickup.condition_note || '—'}</span></div>
14881500
</div>
1501+
${checklistHTML(booking.pickup.checklist)}
14891502
${open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${booking.pickup.id}" data-current="${booking.pickup.odometer_km}">Correct reading</button>` : ''}
14901503
` : '<p class="detail-empty">Not recorded yet.</p>'}
14911504
${attachmentsHTML(booking.files, 'pickup', 'Pickup photos')}
@@ -1510,11 +1523,34 @@ async function renderBookingDetail(id) {
15101523
<div class="detail-field"><span class="k">Extra KM</span><span class="v">${Number(km.extra_km || 0).toLocaleString('en-IN')}</span></div>
15111524
<div class="detail-field"><span class="k">Extra KM Charge</span><span class="v">${formatINR(km.extra_km_charge || 0)}</span></div>
15121525
</div>
1526+
${checklistHTML(booking.return.checklist)}
15131527
${open ? `<button class="btn btn-ghost btn-sm correct-km" data-id="${booking.return.id}" data-current="${booking.return.odometer_km}">Correct reading</button>` : ''}
15141528
` : `<p class="detail-empty">${booking.pickup ? 'Not recorded yet.' : 'Record pickup first.'}</p>`}
15151529
${attachmentsHTML(booking.files, 'return', 'Return photos')}
15161530
</div>
15171531
1532+
<div class="detail-section">
1533+
<div class="detail-section-title">
1534+
<span>Customer Documents</span>
1535+
<button class="btn btn-outline btn-sm" id="detailAddDocBtn">+ Add Document</button>
1536+
</div>
1537+
${documentsHTML(booking.documents)}
1538+
</div>
1539+
1540+
${(booking.damages || []).length ? `
1541+
<div class="detail-section">
1542+
<div class="detail-section-title"><span>Damage</span></div>
1543+
${booking.damages.map((d) => `
1544+
<div class="payment-row">
1545+
<span class="payment-meta">${escapeHTML(d.description)}
1546+
<span class="payment-note">noticed at ${d.noticed_at}${d.note ? ' · ' + escapeHTML(d.note) : ''}</span>
1547+
</span>
1548+
<span class="amount">${formatINR(d.estimated_cost)}</span>
1549+
${open ? `<button class="btn btn-ghost btn-sm void-damage" data-id="${d.id}">Remove</button>` : ''}
1550+
</div>`).join('')}
1551+
${attachmentsHTML(booking.files, 'damage', 'Damage photos')}
1552+
</div>` : ''}
1553+
15181554
<div class="detail-section">
15191555
<div class="detail-section-title"><span>Booking Timeline</span></div>
15201556
${timelineHTML(booking.timeline)}
@@ -1553,6 +1589,57 @@ function wireDetailActions(booking) {
15531589
} catch (err) { showError(err); }
15541590
});
15551591

1592+
// Adding a document. A hidden input rather than a modal: one file, one
1593+
// kind, and a dialog around that is more clicks than the job needs.
1594+
on('detailAddDocBtn', () => {
1595+
if (!booking.customer_id) { alert('This booking has no customer record yet.'); return; }
1596+
const kind = prompt(
1597+
'Which document?\n\n' + DOCUMENT_KINDS.map(([k, l]) => `${k} — ${l}`).join('\n'),
1598+
'licence',
1599+
);
1600+
if (kind === null) return;
1601+
if (!DOCUMENT_KINDS.some(([k]) => k === kind.trim())) {
1602+
alert('That is not one of the documents this keeps.');
1603+
return;
1604+
}
1605+
1606+
const expiry = kind.trim() === 'licence'
1607+
? (prompt('Licence expiry date (YYYY-MM-DD). Leave blank if you do not have it.', '') || '')
1608+
: '';
1609+
1610+
const picker = document.createElement('input');
1611+
picker.type = 'file';
1612+
picker.accept = 'image/jpeg,image/png,image/webp,image/avif,application/pdf';
1613+
picker.addEventListener('change', async () => {
1614+
if (!picker.files || picker.files.length === 0) return;
1615+
try {
1616+
await api.customerFiles.add(booking.customer_id, kind.trim(), picker.files[0], expiry.trim());
1617+
await renderBookingDetail(booking.id);
1618+
} catch (err) { showError(err); }
1619+
});
1620+
picker.click();
1621+
});
1622+
1623+
document.querySelectorAll('.remove-doc').forEach((btn) => {
1624+
btn.addEventListener('click', async () => {
1625+
if (!confirm('Remove this document? It cannot be brought back.')) return;
1626+
try {
1627+
await api.customerFiles.remove(Number(btn.dataset.id));
1628+
await renderBookingDetail(booking.id);
1629+
} catch (err) { showError(err); }
1630+
});
1631+
});
1632+
1633+
document.querySelectorAll('.void-damage').forEach((btn) => {
1634+
btn.addEventListener('click', async () => {
1635+
if (!confirm('Remove this damage record? Any charge raised for it stays — void that separately.')) return;
1636+
try {
1637+
await api.extras.voidDamage(Number(btn.dataset.id));
1638+
await refreshAfterBookingChange();
1639+
} catch (err) { showError(err); }
1640+
});
1641+
});
1642+
15561643
on('detailDeleteBtn', async () => {
15571644
if (!confirm(
15581645
`Delete ${booking.booking_number} for good?\n\n` +
@@ -1623,6 +1710,88 @@ function nowTimeStr() {
16231710
return new Date().toTimeString().slice(0, 5);
16241711
}
16251712

1713+
// ---- Customer documents ----
1714+
//
1715+
// A licence is commonly a PDF, which has no thumbnail to show, so these are a
1716+
// list of rows rather than the strip of pictures the booking attachments use.
1717+
// The row says what it is, whether it has expired, and gives a link that
1718+
// opens it.
1719+
const DOCUMENT_KINDS = [
1720+
['licence', 'Driving licence'],
1721+
['id', 'Aadhaar / ID'],
1722+
['passport', 'Passport'],
1723+
['other', 'Other document'],
1724+
];
1725+
1726+
function documentsHTML(documents) {
1727+
const held = documents || [];
1728+
if (held.length === 0) {
1729+
return '<p class="detail-empty">No documents on file for this customer yet.</p>';
1730+
}
1731+
1732+
const today = todayStr();
1733+
return `
1734+
<div class="doc-list">
1735+
${held.map((d) => {
1736+
const expired = d.expires_on && d.expires_on < today;
1737+
return `
1738+
<div class="doc-row">
1739+
<span class="doc-kind">${escapeHTML(d.label)}</span>
1740+
<span class="doc-meta">${d.is_pdf ? 'PDF' : 'Image'}${d.caption ? ' · ' + escapeHTML(d.caption) : ''}</span>
1741+
${d.expires_on ? `<span class="doc-meta ${expired ? 'doc-expired' : ''}">
1742+
${expired ? 'Expired' : 'Expires'} ${formatDate(d.expires_on)}</span>` : ''}
1743+
<span class="doc-actions">
1744+
<a class="btn btn-ghost btn-sm" href="${d.url}" target="_blank" rel="noopener">Open</a>
1745+
<button class="btn btn-ghost btn-sm remove-doc" data-id="${d.id}">Remove</button>
1746+
</span>
1747+
</div>`;
1748+
}).join('')}
1749+
</div>`;
1750+
}
1751+
1752+
// ---- The handover checklist ----
1753+
//
1754+
// Read out of the boxes rather than kept in a variable, so what is sent is
1755+
// exactly what is on screen. Unticked is sent as false rather than omitted:
1756+
// "not checked" and "checked and wrong" are different things, and only the
1757+
// first is what a blank means here.
1758+
function readChecklist(prefix) {
1759+
const out = {};
1760+
for (const box of document.querySelectorAll(`#${prefix}Checklist [data-check]`)) {
1761+
out[box.dataset.check] = box.checked;
1762+
}
1763+
return out;
1764+
}
1765+
1766+
function clearChecklist(prefix) {
1767+
for (const box of document.querySelectorAll(`#${prefix}Checklist [data-check]`)) {
1768+
box.checked = false;
1769+
}
1770+
}
1771+
1772+
/** The checklist as it was recorded, for the booking detail. */
1773+
function checklistHTML(checklist) {
1774+
if (!checklist || typeof checklist !== 'object') return '';
1775+
const items = Object.entries(checklist);
1776+
if (items.length === 0) return '';
1777+
1778+
const label = (key) => {
1779+
const box = document.querySelector(`[data-check="${key}"]`);
1780+
return box ? box.parentElement.textContent.trim() : key;
1781+
};
1782+
const checked = items.filter(([, v]) => v);
1783+
1784+
return `
1785+
<p class="modal-section-label">Checked at handover (${checked.length} of ${items.length})</p>
1786+
<div class="check-grid">
1787+
${items.map(([key, value]) => `
1788+
<span class="check-item" style="cursor:default">
1789+
<span aria-hidden="true">${value ? '\u2713' : '\u2014'}</span>
1790+
${escapeHTML(label(key))}
1791+
</span>`).join('')}
1792+
</div>`;
1793+
}
1794+
16261795
// ---- Booking attachments ----
16271796
//
16281797
// The five file boxes -- payment screenshot, deposit proof, refund proof,
@@ -1855,6 +2024,7 @@ const pickupModalOverlay = document.getElementById('pickupModalOverlay');
18552024

18562025
function openPickupModal(bookingId, booking) {
18572026
resetAttachmentBox('pickup');
2027+
clearChecklist('pickup');
18582028
document.getElementById('pickupForm').reset();
18592029
document.getElementById('pickupBookingId').value = bookingId;
18602030
document.getElementById('pickupDateField').value = todayStr();
@@ -1878,6 +2048,7 @@ document.getElementById('pickupForm').addEventListener('submit', async (e) => {
18782048
fuel_level: document.getElementById('pickupFuelLevel').value,
18792049
condition_note: document.getElementById('pickupCondition').value.trim(),
18802050
notes: document.getElementById('pickupNotes').value.trim(),
2051+
checklist: readChecklist('pickup'),
18812052
});
18822053
await uploadAttachments('pickup', Number(document.getElementById('pickupBookingId').value));
18832054
pickupModalOverlay.hidden = true;
@@ -1904,6 +2075,7 @@ function updateReturnKmPreview() {
19042075

19052076
function openReturnModal(bookingId, booking) {
19062077
resetAttachmentBox('return');
2078+
clearChecklist('return');
19072079
document.getElementById('returnForm').reset();
19082080
document.getElementById('returnBookingId').value = bookingId;
19092081
document.getElementById('returnDateField').value = todayStr();
@@ -1931,13 +2103,72 @@ document.getElementById('returnForm').addEventListener('submit', async (e) => {
19312103
fuel_level: document.getElementById('returnFuelLevel').value,
19322104
condition_note: document.getElementById('returnCondition').value.trim(),
19332105
notes: document.getElementById('returnNotes').value.trim(),
2106+
checklist: readChecklist('return'),
19342107
});
2108+
2109+
// After the reading, not with it: the charges hang off a booking that has
2110+
// been returned, and a refused charge must not cost someone the reading
2111+
// they just took.
2112+
await raiseReturnCharges(Number(document.getElementById('returnBookingId').value));
19352113
await uploadAttachments('return', Number(document.getElementById('returnBookingId').value));
19362114
returnModalOverlay.hidden = true;
19372115
await refreshAfterBookingChange();
19382116
} catch (err) { showError(err); } finally { btn.disabled = false; }
19392117
});
19402118

2119+
/**
2120+
* Turns the boxes on the return form into charges and a damage record.
2121+
*
2122+
* Each on its own line rather than one lump, because a customer does not
2123+
* accept "other charges" and nobody can explain one a month later.
2124+
*
2125+
* Every failure is reported and none of them stops the rest: a rejected
2126+
* cleaning charge should not take the fuel charge down with it, and the return
2127+
* itself is already saved by the time this runs.
2128+
*/
2129+
async function raiseReturnCharges(bookingId) {
2130+
const lines = [
2131+
['cleaning', 'returnCleaning', 'Cleaning on return'],
2132+
['fuel', 'returnFuel', 'Fuel on return'],
2133+
['late', 'returnLate', 'Late return'],
2134+
['other', 'returnOther', document.getElementById('returnOtherNote').value.trim()],
2135+
];
2136+
2137+
const problems = [];
2138+
2139+
for (const [kind, field, note] of lines) {
2140+
const amount = Number(document.getElementById(field).value) || 0;
2141+
if (amount <= 0) continue;
2142+
if (kind === 'other' && note === '') {
2143+
problems.push('The other charge needs a line saying what it is for, so it was not added.');
2144+
continue;
2145+
}
2146+
try {
2147+
await api.extras.add({ booking_id: bookingId, kind, amount, note });
2148+
} catch (err) { problems.push(`${kind}: ${err.message}`); }
2149+
}
2150+
2151+
const damage = document.getElementById('returnDamage').value.trim();
2152+
if (damage !== '') {
2153+
try {
2154+
await api.extras.addDamage({
2155+
booking_id: bookingId,
2156+
description: damage,
2157+
estimated_cost: document.getElementById('returnDamageCost').value || '0',
2158+
noticed_at: 'return',
2159+
// Recording damage and billing for it are separate decisions. Some
2160+
// comes off the deposit, some is absorbed, and assuming the first
2161+
// would make the other two wrong.
2162+
charge_customer: document.getElementById('returnDamageCharge').checked,
2163+
});
2164+
} catch (err) { problems.push(`damage: ${err.message}`); }
2165+
}
2166+
2167+
if (problems.length) {
2168+
alert('The return was saved. These did not go through:\n\n' + problems.join('\n'));
2169+
}
2170+
}
2171+
19412172
// ---- Dashboard: Rental Overview (point 9) ----
19422173
function renderRentalOverview() {
19432174
const grid = document.getElementById('rentalStatGrid');

‎admin/api.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,35 @@ const api = {
156156
apiRequest('api/booking-files.php?action=delete', { method: 'POST', body: { id } }),
157157
},
158158

159+
// Charges raised after a booking was priced, and the damage behind them.
160+
extras: {
161+
add: (extra) =>
162+
apiRequest('api/booking-extras.php?action=add-extra', { method: 'POST', body: extra }),
163+
void: (id, reason) =>
164+
apiRequest('api/booking-extras.php?action=void-extra', { method: 'POST', body: { id, reason } }),
165+
addDamage: (damage) =>
166+
apiRequest('api/booking-extras.php?action=add-damage', { method: 'POST', body: damage }),
167+
voidDamage: (id) =>
168+
apiRequest('api/booking-extras.php?action=void-damage', { method: 'POST', body: { id } }),
169+
},
170+
171+
// A customer's identity documents. Multipart, and one file at a time --
172+
// these are picked deliberately, one per kind, not dragged in as a batch.
173+
customerFiles: {
174+
list: (customerId) =>
175+
apiRequest(`api/customer-files.php?action=list&customer_id=${customerId}`),
176+
add: (customerId, kind, file, expiresOn = '') => {
177+
const form = new FormData();
178+
form.append('customer_id', String(customerId));
179+
form.append('kind', kind);
180+
if (expiresOn) form.append('expires_on', expiresOn);
181+
form.append('file', file);
182+
return apiUpload('api/customer-files.php?action=add', form);
183+
},
184+
remove: (id) =>
185+
apiRequest('api/customer-files.php?action=delete', { method: 'POST', body: { id } }),
186+
},
187+
159188
payments: {
160189
add: (payment) =>
161190
apiRequest('api/payments.php?action=add', { method: 'POST', body: payment }),

0 commit comments

Comments
 (0)