Compare commits

...

3 Commits

Author SHA1 Message Date
Dustin b919bd8f0d receipts/list: Reverse sort and add pagination
dustin/receipts/pipeline/head This commit looks good Details
Now that there are quite a few receipts in the database, scrolling to
the end to see the most recent entries is rather cumbersome.  Let's show
the most recent receipts first, and hide older ones by default by
splitting the list into multiple pages.
2025-05-18 16:00:26 -05:00
Dustin ad1c857c97 receipt-form: Add Restaurant toggle
The _Add Receipt_ form now has a _Restaurant_ toggle.  When uploading a
receipt that creates or updates a Firefly III transaction, if the toggle
is activated, a special tag will be added to the transaction.  The
assumption is that Firefly will have a rule to automatically adjust the
destination account, category, and/or budget for the transaction if this
tag is present.  The tag is configurable and defaults to `Food & Drink`.
2025-05-08 22:25:29 -05:00
Dustin 0eb0618fd2 add_receipt: Fix editing Firefly xact notes
dustin/receipts/pipeline/head This commit looks good Details
If a new receipt is being attached to an existing Firefly transaction,
we need to update the notes field of the transaction to match what's
specified with the receipt.  This was happening correctly for
transactions that already had value in its notes field, but not for
transactions without any notes at all.  The reason for this is because
we were only updating the field inside a conditional that checked if the
existing value was not equal to the new value, but it did not account
for the case where there was no existing value at all.
2025-05-07 18:24:04 -05:00
13 changed files with 202 additions and 23 deletions

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n count(id) AS \"count!\"\nFROM\n receipts\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "34f56cde503100c09bbb378ce656af95abd81949be0c369a5d7225272e6c9c58"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY date\n", "query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY\n date DESC,\n id DESC\nLIMIT $1\nOFFSET $2\n",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -35,7 +35,10 @@
} }
], ],
"parameters": { "parameters": {
"Left": [] "Left": [
"Int8",
"Int8"
]
}, },
"nullable": [ "nullable": [
false, false,
@ -46,5 +49,5 @@
false false
] ]
}, },
"hash": "71dcdc6a24d99eff2dd7af673a0ebb6fda45b0ebd5244309472921a934e1b829" "hash": "ed7bf495d2eefe7b479a79cc2fc77de3b5a3db4415cd55ecbd21c28c108274a6"
} }

View File

@ -5,6 +5,7 @@ import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/option/option.js"; import "@shoelace-style/shoelace/dist/components/option/option.js";
import "@shoelace-style/shoelace/dist/components/spinner/spinner.js"; import "@shoelace-style/shoelace/dist/components/spinner/spinner.js";
import "@shoelace-style/shoelace/dist/components/select/select.js"; import "@shoelace-style/shoelace/dist/components/select/select.js";
import "@shoelace-style/shoelace/dist/components/switch/switch.js";
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js"; import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
import "./shoelace.js"; import "./shoelace.js";
@ -14,6 +15,7 @@ import "./camera.ts";
import CameraInput from "./camera.ts"; import CameraInput from "./camera.ts";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.js"; import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.js";
import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.js";
import { notify, notifyError } from "./alert"; import { notify, notifyError } from "./alert";
import { getResponseError } from "./ajaxUtil.js"; import { getResponseError } from "./ajaxUtil.js";
@ -32,7 +34,7 @@ const xactselect = document.getElementById("transactions") as SlSelect;
let dirty = false; let dirty = false;
window.addEventListener("beforeunload", function(evt) { window.addEventListener("beforeunload", function (evt) {
if (dirty) { if (dirty) {
evt.preventDefault(); evt.preventDefault();
} }
@ -143,6 +145,7 @@ async function fetchTransactions() {
option.dataset.amount = xact.amount; option.dataset.amount = xact.amount;
option.dataset.date = xact.date.split("T")[0]; option.dataset.date = xact.date.split("T")[0];
option.dataset.vendor = xact.description; option.dataset.vendor = xact.description;
option.dataset.is_restaurant = xact.is_restaurant;
xactselect.insertBefore(option, prev); xactselect.insertBefore(option, prev);
} }
} }
@ -166,6 +169,8 @@ xactselect.addEventListener("sl-change", () => {
} }
} }
}); });
(form.querySelector("[name='is_restaurant']") as SlSwitch).checked =
option.dataset.is_restaurant == "true";
}); });
fetchTransactions(); fetchTransactions();

