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
Dustin 2025-03-09 19:55:08 -05:00
parent 545baa1c36
commit da9d336817
26 changed files with 2029 additions and 37 deletions

View File

@ -1,5 +1,6 @@
* *
!Cargo.* !Cargo.*
!.sqlx/
!js/ !js/
!src/ !src/
!templates/ !templates/

View File

@ -20,3 +20,7 @@ indent_size = 4
[*.json] [*.json]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.sql]
indent_style = space
indent_size = 4

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/Rocket.toml /Rocket.toml
/firefly.token /firefly.token
/target /target
/.postgresql

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

1055
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,11 @@ keywords = ["personal-finance", "receipts"]
chrono = { version = "0.4.40", default-features = false, features = ["serde"] } chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
reqwest = { version = "0.12.12", features = ["json"] } reqwest = { version = "0.12.12", features = ["json"] }
rocket = { version = "0.5.1", default-features = false, 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"] } 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"] } 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" thiserror = "2.0.12"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@ -11,8 +11,8 @@ RUN --mount=type=cache,target=/var/cache \
WORKDIR /build WORKDIR /build
COPY Cargo.* . COPY Cargo.* .
COPY src src COPY src src
COPY .sqlx .sqlx
RUN --mount=type=cache,target=/root/.cargo \ RUN --mount=type=cache,target=/root/.cargo \
cargo build --release --locked cargo build --release --locked

22
createdb.sh Normal file
View File

@ -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

View File

@ -26,3 +26,9 @@ export function notify(
alert.toast(); alert.toast();
} }
export function notifyError(
message: string,
duration: number | null = null,
) {
notify(message, "danger", "exclamation-octagon", duration);
}

View File

@ -11,6 +11,7 @@ const context = await esbuild.context({
], ],
outdir: "./dist", outdir: "./dist",
bundle: true, bundle: true,
sourcemap: true,
platform: "node", platform: "node",
target: "esnext", target: "esnext",
format: "esm", format: "esm",

177
js/camera.ts Normal file
View File

@ -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);

View File

@ -1,5 +1,7 @@
:host, body { :host,
body {
font-family: var(--sl-font-sans); font-family: var(--sl-font-sans);
color: var(--sl-color-neutral-900);
} }
body:has(div#page-loading) main { body:has(div#page-loading) main {
@ -24,9 +26,15 @@ main {
} }
@keyframes pulse { @keyframes pulse {
0% { opacity: 0; } 0% {
50% { opacity: 1; } opacity: 0;
100% { opacity: 0; } }
50% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
@media screen and (min-width: 900px) { @media screen and (min-width: 900px) {
@ -53,6 +61,30 @@ tr {
border-bottom: 1px solid var(--sl-color-neutral-200); border-bottom: 1px solid var(--sl-color-neutral-200);
} }
table td, table th { table td,
table th {
padding: 1rem; 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);
}

100
js/receipt-form.ts Normal file
View File

@ -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);
}
}
});

5
js/receipt-list.ts Normal file
View 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";

6
js/receipt.ts Normal file
View File

@ -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";

2
js/shoelace.ts Normal file
View File

@ -0,0 +1,2 @@
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
setBasePath("/static/shoelace/");

View File

@ -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
);

View File

@ -1,5 +1,6 @@
mod config; mod config;
mod firefly; mod firefly;
mod receipts;
use rocket::form::Form; use rocket::form::Form;
use rocket::fs::{FileServer, TempFile}; use rocket::fs::{FileServer, TempFile};
@ -7,6 +8,7 @@ use rocket::http::Status;
use rocket::response::Redirect; use rocket::response::Redirect;
use rocket::tokio::io::{AsyncReadExt, BufReader}; use rocket::tokio::io::{AsyncReadExt, BufReader};
use rocket::State; use rocket::State;
use rocket_db_pools::Database as RocketDatabase;
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use serde::Serialize; use serde::Serialize;
use tracing::{debug, error}; use tracing::{debug, error};
@ -37,6 +39,10 @@ impl Context {
} }
} }
#[derive(RocketDatabase)]
#[database("receipts")]
struct Database(rocket_db_pools::sqlx::PgPool);
#[derive(Serialize)] #[derive(Serialize)]
pub struct Transaction { pub struct Transaction {
pub id: String, pub id: String,
@ -255,6 +261,8 @@ async fn rocket() -> _ {
update_transaction update_transaction
], ],
) )
.mount("/receipts", receipts::routes())
.mount("/static", FileServer::from("static")) .mount("/static", FileServer::from("static"))
.attach(Template::fairing()) .attach(Template::fairing())
.attach(Database::init())
} }

268
src/receipts.rs Normal file
View File

@ -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,
]
}

10
start-postgresql.sh Normal file
View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}