Store receipts in the database
Rethinking the workflow again. Requiring the transaction to be present in Firefly already will be problematic for two very important cases: * Gas station purchase never show up in Firefly automatically * HSA purchase show up hours or days later and have no information besides the amount These are arguably the most important cases, since they are the ones that really need receipts in order to keep the transaction register correct. Thus, we need a different way to handle these types of transactions. Really, what I need is a way to associate transaction data with an image I really liked the original idea of storing receipts in Paperless, but that ended up not working out because the OCR failed miserably and thus made it impossible to search, so finding a receipt meant looking at each image individually. I think, therefore, the best solution is to store the images along with manually-entered data. To implement this new functionality, I am using `sqlx`, a SQL toolkit for Rust. It's not exactly an ORM, nor does it have a dynamic query builder like SQLAlchemy, but it does have compile-time checking of query strings and can produce type-safe query results. Rocket has support for managing its connection pools as part of the server state, so that simplifies usage quite a bit. On the front-end, I factored out the camera image capture into an HTML custom element, `camera-input`. I did not update the original form to use it, since I imagine that workflow will actually go away entirely.bugfix/ci-buildah
parent
545baa1c36
commit
da9d336817
|
@ -1,5 +1,6 @@
|
|||
*
|
||||
!Cargo.*
|
||||
!.sqlx/
|
||||
!js/
|
||||
!src/
|
||||
!templates/
|
||||
|
|
|
@ -20,3 +20,7 @@ indent_size = 4
|
|||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.sql]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/Rocket.toml
|
||||
/firefly.token
|
||||
/target
|
||||
/.postgresql
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nINSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n$1, $2, $3, $4, $5, $6\n)\nRETURNING id\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Numeric",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "16d204cf990ebd5f78cfe21a5a9cbcfc9b0f084b755d0d9f065dbfe31c53679d"
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "vendor",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "date",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Numeric"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "notes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "filename",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9fe465619a9818b201fbf091018a75eea38e846468d1877062db8c5606550a13"
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT filename, image FROM receipts WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "filename",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "image",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "da76776380d89783df7270592d03afbd07c627342af3d03dc83932b152402941"
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nSELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "vendor",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "date",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Numeric"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "notes",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "filename",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e62b8704ebb897be63463427ba9e808ddb9c7fb5d3326b388d5e7f1066732aa9"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -13,8 +13,11 @@ keywords = ["personal-finance", "receipts"]
|
|||
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
|
||||
reqwest = { version = "0.12.12", features = ["json"] }
|
||||
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
|
||||
rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] }
|
||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||
rust_decimal = { version = "1.36.0", features = ["serde-with-str"] }
|
||||
serde = { version = "1.0.218", default-features = false, features = ["derive"] }
|
||||
sqlx = { version = "~0.7.4", default-features = false, features = ["chrono", "macros", "postgres", "rust_decimal", "time"] }
|
||||
thiserror = "2.0.12"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
|
|
@ -11,8 +11,8 @@ RUN --mount=type=cache,target=/var/cache \
|
|||
WORKDIR /build
|
||||
|
||||
COPY Cargo.* .
|
||||
|
||||
COPY src src
|
||||
COPY .sqlx .sqlx
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cargo \
|
||||
cargo build --release --locked
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
C() {
|
||||
podman exec -u postgres postgresql "$@"
|
||||
}
|
||||
|
||||
until C pg_isready; do sleep 1; done
|
||||
|
||||
if
|
||||
! C psql -At -c 'SELECT 1 FROM pg_user WHERE usename = '\'receipts\' \
|
||||
| grep -q .
|
||||
then
|
||||
C createuser -DERS receipts
|
||||
fi
|
||||
if
|
||||
! C psql -At -c 'SELECT 1 FROM pg_database WHERE datname = '\'receipts\' \
|
||||
| grep -q .
|
||||
then
|
||||
C createdb -O receipts receipts
|
||||
fi
|
|
@ -26,3 +26,9 @@ export function notify(
|
|||
alert.toast();
|
||||
}
|
||||
|
||||
export function notifyError(
|
||||
message: string,
|
||||
duration: number | null = null,
|
||||
) {
|
||||
notify(message, "danger", "exclamation-octagon", duration);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ const context = await esbuild.context({
|
|||
],
|
||||
outdir: "./dist",
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
platform: "node",
|
||||
target: "esnext",
|
||||
format: "esm",
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
||||
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
|
||||
|
||||
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
|
||||
|
||||
import { notifyError } from "./alert.js";
|
||||
|
||||
const STYLESHEET = `
|
||||
.camera-input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.camera-input video,
|
||||
.camera-input canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.camera-buttons sl-icon-button {
|
||||
font-size: 3em;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default class CameraInput extends HTMLElement {
|
||||
elmVideo: HTMLVideoElement;
|
||||
btnShutter: SlIconButton;
|
||||
btnClear: SlIconButton;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = "";
|
||||
const shadow = this.attachShadow({ mode: "open" });
|
||||
shadow.append(
|
||||
Object.assign(document.createElement("style"), {
|
||||
textContent: STYLESHEET,
|
||||
}),
|
||||
);
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "camera-input";
|
||||
shadow.appendChild(wrapper);
|
||||
|
||||
this.elmVideo = Object.assign(document.createElement("video"), {
|
||||
className: "hidden",
|
||||
});
|
||||
this.elmVideo.addEventListener("canplay", () => {
|
||||
this.btnShutter.disabled = false;
|
||||
});
|
||||
wrapper.appendChild(this.elmVideo);
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.classList.add("camera-buttons");
|
||||
wrapper.appendChild(buttons);
|
||||
this.btnShutter = Object.assign(
|
||||
document.createElement("sl-icon-button"),
|
||||
{
|
||||
name: "camera",
|
||||
label: "Take Photo",
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
this.btnShutter.addEventListener("click", () => {
|
||||
this.takePhoto();
|
||||
});
|
||||
const ttShutter = Object.assign(document.createElement("sl-tooltip"), {
|
||||
content: "Take Photo",
|
||||
});
|
||||
ttShutter.appendChild(this.btnShutter);
|
||||
buttons.appendChild(ttShutter);
|
||||
this.btnClear = Object.assign(
|
||||
document.createElement("sl-icon-button"),
|
||||
{
|
||||
name: "trash",
|
||||
label: "Start Over",
|
||||
disabled: true,
|
||||
className: "hidden",
|
||||
},
|
||||
);
|
||||
this.btnClear.addEventListener("click", () => {
|
||||
this.clearCamera();
|
||||
this.startCamera();
|
||||
});
|
||||
const ttClear = Object.assign(document.createElement("sl-tooltip"), {
|
||||
content: "Start Over",
|
||||
});
|
||||
ttClear.appendChild(this.btnClear);
|
||||
buttons.appendChild(ttClear);
|
||||
}
|
||||
|
||||
public async getBlob(): Promise<Blob | null> {
|
||||
const canvas = this.shadowRoot!.querySelector("canvas");
|
||||
return await new Promise((resolve) => {
|
||||
if (canvas) {
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, "image/jpeg");
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearCamera() {
|
||||
this.elmVideo.pause();
|
||||
this.elmVideo.srcObject = null;
|
||||
this.elmVideo.classList.add("hidden");
|
||||
this.elmVideo.parentNode
|
||||
?.querySelectorAll("canvas")
|
||||
.forEach((e) => e.remove());
|
||||
this.btnShutter.disabled = true;
|
||||
this.btnShutter.classList.add("hidden");
|
||||
this.btnClear.disabled = true;
|
||||
this.btnClear.classList.add("hidden");
|
||||
this.sendReady(false);
|
||||
}
|
||||
|
||||
sendReady(hasPhoto: boolean) {
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { hasPhoto } }));
|
||||
}
|
||||
|
||||
async startCamera() {
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: {
|
||||
ideal: "environment",
|
||||
},
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
notifyError(`${ex}`);
|
||||
return;
|
||||
}
|
||||
this.btnShutter.classList.remove("hidden");
|
||||
this.elmVideo.classList.remove("hidden");
|
||||
this.elmVideo.srcObject = stream;
|
||||
this.elmVideo.play();
|
||||
}
|
||||
|
||||
takePhoto() {
|
||||
this.btnShutter.disabled = true;
|
||||
this.btnShutter.classList.add("hidden");
|
||||
this.elmVideo.pause();
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
notifyError("Failed to get canvas 2D rendering context");
|
||||
return;
|
||||
}
|
||||
const width = this.elmVideo.videoWidth;
|
||||
const height = this.elmVideo.videoHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.drawImage(this.elmVideo, 0, 0, width, height);
|
||||
this.elmVideo.srcObject = null;
|
||||
this.elmVideo.classList.add("hidden");
|
||||
this.elmVideo.after(canvas);
|
||||
this.btnClear.disabled = false;
|
||||
this.btnClear.classList.remove("hidden");
|
||||
this.sendReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("camera-input", CameraInput);
|
|
@ -1,5 +1,7 @@
|
|||
:host, body {
|
||||
:host,
|
||||
body {
|
||||
font-family: var(--sl-font-sans);
|
||||
color: var(--sl-color-neutral-900);
|
||||
}
|
||||
|
||||
body:has(div#page-loading) main {
|
||||
|
@ -24,9 +26,15 @@ main {
|
|||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
|
@ -53,6 +61,30 @@ tr {
|
|||
border-bottom: 1px solid var(--sl-color-neutral-200);
|
||||
}
|
||||
|
||||
table td, table th {
|
||||
table td,
|
||||
table th {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
col.shrink {
|
||||
text-align: center;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
sl-input[data-user-invalid]::part(base),
|
||||
sl-select[data-user-invalid]::part(combobox),
|
||||
sl-checkbox[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
}
|
||||
|
||||
sl-input:focus-within[data-user-invalid]::part(base),
|
||||
sl-select:focus-within[data-user-invalid]::part(combobox),
|
||||
sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
||||
import "@shoelace-style/shoelace/dist/components/details/details.js";
|
||||
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
|
||||
import "@shoelace-style/shoelace/dist/components/input/input.js";
|
||||
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
|
||||
|
||||
import "./shoelace.js";
|
||||
|
||||
import "./camera.ts";
|
||||
|
||||
import CameraInput from "./camera.ts";
|
||||
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
|
||||
|
||||
import { notify, notifyError } from "./alert";
|
||||
|
||||
const form = document.forms[0];
|
||||
const cameraInput = form.querySelector("camera-input") as CameraInput;
|
||||
const btnSubmit = form.querySelector("sl-button[type='submit']") as SlButton;
|
||||
const btnUpload = form.querySelector(
|
||||
"sl-button[class='choose-file']",
|
||||
) as SlButton;
|
||||
const inpImage = form.photo as HTMLInputElement;
|
||||
const imgPreview = document.getElementById("upload-preview") as HTMLImageElement;
|
||||
|
||||
form.addEventListener("submit", async (evt) => {
|
||||
evt.preventDefault();
|
||||
btnSubmit.loading = true;
|
||||
const data = new FormData(form);
|
||||
const blob = await cameraInput.getBlob();
|
||||
if (blob) {
|
||||
data.append("photo", blob, "photo.jpg");
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch("", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
} catch (e) {
|
||||
notifyError(`Failed to submit form: ${e}`);
|
||||
return;
|
||||
} finally {
|
||||
btnSubmit.loading = false;
|
||||
}
|
||||
if (r.ok) {
|
||||
notify("Successfully uploaded receipt", undefined, undefined, null);
|
||||
window.location.href = "/receipts";
|
||||
} else {
|
||||
let ct = r.headers.get("Content-Type");
|
||||
if (ct && ct.indexOf("json") > -1) {
|
||||
const json = await r.json();
|
||||
if (json.error) {
|
||||
notifyError(json.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const html = await r.text();
|
||||
if (html) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
notifyError(doc.body.textContent ?? "");
|
||||
} else {
|
||||
notifyError(r.statusText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cameraInput.addEventListener("ready", ((evt: CustomEvent) => {
|
||||
btnSubmit.disabled = !evt.detail.hasPhoto;
|
||||
btnUpload.disabled = !!evt.detail.hasPhoto;
|
||||
if (!!evt.detail.hasPhoto) {
|
||||
inpImage.value = "";
|
||||
imgPreview.src = "";
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
const cameraDetails = document.querySelector(
|
||||
"sl-details[summary='Take Photo']",
|
||||
)!;
|
||||
let cameraInitialized = false;
|
||||
cameraDetails.addEventListener("sl-show", () => {
|
||||
if (!cameraInitialized) {
|
||||
cameraInitialized = true;
|
||||
cameraInput.startCamera();
|
||||
}
|
||||
});
|
||||
|
||||
btnUpload.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
form.photo.showPicker();
|
||||
});
|
||||
|
||||
inpImage.addEventListener("change", () => {
|
||||
if (inpImage.files) {
|
||||
const file = inpImage.files[0];
|
||||
if (file) {
|
||||
btnSubmit.disabled = false;
|
||||
imgPreview.src = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
||||
import "@shoelace-style/shoelace/dist/components/card/card.js";
|
||||
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
|
||||
|
||||
import "./shoelace.js";
|
|
@ -0,0 +1,6 @@
|
|||
import "@shoelace-style/shoelace/dist/components/button/button.js";
|
||||
import "@shoelace-style/shoelace/dist/components/details/details.js";
|
||||
import "@shoelace-style/shoelace/dist/components/input/input.js";
|
||||
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
|
||||
|
||||
import "./shoelace.js";
|
|
@ -0,0 +1,2 @@
|
|||
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
|
||||
setBasePath("/static/shoelace/");
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE receipts (
|
||||
id serial PRIMARY KEY,
|
||||
date timestamp with time zone NOT NULL,
|
||||
vendor varchar(99) NOT NULL,
|
||||
amount decimal NOT NULL,
|
||||
notes text,
|
||||
filename varchar(199) NOT NULL,
|
||||
image bytea NOT NULL
|
||||
);
|
|
@ -1,5 +1,6 @@
|
|||
mod config;
|
||||
mod firefly;
|
||||
mod receipts;
|
||||
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::{FileServer, TempFile};
|
||||
|
@ -7,6 +8,7 @@ use rocket::http::Status;
|
|||
use rocket::response::Redirect;
|
||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||
use rocket::State;
|
||||
use rocket_db_pools::Database as RocketDatabase;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, error};
|
||||
|
@ -37,6 +39,10 @@ impl Context {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(RocketDatabase)]
|
||||
#[database("receipts")]
|
||||
struct Database(rocket_db_pools::sqlx::PgPool);
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Transaction {
|
||||
pub id: String,
|
||||
|
@ -255,6 +261,8 @@ async fn rocket() -> _ {
|
|||
update_transaction
|
||||
],
|
||||
)
|
||||
.mount("/receipts", receipts::routes())
|
||||
.mount("/static", FileServer::from("static"))
|
||||
.attach(Template::fairing())
|
||||
.attach(Database::init())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,268 @@
|
|||
use chrono::{DateTime, FixedOffset};
|
||||
use rocket::form::Form;
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::http::{ContentType, MediaType, Status};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::tokio::io::{AsyncReadExt, BufReader};
|
||||
use rocket::Route;
|
||||
use rocket_db_pools::Connection as DatabaseConnection;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use serde::Serialize;
|
||||
use sqlx::types::Decimal;
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::Database;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Receipt {
|
||||
pub id: i32,
|
||||
pub date: DateTime<FixedOffset>,
|
||||
pub vendor: String,
|
||||
pub amount: Decimal,
|
||||
pub notes: Option<String>,
|
||||
pub filename: String,
|
||||
pub image: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReceiptJson {
|
||||
pub id: i32,
|
||||
pub date: DateTime<FixedOffset>,
|
||||
pub vendor: String,
|
||||
pub amount: Decimal,
|
||||
pub notes: Option<String>,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(rocket::FromForm)]
|
||||
pub struct ReceiptPostForm<'r> {
|
||||
pub date: String,
|
||||
pub vendor: String,
|
||||
pub amount: String,
|
||||
pub notes: String,
|
||||
pub photo: TempFile<'r>,
|
||||
}
|
||||
|
||||
struct ReceiptPostData {
|
||||
pub date: DateTime<FixedOffset>,
|
||||
pub vendor: String,
|
||||
pub amount: Decimal,
|
||||
pub notes: String,
|
||||
pub filename: String,
|
||||
pub photo: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ReceiptPostFormError {
|
||||
#[error("Invalid date: {0}")]
|
||||
Date(#[from] chrono::format::ParseError),
|
||||
#[error("Invalid amount: {0}")]
|
||||
Amount(#[from] rust_decimal::Error),
|
||||
#[error("Error reading photo: {0}")]
|
||||
Photo(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AddReceiptResponse {
|
||||
Success(ReceiptJson),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl ReceiptPostData {
|
||||
async fn from_form(
|
||||
form: &ReceiptPostForm<'_>,
|
||||
) -> Result<Self, ReceiptPostFormError> {
|
||||
let date = DateTime::parse_from_str(
|
||||
&format!("{} 00:00:00 +0000", form.date),
|
||||
"%Y-%m-%d %H:%M:%S %z",
|
||||
)?;
|
||||
let vendor = form.vendor.clone();
|
||||
use rust_decimal::prelude::FromStr;
|
||||
let amount = Decimal::from_str(&form.amount)?;
|
||||
let notes = form.notes.clone();
|
||||
let filename = form
|
||||
.photo
|
||||
.raw_name()
|
||||
.map(|n| n.dangerous_unsafe_unsanitized_raw().as_str())
|
||||
.unwrap_or("photo.jpg")
|
||||
.into();
|
||||
let stream = form.photo.open().await?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut photo = Vec::new();
|
||||
reader.read_to_end(&mut photo).await?;
|
||||
Ok(Self {
|
||||
date,
|
||||
vendor,
|
||||
amount,
|
||||
notes,
|
||||
filename,
|
||||
photo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/")]
|
||||
pub async fn list_receipts(
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> (Status, Template) {
|
||||
let result = rocket_db_pools::sqlx::query_as!(
|
||||
ReceiptJson,
|
||||
r##"
|
||||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
ORDER BY date
|
||||
"##,
|
||||
)
|
||||
.fetch_all(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => (
|
||||
Status::Ok,
|
||||
Template::render(
|
||||
"receipt-list",
|
||||
context! {
|
||||
receipts: r,
|
||||
},
|
||||
),
|
||||
),
|
||||
Err(e) => (
|
||||
Status::InternalServerError,
|
||||
Template::render(
|
||||
"error",
|
||||
context! {
|
||||
error: e.to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/add")]
|
||||
pub async fn receipt_form() -> Template {
|
||||
Template::render("receipt-form", context! {})
|
||||
}
|
||||
|
||||
#[rocket::post("/add", data = "<form>")]
|
||||
pub async fn add_receipt(
|
||||
form: Form<ReceiptPostForm<'_>>,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> (Status, Json<AddReceiptResponse>) {
|
||||
let data = match ReceiptPostData::from_form(&form).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
Status::BadRequest,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
);
|
||||
},
|
||||
};
|
||||
let result = rocket_db_pools::sqlx::query!(
|
||||
r##"
|
||||
INSERT INTO receipts (
|
||||
vendor, date, amount, notes, filename, image
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
RETURNING id
|
||||
"##,
|
||||
data.vendor,
|
||||
data.date,
|
||||
data.amount,
|
||||
data.notes,
|
||||
data.filename,
|
||||
data.photo,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
info!("Created new receipt {}", r.id);
|
||||
(
|
||||
Status::Ok,
|
||||
Json(AddReceiptResponse::Success(ReceiptJson {
|
||||
id: r.id,
|
||||
vendor: data.vendor,
|
||||
date: data.date,
|
||||
amount: data.amount,
|
||||
notes: Some(data.notes),
|
||||
filename: data.filename,
|
||||
})),
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to insert new receipt record: {}", e);
|
||||
(
|
||||
Status::InternalServerError,
|
||||
Json(AddReceiptResponse::Error(e.to_string())),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/<id>")]
|
||||
pub async fn get_receipt(
|
||||
id: i32,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> Option<Template> {
|
||||
let result = rocket_db_pools::sqlx::query_as!(
|
||||
ReceiptJson,
|
||||
r##"
|
||||
SELECT
|
||||
id, vendor, date, amount, notes, filename
|
||||
FROM
|
||||
receipts
|
||||
WHERE
|
||||
id = $1
|
||||
"##,
|
||||
id,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => Some(Template::render("receipt", r)),
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
#[rocket::get("/<id>/view/<filename>")]
|
||||
pub async fn view_receipt_photo(
|
||||
id: i32,
|
||||
#[allow(unused_variables)] filename: &str,
|
||||
mut db: DatabaseConnection<Database>,
|
||||
) -> Option<(ContentType, Vec<u8>)> {
|
||||
let result = rocket_db_pools::sqlx::query!(
|
||||
"SELECT filename, image FROM receipts WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_one(&mut **db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let mt = r
|
||||
.filename
|
||||
.rsplit_once('.')
|
||||
.and_then(|(_, ext)| MediaType::from_extension(ext))
|
||||
.unwrap_or(MediaType::Binary);
|
||||
Some((ContentType(mt), r.image))
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Error fetching receipt image: {}", e);
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
rocket::routes![
|
||||
add_receipt,
|
||||
get_receipt,
|
||||
list_receipts,
|
||||
receipt_form,
|
||||
view_receipt_photo,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
#!/bin/sh
|
||||
mkdir -p .postgresql
|
||||
podman run \
|
||||
--rm \
|
||||
-d \
|
||||
--name postgresql \
|
||||
-e POSTGRES_PASSWORD=$(tr -cd a-zA-Z0-9 < /dev/urandom | head -c 32) \
|
||||
-v postgresql:/var/lib/postgresql/data \
|
||||
-v ./.postgresql:/var/run/postgresql:rw,z \
|
||||
docker.io/library/postgres
|
|
@ -0,0 +1,57 @@
|
|||
{% extends "base" %} {% block head %}
|
||||
<title>Add Receipt</title>
|
||||
{% endblock %} {% block main %}
|
||||
<h1>Add Receipt</h1>
|
||||
<nav>
|
||||
<sl-button href="/receipts">
|
||||
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
|
||||
</sl-button>
|
||||
</nav>
|
||||
<article>
|
||||
<form>
|
||||
<p>
|
||||
<sl-input
|
||||
name="vendor"
|
||||
label="Vendor"
|
||||
placeholder="Vendor"
|
||||
required
|
||||
></sl-input>
|
||||
</p>
|
||||
<p>
|
||||
<sl-input type="date" name="date" label="Date" required></sl-input>
|
||||
</p>
|
||||
<p>
|
||||
<sl-input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
label="Amount"
|
||||
name="amount"
|
||||
required
|
||||
></sl-input>
|
||||
</p>
|
||||
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
|
||||
<sl-details summary="Take Photo" id="photo-box">
|
||||
<camera-input>Your browser does not support taking photos.</camera-input>
|
||||
</sl-details>
|
||||
<sl-details summary="Upload Photo" style="text-align: center">
|
||||
<p>
|
||||
<img id="upload-preview" style="max-height: 400px"/>
|
||||
</p>
|
||||
<p>
|
||||
<input name="photo" type="file" style="display: none" />
|
||||
<sl-button size="large" variant="primary" class="choose-file">
|
||||
<sl-icon slot="prefix" name="image" label="Choose File"></sl-icon>
|
||||
Choose File
|
||||
</sl-button>
|
||||
</p>
|
||||
</sl-details>
|
||||
<footer class="table-actions">
|
||||
<sl-button type="submit" variant="primary" disabled>Submit</sl-button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/receipt-form.js"></script>
|
||||
{% endblock %}
|
|
@ -0,0 +1,69 @@
|
|||
{% extends "base" %} {% block head %}
|
||||
<title>Receipts</title>
|
||||
<style>
|
||||
.receipt-card {
|
||||
max-width: 20%;
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
.receipt-card > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.receipt-card .vendor {
|
||||
font-weight: bold;
|
||||
font-size: 115%;
|
||||
}
|
||||
|
||||
.receipt-card .date {
|
||||
font-size: 75%;
|
||||
color: var(--sl-color-neutral-500);
|
||||
}
|
||||
|
||||
.receipt-card :link,
|
||||
.receipt-card :visited {
|
||||
color: var(--sl-color-neutral-900);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block main %}
|
||||
<h1>Receipts</h1>
|
||||
<p class="table-actions">
|
||||
<sl-button variant="primary" size="large" href="/receipts/add"
|
||||
><sl-icon slot="prefix" name="file-earmark-plus"></sl-icon>Add
|
||||
Receipt</sl-button
|
||||
>
|
||||
</p>
|
||||
{% for receipt in receipts -%}
|
||||
<sl-card class="receipt-card">
|
||||
<img
|
||||
slot="image"
|
||||
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}"
|
||||
alt="Receipt {{ receipt.id }}"
|
||||
/>
|
||||
<a href="/receipts/{{ receipt.id }}">
|
||||
<div class="vendor">{{ receipt.vendor }}</div>
|
||||
<div class="amount">$ {{ receipt.amount }}</div>
|
||||
</a>
|
||||
<div class="date">
|
||||
{{ receipt.date | date(format="%A %_d %B %Y") }}
|
||||
</div>
|
||||
</sl-card>
|
||||
{% endfor %} {% endblock %} {% block scripts %}
|
||||
<script src="/static/receipt-list.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll(".receipt-card").forEach((e) => {
|
||||
const a = e.querySelector("a");
|
||||
if (a && a.href) {
|
||||
e.style.cursor = "pointer";
|
||||
e.addEventListener("click", () => {
|
||||
window.location.href = a.href;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -0,0 +1,61 @@
|
|||
{% extends "base" %} {% block head %}
|
||||
<title>Receipt: ${{ amount }} at {{ vendor }}</title>
|
||||
<style>
|
||||
@media screen and (min-width: 900px) {
|
||||
article {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attributes {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.photo img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block main %}
|
||||
<h1>Receipt</h1>
|
||||
<nav>
|
||||
<sl-button href="/receipts">
|
||||
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
|
||||
</sl-button>
|
||||
</nav>
|
||||
<article>
|
||||
<div class="attributes">
|
||||
<p>
|
||||
<sl-input
|
||||
label="Date"
|
||||
value='{{ date | date(format="%A %_d %B %Y") }}'
|
||||
readonly
|
||||
></sl-input>
|
||||
</p>
|
||||
<p>
|
||||
<sl-input label="Vendor" value="{{ vendor }}" readonly></sl-input>
|
||||
</p>
|
||||
<p>
|
||||
<sl-input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
label="Amount"
|
||||
name="amount"
|
||||
value="{{ amount }}"
|
||||
readonly
|
||||
></sl-input>
|
||||
</p>
|
||||
<p>
|
||||
<sl-textarea label="Notes" name="notes" readonly>{{ notes }}</sl-textarea>
|
||||
</p>
|
||||
</div>
|
||||
<div class="photo">
|
||||
<p>
|
||||
<img src="/receipts/{{ id }}/view/{{ filename }}" />
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/receipt.js"></script>
|
||||
{% endblock %}
|
Loading…
Reference in New Issue