View File

@ -0,0 +1,4 @@
SELECT
count(id) AS "count!"
FROM
receipts

View File

@ -2,4 +2,8 @@ SELECT
id, vendor, date, amount, notes, filename id, vendor, date, amount, notes, filename
FROM FROM
receipts receipts
ORDER BY date ORDER BY
date DESC,
id DESC
LIMIT $1
OFFSET $2

View File

@ -8,9 +8,15 @@ pub struct FireflyConfig {
pub token: PathBuf, pub token: PathBuf,
pub search_query: String, pub search_query: String,
pub default_account: String, pub default_account: String,
#[serde(default = "default_restaurant_tag")]
pub restaurant_tag: String
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Config { pub struct Config {
pub firefly: FireflyConfig, pub firefly: FireflyConfig,
} }
fn default_restaurant_tag() -> String {
"Food & Drink".into()
}

View File

@ -16,6 +16,7 @@ pub struct TransactionSplit {
pub amount: String, pub amount: String,
pub description: String, pub description: String,
pub notes: Option<String>, pub notes: Option<String>,
pub tags: Option<Vec<String>>,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
@ -40,16 +41,18 @@ pub struct TransactionSingle {
pub data: TransactionRead, pub data: TransactionRead,
} }
#[derive(Serialize)] #[derive(Debug, Serialize)]
pub struct TransactionSplitUpdate { pub struct TransactionSplitUpdate {
pub transaction_journal_id: String, pub transaction_journal_id: String,
pub amount: String, pub amount: String,
pub description: String, pub description: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>, pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
} }
#[derive(Serialize)] #[derive(Debug, Serialize)]
pub struct TransactionUpdate { pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>, pub transactions: Vec<TransactionSplitUpdate>,
} }
@ -71,6 +74,7 @@ pub struct TransactionSplitStore {
pub notes: Option<String>, pub notes: Option<String>,
pub source_name: Option<String>, pub source_name: Option<String>,
pub destination_name: Option<String>, pub destination_name: Option<String>,
pub tags: Option<Vec<String>>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -132,6 +136,7 @@ impl From<TransactionRead> for TransactionUpdate {
amount: s.amount, amount: s.amount,
description: s.description, description: s.description,
notes: s.notes, notes: s.notes,
tags: s.tags,
}) })
.collect(); .collect();
TransactionUpdate { transactions } TransactionUpdate { transactions }
@ -139,13 +144,14 @@ impl From<TransactionRead> for TransactionUpdate {
} }
impl TransactionStore { impl TransactionStore {
pub fn new_deposit<A, D, S, T, N>( pub fn new_deposit<A, D, S, T, N, G>(
date: DateTime<FixedOffset>, date: DateTime<FixedOffset>,
amount: A, amount: A,
description: D, description: D,
source_account: S, source_account: S,
destination_account: T, destination_account: T,
notes: N, notes: N,
tags: G,
) -> Self ) -> Self
where where
A: Into<String>, A: Into<String>,
@ -153,6 +159,7 @@ impl TransactionStore {
S: Into<Option<String>>, S: Into<Option<String>>,
T: Into<Option<String>>, T: Into<Option<String>>,
N: Into<Option<String>>, N: Into<Option<String>>,
G: Into<Option<Vec<String>>>,
{ {
Self { Self {
error_if_duplicate_hash: true, error_if_duplicate_hash: true,
@ -167,16 +174,18 @@ impl TransactionStore {
source_name: source_account.into(), source_name: source_account.into(),
destination_name: destination_account.into(), destination_name: destination_account.into(),
notes: notes.into(), notes: notes.into(),
tags: tags.into(),
}], }],
} }
} }
pub fn new_withdrawal<A, D, S, T, N>( pub fn new_withdrawal<A, D, S, T, N, G>(
date: DateTime<FixedOffset>, date: DateTime<FixedOffset>,
amount: A, amount: A,
description: D, description: D,
source_account: S, source_account: S,
destination_account: T, destination_account: T,
notes: N, notes: N,
tags: G,
) -> Self ) -> Self
where where
A: Into<String>, A: Into<String>,
@ -184,6 +193,7 @@ impl TransactionStore {
S: Into<Option<String>>, S: Into<Option<String>>,
T: Into<Option<String>>, T: Into<Option<String>>,
N: Into<Option<String>>, N: Into<Option<String>>,
G: Into<Option<Vec<String>>>,
{ {
Self { Self {
error_if_duplicate_hash: true, error_if_duplicate_hash: true,
@ -198,6 +208,7 @@ impl TransactionStore {
source_name: source_account.into(), source_name: source_account.into(),
destination_name: destination_account.into(), destination_name: destination_account.into(),
notes: notes.into(), notes: notes.into(),
tags: tags.into(),
}], }],
} }
} }

