diff --git a/migrations/2024-01-22-103957_init/up.sql b/migrations/2024-01-22-103957_init/up.sql deleted file mode 100644 index fd6470b..0000000 --- a/migrations/2024-01-22-103957_init/up.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Your SQL goes here -CREATE TYPE invoice_status AS ENUM ('open', 'accepted', 'paid'); - -CREATE TABLE parties( - id SERIAL PRIMARY KEY, - name VARCHAR(128) NOT NULL, - street VARCHAR(128) NOT NULL, - city VARCHAR(128) NOT NULL, - zip VARCHAR(128) NOT NULL, - bank_account VARCHAR(128) NOT NULL, - -- NOTE: This constraint is a bit heavy - CONSTRAINT no_duplicates - UNIQUE (name, street, city, zip, bank_account) -); - -CREATE TABLE invoices( - id SERIAL PRIMARY KEY, - status invoice_status NOT NULL, - creation_time TIMESTAMPTZ NOT NULL, - counter_party_id int NOT NULL, - due_date DATE NOT NULL, - CONSTRAINT fk_party - FOREIGN KEY(counter_party_id) - REFERENCES parties(id) -); - -CREATE TABLE invoice_rows( - id SERIAL PRIMARY KEY, - invoice_id int NOT NULL, - product VARCHAR(128) NOT NULL, - quantity int NOT NULL, - unit VARCHAR(128) NOT NULL, - unit_price int NOT NULL, - CONSTRAINT fk_invoice - FOREIGN KEY(invoice_id) - REFERENCES invoices(id) - ON DELETE CASCADE -); - -CREATE TABLE invoice_attachments( - id SERIAL PRIMARY KEY, - invoice_id int NOT NULL, - filename VARCHAR(128) NOT NULL, - hash VARCHAR(64) NOT NULL, - CONSTRAINT fk_invoice - FOREIGN KEY(invoice_id) - REFERENCES invoices(id) - ON DELETE CASCADE -); diff --git a/migrations/2024-01-22-103957_init/down.sql b/migrations/2024-03-14-222214_init/down.sql similarity index 50% rename from migrations/2024-01-22-103957_init/down.sql rename to migrations/2024-03-14-222214_init/down.sql index cbc4a6e..5378f38 100644 --- a/migrations/2024-01-22-103957_init/down.sql +++ b/migrations/2024-03-14-222214_init/down.sql @@ -1,6 +1,14 @@ -- This file should undo anything in `up.sql` DROP TABLE invoice_attachments; + DROP TABLE invoice_rows; + DROP TABLE invoices; -DROP TABLE parties; -DROP TYPE invoice_status; + +DROP INDEX idx_invoices_status; + +DROP INDEX idx_invoices_creation_time; + +DROP TABLE addresses; + +DROP TYPE invoice_status; \ No newline at end of file diff --git a/migrations/2024-03-14-222214_init/up.sql b/migrations/2024-03-14-222214_init/up.sql new file mode 100644 index 0000000..8df492b --- /dev/null +++ b/migrations/2024-03-14-222214_init/up.sql @@ -0,0 +1,46 @@ +-- Enum for invoice status +CREATE TYPE invoice_status AS ENUM ('open', 'accepted', 'paid', 'cancelled'); + +-- Addresses table +CREATE TABLE addresses ( + id SERIAL PRIMARY KEY, + street VARCHAR(128) NOT NULL, + city VARCHAR(128) NOT NULL, + zip VARCHAR(128) NOT NULL, + CONSTRAINT no_duplicates UNIQUE (street, city, zip) +); + +CREATE TABLE invoices ( + id SERIAL PRIMARY KEY, + status invoice_status NOT NULL DEFAULT 'open', + creation_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + recipient_name VARCHAR(128) NOT NULL, + recipient_email VARCHAR(128) NOT NULL, + bank_account_number VARCHAR(128) NOT NULL, + address_id INT NOT NULL, + FOREIGN KEY (address_id) REFERENCES addresses(id) +); + +CREATE INDEX idx_invoices_status ON invoices(status); + +CREATE INDEX idx_invoices_creation_time ON invoices(creation_time); + +-- Invoice rows table +CREATE TABLE invoice_rows ( + id SERIAL PRIMARY KEY, + invoice_id INT NOT NULL, + product VARCHAR(128) NOT NULL, + quantity INT NOT NULL, + unit VARCHAR(128) NOT NULL, + unit_price INT NOT NULL, + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE +); + +-- Invoice attachments table +CREATE TABLE invoice_attachments ( + id SERIAL PRIMARY KEY, + invoice_id INT NOT NULL, + filename VARCHAR(128) NOT NULL, + hash VARCHAR(64) NOT NULL, + FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/api/invoices.rs b/src/api/invoices.rs index 38effad..91b949e 100644 --- a/src/api/invoices.rs +++ b/src/api/invoices.rs @@ -1,11 +1,12 @@ use crate::database::DatabaseConnection; use crate::error::Error; +use crate::models::{Attachment, Invoice, InvoiceRow}; use axum::{async_trait, body::Bytes, http::StatusCode, Json}; use axum_typed_multipart::{ FieldData, FieldMetadata, TryFromChunks, TryFromMultipart, TypedMultipart, TypedMultipartError, }; use axum_valid::Garde; -use chrono::{DateTime, NaiveDate, Utc}; +use chrono::{DateTime, Utc}; use futures::stream::Stream; use futures::stream::{self, TryStreamExt}; use garde::Validate; @@ -28,14 +29,19 @@ impl TryFromChunks for CreateInvoice { /// Body for the request for creating new invoices #[derive(Clone, Debug, Serialize, Deserialize, Validate)] pub struct CreateInvoice { - /// The other party of the invoice + /// The recipient's name + #[garde(byte_length(max = 128))] + pub recipient_name: String, + /// The recipient's email + #[garde(byte_length(max = 128))] + pub recipient_email: String, + /// The recipient's address #[garde(dive)] - pub counter_party: crate::models::NewParty, - /// The due date of the invoice. - /// It cannot be in the past - //TODO: #[garde(time(op = after_now))] - #[garde(skip)] - pub due_date: NaiveDate, + pub address: crate::models::NewAddress, + /// The recipient's bank account number + // TODO: maybe validate with https://crates.io/crates/iban_validate/ + #[garde(byte_length(max = 128))] + pub bank_account_number: String, /// The rows of the invoice #[garde(length(min = 1), dive)] pub rows: Vec, @@ -84,11 +90,26 @@ pub struct PopulatedInvoice { pub id: i32, pub status: crate::models::InvoiceStatus, pub creation_time: DateTime, - pub due_date: NaiveDate, - pub counter_party: crate::models::Party, + pub recipient_name: String, + pub recipient_email: String, + pub bank_account_number: String, pub rows: Vec, pub attachments: Vec, } +impl PopulatedInvoice { + pub fn new(invoice: Invoice, rows: Vec, attachments: Vec) -> Self { + Self { + id: invoice.id, + status: invoice.status, + creation_time: invoice.creation_time, + recipient_name: invoice.recipient_name, + recipient_email: invoice.recipient_email, + bank_account_number: invoice.bank_account_number, + rows, + attachments, + } + } +} async fn try_handle_file(field: &FieldData) -> Result { let filename = field diff --git a/src/database/invoices.rs b/src/database/invoices.rs index 8bd7101..e66a8b7 100644 --- a/src/database/invoices.rs +++ b/src/database/invoices.rs @@ -2,17 +2,17 @@ use super::DatabaseConnection; use crate::api::invoices::{CreateInvoice, PopulatedInvoice}; use crate::error::Error; use crate::models::*; -use futures::TryStreamExt; use diesel::prelude::*; use diesel_async::RunQueryDsl; impl DatabaseConnection { - pub async fn create_party(&mut self, party: &NewParty) -> Result { - use crate::schema::parties::dsl::*; + /// create an address, returning an id of the address, either + pub async fn create_address(&mut self, address: &NewAddress) -> Result { + use crate::schema::addresses::dsl::*; - diesel::insert_into(parties) - .values(party) + diesel::insert_into(addresses) + .values(address) .on_conflict(diesel::upsert::on_constraint("no_duplicates")) .do_nothing() .execute(&mut self.0) @@ -20,15 +20,15 @@ impl DatabaseConnection { // NOTE: Diesel is dumb so we have to requery for the data // because on_conflict() doesn't support returning() - Ok(parties + Ok(addresses + .select(id) .filter( - name.eq(&party.name) - .and(street.eq(&party.street)) - .and(city.eq(&party.city)) - .and(zip.eq(&party.zip)) - .and(bank_account.eq(&party.bank_account)), + street + .eq(&address.street) + .and(city.eq(&address.city)) + .and(zip.eq(&address.zip)), ) - .first::(&mut self.0) + .first::(&mut self.0) .await?) } @@ -36,16 +36,18 @@ impl DatabaseConnection { &mut self, invoice: CreateInvoice, ) -> Result { - let party = self.create_party(&invoice.counter_party).await?; + let address_id = self.create_address(&invoice.address).await?; + // TODO: this could (but should it is totally another question) be done with an impl for CreateInvoice, + // as this is the only thing CreateInvoice is used for let inv = NewInvoice { - status: InvoiceStatus::Open, - counter_party_id: party.id, - creation_time: chrono::Utc::now(), - due_date: invoice.due_date, + address_id, + recipient_name: invoice.recipient_name, + recipient_email: invoice.recipient_email, + bank_account_number: invoice.bank_account_number, }; - let created = { + let created_invoice = { use crate::schema::invoices::dsl::*; diesel::insert_into(invoices) .values(&inv) @@ -62,7 +64,7 @@ impl DatabaseConnection { .rows .into_iter() .map(|r| NewInvoiceRow { - invoice_id: created.id, + invoice_id: created_invoice.id, product: r.product, quantity: r.quantity, unit: r.unit, @@ -83,7 +85,7 @@ impl DatabaseConnection { .attachments .into_iter() .map(|a| NewAttachment { - invoice_id: created.id, + invoice_id: created_invoice.id, hash: a.hash, filename: a.filename, }) @@ -94,41 +96,21 @@ impl DatabaseConnection { .await? }; - Ok(PopulatedInvoice { - id: created.id, - status: created.status, - creation_time: created.creation_time, - counter_party: party, - rows, - due_date: created.due_date, - attachments, - }) + Ok(created_invoice.into_populated(rows, attachments)) } pub async fn list_invoices(&mut self) -> Result, Error> { - let (invoices, parties): (Vec, Vec) = { - use crate::schema::invoices; - use crate::schema::parties; - invoices::table - .inner_join(parties::table) - .select((Invoice::as_select(), Party::as_select())) - .load_stream::<(Invoice, Party)>(&mut self.0) - .await? - .try_fold( - (Vec::new(), Vec::new()), - |(mut invoices, mut parties), (invoice, party)| { - invoices.push(invoice); - parties.push(party); - futures::future::ready(Ok((invoices, parties))) - }, - ) - .await? - }; - let invoice_rows = InvoiceRow::belonging_to(&invoices) + use crate::schema::invoices; + let invoices = invoices::table + .select(Invoice::as_select()) + .load(&mut self.0) + .await?; + + let invoice_rows: Vec> = InvoiceRow::belonging_to(&invoices) .select(InvoiceRow::as_select()) .load(&mut self.0) .await? .grouped_by(&invoices); - let attachments = Attachment::belonging_to(&invoices) + let attachments: Vec> = Attachment::belonging_to(&invoices) .select(Attachment::as_select()) .load(&mut self.0) .await? @@ -137,16 +119,7 @@ impl DatabaseConnection { .into_iter() .zip(attachments) .zip(invoices) - .zip(parties) - .map(|(((rows, attachments), invoice), party)| PopulatedInvoice { - id: invoice.id, - status: invoice.status, - creation_time: invoice.creation_time, - counter_party: party, - rows, - due_date: invoice.due_date, - attachments, - }) + .map(|((rows, attachments), invoice)| invoice.into_populated(rows, attachments)) .collect::>()) } } diff --git a/src/models.rs b/src/models.rs index 4541187..fe70246 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,8 @@ -use crate::schema::{invoice_attachments, invoice_rows, invoices, parties}; -use chrono::{DateTime, NaiveDate, Utc}; +use crate::{ + api::invoices::PopulatedInvoice, + schema::{addresses, invoice_attachments, invoice_rows, invoices}, +}; +use chrono::{DateTime, Utc}; use garde::Validate; use diesel::prelude::*; @@ -7,13 +10,23 @@ use serde_derive::{Deserialize, Serialize}; // NOTES: // This is implemented based on https://github.com/Tietokilta/laskugeneraattori/blob/main/backend/src/procountor.rs#L293 -// major changes are justified below: -// - I think PaymentInfo and Party can be joined into one struct/field -// => due date is moved to the Invoice struct -// - I deem the inclusion of currencies or payment methods unnecessary for now -// - I don't think having a massive enum for product units is necessary, just have it as string :D -// - Is VAT really necessary to account for? I'm leaving it out for now -// - I'm also leaving InvoiceType out, at least for now +// +// InvoiceRow: Is VAT really necessary to account for? TODO(?) +// Invoice: due date TODO: +// this is not prio 1, but implementation idea: +// when creating new invoice is set to NULL, as we don't know if the invoice is valid +// Set to date X when the treasurer accepts this invoice +// the open question is: What is the date X? Have to coordinate with treasurer / is this even necessary +// - Having a massive enum for product units is necessary, just have it as string :D +// As one recipient has to be able to: +// - have different emails +// - have different addresses +// - have different bank account for payment +// - have different names even? ¯\_(ツ)_/¯ +// It would involve creating a quite overcomplicated schema with multiple many-to-many relations for not that much of benefit +// as this is anyways a free fill form where people can spam whatever information. +// if there were to be any stronger authentication (guild auth?) then this would change (read: not in the near future.) +// Invoices table with recipient name, email and account number #[derive(diesel_derive_enum::DbEnum, Debug, Clone, Copy, Serialize, Deserialize)] #[ExistingTypePath = "crate::schema::sql_types::InvoiceStatus"] @@ -22,64 +35,63 @@ pub enum InvoiceStatus { Open, Accepted, Paid, + Cancelled, } - -/// A party of the invoice -#[derive(Identifiable, Queryable, Selectable, Clone, Debug, Serialize, Deserialize)] -#[diesel(table_name = parties)] -pub struct Party { +#[derive(Identifiable, Queryable, Selectable, Clone, Debug)] +#[diesel(table_name = addresses)] +pub struct Address { pub id: i32, - /// The name can be at most 128 characters - pub name: String, - /// The street can be at most 128 characters + /// The street address can be at most 128 characters pub street: String, /// The city can be at most 128 characters pub city: String, /// The zipcode can be at most 128 characters (:D) pub zip: String, - /// The bank_account can be at most 128 characters - pub bank_account: String, } - #[derive(Insertable, Debug, Clone, Serialize, Deserialize, Validate)] -#[diesel(table_name = parties)] -pub struct NewParty { - /// The name can be at most 128 characters - #[garde(byte_length(max = 128))] - pub name: String, - /// The street can be at most 128 characters +#[diesel(table_name=addresses)] +pub struct NewAddress { #[garde(byte_length(max = 128))] pub street: String, - /// The city can be at most 128 characters #[garde(byte_length(max = 128))] pub city: String, - /// The zipcode can be at most 128 characters (:D) #[garde(byte_length(max = 128))] pub zip: String, - /// The bank_account can be at most 128 characters - #[garde(byte_length(max = 128))] - pub bank_account: String, } - /// The invoice model as stored in the database #[derive(Identifiable, Queryable, Selectable, Associations, Clone, Debug)] -#[diesel(belongs_to(Party, foreign_key = counter_party_id))] +#[diesel(belongs_to(Address))] #[diesel(table_name = invoices)] pub struct Invoice { pub id: i32, pub status: InvoiceStatus, pub creation_time: DateTime, - pub counter_party_id: i32, - pub due_date: NaiveDate, + // TODO: see NOTES above + // pub due_date:Option, + /// invoice recipient's name can be at most 128 characters + pub recipient_name: String, + /// invoice recipient's email can be at most 128 characters + pub recipient_email: String, + /// A back account number can be at most 128 characters + pub bank_account_number: String, + pub address_id: i32, +} +impl Invoice { + pub fn into_populated( + self, + rows: Vec, + attachments: Vec, + ) -> PopulatedInvoice { + PopulatedInvoice::new(self, rows, attachments) + } } - #[derive(Insertable)] #[diesel(table_name = invoices)] pub struct NewInvoice { - pub status: InvoiceStatus, - pub creation_time: DateTime, - pub counter_party_id: i32, - pub due_date: NaiveDate, + pub address_id: i32, + pub recipient_name: String, + pub recipient_email: String, + pub bank_account_number: String, } /// A single row of an invoice diff --git a/src/schema.rs b/src/schema.rs index e57a831..c24de3c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -6,6 +6,18 @@ pub mod sql_types { pub struct InvoiceStatus; } +diesel::table! { + addresses (id) { + id -> Int4, + #[max_length = 128] + street -> Varchar, + #[max_length = 128] + city -> Varchar, + #[max_length = 128] + zip -> Varchar, + } +} + diesel::table! { invoice_attachments (id) { id -> Int4, @@ -38,34 +50,23 @@ diesel::table! { id -> Int4, status -> InvoiceStatus, creation_time -> Timestamptz, - counter_party_id -> Int4, - due_date -> Date, - } -} - -diesel::table! { - parties (id) { - id -> Int4, #[max_length = 128] - name -> Varchar, + recipient_name -> Varchar, #[max_length = 128] - street -> Varchar, - #[max_length = 128] - city -> Varchar, - #[max_length = 128] - zip -> Varchar, + recipient_email -> Varchar, #[max_length = 128] - bank_account -> Varchar, + bank_account_number -> Varchar, + address_id -> Int4, } } diesel::joinable!(invoice_attachments -> invoices (invoice_id)); diesel::joinable!(invoice_rows -> invoices (invoice_id)); -diesel::joinable!(invoices -> parties (counter_party_id)); +diesel::joinable!(invoices -> addresses (address_id)); diesel::allow_tables_to_appear_in_same_query!( + addresses, invoice_attachments, invoice_rows, invoices, - parties, ); diff --git a/src/tests/invoices.rs b/src/tests/invoices.rs index c4fba27..7e6ba41 100644 --- a/src/tests/invoices.rs +++ b/src/tests/invoices.rs @@ -1,24 +1,34 @@ +use std::sync::Once; + use crate::api::app; use crate::api::invoices::{CreateInvoice, CreateInvoiceRow, PopulatedInvoice}; -use crate::models::NewParty; +use crate::models::NewAddress; use axum::http::StatusCode; +use axum::Router; use axum_test::multipart::MultipartForm; use axum_test::TestServer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; +static INIT: Once = Once::new(); +async fn test_init() -> Router { + INIT.call_once(|| { + tracing_subscriber::registry() + .with::( + "laskugeneraattori=debug,tower_http=debug,axum::rejection=trace".into(), + ) + .with(tracing_subscriber::fmt::layer()) + .init() + }); + app().with_state(crate::database::new().await) +} #[tokio::test] async fn create() { - let app = app().with_state(crate::database::new().await); + let app = test_init().await; let body = CreateInvoice { - counter_party: NewParty { - name: String::from("Velkoja"), - street: String::from("Otakaari"), - city: String::from("Espoo"), - zip: String::from("02jotain"), - bank_account: String::from("ei ole"), - }, - due_date: chrono::Local::now().date_naive(), rows: vec![ CreateInvoiceRow { product: String::from("pleikkari"), @@ -40,6 +50,14 @@ async fn create() { }, ], attachments: vec![], + recipient_name: "Velkoja".into(), + recipient_email: "velkoja@velat.com".into(), + address: NewAddress { + street: "Otakaari 18A 69".into(), + city: "Espoo".into(), + zip: "02jotain".into(), + }, + bank_account_number: "ei ole".into(), }; let body = MultipartForm::new().add_text("data", serde_json::to_string(&body).unwrap()); @@ -52,7 +70,7 @@ async fn create() { #[tokio::test] async fn list_all() { - let app = app().with_state(crate::database::new().await); + let app = test_init().await; let server = TestServer::new(app).unwrap(); let response = server.get("/invoices").await; @@ -61,17 +79,17 @@ async fn list_all() { #[tokio::test] async fn create_list_all() { - let app = app().with_state(crate::database::new().await); + let app = test_init().await; let body = CreateInvoice { - counter_party: NewParty { - name: String::from("Velkoja"), - street: String::from("Otakaari"), - city: String::from("Espoo"), - zip: String::from("02jotain"), - bank_account: String::from("ei ole"), + recipient_name: "Velkoja".into(), + recipient_email: "velkoja@velat.com".into(), + address: NewAddress { + street: "Otakaari 18A 69".into(), + city: "Espoo".into(), + zip: "02jotain".into(), }, - due_date: chrono::Local::now().date_naive(), + bank_account_number: "ei ole".into(), rows: vec![ CreateInvoiceRow { product: String::from("pleikkari"),