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",
"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": {
"columns": [
{
@ -35,7 +35,10 @@
}
],
"parameters": {
"Left": []
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
@ -46,5 +49,5 @@
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/spinner/spinner.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.js";
@ -14,6 +15,7 @@ import "./camera.ts";
import CameraInput from "./camera.ts";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.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 { getResponseError } from "./ajaxUtil.js";
@ -32,7 +34,7 @@ const xactselect = document.getElementById("transactions") as SlSelect;
let dirty = false;
window.addEventListener("beforeunload", function(evt) {
window.addEventListener("beforeunload", function (evt) {
if (dirty) {
evt.preventDefault();
}
@ -143,6 +145,7 @@ async function fetchTransactions() {
option.dataset.amount = xact.amount;
option.dataset.date = xact.date.split("T")[0];
option.dataset.vendor = xact.description;
option.dataset.is_restaurant = xact.is_restaurant;
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();

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
FROM
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 search_query: String,
pub default_account: String,
#[serde(default = "default_restaurant_tag")]
pub restaurant_tag: String
}
#[derive(Debug, Deserialize)]
pub struct Config {
pub firefly: FireflyConfig,
}
fn default_restaurant_tag() -> String {
"Food & Drink".into()
}

View File

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

View File

@ -36,6 +36,7 @@ pub struct ReceiptPostForm<'r> {
pub date: String,
pub vendor: String,
pub amount: String,
pub is_restaurant: Option<bool>,
pub notes: String,
pub photo: TempFile<'r>,
}
@ -44,6 +45,7 @@ pub struct ReceiptPostData {
pub date: DateTime<FixedOffset>,
pub vendor: String,
pub amount: Decimal,
pub is_restaurant: bool,
pub notes: String,
pub filename: String,
pub photo: Vec<u8>,
@ -86,6 +88,7 @@ impl ReceiptPostData {
let vendor = form.vendor.clone();
use rust_decimal::prelude::FromStr;
let amount = Decimal::from_str(&form.amount)?;
let is_restaurant = form.is_restaurant.unwrap_or_default();
let notes = form.notes.clone();
let stream = form.photo.open().await?;
let mut reader = BufReader::new(stream);
@ -116,6 +119,7 @@ impl ReceiptPostData {
date,
vendor,
amount,
is_restaurant,
notes,
filename,
photo,
@ -134,12 +138,21 @@ impl ReceiptsRepository {
pub async fn list_receipts(
&mut self,
limit: i64,
offset: i64,
) -> 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)
.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(
&mut self,
data: &ReceiptPostData,

View File

@ -1,3 +1,5 @@
use std::ops::RangeInclusive;
use rocket::form::Form;
use rocket::http::{ContentType, Header, MediaType, Status};
use rocket::serde::json::Json;
@ -5,25 +7,69 @@ use rocket::{Route, State};
use rocket_db_pools::Connection as DatabaseConnection;
use rocket_dyn_templates::{context, Template};
use rust_decimal::prelude::ToPrimitive;
use tracing::{debug, error, info};
use tracing::{debug, error, info, trace};
use crate::firefly::{TransactionStore, TransactionUpdate};
use crate::imaging;
use crate::receipts::*;
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(
db: DatabaseConnection<Database>,
page: Option<i64>,
count: Option<i64>,
) -> (Status, Template) {
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) => (
Status::Ok,
Template::render(
"receipt-list",
context! {
receipts: r,
pages: paginate(total, count, page),
count: count,
},
),
),
@ -75,6 +121,11 @@ pub async fn add_receipt(
};
let xact = match form.transaction {
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(
data.date,
data.amount.to_string(),
@ -82,6 +133,7 @@ pub async fn add_receipt(
ctx.config.firefly.default_account.clone(),
Some("(no name)".into()),
data.notes,
tags,
);
match ctx.firefly.create_transaction(data).await {
Ok(t) => {
@ -102,6 +154,7 @@ pub async fn add_receipt(
Some("(no name)".into()),
ctx.config.firefly.default_account.clone(),
data.notes,
None,
);
match ctx.firefly.create_transaction(data).await {
Ok(t) => {
@ -138,8 +191,32 @@ pub async fn add_receipt(
split.notes = Some(data.notes.clone());
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 {
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(
result
.data
.into_iter()
.filter_map(|t| match Transaction::try_from(t) {
.filter_map(|t| {
match Transaction::from_firefly(t, restaurant_tag) {
Ok(t) => Some(t),
Err(e) => {
error!("Error parsing transaction details: {}", e);
None
},
}
})
.collect(),
))

View File

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

View File

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

View File

@ -80,6 +80,18 @@
#confirm-delete dl dd {
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>
{% endblock %} {% block main %}
<h1>Receipts</h1>
@ -114,6 +126,13 @@
</sl-card>
{% endfor %}
</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">
<p>
Are you sure you want to delete receipt <span class="receipt-id"></span>?