Skip to content

Commit

Permalink
feat(finance): add credit notes
Browse files Browse the repository at this point in the history
  • Loading branch information
Defelo committed Dec 4, 2024
1 parent 8dbc197 commit 1706b50
Show file tree
Hide file tree
Showing 34 changed files with 945 additions and 72 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ repl-result-*
.devenv
.lcov*
.invoices
.credit_notes
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions Cargo.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -1609,6 +1618,12 @@ rec {
usesDefaultFeatures = false;
features = [ "std" ];
}
{
name = "chrono";
packageId = "chrono";
usesDefaultFeatures = false;
features = [ "serde" "clock" ];
}
{
name = "rust_decimal";
packageId = "rust_decimal";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions academy/src/environment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down
16 changes: 12 additions & 4 deletions academy/src/environment/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ pub type OAuth2Login = OAuth2LoginServiceImpl<OAuth2Api>;
pub type OAuth2Registration = OAuth2RegistrationServiceImpl<Secret, Cache>;

pub type CoinFeature = CoinFeatureServiceImpl<Database, Auth, UserRepo, CoinRepo, Coin>;
pub type Coin = CoinServiceImpl<CoinRepo>;
pub type Coin = CoinServiceImpl<Id, Time, CoinRepo>;

pub type PaypalFeature = PaypalFeatureServiceImpl<
Database,
Expand All @@ -192,11 +192,19 @@ pub type PaypalFeature = PaypalFeatureServiceImpl<
FinanceInvoice,
FinanceCoin,
>;
pub type PaypalCoinOrder = PaypalCoinOrderServiceImpl<Time, PaypalRepo, CoinRepo>;
pub type PaypalCoinOrder = PaypalCoinOrderServiceImpl<Time, PaypalRepo, Coin>;

pub type FinanceFeature = FinanceFeatureServiceImpl<Database, Auth, Jwt, FinanceInvoice>;
pub type FinanceInvoice =
FinanceInvoiceServiceImpl<Fs, Template, RenderPdf, PaypalRepo, UserRepo, FinanceCoin>;
pub type FinanceInvoice = FinanceInvoiceServiceImpl<
Time,
Fs,
Template,
RenderPdf,
PaypalRepo,
UserRepo,
CoinRepo,
FinanceCoin,
>;
pub type FinanceCoin = FinanceCoinServiceImpl;

pub type Internal = InternalServiceImpl<Database, AuthInternal, UserRepo, Coin>;
40 changes: 36 additions & 4 deletions academy_api/rest/src/routes/finance.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::Arc;

use academy_core_finance_contracts::{
FinanceDownloadInvoiceError, FinanceFeatureService, FinanceGetDownloadTokenError,
FinanceDownloadError, FinanceFeatureService, FinanceGetDownloadTokenError,
};
use aide::{
axum::{routing, ApiRouter},
Expand Down Expand Up @@ -40,6 +40,10 @@ pub fn router(service: Arc<impl FinanceFeatureService>) -> 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))
}
Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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<Arc<impl FinanceFeatureService>>,
Path(DownloadCreditNotePath { token, year, month }): Path<DownloadCreditNotePath>,
) -> 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::<InvalidTokenError>()
.add_error::<CreditNoteNotYetAvailableError>()
.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");
}
1 change: 1 addition & 0 deletions academy_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
2 changes: 2 additions & 0 deletions academy_core/coin/impl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ 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

[dev-dependencies]
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
68 changes: 54 additions & 14 deletions academy_core/coin/impl/src/coin.rs
Original file line number Diff line number Diff line change
@@ -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<CoinRepo> {
#[derive(Debug, Clone, Build, Default)]
pub struct CoinServiceImpl<Id, Time, CoinRepo> {
id: Id,
time: Time,
coin_repo: CoinRepo,
}

impl<Txn, CoinRepo> CoinService<Txn> for CoinServiceImpl<CoinRepo>
impl<Txn, Id, Time, CoinRepo> CoinService<Txn> for CoinServiceImpl<Id, Time, CoinRepo>
where
Txn: Send + Sync + 'static,
Id: IdService,
Time: TimeService,
CoinRepo: CoinRepository<Txn>,
{
#[trace_instrument(skip(self, txn))]
Expand All @@ -24,28 +29,44 @@ where
user_id: UserId,
coins: i64,
withhold: bool,
// TODO: save transactions
_description: Option<TransactionDescription>,
_include_in_credit_note: bool,
description: Option<TransactionDescription>,
include_in_credit_note: bool,
) -> Result<Balance, CoinAddCoinsError> {
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<MockIdService, MockTimeService, MockCoinRepository<()>>;

#[tokio::test]
async fn add_coins_ok() {
// Arrange
Expand All @@ -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
Expand All @@ -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
Expand Down
29 changes: 28 additions & 1 deletion academy_core/finance/contracts/src/invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ pub trait FinanceInvoiceService<Txn: Send + Sync + 'static>: Send + Sync + 'stat
user_id: Option<UserId>,
invoice_number: u64,
) -> impl Future<Output = anyhow::Result<Option<Vec<u8>>>> + 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<Output = anyhow::Result<Option<Vec<u8>>>> + Send;
}

#[cfg(feature = "mock")]
impl<Txn: Send + Sync + 'static> MockFinanceInvoiceService<Txn> {
pub fn with_get_invoice_pdf(
mut self,

user_id: Option<UserId>,
invoice_number: u64,
result: Option<Vec<u8>>,
Expand All @@ -32,4 +40,23 @@ impl<Txn: Send + Sync + 'static> MockFinanceInvoiceService<Txn> {
.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<Vec<u8>>,
) -> 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
}
}
Loading

0 comments on commit 1706b50

Please sign in to comment.