Node SQLite uploads receipt box
A receipt box keeps each uploaded image with its merchant, amount, category, and purchase date, then totals the selected month. Tokay runs it as one Web Service and preserves both the SQLite records and image files.
Last updated
This shape works well when your app naturally treats a small database and local uploads as one unit. You can keep that simple layout while Tokay gives the directory a durable home.
- Runtime
- Node.js
- Framework
- Express
- Service types
- Web Service
- Resources
- SQLite, Persistent files
- Required secrets
- None
What it does
The page begins with two sample receipts and shows their combined amount for the current month.
Use Add a receipt to save a merchant, amount, category, date, and JPEG, PNG, or WebP image. The new card links to the full upload.
Choose another month and press Show month to see only that period's receipts and total.
Deploy it
Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.
Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.
New to Tokay? The getting started guide walks you through your first deploy.
Try it
- Add a receipt with your own image and open its thumbnail at full size.
- Note the monthly total, then deploy the same code again.
- Return to that month. The record, total, and uploaded image remain together.
The two sample receipt illustrations are original assets dedicated to CC0 1.0 Universal.
How it works
You can follow the data without crossing systems. SQLite records what you bought, and the uploads directory keeps the matching image under the same persistent root.
The server checks file type and enforces a 3 MB limit before saving an upload. Tokay replicates the resulting data beyond the app and takes periodic offsite snapshots. Read Files and persistent storage for details.
Secrets
There are no secrets to add. SQLite and the uploaded images use Tokay's persistent storage.
Grow from here
Try the Prisma migrations walkthrough when you want PostgreSQL and reviewed schema changes.
Source code
src/assets/market-receipt.svg Raw
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="960" viewBox="0 0 720 960">
<metadata>Original sample artwork dedicated to CC0 1.0 Universal</metadata>
<rect width="720" height="960" fill="#d8d1c4"/>
<g transform="rotate(-2 360 480)">
<rect x="145" y="70" width="430" height="820" rx="6" fill="#fffdf4" stroke="#b9ad99" stroke-width="3"/>
<text x="360" y="145" text-anchor="middle" font-family="serif" font-size="34" fill="#2b342c">GREEN BASKET</text>
<text x="360" y="184" text-anchor="middle" font-family="monospace" font-size="18" fill="#5d625d">NEIGHBORHOOD MARKET</text>
<path d="M190 225h340M190 698h340" stroke="#827b70" stroke-width="2" stroke-dasharray="8 7"/>
<g font-family="monospace" font-size="22" fill="#343834">
<text x="195" y="285">APPLES</text><text x="510" y="285" text-anchor="end">6.40</text>
<text x="195" y="335">BREAD</text><text x="510" y="335" text-anchor="end">5.25</text>
<text x="195" y="385">COFFEE</text><text x="510" y="385" text-anchor="end">14.00</text>
<text x="195" y="435">GREENS</text><text x="510" y="435" text-anchor="end">8.10</text>
<text x="195" y="485">OATS</text><text x="510" y="485" text-anchor="end">7.05</text>
<text x="195" y="760">TOTAL</text><text x="510" y="760" text-anchor="end">40.80</text>
</g>
<text x="360" y="835" text-anchor="middle" font-family="monospace" font-size="18" fill="#62675f">THANK YOU</text>
</g>
</svg>
src/assets/transit-receipt.svg Raw
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="960" viewBox="0 0 720 960">
<metadata>Original sample artwork dedicated to CC0 1.0 Universal</metadata>
<rect width="720" height="960" fill="#cbd4d2"/>
<g transform="rotate(1.5 360 480)">
<rect x="128" y="85" width="464" height="790" rx="10" fill="#f8fbf7" stroke="#7c918b" stroke-width="3"/>
<circle cx="360" cy="170" r="54" fill="#31594d"/>
<path d="M328 170h64M360 138v64" stroke="#f8fbf7" stroke-width="13"/>
<text x="360" y="265" text-anchor="middle" font-family="sans-serif" font-size="31" font-weight="700" fill="#253f37">NORTH CITY TRANSIT</text>
<g font-family="monospace" font-size="22" fill="#34443f">
<text x="180" y="365">WEEKLY PASS</text><text x="540" y="365" text-anchor="end">32.00</text>
<text x="180" y="425">ZONE</text><text x="540" y="425" text-anchor="end">CENTRAL</text>
<text x="180" y="485">METHOD</text><text x="540" y="485" text-anchor="end">CARD</text>
</g>
<path d="M175 555h370" stroke="#8ca099" stroke-width="2" stroke-dasharray="7 8"/>
<text x="180" y="635" font-family="monospace" font-size="25" fill="#34443f">TOTAL</text>
<text x="540" y="635" text-anchor="end" font-family="monospace" font-size="34" font-weight="700" fill="#253f37">$32.00</text>
<g fill="#253f37"><rect x="205" y="735" width="7" height="70"/><rect x="225" y="735" width="3" height="70"/><rect x="238" y="735" width="11" height="70"/><rect x="264" y="735" width="4" height="70"/><rect x="280" y="735" width="9" height="70"/><rect x="306" y="735" width="3" height="70"/><rect x="321" y="735" width="13" height="70"/><rect x="348" y="735" width="6" height="70"/><rect x="370" y="735" width="3" height="70"/><rect x="386" y="735" width="12" height="70"/><rect x="414" y="735" width="5" height="70"/><rect x="435" y="735" width="10" height="70"/><rect x="461" y="735" width="4" height="70"/><rect x="478" y="735" width="12" height="70"/><rect x="510" y="735" width="5" height="70"/></g>
</g>
</svg>
src/server.ts Raw
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import express from "express";
import multer from "multer";
const app = express();
const port = Number(process.env.PORT || 3000);
const dataDir = process.env.TOKAY_DATA_DIR || path.join(process.cwd(), "data");
const uploadsDir = path.join(dataDir, "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
const database = new Database(path.join(dataDir, "receipts.sqlite"));
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
merchant TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
category TEXT NOT NULL,
purchased_on TEXT NOT NULL,
image_filename TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
const categories = ["Food", "Travel", "Supplies", "Software", "Other"];
const mimeExtensions = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
]);
function escapeHtml(value: unknown) {
const entities: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
"'": "'",
'"': """,
};
return String(value).replace(/[&<>'"]/g, character => entities[character]);
}
function monthToday() {
return new Date().toISOString().slice(0, 7);
}
function seedReceipts() {
const seedMonth = monthToday();
const day = Math.max(1, Math.min(Number(new Date().toISOString().slice(8, 10)), 12));
const samples = [
{ id: "sample-market", merchant: "Green Basket Market", amount: 4080, category: "Food", day: Math.max(1, day - 4), filename: "sample-market.svg", asset: "market-receipt.svg" },
{ id: "sample-transit", merchant: "North City Transit", amount: 3200, category: "Travel", day, filename: "sample-transit.svg", asset: "transit-receipt.svg" },
];
const insert = database.prepare(`
INSERT OR IGNORE INTO receipts
(id, merchant, amount_cents, category, purchased_on, image_filename, created_at)
VALUES
(@id, @merchant, @amount, @category, @purchasedOn, @filename, @createdAt)
`);
for (const sample of samples) {
const target = path.join(uploadsDir, sample.filename);
if (!fs.existsSync(target)) fs.copyFileSync(path.join(import.meta.dirname, "assets", sample.asset), target);
insert.run({
...sample,
purchasedOn: `${seedMonth}-${String(sample.day).padStart(2, "0")}`,
createdAt: new Date().toISOString(),
});
}
}
seedReceipts();
const storage = multer.diskStorage({
destination: (_request, _file, callback) => callback(null, uploadsDir),
filename: (_request, file, callback) => callback(null, `${crypto.randomUUID()}${mimeExtensions.get(file.mimetype)}`),
});
const upload = multer({
storage,
limits: { fileSize: 3 * 1024 * 1024, files: 1 },
fileFilter: (_request, file, callback) => callback(null, mimeExtensions.has(file.mimetype)),
});
app.use(express.urlencoded({ extended: false }));
app.use("/receipt-images", express.static(uploadsDir, { fallthrough: false, maxAge: "1h" }));
app.get("/health", (_request, response) => response.json({ ok: true }));
app.get("/", (request, response) => {
const requestedMonth = String(request.query.month || "");
const month = /^\d{4}-\d{2}$/.test(requestedMonth) ? requestedMonth : monthToday();
const receipts = database.prepare(`
SELECT id, merchant, amount_cents, category, purchased_on, image_filename
FROM receipts
WHERE substr(purchased_on, 1, 7) = ?
ORDER BY purchased_on DESC, created_at DESC
`).all(month) as Array<Record<string, string | number>>;
const total = receipts.reduce((sum, receipt) => sum + Number(receipt.amount_cents), 0);
const cards = receipts.map(receipt => `
<article>
<a class="image" href="/receipt-images/${encodeURIComponent(String(receipt.image_filename))}"><img src="/receipt-images/${encodeURIComponent(String(receipt.image_filename))}" alt="Receipt from ${escapeHtml(receipt.merchant)}"></a>
<div><p class="category">${escapeHtml(receipt.category)}</p><h2>${escapeHtml(receipt.merchant)}</h2><p>${escapeHtml(receipt.purchased_on)}</p></div>
<strong>$${(Number(receipt.amount_cents) / 100).toFixed(2)}</strong>
</article>
`).join("");
response.send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Receipt Box</title>
<style>
:root{font-family:ui-sans-serif,system-ui,sans-serif;background:#f4f0e7;color:#202921}*{box-sizing:border-box}body{margin:0}header,main{width:min(1020px,calc(100% - 36px));margin:auto}header{padding:28px 0;border-bottom:1px solid #c8c1b5;display:flex;justify-content:space-between}header b{font:500 1.35rem Georgia,serif}.hero{display:grid;grid-template-columns:1fr auto;gap:24px;align-items:end;padding:70px 0 45px}.eyebrow,.category{color:#8c543c;font-size:.72rem;font-weight:800;letter-spacing:.11em;text-transform:uppercase}h1{max-width:670px;margin:8px 0 0;font:500 clamp(3rem,8vw,6.5rem)/.9 Georgia,serif;letter-spacing:-.05em}.total{text-align:right}.total strong{display:block;font:500 2.6rem Georgia,serif}.grid{display:grid;grid-template-columns:320px 1fr;gap:26px;padding-bottom:80px}.panel{padding:24px;border-radius:17px;background:#fffdf7;box-shadow:0 18px 45px #665b4812}.panel h2{font:500 1.7rem Georgia,serif}.field{display:block;margin:16px 0 7px;font-size:.78rem;font-weight:800}input,select,button{width:100%;padding:12px;border:1px solid #cfc6b6;border-radius:8px;background:white;font:inherit}button{margin-top:20px;border:0;background:#31594d;color:white;font-weight:800;cursor:pointer}.month{display:flex;gap:10px;margin-bottom:15px}.month button{width:auto;margin:0}.list{display:grid;gap:12px}article{display:grid;grid-template-columns:86px 1fr auto;gap:18px;align-items:center;padding:13px;border-radius:13px;background:#fffdf7}.image{width:86px;height:86px;border-radius:9px;overflow:hidden;background:#ddd6ca}.image img{width:100%;height:100%;object-fit:cover}article h2{margin:3px 0;font:500 1.35rem Georgia,serif}article p{margin:0;color:#6b706a}article>strong{font:500 1.4rem Georgia,serif}.empty{padding:50px;text-align:center;color:#6b706a;background:#fffdf7;border-radius:13px}@media(max-width:760px){.hero,.grid{grid-template-columns:1fr}.total{text-align:left}article{grid-template-columns:70px 1fr}.image{width:70px;height:70px}article>strong{grid-column:2}}
</style></head><body><header><b>Receipt Box</b><span>Saved locally</span></header><main>
<section class="hero"><div><p class="eyebrow">A calm place for the paper trail</p><h1>Receipts that stay put.</h1></div><div class="total"><span>${receipts.length} this month</span><strong>$${(total / 100).toFixed(2)}</strong></div></section>
<div class="grid"><form class="panel" action="/receipts" method="post" enctype="multipart/form-data"><h2>Add a receipt</h2>
<label class="field" for="merchant">Merchant</label><input id="merchant" name="merchant" maxlength="80" required>
<label class="field" for="amount">Amount</label><input id="amount" name="amount" inputmode="decimal" placeholder="24.50" pattern="\\d+(\\.\\d{1,2})?" required>
<label class="field" for="category">Category</label><select id="category" name="category">${categories.map(category => `<option>${category}</option>`).join("")}</select>
<label class="field" for="date">Purchase date</label><input id="date" name="purchased_on" type="date" value="${new Date().toISOString().slice(0, 10)}" required>
<label class="field" for="image">Photo or scan</label><input id="image" name="image" type="file" accept="image/jpeg,image/png,image/webp" capture="environment" required>
<button>Save receipt</button></form>
<section><form class="month" method="get"><input aria-label="Month" name="month" type="month" value="${escapeHtml(month)}"><button>Show month</button></form><div class="list">${cards || '<p class="empty">No receipts in this month yet.</p>'}</div></section>
</div></main></body></html>`);
});
app.post("/receipts", upload.single("image"), (request, response, next) => {
try {
if (!request.file) return response.status(400).send("Choose a JPEG, PNG, or WebP receipt image under 3 MB.");
const merchant = String(request.body.merchant || "").trim();
const amount = String(request.body.amount || "").trim();
const category = String(request.body.category || "");
const purchasedOn = String(request.body.purchased_on || "");
const parsedDate = new Date(`${purchasedOn}T00:00:00Z`);
const validDate = !Number.isNaN(parsedDate.valueOf()) && parsedDate.toISOString().slice(0, 10) === purchasedOn;
if (!merchant || merchant.length > 80 || !/^\d+(\.\d{1,2})?$/.test(amount) || !categories.includes(category) || !validDate) {
fs.unlinkSync(request.file.path);
return response.status(400).send("Check the merchant, amount, category, and purchase date.");
}
const amountCents = Math.round(Number(amount) * 100);
if (!Number.isSafeInteger(amountCents) || amountCents < 1 || amountCents > 100_000_000) {
fs.unlinkSync(request.file.path);
return response.status(400).send("Enter an amount between 0.01 and 1,000,000.00.");
}
database.prepare(`
INSERT INTO receipts (id, merchant, amount_cents, category, purchased_on, image_filename, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(crypto.randomUUID(), merchant, amountCents, category, purchasedOn, request.file.filename, new Date().toISOString());
response.redirect(303, `/?month=${encodeURIComponent(purchasedOn.slice(0, 7))}`);
} catch (error) {
if (request.file?.path && fs.existsSync(request.file.path)) fs.unlinkSync(request.file.path);
next(error);
}
});
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
console.error(error);
if (error instanceof multer.MulterError && error.code === "LIMIT_FILE_SIZE") return response.status(413).send("Keep the image under 3 MB.");
response.status(500).send("The receipt could not be saved.");
});
const server = app.listen(port, "0.0.0.0", () => console.log(`Receipt Box ready on port ${port} with data in ${dataDir}`));
process.on("SIGTERM", () => server.close(() => { database.close(); process.exit(0); }));
package.json Raw
{
"name": "node-sqlite-uploads-receipt-box",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": { "start": "tsx src/server.ts" },
"dependencies": {
"better-sqlite3": "12.11.1",
"express": "5.2.1",
"multer": "2.2.0",
"tsx": "4.23.1"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/express": "5.0.6",
"@types/multer": "2.2.0",
"@types/node": "24.13.3",
"typescript": "7.0.2"
}
}
tsconfig.json Raw
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
Built from revision 52c7b7f