View File

@ -36,6 +36,7 @@ pub struct ReceiptPostForm<'r> {
pub date: String, pub date: String,
pub vendor: String, pub vendor: String,
pub amount: String, pub amount: String,
pub is_restaurant: Option<bool>,
pub notes: String, pub notes: String,
pub photo: TempFile<'r>, pub photo: TempFile<'r>,
} }
@ -44,6 +45,7 @@ pub struct ReceiptPostData {
pub date: DateTime<FixedOffset>, pub date: DateTime<FixedOffset>,
pub vendor: String, pub vendor: String,
pub amount: Decimal, pub amount: Decimal,
pub is_restaurant: bool,
pub notes: String, pub notes: String,
pub filename: String, pub filename: String,
pub photo: Vec<u8>, pub photo: Vec<u8>,
@ -86,6 +88,7 @@ impl ReceiptPostData {
let vendor = form.vendor.clone(); let vendor = form.vendor.clone();
use rust_decimal::prelude::FromStr; use rust_decimal::prelude::FromStr;
let amount = Decimal::from_str(&form.amount)?; let amount = Decimal::from_str(&form.amount)?;
let is_restaurant = form.is_restaurant.unwrap_or_default();
let notes = form.notes.clone(); let notes = form.notes.clone();
let stream = form.photo.open().await?; let stream = form.photo.open().await?;
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
@ -116,6 +119,7 @@ impl ReceiptPostData {
date, date,
vendor, vendor,
amount, amount,
is_restaurant,
notes, notes,
filename, filename,
photo, photo,
@ -134,12 +138,21 @@ impl ReceiptsRepository {
pub async fn list_receipts( pub async fn list_receipts(
&mut self, &mut self,
limit: i64,
offset: i64,
) -> Result<Vec<ReceiptJson>, sqlx::Error> { ) -> Result<Vec<ReceiptJson>, sqlx::Error> {
sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql") sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql", limit, offset)
.fetch_all(&mut **self.conn) .fetch_all(&mut **self.conn)
.await .await
} }
pub async fn count_receipts(&mut self) -> Result<i64, sqlx::Error> {
Ok(sqlx::query_file!("sql/receipts/count-receipts.sql")
.fetch_one(&mut **self.conn)
.await?
.count)
}
pub async fn add_receipt( pub async fn add_receipt(
&mut self, &mut self,
data: &ReceiptPostData, data: &ReceiptPostData,

View File

@ -1,3 +1,5 @@
use std::ops::RangeInclusive;
use rocket::form::Form; use rocket::form::Form;
use rocket::http::{ContentType, Header, MediaType, Status}; use rocket::http::{ContentType, Header, MediaType, Status};
use rocket::serde::json::Json; use rocket::serde::json::Json;
@ -5,25 +7,69 @@ use rocket::{Route, State};
use rocket_db_pools::Connection as DatabaseConnection; use rocket_db_pools::Connection as DatabaseConnection;
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use rust_decimal::prelude::ToPrimitive; use rust_decimal::prelude::ToPrimitive;
use tracing::{debug, error, info}; use tracing::{debug, error, info, trace};
use crate::firefly::{TransactionStore, TransactionUpdate}; use crate::firefly::{TransactionStore, TransactionUpdate};
use crate::imaging; use crate::imaging;
use crate::receipts::*; use crate::receipts::*;
use crate::{Context, Database}; use crate::{Context, Database};
#[rocket::get("/")] fn paginate(total: i64, count: i64, current: i64) -> Vec<String> {
let start = 1;
let end = (total / count).max(1);
let pages = RangeInclusive::new(start, end);
if end < 10 {
pages.map(|p| format!("{}", p)).collect()
} else {
pages
.filter_map(|p| {
if p == start
|| (current - 2 <= p && p <= current + 2)
|| p == end
{
Some(format!("{}", p))
} else if p == current - 3 || p == current + 3 {
Some("...".into())
} else {
None
}
})
.collect()
}
}
#[rocket::get("/?<page>&<count>")]
pub async fn list_receipts( pub async fn list_receipts(
db: DatabaseConnection<Database>, db: DatabaseConnection<Database>,
page: Option<i64>,
count: Option<i64>,
) -> (Status, Template) { ) -> (Status, Template) {
let mut repo = ReceiptsRepository::new(db); let mut repo = ReceiptsRepository::new(db);
match repo.list_receipts().await { let count = count.unwrap_or(25);
let page = page.unwrap_or(1);
let total = match repo.count_receipts().await {
Ok(r) => r,
Err(e) => {
return (
Status::InternalServerError,
Template::render(
"error",
context! {
error: e.to_string(),
},
),
)
},
};
match repo.list_receipts(count, (page - 1) * count).await {
Ok(r) => ( Ok(r) => (
Status::Ok, Status::Ok,
Template::render( Template::render(
"receipt-list", "receipt-list",
context! { context! {
receipts: r, receipts: r,
pages: paginate(total, count, page),
count: count,
}, },
), ),
), ),
@ -75,6 +121,11 @@ pub async fn add_receipt(
}; };
let xact = match form.transaction { let xact = match form.transaction {
Some(ref s) if s == "new" => { Some(ref s) if s == "new" => {
let tags = if data.is_restaurant {
Some(vec![ctx.config.firefly.restaurant_tag.clone()])
} else {
None
};
let data = TransactionStore::new_withdrawal( let data = TransactionStore::new_withdrawal(
data.date, data.date,
data.amount.to_string(), data.amount.to_string(),
@ -82,6 +133,7 @@ pub async fn add_receipt(
ctx.config.firefly.default_account.clone(), ctx.config.firefly.default_account.clone(),
Some("(no name)".into()), Some("(no name)".into()),
data.notes, data.notes,
tags,
); );
match ctx.firefly.create_transaction(data).await { match ctx.firefly.create_transaction(data).await {
Ok(t) => { Ok(t) => {
@ -102,6 +154,7 @@ pub async fn add_receipt(
Some("(no name)".into()), Some("(no name)".into()),
ctx.config.firefly.default_account.clone(), ctx.config.firefly.default_account.clone(),
data.notes, data.notes,
None,
); );
match ctx.firefly.create_transaction(data).await { match ctx.firefly.create_transaction(data).await {
Ok(t) => { Ok(t) => {
@ -138,8 +191,32 @@ pub async fn add_receipt(
split.notes = Some(data.notes.clone()); split.notes = Some(data.notes.clone());
needs_update = true; needs_update = true;
} }
} else {
split.notes = data.notes.into();
needs_update = true;
} }
} }
if data.is_restaurant {
if let Some(tags) = &mut split.tags {
if !tags
.contains(&ctx.config.firefly.restaurant_tag)
{
tags.push(
ctx.config.firefly.restaurant_tag.clone(),
);
needs_update = true;
}
} else {
split.tags.replace(vec![ctx
.config
.firefly
.restaurant_tag
.clone()]);
needs_update = true;
}
}
trace!("Original transaction: {:?}", split);
trace!("Updated transaction: {:?}", update);
} else { } else {
debug!("Transaction {} has no splits", id); debug!("Transaction {} has no splits", id);
} }

View File

@ -21,16 +21,20 @@ async fn transaction_list(
) )
})?; })?;
let restaurant_tag = Some(&ctx.config.firefly.restaurant_tag);
Ok(Json( Ok(Json(
result result
.data .data
.into_iter() .into_iter()
.filter_map(|t| match Transaction::try_from(t) { .filter_map(|t| {
Ok(t) => Some(t), match Transaction::from_firefly(t, restaurant_tag) {
Err(e) => { Ok(t) => Some(t),
error!("Error parsing transaction details: {}", e); Err(e) => {
None error!("Error parsing transaction details: {}", e);
}, None
},
}
}) })
.collect(), .collect(),
)) ))

View File

@ -10,12 +10,14 @@ pub struct Transaction {
pub amount: f64, pub amount: f64,
pub description: String, pub description: String,
pub date: DateTime<FixedOffset>, pub date: DateTime<FixedOffset>,
pub is_restaurant: bool,
} }
impl TryFrom<TransactionRead> for Transaction { impl Transaction {
type Error = &'static str; pub fn from_firefly<T: AsRef<str>>(
t: TransactionRead,
fn try_from(t: TransactionRead) -> Result<Self, Self::Error> { restaurant_tag: Option<T>,
) -> Result<Self, &'static str> {
let first_split = match t.attributes.transactions.first() { let first_split = match t.attributes.transactions.first() {
Some(t) => t, Some(t) => t,
None => { None => {
@ -30,11 +32,21 @@ impl TryFrom<TransactionRead> for Transaction {
} else { } else {
first_split.description.clone() first_split.description.clone()
}; };
let is_restaurant = if let Some(tag) = restaurant_tag {
if let Some(tags) = &first_split.tags {
tags.iter().any(|a| a == tag.as_ref())
} else {
false
}
} else {
false
};
Ok(Self { Ok(Self {
id: t.id, id: t.id,
amount, amount,
description, description,
date, date,
is_restaurant,
}) })
} }
} }

View File

@ -32,6 +32,7 @@
required required
></sl-input> ></sl-input>
</p> </p>
<p><sl-switch name="is_restaurant">Restaurant</sl-switch>
<p> <p>
<sl-input type="date" name="date" label="Date" required></sl-input> <sl-input type="date" name="date" label="Date" required></sl-input>
</p> </p>

View File

@ -80,6 +80,18 @@
#confirm-delete dl dd { #confirm-delete dl dd {
margin-left: 0; margin-left: 0;
} }
ul.pagination {
margin: 0;
padding: 0;
list-style-type: none;
text-align: center;
}
ul.pagination li {
display: inline-block;
margin: 1em;
}
</style> </style>
{% endblock %} {% block main %} {% endblock %} {% block main %}
<h1>Receipts</h1> <h1>Receipts</h1>
@ -114,6 +126,13 @@
</sl-card> </sl-card>
{% endfor %} {% endfor %}
</article> </article>
<ul class="pagination">
{%- for page in pages %}
<li>{% if page == "..." %}...{% else
%}<sl-button href="?page={{ page }}&count={{ count }}">{{ page }}</sl-button>{%
endif %}</li>
{%- endfor %}
</ul>
<sl-dialog id="confirm-delete" label="Delete Receipt"> <sl-dialog id="confirm-delete" label="Delete Receipt">
<p> <p>
Are you sure you want to delete receipt <span class="receipt-id"></span>? Are you sure you want to delete receipt <span class="receipt-id"></span>?