From 1706b50066e62e4e0738f753844951ca1fa8cabb Mon Sep 17 00:00:00 2001 From: Defelo Date: Fri, 29 Nov 2024 22:18:40 +0100 Subject: [PATCH] feat(finance): add credit notes --- .gitignore | 1 + Cargo.lock | 3 + Cargo.nix | 24 ++ academy/src/environment/mod.rs | 1 + academy/src/environment/types.rs | 16 +- academy_api/rest/src/routes/finance.rs | 40 ++- academy_config/src/lib.rs | 1 + academy_core/coin/impl/Cargo.toml | 2 + academy_core/coin/impl/src/coin.rs | 68 +++- academy_core/finance/contracts/src/invoice.rs | 29 +- academy_core/finance/contracts/src/lib.rs | 12 +- academy_core/finance/impl/Cargo.toml | 1 + academy_core/finance/impl/src/invoice.rs | 309 +++++++++++++++++- academy_core/finance/impl/src/lib.rs | 31 +- .../impl/src/tests/download_credit_note.rs | 93 ++++++ .../impl/src/tests/download_invoice.rs | 6 +- academy_core/finance/impl/src/tests/mod.rs | 2 + academy_core/paypal/impl/Cargo.toml | 2 + academy_core/paypal/impl/src/coin_order.rs | 40 ++- academy_models/src/coin.rs | 19 +- academy_persistence/contracts/src/coin.rs | 51 ++- academy_persistence/contracts/src/user.rs | 18 + ...9164148_create_transactions_table.down.sql | 1 + ...129164148_create_transactions_table.up.sql | 8 + ...85350_create_user_number_sequence.down.sql | 2 + ...9185350_create_user_number_sequence.up.sql | 5 + academy_persistence/postgres/src/coin.rs | 75 ++++- academy_persistence/postgres/src/user.rs | 29 ++ .../postgres/tests/repos/coins.rs | 76 ++++- .../postgres/tests/repos/user.rs | 15 + config.dev.toml | 1 + config.toml | 1 + nix/module.nix | 1 + nix/tests/finance.py | 34 +- 34 files changed, 945 insertions(+), 72 deletions(-) create mode 100644 academy_core/finance/impl/src/tests/download_credit_note.rs create mode 100644 academy_persistence/postgres/migrations/20241129164148_create_transactions_table.down.sql create mode 100644 academy_persistence/postgres/migrations/20241129164148_create_transactions_table.up.sql create mode 100644 academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.down.sql create mode 100644 academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.up.sql diff --git a/.gitignore b/.gitignore index d225fe5..74c71fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ repl-result-* .devenv .lcov* .invoices +.credit_notes diff --git a/Cargo.lock b/Cargo.lock index b09f849..85e7f53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,7 @@ dependencies = [ "academy_di", "academy_models", "academy_persistence_contracts", + "academy_shared_contracts", "academy_utils", "tokio", "tracing", @@ -263,6 +264,7 @@ dependencies = [ "academy_templates_contracts", "academy_utils", "anyhow", + "chrono", "rust_decimal", "rust_decimal_macros", "serde", @@ -392,6 +394,7 @@ name = "academy_core_paypal_impl" version = "0.0.0" dependencies = [ "academy_auth_contracts", + "academy_core_coin_contracts", "academy_core_finance_contracts", "academy_core_paypal_contracts", "academy_demo", diff --git a/Cargo.nix b/Cargo.nix index 3daa3c0..a95fc54 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -1330,6 +1330,10 @@ rec { name = "academy_persistence_contracts"; packageId = "academy_persistence_contracts"; } + { + name = "academy_shared_contracts"; + packageId = "academy_shared_contracts"; + } { name = "academy_utils"; packageId = "academy_utils"; @@ -1356,6 +1360,11 @@ rec { packageId = "academy_persistence_contracts"; features = [ "mock" ]; } + { + name = "academy_shared_contracts"; + packageId = "academy_shared_contracts"; + features = [ "mock" ]; + } { name = "tokio"; packageId = "tokio"; @@ -1609,6 +1618,12 @@ rec { usesDefaultFeatures = false; features = [ "std" ]; } + { + name = "chrono"; + packageId = "chrono"; + usesDefaultFeatures = false; + features = [ "serde" "clock" ]; + } { name = "rust_decimal"; packageId = "rust_decimal"; @@ -2160,6 +2175,10 @@ rec { name = "academy_auth_contracts"; packageId = "academy_auth_contracts"; } + { + name = "academy_core_coin_contracts"; + packageId = "academy_core_coin_contracts"; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; @@ -2230,6 +2249,11 @@ rec { packageId = "academy_auth_contracts"; features = [ "mock" ]; } + { + name = "academy_core_coin_contracts"; + packageId = "academy_core_coin_contracts"; + features = [ "mock" ]; + } { name = "academy_core_finance_contracts"; packageId = "academy_core_finance_contracts"; diff --git a/academy/src/environment/mod.rs b/academy/src/environment/mod.rs index 5b27e89..a1008b1 100644 --- a/academy/src/environment/mod.rs +++ b/academy/src/environment/mod.rs @@ -234,6 +234,7 @@ impl ConfigProvider { let finance_service_config = FinanceServiceConfig { vat_percent: config.finance.vat_percent, invoices_archive: config.finance.invoices_archive.clone().into(), + credit_notes_archive: config.finance.credit_notes_archive.clone().into(), download_token_ttl: config.jwt.download_token_ttl.into(), }; diff --git a/academy/src/environment/types.rs b/academy/src/environment/types.rs index 9cfe4fe..4605fdb 100644 --- a/academy/src/environment/types.rs +++ b/academy/src/environment/types.rs @@ -179,7 +179,7 @@ pub type OAuth2Login = OAuth2LoginServiceImpl; pub type OAuth2Registration = OAuth2RegistrationServiceImpl; pub type CoinFeature = CoinFeatureServiceImpl; -pub type Coin = CoinServiceImpl; +pub type Coin = CoinServiceImpl; pub type PaypalFeature = PaypalFeatureServiceImpl< Database, @@ -192,11 +192,19 @@ pub type PaypalFeature = PaypalFeatureServiceImpl< FinanceInvoice, FinanceCoin, >; -pub type PaypalCoinOrder = PaypalCoinOrderServiceImpl; +pub type PaypalCoinOrder = PaypalCoinOrderServiceImpl; pub type FinanceFeature = FinanceFeatureServiceImpl; -pub type FinanceInvoice = - FinanceInvoiceServiceImpl; +pub type FinanceInvoice = FinanceInvoiceServiceImpl< + Time, + Fs, + Template, + RenderPdf, + PaypalRepo, + UserRepo, + CoinRepo, + FinanceCoin, +>; pub type FinanceCoin = FinanceCoinServiceImpl; pub type Internal = InternalServiceImpl; diff --git a/academy_api/rest/src/routes/finance.rs b/academy_api/rest/src/routes/finance.rs index 2c3c9a8..be4f1d2 100644 --- a/academy_api/rest/src/routes/finance.rs +++ b/academy_api/rest/src/routes/finance.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use academy_core_finance_contracts::{ - FinanceDownloadInvoiceError, FinanceFeatureService, FinanceGetDownloadTokenError, + FinanceDownloadError, FinanceFeatureService, FinanceGetDownloadTokenError, }; use aide::{ axum::{routing, ApiRouter}, @@ -40,6 +40,10 @@ pub fn router(service: Arc) -> ApiRouter<()> { "/finance/invoices/:token/:invoice_number/invoice.pdf", routing::get_with(download_invoice, download_invoice_docs), ) + .api_route( + "/finance/credit_notes/:token/:year/:month/credit_note.pdf", + routing::get_with(download_credit_note, download_credit_note_docs), + ) .with_state(service) .with_path_items(|op| op.tag(TAG)) } @@ -77,9 +81,9 @@ async fn download_invoice( ) -> Response { match service.download_invoice(&token, invoice_number).await { Ok(pdf) => (TypedHeader(ContentType::from(APPLICATION_PDF)), pdf).into_response(), - Err(FinanceDownloadInvoiceError::InvalidToken) => InvalidTokenError.into_response(), - Err(FinanceDownloadInvoiceError::NotFound) => InvoiceNotFoundError.into_response(), - Err(FinanceDownloadInvoiceError::Other(err)) => internal_server_error(err), + Err(FinanceDownloadError::InvalidToken) => InvalidTokenError.into_response(), + Err(FinanceDownloadError::NotFound) => InvoiceNotFoundError.into_response(), + Err(FinanceDownloadError::Other(err)) => internal_server_error(err), } } @@ -90,7 +94,35 @@ fn download_invoice_docs(op: TransformOperation) -> TransformOperation { .with(internal_server_error_docs) } +#[derive(Deserialize, JsonSchema)] +struct DownloadCreditNotePath { + token: String, + year: i32, + month: u32, +} + +async fn download_credit_note( + service: State>, + Path(DownloadCreditNotePath { token, year, month }): Path, +) -> Response { + match service.download_credit_note(&token, year, month).await { + Ok(pdf) => (TypedHeader(ContentType::from(APPLICATION_PDF)), pdf).into_response(), + Err(FinanceDownloadError::InvalidToken) => InvalidTokenError.into_response(), + Err(FinanceDownloadError::NotFound) => CreditNoteNotYetAvailableError.into_response(), + Err(FinanceDownloadError::Other(err)) => internal_server_error(err), + } +} + +fn download_credit_note_docs(op: TransformOperation) -> TransformOperation { + op.summary("Download a credit note") + .add_error::() + .add_error::() + .with(internal_server_error_docs) +} + error_code! { /// The invoice does not exist. InvoiceNotFoundError(NOT_FOUND, "Invoice not found"); + /// The credit note is not available yet. + CreditNoteNotYetAvailableError(NOT_FOUND, "Credit note not yet available"); } diff --git a/academy_config/src/lib.rs b/academy_config/src/lib.rs index 8644afd..816bea7 100644 --- a/academy_config/src/lib.rs +++ b/academy_config/src/lib.rs @@ -229,6 +229,7 @@ pub struct RenderConfig { pub struct FinanceConfig { pub vat_percent: Decimal, pub invoices_archive: PathBuf, + pub credit_notes_archive: PathBuf, } #[derive(Debug, Deserialize)] diff --git a/academy_core/coin/impl/Cargo.toml b/academy_core/coin/impl/Cargo.toml index 1a84521..3a5bc14 100644 --- a/academy_core/coin/impl/Cargo.toml +++ b/academy_core/coin/impl/Cargo.toml @@ -15,6 +15,7 @@ academy_core_coin_contracts.workspace = true academy_di.workspace = true academy_models.workspace = true academy_persistence_contracts.workspace = true +academy_shared_contracts.workspace = true academy_utils.workspace = true tracing.workspace = true @@ -22,4 +23,5 @@ tracing.workspace = true academy_auth_contracts = { workspace = true, features = ["mock"] } academy_demo.workspace = true academy_persistence_contracts = { workspace = true, features = ["mock"] } +academy_shared_contracts = { workspace = true, features = ["mock"] } tokio.workspace = true diff --git a/academy_core/coin/impl/src/coin.rs b/academy_core/coin/impl/src/coin.rs index acd22df..b24e64b 100644 --- a/academy_core/coin/impl/src/coin.rs +++ b/academy_core/coin/impl/src/coin.rs @@ -1,20 +1,25 @@ use academy_core_coin_contracts::coin::{CoinAddCoinsError, CoinService}; use academy_di::Build; use academy_models::{ - coin::{Balance, TransactionDescription}, + coin::{Balance, Transaction, TransactionDescription}, user::UserId, }; use academy_persistence_contracts::coin::{CoinRepoAddCoinsError, CoinRepository}; +use academy_shared_contracts::{id::IdService, time::TimeService}; use academy_utils::trace_instrument; -#[derive(Debug, Clone, Build)] -pub struct CoinServiceImpl { +#[derive(Debug, Clone, Build, Default)] +pub struct CoinServiceImpl { + id: Id, + time: Time, coin_repo: CoinRepo, } -impl CoinService for CoinServiceImpl +impl CoinService for CoinServiceImpl where Txn: Send + Sync + 'static, + Id: IdService, + Time: TimeService, CoinRepo: CoinRepository, { #[trace_instrument(skip(self, txn))] @@ -24,28 +29,44 @@ where user_id: UserId, coins: i64, withhold: bool, - // TODO: save transactions - _description: Option, - _include_in_credit_note: bool, + description: Option, + include_in_credit_note: bool, ) -> Result { - self.coin_repo + let new_balance = self + .coin_repo .add_coins(txn, user_id, coins, withhold) .await .map_err(|err| match err { CoinRepoAddCoinsError::NotEnoughCoins => CoinAddCoinsError::NotEnoughCoins, CoinRepoAddCoinsError::Other(err) => err.into(), - }) + })?; + + let transaction = Transaction { + id: self.id.generate(), + user_id, + coins, + description, + created_at: self.time.now(), + include_in_credit_note, + }; + self.coin_repo.create_transaction(txn, &transaction).await?; + + Ok(new_balance) } } #[cfg(test)] mod tests { - use academy_demo::user::FOO; + use academy_demo::{user::FOO, UUID1}; + use academy_models::coin::TransactionId; use academy_persistence_contracts::coin::MockCoinRepository; + use academy_shared_contracts::{id::MockIdService, time::MockTimeService}; use academy_utils::assert_matches; use super::*; + type Sut = CoinServiceImpl>; + #[tokio::test] async fn add_coins_ok() { // Arrange @@ -56,10 +77,26 @@ mod tests { let description = TransactionDescription::try_new("test123").unwrap(); - let coin_repo = - MockCoinRepository::new().with_add_coins(FOO.user.id, -1337, false, Ok(expected)); + let id = MockIdService::new().with_generate(TransactionId::from(UUID1)); - let sut = CoinServiceImpl { coin_repo }; + let time = MockTimeService::new().with_now(FOO.user.last_login.unwrap()); + + let coin_repo = MockCoinRepository::new() + .with_add_coins(FOO.user.id, -1337, false, Ok(expected)) + .with_create_transaction(Transaction { + id: UUID1.into(), + user_id: FOO.user.id, + coins: -1337, + description: Some(description.clone()), + created_at: FOO.user.last_login.unwrap(), + include_in_credit_note: true, + }); + + let sut = CoinServiceImpl { + id, + time, + coin_repo, + }; // Act let result = sut @@ -82,7 +119,10 @@ mod tests { Err(CoinRepoAddCoinsError::NotEnoughCoins), ); - let sut = CoinServiceImpl { coin_repo }; + let sut = CoinServiceImpl { + coin_repo, + ..Sut::default() + }; // Act let result = sut diff --git a/academy_core/finance/contracts/src/invoice.rs b/academy_core/finance/contracts/src/invoice.rs index ed05c3f..10bd10c 100644 --- a/academy_core/finance/contracts/src/invoice.rs +++ b/academy_core/finance/contracts/src/invoice.rs @@ -11,13 +11,21 @@ pub trait FinanceInvoiceService: Send + Sync + 'stat user_id: Option, invoice_number: u64, ) -> impl Future>>> + Send; + + /// Generate or return the archived credit note by month. + fn get_credit_note( + &self, + txn: &mut Txn, + user_id: UserId, + year: i32, + month: u32, + ) -> impl Future>>> + Send; } #[cfg(feature = "mock")] impl MockFinanceInvoiceService { pub fn with_get_invoice_pdf( mut self, - user_id: Option, invoice_number: u64, result: Option>, @@ -32,4 +40,23 @@ impl MockFinanceInvoiceService { .return_once(|_, _, _| Box::pin(std::future::ready(Ok(result)))); self } + + pub fn with_get_credit_note( + mut self, + user_id: UserId, + year: i32, + month: u32, + result: Option>, + ) -> Self { + self.expect_get_credit_note() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(user_id), + mockall::predicate::eq(year), + mockall::predicate::eq(month), + ) + .return_once(|_, _, _, _| Box::pin(std::future::ready(Ok(result)))); + self + } } diff --git a/academy_core/finance/contracts/src/lib.rs b/academy_core/finance/contracts/src/lib.rs index 13dc8a8..76fe863 100644 --- a/academy_core/finance/contracts/src/lib.rs +++ b/academy_core/finance/contracts/src/lib.rs @@ -20,7 +20,15 @@ pub trait FinanceFeatureService: Send + Sync + 'static { &self, token: &str, invoice_number: u64, - ) -> impl Future, FinanceDownloadInvoiceError>> + Send; + ) -> impl Future, FinanceDownloadError>> + Send; + + /// Download the given credit note pdf. + fn download_credit_note( + &self, + token: &str, + year: i32, + month: u32, + ) -> impl Future, FinanceDownloadError>> + Send; } #[derive(Debug, Error)] @@ -32,7 +40,7 @@ pub enum FinanceGetDownloadTokenError { } #[derive(Debug, Error)] -pub enum FinanceDownloadInvoiceError { +pub enum FinanceDownloadError { #[error("The download token is invalid or has expired.")] InvalidToken, #[error("The invoice does not exist.")] diff --git a/academy_core/finance/impl/Cargo.toml b/academy_core/finance/impl/Cargo.toml index f1b5967..a7946ee 100644 --- a/academy_core/finance/impl/Cargo.toml +++ b/academy_core/finance/impl/Cargo.toml @@ -20,6 +20,7 @@ academy_shared_contracts.workspace = true academy_templates_contracts.workspace = true academy_utils.workspace = true anyhow.workspace = true +chrono.workspace = true rust_decimal.workspace = true rust_decimal_macros.workspace = true serde.workspace = true diff --git a/academy_core/finance/impl/src/invoice.rs b/academy_core/finance/impl/src/invoice.rs index 46d8b4b..47c8057 100644 --- a/academy_core/finance/impl/src/invoice.rs +++ b/academy_core/finance/impl/src/invoice.rs @@ -4,36 +4,62 @@ use academy_core_finance_contracts::{ }; use academy_di::Build; use academy_models::user::UserId; -use academy_persistence_contracts::{paypal::PaypalRepository, user::UserRepository}; +use academy_persistence_contracts::{ + coin::CoinRepository, paypal::PaypalRepository, user::UserRepository, +}; use academy_render_contracts::pdf::RenderPdfService; -use academy_shared_contracts::fs::FsService; +use academy_shared_contracts::{fs::FsService, time::TimeService}; use academy_templates_contracts::{InvoiceItem, InvoiceTemplate, TemplateService}; use anyhow::Context; +use chrono::{NaiveDate, NaiveTime, TimeZone, Utc}; use tracing::instrument; use crate::FinanceServiceConfig; #[derive(Debug, Clone, Build)] #[cfg_attr(test, derive(Default))] -pub struct FinanceInvoiceServiceImpl { +pub struct FinanceInvoiceServiceImpl< + Time, + Fs, + Template, + RenderPdf, + PaypalRepo, + UserRepo, + CoinRepo, + FinanceCoin, +> { + time: Time, fs: Fs, template: Template, render_pdf: RenderPdf, paypal_repo: PaypalRepo, user_repo: UserRepo, + coin_repo: CoinRepo, finance_coin: FinanceCoin, config: FinanceServiceConfig, } -impl FinanceInvoiceService - for FinanceInvoiceServiceImpl +impl + FinanceInvoiceService + for FinanceInvoiceServiceImpl< + Time, + Fs, + Template, + RenderPdf, + PaypalRepo, + UserRepo, + CoinRepo, + FinanceCoin, + > where Txn: Send + Sync + 'static, + Time: TimeService, Fs: FsService, Template: TemplateService, RenderPdf: RenderPdfService, PaypalRepo: PaypalRepository, UserRepo: UserRepository, + CoinRepo: CoinRepository, FinanceCoin: FinanceCoinService, { #[instrument(skip(self, txn))] @@ -120,6 +146,105 @@ where Ok(Some(invoice_pdf)) } + + #[instrument(skip(self, txn))] + async fn get_credit_note( + &self, + txn: &mut Txn, + user_id: UserId, + year: i32, + month: u32, + ) -> anyhow::Result>> { + let Some(start_of_month) = Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).single() else { + return Ok(None); + }; + let Some(date) = first_day_of_next_month(year, month) else { + return Ok(None); + }; + let timestamp = date + .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) + .and_utc(); + + if self.time.now() < timestamp { + return Ok(None); + } + + let user_number = self.user_repo.get_number(txn, user_id).await?; + + let credit_note_number = format!("G{year:04}{month:02}-{user_number}"); + let archive_path = self + .config + .credit_notes_archive + .join(format!("{credit_note_number}.pdf")); + + if let Some(credit_note) = self.fs.read_file(&archive_path).await? { + return Ok(Some(credit_note)); + } + + let Some(user_composite) = self.user_repo.get_composite(txn, user_id).await? else { + return Ok(None); + }; + + let transactions = self + .coin_repo + .get_transactions(txn, user_id, start_of_month..timestamp) + .await?; + + let items = transactions + .into_iter() + .filter(|t| t.include_in_credit_note && t.coins > 0) + .map(|t| { + let coins = t.coins as u64; + let prices = self.finance_coin.get_price(coins); + InvoiceItem { + description: t.description.map(|x| x.into_inner()).unwrap_or_default(), + net_unit: prices.net_unit, + count: coins, + net_total: prices.net_total, + } + }) + .collect::>(); + + let coins_total = items.iter().map(|item| item.count).sum(); + let price_total = self.finance_coin.get_price(coins_total); + + let credit_note_html = self + .template + .render(&InvoiceTemplate { + title: "Gutschrift", + customer_details: user_composite.invoice_info.into_details(Some( + user_composite.profile.display_name.clone().into_inner(), + )), + timestamp, + invoice_number: credit_note_number, + items, + vat_percent: self.config.vat_percent, + net_total: price_total.net_total, + vat_total: price_total.vat_total, + gross_total: price_total.gross_total, + _static: Default::default(), + }) + .context("Failed to render credit note template")?; + + let credit_note_pdf = self + .render_pdf + .render(&credit_note_html) + .await + .context("Failed to render credit note pdf")?; + + self.fs.store_file(&archive_path, &credit_note_pdf).await?; + + Ok(Some(credit_note_pdf)) + } +} + +fn first_day_of_next_month(year: i32, month: u32) -> Option { + debug_assert!((1..=12).contains(&month)); + if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1) + } } #[cfg(test)] @@ -127,27 +252,37 @@ mod tests { use std::path::PathBuf; use academy_core_finance_contracts::coin::MockFinanceCoinService; - use academy_demo::user::{BAR, FOO}; - use academy_models::paypal::{PaypalCoinOrder, PaypalOrderId}; - use academy_persistence_contracts::{paypal::MockPaypalRepository, user::MockUserRepository}; + use academy_demo::{ + user::{BAR, FOO}, + UUID1, + }; + use academy_models::{ + coin::Transaction, + paypal::{PaypalCoinOrder, PaypalOrderId}, + }; + use academy_persistence_contracts::{ + coin::MockCoinRepository, paypal::MockPaypalRepository, user::MockUserRepository, + }; use academy_render_contracts::pdf::MockRenderPdfService; - use academy_shared_contracts::fs::MockFsService; + use academy_shared_contracts::{fs::MockFsService, time::MockTimeService}; use academy_templates_contracts::MockTemplateService; use rust_decimal_macros::dec; use super::*; type Sut = FinanceInvoiceServiceImpl< + MockTimeService, MockFsService, MockTemplateService, MockRenderPdfService, MockPaypalRepository<()>, MockUserRepository<()>, + MockCoinRepository<()>, MockFinanceCoinService, >; #[tokio::test] - async fn ok() { + async fn get_invoice_ok() { // Arrange let order = PaypalCoinOrder { id: PaypalOrderId::try_new("asdf1234").unwrap(), @@ -227,7 +362,7 @@ mod tests { } #[tokio::test] - async fn cached_no_user_id_check() { + async fn get_invoice_cached_no_user_id_check() { // Arrange let pdf = vec![1, 2, 3, 4]; @@ -247,7 +382,7 @@ mod tests { } #[tokio::test] - async fn cached_with_successful_user_id_check() { + async fn get_invoice_cached_with_successful_user_id_check() { // Arrange let pdf = vec![1, 2, 3, 4]; @@ -282,7 +417,7 @@ mod tests { } #[tokio::test] - async fn cached_with_failing_user_id_check() { + async fn get_invoice_cached_with_failing_user_id_check() { // Arrange let pdf = vec![1, 2, 3, 4]; @@ -317,7 +452,7 @@ mod tests { } #[tokio::test] - async fn not_found() { + async fn get_invoice_not_found() { // Arrange let fs = MockFsService::new().with_read_file("/invoices/R0000042.pdf".into(), None); @@ -341,7 +476,7 @@ mod tests { } #[tokio::test] - async fn different_user() { + async fn get_invoice_different_user() { // Arrange let order = PaypalCoinOrder { id: PaypalOrderId::try_new("asdf1234").unwrap(), @@ -372,4 +507,148 @@ mod tests { // Assert assert_eq!(result, None); } + + #[tokio::test] + async fn get_credit_note_ok() { + // Arrange + let now = Utc.with_ymd_and_hms(2024, 3, 14, 0, 0, 0).unwrap(); + + let time = MockTimeService::new().with_now(now); + + let user_repo = MockUserRepository::new() + .with_get_number(FOO.user.id, 7) + .with_get_composite(FOO.user.id, Some(FOO.clone())); + + let pdf = vec![1, 2, 3, 4]; + + let fs = MockFsService::new() + .with_read_file("/credit_notes/G202402-7.pdf".into(), None) + .with_store_file("/credit_notes/G202402-7.pdf".into(), pdf.clone()); + + let transaction = Transaction { + id: UUID1.into(), + user_id: FOO.user.id, + coins: 1337, + description: Some("hello world".try_into().unwrap()), + created_at: Utc.with_ymd_and_hms(2024, 2, 7, 13, 37, 42).unwrap(), + include_in_credit_note: true, + }; + + let timestamp = Utc.with_ymd_and_hms(2024, 3, 1, 0, 0, 0).unwrap(); + let coin_repo = MockCoinRepository::new().with_get_transactions( + FOO.user.id, + Utc.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap()..timestamp, + vec![transaction.clone()], + ); + + let prices = CoinPrices { + net_unit: 1.into(), + net_total: 2.into(), + vat_total: 3.into(), + gross_total: 4.into(), + }; + let finance_coin = MockFinanceCoinService::new() + .with_get_price(1337, prices) + .with_get_price(1337, prices); + + let template = MockTemplateService::new().with_render( + InvoiceTemplate { + title: "Gutschrift", + customer_details: FOO + .invoice_info + .clone() + .into_details(Some(FOO.profile.display_name.clone().into_inner())), + timestamp, + invoice_number: "G202402-7".into(), + items: vec![InvoiceItem { + description: "hello world".into(), + net_unit: prices.net_unit, + count: 1337, + net_total: prices.net_total, + }], + vat_percent: dec!(19), + net_total: prices.net_total, + vat_total: prices.vat_total, + gross_total: prices.gross_total, + _static: Default::default(), + }, + "credit-note-template-html".into(), + ); + + let render_pdf = MockRenderPdfService::new() + .with_render("credit-note-template-html".into(), pdf.clone()); + + let sut = FinanceInvoiceServiceImpl { + time, + user_repo, + fs, + coin_repo, + finance_coin, + template, + render_pdf, + ..Sut::default() + }; + + // Act + let result = sut + .get_credit_note(&mut (), FOO.user.id, 2024, 2) + .await + .unwrap(); + + // Assert + assert_eq!(result, Some(pdf)); + } + + #[tokio::test] + async fn get_credit_note_not_available_yet() { + // Arrange + let now = Utc.with_ymd_and_hms(2024, 3, 14, 0, 0, 0).unwrap(); + + let time = MockTimeService::new().with_now(now); + + let sut = FinanceInvoiceServiceImpl { + time, + ..Sut::default() + }; + + // Act + let result = sut + .get_credit_note(&mut (), FOO.user.id, 2024, 3) + .await + .unwrap(); + + // Assert + assert_eq!(result, None); + } + + #[tokio::test] + async fn get_credit_note_cached() { + // Arrange + let now = Utc.with_ymd_and_hms(2024, 3, 14, 0, 0, 0).unwrap(); + + let time = MockTimeService::new().with_now(now); + + let user_repo = MockUserRepository::new().with_get_number(FOO.user.id, 7); + + let pdf = vec![1, 2, 3, 4]; + + let fs = MockFsService::new() + .with_read_file("/credit_notes/G202402-7.pdf".into(), Some(pdf.clone())); + + let sut = FinanceInvoiceServiceImpl { + time, + user_repo, + fs, + ..Sut::default() + }; + + // Act + let result = sut + .get_credit_note(&mut (), FOO.user.id, 2024, 2) + .await + .unwrap(); + + // Assert + assert_eq!(result, Some(pdf)); + } } diff --git a/academy_core/finance/impl/src/lib.rs b/academy_core/finance/impl/src/lib.rs index ca5ac5e..dcef89b 100644 --- a/academy_core/finance/impl/src/lib.rs +++ b/academy_core/finance/impl/src/lib.rs @@ -2,7 +2,7 @@ use std::{path::Path, sync::Arc, time::Duration}; use academy_auth_contracts::{AuthResultExt, AuthService}; use academy_core_finance_contracts::{ - invoice::FinanceInvoiceService, FinanceDownloadInvoiceError, FinanceFeatureService, + invoice::FinanceInvoiceService, FinanceDownloadError, FinanceFeatureService, FinanceGetDownloadTokenError, }; use academy_di::Build; @@ -34,6 +34,7 @@ pub struct FinanceFeatureServiceImpl { pub struct FinanceServiceConfig { pub vat_percent: Decimal, pub invoices_archive: Arc, + pub credit_notes_archive: Arc, pub download_token_ttl: Duration, } @@ -66,11 +67,11 @@ where &self, token: &str, invoice_number: u64, - ) -> Result, FinanceDownloadInvoiceError> { + ) -> Result, FinanceDownloadError> { let DownloadToken { sub: user_id, .. } = self.jwt.verify(token).map_err(|err| match err { VerifyJwtError::Expired(_) | VerifyJwtError::Invalid => { - FinanceDownloadInvoiceError::InvalidToken + FinanceDownloadError::InvalidToken } })?; @@ -79,7 +80,29 @@ where self.finance_invoice .get_invoice_pdf(&mut txn, Some(user_id), invoice_number) .await? - .ok_or(FinanceDownloadInvoiceError::NotFound) + .ok_or(FinanceDownloadError::NotFound) + } + + #[instrument(skip(self))] + async fn download_credit_note( + &self, + token: &str, + year: i32, + month: u32, + ) -> Result, FinanceDownloadError> { + let DownloadToken { sub: user_id, .. } = + self.jwt.verify(token).map_err(|err| match err { + VerifyJwtError::Expired(_) | VerifyJwtError::Invalid => { + FinanceDownloadError::InvalidToken + } + })?; + + let mut txn = self.db.begin_transaction().await?; + + self.finance_invoice + .get_credit_note(&mut txn, user_id, year, month) + .await? + .ok_or(FinanceDownloadError::NotFound) } } diff --git a/academy_core/finance/impl/src/tests/download_credit_note.rs b/academy_core/finance/impl/src/tests/download_credit_note.rs new file mode 100644 index 0000000..4dce5bc --- /dev/null +++ b/academy_core/finance/impl/src/tests/download_credit_note.rs @@ -0,0 +1,93 @@ +use academy_core_finance_contracts::{ + invoice::MockFinanceInvoiceService, FinanceDownloadError, FinanceFeatureService, +}; +use academy_demo::user::FOO; +use academy_persistence_contracts::MockDatabase; +use academy_shared_contracts::jwt::{MockJwtService, VerifyJwtError}; +use academy_utils::assert_matches; + +use crate::{tests::Sut, DownloadToken, FinanceFeatureServiceImpl}; + +#[tokio::test] +async fn ok() { + // Arrange + let expected = vec![1, 2, 3, 4]; + + let jwt = MockJwtService::new().with_verify( + "the-jwt".into(), + Ok(DownloadToken { + sub: FOO.user.id, + aud: Default::default(), + }), + ); + + let db = MockDatabase::build(false); + + let finance_invoice = MockFinanceInvoiceService::new().with_get_credit_note( + FOO.user.id, + 2024, + 3, + Some(expected.clone()), + ); + + let sut = FinanceFeatureServiceImpl { + jwt, + db, + finance_invoice, + ..Sut::default() + }; + + // Act + let result = sut.download_credit_note("the-jwt", 2024, 3).await; + + // Assert + assert_eq!(result.unwrap(), expected); +} + +#[tokio::test] +async fn invalid_token() { + // Arrange + let jwt = MockJwtService::new() + .with_verify::("the-jwt".into(), Err(VerifyJwtError::Invalid)); + + let sut = FinanceFeatureServiceImpl { + jwt, + ..Sut::default() + }; + + // Act + let result = sut.download_credit_note("the-jwt", 2024, 3).await; + + // Assert + assert_matches!(result, Err(FinanceDownloadError::InvalidToken)); +} + +#[tokio::test] +async fn not_found() { + // Arrange + let jwt = MockJwtService::new().with_verify( + "the-jwt".into(), + Ok(DownloadToken { + sub: FOO.user.id, + aud: Default::default(), + }), + ); + + let db = MockDatabase::build(false); + + let finance_invoice = + MockFinanceInvoiceService::new().with_get_credit_note(FOO.user.id, 2024, 3, None); + + let sut = FinanceFeatureServiceImpl { + jwt, + db, + finance_invoice, + ..Sut::default() + }; + + // Act + let result = sut.download_credit_note("the-jwt", 2024, 3).await; + + // Assert + assert_matches!(result, Err(FinanceDownloadError::NotFound)); +} diff --git a/academy_core/finance/impl/src/tests/download_invoice.rs b/academy_core/finance/impl/src/tests/download_invoice.rs index 4b6a664..05357c8 100644 --- a/academy_core/finance/impl/src/tests/download_invoice.rs +++ b/academy_core/finance/impl/src/tests/download_invoice.rs @@ -1,5 +1,5 @@ use academy_core_finance_contracts::{ - invoice::MockFinanceInvoiceService, FinanceDownloadInvoiceError, FinanceFeatureService, + invoice::MockFinanceInvoiceService, FinanceDownloadError, FinanceFeatureService, }; use academy_demo::user::FOO; use academy_persistence_contracts::MockDatabase; @@ -58,7 +58,7 @@ async fn invalid_token() { let result = sut.download_invoice("the-jwt", 42).await; // Assert - assert_matches!(result, Err(FinanceDownloadInvoiceError::InvalidToken)); + assert_matches!(result, Err(FinanceDownloadError::InvalidToken)); } #[tokio::test] @@ -88,5 +88,5 @@ async fn not_found() { let result = sut.download_invoice("the-jwt", 42).await; // Assert - assert_matches!(result, Err(FinanceDownloadInvoiceError::NotFound)); + assert_matches!(result, Err(FinanceDownloadError::NotFound)); } diff --git a/academy_core/finance/impl/src/tests/mod.rs b/academy_core/finance/impl/src/tests/mod.rs index 965458e..0dd61bc 100644 --- a/academy_core/finance/impl/src/tests/mod.rs +++ b/academy_core/finance/impl/src/tests/mod.rs @@ -8,6 +8,7 @@ use rust_decimal_macros::dec; use crate::{FinanceFeatureServiceImpl, FinanceServiceConfig}; +mod download_credit_note; mod download_invoice; mod get_download_token; @@ -23,6 +24,7 @@ impl Default for FinanceServiceConfig { Self { vat_percent: dec!(19), invoices_archive: Path::new("/invoices").into(), + credit_notes_archive: Path::new("/credit_notes").into(), download_token_ttl: Duration::from_secs(600), } } diff --git a/academy_core/paypal/impl/Cargo.toml b/academy_core/paypal/impl/Cargo.toml index 0b04cd7..5bb11f2 100644 --- a/academy_core/paypal/impl/Cargo.toml +++ b/academy_core/paypal/impl/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] academy_auth_contracts.workspace = true +academy_core_coin_contracts.workspace = true academy_core_finance_contracts.workspace = true academy_core_paypal_contracts.workspace = true academy_di.workspace = true @@ -28,6 +29,7 @@ tracing.workspace = true [dev-dependencies] academy_auth_contracts = { workspace = true, features = ["mock"] } +academy_core_coin_contracts = { workspace = true, features = ["mock"] } academy_core_finance_contracts = { workspace = true, features = ["mock"] } academy_core_paypal_contracts = { workspace = true, features = ["mock"] } academy_demo.workspace = true diff --git a/academy_core/paypal/impl/src/coin_order.rs b/academy_core/paypal/impl/src/coin_order.rs index 21da2be..7cd443a 100644 --- a/academy_core/paypal/impl/src/coin_order.rs +++ b/academy_core/paypal/impl/src/coin_order.rs @@ -1,3 +1,4 @@ +use academy_core_coin_contracts::coin::CoinService; use academy_core_paypal_contracts::coin_order::PaypalCoinOrderService; use academy_di::Build; use academy_models::{ @@ -5,24 +6,24 @@ use academy_models::{ paypal::{PaypalCoinOrder, PaypalOrderId}, user::UserId, }; -use academy_persistence_contracts::{coin::CoinRepository, paypal::PaypalRepository}; +use academy_persistence_contracts::paypal::PaypalRepository; use academy_shared_contracts::time::TimeService; use academy_utils::trace_instrument; #[derive(Debug, Clone, Build, Default)] -pub struct PaypalCoinOrderServiceImpl { +pub struct PaypalCoinOrderServiceImpl { time: Time, paypal_repo: PaypalRepo, - coin_repo: CoinRepo, + coin: Coin, } -impl PaypalCoinOrderService - for PaypalCoinOrderServiceImpl +impl PaypalCoinOrderService + for PaypalCoinOrderServiceImpl where Txn: Send + Sync + 'static, Time: TimeService, PaypalRepo: PaypalRepository, - CoinRepo: CoinRepository, + Coin: CoinService, { #[trace_instrument(skip(self, txn))] async fn create( @@ -58,8 +59,15 @@ where .await?; let new_balance = self - .coin_repo - .add_coins(txn, order.user_id, order.coins.try_into()?, false) + .coin + .add_coins( + txn, + order.user_id, + order.coins.try_into()?, + false, + Some("PayPal".try_into()?), + false, + ) .await?; Ok(new_balance) @@ -70,17 +78,15 @@ where mod tests { use std::time::Duration; + use academy_core_coin_contracts::coin::MockCoinService; use academy_demo::user::FOO; - use academy_persistence_contracts::{coin::MockCoinRepository, paypal::MockPaypalRepository}; + use academy_persistence_contracts::paypal::MockPaypalRepository; use academy_shared_contracts::time::MockTimeService; use super::*; - type Sut = PaypalCoinOrderServiceImpl< - MockTimeService, - MockPaypalRepository<()>, - MockCoinRepository<()>, - >; + type Sut = + PaypalCoinOrderServiceImpl, MockCoinService<()>>; #[tokio::test] async fn create() { @@ -143,17 +149,19 @@ mod tests { let paypal_repo = MockPaypalRepository::new().with_capture_coin_order(order.id.clone(), now); - let coin_repo = MockCoinRepository::new().with_add_coins( + let coin = MockCoinService::new().with_add_coins( order.user_id, order.coins as _, false, + Some("PayPal".try_into().unwrap()), + false, Ok(expected), ); let sut = PaypalCoinOrderServiceImpl { time, paypal_repo, - coin_repo, + coin, }; // Act diff --git a/academy_models/src/coin.rs b/academy_models/src/coin.rs index f795739..15a47d5 100644 --- a/academy_models/src/coin.rs +++ b/academy_models/src/coin.rs @@ -1,4 +1,11 @@ -use crate::macros::nutype_string; +use chrono::{DateTime, Utc}; + +use crate::{ + macros::{id, nutype_string}, + user::UserId, +}; + +id!(TransactionId); #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Balance { @@ -7,3 +14,13 @@ pub struct Balance { } nutype_string!(TransactionDescription(validate(len_char_max = 4096))); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + pub id: TransactionId, + pub user_id: UserId, + pub coins: i64, + pub description: Option, + pub created_at: DateTime, + pub include_in_credit_note: bool, +} diff --git a/academy_persistence/contracts/src/coin.rs b/academy_persistence/contracts/src/coin.rs index e219946..bc10fc8 100644 --- a/academy_persistence/contracts/src/coin.rs +++ b/academy_persistence/contracts/src/coin.rs @@ -1,6 +1,10 @@ -use std::future::Future; +use std::{future::Future, ops::Range}; -use academy_models::{coin::Balance, user::UserId}; +use academy_models::{ + coin::{Balance, Transaction}, + user::UserId, +}; +use chrono::{DateTime, Utc}; use thiserror::Error; #[cfg_attr(feature = "mock", mockall::automock)] @@ -27,6 +31,21 @@ pub trait CoinRepository: Send + Sync + 'static { txn: &mut Txn, user_id: UserId, ) -> impl Future> + Send; + + /// Return all transactions of the given user in the given datetime range. + fn get_transactions( + &self, + txn: &mut Txn, + user_id: UserId, + datetime_range: Range>, + ) -> impl Future>> + Send; + + /// Create a new transaction. + fn create_transaction( + &self, + txn: &mut Txn, + transaction: &Transaction, + ) -> impl Future> + Send; } #[derive(Debug, Error)] @@ -79,4 +98,32 @@ impl MockCoinRepository { .return_once(|_, _| Box::pin(std::future::ready(Ok(())))); self } + + pub fn with_get_transactions( + mut self, + user_id: UserId, + datetime_range: Range>, + result: Vec, + ) -> Self { + self.expect_get_transactions() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(user_id), + mockall::predicate::eq(datetime_range), + ) + .return_once(|_, _, _| Box::pin(std::future::ready(Ok(result)))); + self + } + + pub fn with_create_transaction(mut self, transaction: Transaction) -> Self { + self.expect_create_transaction() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(transaction), + ) + .return_once(|_, _| Box::pin(std::future::ready(Ok(())))); + self + } } diff --git a/academy_persistence/contracts/src/user.rs b/academy_persistence/contracts/src/user.rs index 7a18def..f03374d 100644 --- a/academy_persistence/contracts/src/user.rs +++ b/academy_persistence/contracts/src/user.rs @@ -148,6 +148,13 @@ pub trait UserRepository: Send + Sync + 'static { txn: &mut Txn, user_id: UserId, ) -> impl Future> + Send; + + /// Return the unique user number of the given user. + fn get_number( + &self, + txn: &mut Txn, + user_id: UserId, + ) -> impl Future> + Send; } #[derive(Debug, Error)] @@ -371,4 +378,15 @@ impl MockUserRepository { .return_once(move |_, _| Box::pin(std::future::ready(Ok(result)))); self } + + pub fn with_get_number(mut self, user_id: UserId, number: u64) -> Self { + self.expect_get_number() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(user_id), + ) + .return_once(move |_, _| Box::pin(std::future::ready(Ok(number)))); + self + } } diff --git a/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.down.sql b/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.down.sql new file mode 100644 index 0000000..9041d4e --- /dev/null +++ b/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.down.sql @@ -0,0 +1 @@ +drop table transactions; diff --git a/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.up.sql b/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.up.sql new file mode 100644 index 0000000..386a50f --- /dev/null +++ b/academy_persistence/postgres/migrations/20241129164148_create_transactions_table.up.sql @@ -0,0 +1,8 @@ +create table transactions ( + id uuid primary key, + user_id uuid not null references users(id) on delete cascade, + created_at timestamp with time zone not null, + coins bigint not null, + description text, + include_in_credit_note boolean not null +); diff --git a/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.down.sql b/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.down.sql new file mode 100644 index 0000000..69df27e --- /dev/null +++ b/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.down.sql @@ -0,0 +1,2 @@ +drop table user_numbers; +drop sequence user_number; diff --git a/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.up.sql b/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.up.sql new file mode 100644 index 0000000..a92cf18 --- /dev/null +++ b/academy_persistence/postgres/migrations/20241129185350_create_user_number_sequence.up.sql @@ -0,0 +1,5 @@ +create sequence user_number start with 1; +create table user_numbers ( + user_id uuid primary key references users(id) on delete cascade, + number bigint unique not null +); diff --git a/academy_persistence/postgres/src/coin.rs b/academy_persistence/postgres/src/coin.rs index 842656a..d2f6ee8 100644 --- a/academy_persistence/postgres/src/coin.rs +++ b/academy_persistence/postgres/src/coin.rs @@ -1,10 +1,19 @@ +use std::ops::Range; + use academy_di::Build; -use academy_models::{coin::Balance, user::UserId}; +use academy_models::{ + coin::{Balance, Transaction}, + user::UserId, +}; use academy_persistence_contracts::coin::{CoinRepoAddCoinsError, CoinRepository}; use academy_utils::trace_instrument; use bb8_postgres::tokio_postgres::{self, Row}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::{arg_indices, columns, ColumnCounter, PostgresTransaction}; -use crate::{ColumnCounter, PostgresTransaction}; +columns!(transaction as "t": "id", "user_id", "created_at", "coins", "description", "include_in_credit_note"); #[derive(Debug, Clone, Build)] pub struct PostgresCoinRepository; @@ -85,6 +94,54 @@ impl CoinRepository for PostgresCoinRepository { .map(|_| ()) .map_err(Into::into) } + + async fn get_transactions( + &self, + txn: &mut PostgresTransaction, + user_id: UserId, + datetime_range: Range>, + ) -> anyhow::Result> { + txn.txn() + .query( + &format!( + "select {TRANSACTION_COLS} from transactions t where user_id=$1 and $2 <= \ + created_at and created_at < $3 order by created_at asc" + ), + &[&*user_id, &datetime_range.start, &datetime_range.end], + ) + .await + .map_err(Into::into) + .and_then(|rows| { + rows.into_iter() + .map(|row| decode_transaction(&row, &mut Default::default())) + .collect() + }) + } + + async fn create_transaction( + &self, + txn: &mut PostgresTransaction, + transaction: &Transaction, + ) -> anyhow::Result<()> { + txn.txn() + .execute( + &format!( + "insert into transactions ({TRANSACTION_COL_NAMES}) values ({})", + arg_indices(1..=TRANSACTION_CNT) + ), + &[ + &*transaction.id, + &*transaction.user_id, + &transaction.created_at, + &transaction.coins, + &transaction.description.as_deref(), + &transaction.include_in_credit_note, + ], + ) + .await + .map(|_| ()) + .map_err(Into::into) + } } fn decode_balance(row: &Row, cnt: &mut ColumnCounter) -> anyhow::Result { @@ -105,3 +162,17 @@ fn map_add_coins_error(err: tokio_postgres::Error) -> CoinRepoAddCoinsError { _ => CoinRepoAddCoinsError::Other(err.into()), } } + +fn decode_transaction(row: &Row, cnt: &mut ColumnCounter) -> anyhow::Result { + Ok(Transaction { + id: row.get::<_, Uuid>(cnt.idx()).into(), + user_id: row.get::<_, Uuid>(cnt.idx()).into(), + created_at: row.get(cnt.idx()), + coins: row.get(cnt.idx()), + description: row + .get::<_, Option>(cnt.idx()) + .map(TryInto::try_into) + .transpose()?, + include_in_credit_note: row.get(cnt.idx()), + }) +} diff --git a/academy_persistence/postgres/src/user.rs b/academy_persistence/postgres/src/user.rs index 549e45b..b0757d2 100644 --- a/academy_persistence/postgres/src/user.rs +++ b/academy_persistence/postgres/src/user.rs @@ -480,6 +480,35 @@ impl UserRepository for PostgresUserRepository { .map(|n| n != 0) .map_err(Into::into) } + + async fn get_number( + &self, + txn: &mut PostgresTransaction, + user_id: UserId, + ) -> anyhow::Result { + if let Some(number) = txn + .txn() + .query_opt( + "select number from user_numbers where user_id=$1", + &[&*user_id], + ) + .await + .map(|row| row.map(|row| row.get::<_, i64>(0) as _))? + { + return Ok(number); + } + + txn.txn() + .query_one( + "insert into user_numbers as un (user_id, number) values ($1, \ + nextval('user_number')) on conflict (user_id) do update set number=un.number \ + returning number", + &[&*user_id], + ) + .await + .map(|row| row.get::<_, i64>(0) as _) + .map_err(Into::into) + } } fn make_filter<'a>( diff --git a/academy_persistence/postgres/tests/repos/coins.rs b/academy_persistence/postgres/tests/repos/coins.rs index 10a9f66..84fa1e5 100644 --- a/academy_persistence/postgres/tests/repos/coins.rs +++ b/academy_persistence/postgres/tests/repos/coins.rs @@ -1,11 +1,12 @@ -use academy_demo::user::FOO; -use academy_models::coin::Balance; +use academy_demo::{user::FOO, UUID1, UUID2}; +use academy_models::coin::{Balance, Transaction}; use academy_persistence_contracts::{ coin::{CoinRepoAddCoinsError, CoinRepository}, - Database, Transaction, + Database, Transaction as _, }; use academy_persistence_postgres::coin::PostgresCoinRepository; use academy_utils::assert_matches; +use chrono::{TimeZone, Utc}; use crate::common::setup; @@ -118,6 +119,75 @@ async fn release_coins() { assert_eq!(result.unwrap(), balance(1379, 0)); } +#[tokio::test] +async fn transactions() { + let db = setup().await; + let mut txn = db.begin_transaction().await.unwrap(); + + let d1 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let d2 = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(); + let d3 = Utc.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(); + let d4 = Utc.with_ymd_and_hms(2024, 1, 4, 0, 0, 0).unwrap(); + let d5 = Utc.with_ymd_and_hms(2024, 1, 5, 0, 0, 0).unwrap(); + + let t1 = Transaction { + id: UUID1.into(), + user_id: FOO.user.id, + coins: 42, + description: None, + created_at: d2, + include_in_credit_note: true, + }; + let t2 = Transaction { + id: UUID2.into(), + user_id: FOO.user.id, + coins: 1337, + description: None, + created_at: d4, + include_in_credit_note: true, + }; + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d1..d5) + .await + .unwrap(); + assert_eq!(result, []); + + REPO.create_transaction(&mut txn, &t1).await.unwrap(); + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d1..d5) + .await + .unwrap(); + assert_eq!(result, [t1.clone()]); + + REPO.create_transaction(&mut txn, &t2).await.unwrap(); + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d1..d5) + .await + .unwrap(); + assert_eq!(result, [t1.clone(), t2.clone()]); + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d1..d2) + .await + .unwrap(); + assert_eq!(result, []); + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d1..d3) + .await + .unwrap(); + assert_eq!(result, [t1]); + + let result = REPO + .get_transactions(&mut txn, FOO.user.id, d3..d5) + .await + .unwrap(); + assert_eq!(result, [t2]); +} + fn balance(coins: u64, withheld_coins: u64) -> Balance { Balance { coins, diff --git a/academy_persistence/postgres/tests/repos/user.rs b/academy_persistence/postgres/tests/repos/user.rs index d202698..2923945 100644 --- a/academy_persistence/postgres/tests/repos/user.rs +++ b/academy_persistence/postgres/tests/repos/user.rs @@ -465,3 +465,18 @@ async fn password() { let result = REPO.get_password_hash(&mut txn, FOO.user.id).await.unwrap(); assert_eq!(result, None); } + +#[tokio::test] +async fn user_numbers() { + let db = setup().await; + let mut txn = db.begin_transaction().await.unwrap(); + + assert_eq!(REPO.get_number(&mut txn, FOO.user.id).await.unwrap(), 1); + assert_eq!(REPO.get_number(&mut txn, FOO.user.id).await.unwrap(), 1); + + assert_eq!(REPO.get_number(&mut txn, BAR.user.id).await.unwrap(), 2); + + assert_eq!(REPO.get_number(&mut txn, FOO.user.id).await.unwrap(), 1); + + assert_eq!(REPO.get_number(&mut txn, BAR.user.id).await.unwrap(), 2); +} diff --git a/config.dev.toml b/config.dev.toml index 8d01f82..14bd2fa 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -40,6 +40,7 @@ chrome_bin = "/usr/bin/chromium" [finance] invoices_archive = ".invoices" +credit_notes_archive = ".credit_notes" [oauth2.providers.test] enable = true diff --git a/config.toml b/config.toml index 2a5394e..e0c5f76 100644 --- a/config.toml +++ b/config.toml @@ -81,6 +81,7 @@ purchase_max = 1000000 [finance] vat_percent = 19 # invoices_archive = "" +# credit_notes_archive = "" # [sentry] # enable = true diff --git a/nix/module.nix b/nix/module.nix index 0e88857..90fc22f 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -140,6 +140,7 @@ in { cache.url = lib.mkIf cfg.localCache "redis+unix://${config.services.redis.servers.academy.unixSocket}"; render.chrome_bin = lib.mkDefault (lib.getExe cfg.chromePackage); finance.invoices_archive = lib.mkDefault "/var/lib/academy/invoices"; + finance.credit_notes_archive = lib.mkDefault "/var/lib/academy/credit_notes"; }; environment.systemPackages = [wrapper]; diff --git a/nix/tests/finance.py b/nix/tests/finance.py index 7d70388..e43bdfd 100644 --- a/nix/tests/finance.py +++ b/nix/tests/finance.py @@ -1,4 +1,7 @@ -from utils import c, create_verified_account +import os +from datetime import date + +from utils import c, create_admin_account, create_verified_account, make_client, refresh_session a = create_verified_account("a", "a@a", "a") c.patch("/auth/users/me", json={"business": False, "country": "Germany"}) @@ -16,6 +19,7 @@ assert resp.status_code == 200 token = resp.json() +# invoices resp = c.get(f"/finance/invoices/{token}/2/invoice.pdf") assert resp.status_code == 200 assert resp.content == open("/var/lib/academy/invoices/R0000002.pdf", "rb").read() @@ -23,3 +27,31 @@ resp = c.get(f"/finance/invoices/{token}/1/invoice.pdf") assert resp.status_code == 404 assert resp.json() == {"detail": "Invoice not found"} + +# credit notes +c2 = make_client() +create_admin_account("adm", "adm@example.com", "adm", c2) +resp = c2.post(f"/shop/coins/{b['user']['id']}", json={"coins": 1337, "description": "hello world"}) +assert resp.status_code == 200 + +today = date.today() +resp = c.get(f"/finance/credit_notes/{token}/{today.year}/{today.month}/credit_note.pdf") +assert resp.status_code == 404 +assert resp.json() == {"detail": "Credit note not yet available"} + +os.system("date -s '+20days'") +refresh_session() +os.system("date -s '+20days'") +refresh_session() + +resp = c.get(f"/finance/credit_notes/{token}/{today.year}/{today.month}/credit_note.pdf") +assert resp.status_code == 401 +assert resp.json() == {"detail": "Invalid token"} + +resp = c.get("/finance/token") +assert resp.status_code == 200 +token = resp.json() + +resp = c.get(f"/finance/credit_notes/{token}/{today.year}/{today.month}/credit_note.pdf") +assert resp.status_code == 200 +assert resp.content == open(f"/var/lib/academy/credit_notes/G{today.year:04}{today.month:02}-1.pdf", "rb").read()