Skip to content

Commit

Permalink
feat(paypal): store generated invoices to local filesystem
Browse files Browse the repository at this point in the history
  • Loading branch information
Defelo committed Nov 21, 2024
1 parent 9d2078d commit ad2a4bf
Show file tree
Hide file tree
Showing 15 changed files with 91 additions and 12 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
target
.env
result
result-*
repl-result-*
.direnv
.devenv
.lcov*
.invoices
1 change: 1 addition & 0 deletions academy/src/environment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ impl ConfigProvider {
let paypal_feature_config = PaypalFeatureConfig {
purchase_range: config.coin.purchase_min..=config.coin.purchase_max,
vat_percent: config.finance.vat_percent,
invoices_archive: config.finance.invoices_archive.clone().into(),
};

Ok(Self {
Expand Down
8 changes: 5 additions & 3 deletions academy/src/environment/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ use academy_persistence_postgres::{
};
use academy_render_impl::pdf::RenderPdfServiceImpl;
use academy_shared_impl::{
captcha::CaptchaServiceImpl, hash::HashServiceImpl, id::IdServiceImpl, jwt::JwtServiceImpl,
password::PasswordServiceImpl, secret::SecretServiceImpl, time::TimeServiceImpl,
totp::TotpServiceImpl,
captcha::CaptchaServiceImpl, fs::FsServiceImpl, hash::HashServiceImpl, id::IdServiceImpl,
jwt::JwtServiceImpl, password::PasswordServiceImpl, secret::SecretServiceImpl,
time::TimeServiceImpl, totp::TotpServiceImpl,
};
use academy_templates_impl::TemplateServiceImpl;

Expand Down Expand Up @@ -83,6 +83,7 @@ pub type RenderPdf = RenderPdfServiceImpl;

// Shared
pub type Captcha = CaptchaServiceImpl<RecaptchaApi>;
pub type Fs = FsServiceImpl;
pub type Hash = HashServiceImpl;
pub type Id = IdServiceImpl;
pub type Jwt = JwtServiceImpl<Time>;
Expand Down Expand Up @@ -187,6 +188,7 @@ pub type PaypalFeature = PaypalFeatureServiceImpl<
Template,
TemplateEmail,
RenderPdf,
Fs,
>;
pub type PaypalCoinOrder = PaypalCoinOrderServiceImpl<Time, PaypalRepo, CoinRepo>;

Expand Down
1 change: 1 addition & 0 deletions academy_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ pub struct RenderConfig {
#[derive(Debug, Deserialize)]
pub struct FinanceConfig {
pub vat_percent: Decimal,
pub invoices_archive: PathBuf,
}

#[derive(Debug, Deserialize)]
Expand Down
16 changes: 14 additions & 2 deletions academy_core/paypal/impl/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::ops::RangeInclusive;
use std::{ops::RangeInclusive, path::Path, sync::Arc};

use academy_auth_contracts::{AuthResultExt, AuthService};
use academy_core_paypal_contracts::{
Expand All @@ -15,7 +15,7 @@ use academy_persistence_contracts::{
paypal::PaypalRepository, user::UserRepository, Database, Transaction,
};
use academy_render_contracts::pdf::RenderPdfService;
use academy_shared_contracts::time::TimeService;
use academy_shared_contracts::{fs::FsService, time::TimeService};
use academy_templates_contracts::{
InvoiceItem, InvoiceTemplate, PurchaseConfirmationTemplate, TemplateService, LOGO_BASE64,
};
Expand All @@ -42,6 +42,7 @@ pub struct PaypalFeatureServiceImpl<
Template,
TemplateEmail,
RenderPdf,
Fs,
> {
db: Db,
auth: Auth,
Expand All @@ -53,13 +54,15 @@ pub struct PaypalFeatureServiceImpl<
template_email: TemplateEmail,
template: Template,
render_pdf: RenderPdf,
fs: Fs,
config: PaypalFeatureConfig,
}

#[derive(Debug, Clone)]
pub struct PaypalFeatureConfig {
pub purchase_range: RangeInclusive<u64>,
pub vat_percent: Decimal,
pub invoices_archive: Arc<Path>,
}

impl<
Expand All @@ -73,6 +76,7 @@ impl<
Template,
TemplateEmail,
RenderPdf,
Fs,
> PaypalFeatureService
for PaypalFeatureServiceImpl<
Db,
Expand All @@ -85,6 +89,7 @@ impl<
Template,
TemplateEmail,
RenderPdf,
Fs,
>
where
Db: Database,
Expand All @@ -97,6 +102,7 @@ where
Template: TemplateService,
TemplateEmail: TemplateEmailService,
RenderPdf: RenderPdfService,
Fs: FsService,
{
#[trace_instrument(skip(self))]
fn get_client_id(&self) -> &str {
Expand Down Expand Up @@ -199,6 +205,11 @@ where
let gross_total = dec!(0.01) * Decimal::from(coins);
debug_assert_eq!(gross_total, (net_total + vat_total).round_dp(4));

let archive_path = self
.config
.invoices_archive
.join(format!("{invoice_number}.pdf"));

let invoice_html = self
.template
.render(&InvoiceTemplate {
Expand Down Expand Up @@ -226,6 +237,7 @@ where
.render(&invoice_html)
.await
.context("Failed to render invoice pdf")?;
self.fs.store_file(&archive_path, &invoice_pdf).await?;

self.template_email
.send_purchase_confirmation_email(
Expand Down
7 changes: 5 additions & 2 deletions academy_core/paypal/impl/src/tests/capture_coin_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use academy_persistence_contracts::{
paypal::MockPaypalRepository, user::MockUserRepository, MockDatabase,
};
use academy_render_contracts::pdf::MockRenderPdfService;
use academy_shared_contracts::time::MockTimeService;
use academy_shared_contracts::{fs::MockFsService, time::MockTimeService};
use academy_templates_contracts::{
InvoiceItem, InvoiceTemplate, MockTemplateService, PurchaseConfirmationTemplate, LOGO_BASE64,
};
Expand Down Expand Up @@ -101,10 +101,12 @@ async fn ok() {
vat_total: dec!(0.01) / dec!(1.19) * Decimal::from(order.coins) * dec!(0.19),
gross_total: dec!(0.01) * Decimal::from(order.coins),
},
pdf,
pdf.clone(),
true,
);

let fs = MockFsService::new().with_store_file("/invoices/R0000042.pdf".into(), pdf);

let sut = PaypalFeatureServiceImpl {
auth,
db,
Expand All @@ -116,6 +118,7 @@ async fn ok() {
template,
render_pdf,
template_email,
fs,
..Sut::default()
};

Expand Down
6 changes: 5 additions & 1 deletion academy_core/paypal/impl/src/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::Path;

use academy_auth_contracts::MockAuthService;
use academy_core_paypal_contracts::coin_order::MockPaypalCoinOrderService;
use academy_email_contracts::template::MockTemplateEmailService;
Expand All @@ -6,7 +8,7 @@ use academy_persistence_contracts::{
paypal::MockPaypalRepository, user::MockUserRepository, MockDatabase, MockTransaction,
};
use academy_render_contracts::pdf::MockRenderPdfService;
use academy_shared_contracts::time::MockTimeService;
use academy_shared_contracts::{fs::MockFsService, time::MockTimeService};
use academy_templates_contracts::MockTemplateService;
use rust_decimal_macros::dec;

Expand All @@ -26,13 +28,15 @@ type Sut = PaypalFeatureServiceImpl<
MockTemplateService,
MockTemplateEmailService,
MockRenderPdfService,
MockFsService,
>;

impl Default for PaypalFeatureConfig {
fn default() -> Self {
Self {
purchase_range: 5..=5000,
vat_percent: dec!(19),
invoices_archive: Path::new("/invoices").into(),
}
}
}
26 changes: 26 additions & 0 deletions academy_shared/contracts/src/fs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
use std::{future::Future, path::Path};

#[cfg_attr(feature = "mock", mockall::automock)]
pub trait FsService: Send + Sync + 'static {
/// Write `content` into the file at `path`, creating the file if it does
/// not exist yet and otherwise overwriting its previous content.
fn store_file(
&self,
path: &Path,
content: &[u8],
) -> impl Future<Output = anyhow::Result<()>> + Send;
}

#[cfg(feature = "mock")]
impl MockFsService {
pub fn with_store_file(mut self, path: std::path::PathBuf, content: Vec<u8>) -> Self {
self.expect_store_file()
.once()
.with(
mockall::predicate::eq(path),
mockall::predicate::eq(content),
)
.return_once(|_, _| Box::pin(std::future::ready(Ok(()))));
self
}
}
1 change: 1 addition & 0 deletions academy_shared/contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod captcha;
pub mod fs;
pub mod hash;
pub mod id;
pub mod jwt;
Expand Down
16 changes: 16 additions & 0 deletions academy_shared/impl/src/fs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use std::path::Path;

use academy_di::Build;
use academy_shared_contracts::fs::FsService;

#[derive(Debug, Clone, Build)]
pub struct FsServiceImpl;

impl FsService for FsServiceImpl {
async fn store_file(&self, path: &Path, content: &[u8]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await.map_err(Into::into)
}
}
1 change: 1 addition & 0 deletions academy_shared/impl/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod captcha;
pub mod fs;
pub mod hash;
pub mod id;
pub mod jwt;
Expand Down
5 changes: 4 additions & 1 deletion config.dev.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[http]
address = "127.0.0.1:8000"
allowed_origins = [".*"] # RegexSet
allowed_origins = [".*"]

[database]
url = "postgres://[email protected]:5432/academy" # https://docs.rs/tokio-postgres/latest/tokio_postgres/config/struct.Config.html
Expand Down Expand Up @@ -38,6 +38,9 @@ client_secret = "test-secret"
[render]
chrome_bin = "/usr/bin/chromium"

[finance]
invoices_archive = ".invoices"

[oauth2.providers.test]
enable = true
name = "Test"
Expand Down
3 changes: 2 additions & 1 deletion config.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[http]
# address = "0.0.0.0:80"
# real_ip = { header = "X-Real-Ip", set_from = "127.0.0.1" }
allowed_origins = [] # RegexSet
allowed_origins = []

[database]
# url = "" # https://docs.rs/tokio-postgres/latest/tokio_postgres/config/struct.Config.html
Expand Down Expand Up @@ -79,6 +79,7 @@ purchase_max = 1000000

[finance]
vat_percent = 19
# invoices_archive = ""

# [sentry]
# enable = true
Expand Down
2 changes: 2 additions & 0 deletions nix/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ in {
serviceConfig = {
User = "academy";
Group = "academy";
StateDirectory = "academy";
};

environment = {
Expand Down Expand Up @@ -138,6 +139,7 @@ in {
database.url = lib.mkIf cfg.localDatabase "host=/run/postgresql user=academy";
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";
};

environment.systemPackages = [wrapper];
Expand Down
7 changes: 5 additions & 2 deletions nix/tests/paypal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import hashlib
from io import BytesIO
from typing import cast

from pypdf import PdfReader
from utils import c, create_verified_account, decode_mail_header, decode_mail_part, fetch_mail, get_mail_parts
Expand Down Expand Up @@ -69,13 +68,15 @@

assert invoice["Content-Disposition"] == 'attachment; filename="rechnung.pdf"'
assert invoice["Content-Type"] == "application/pdf"
pdf = PdfReader(BytesIO(decode_mail_part(invoice)))
invoice_pdf = decode_mail_part(invoice)
pdf = PdfReader(BytesIO(invoice_pdf))
assert pdf.metadata and pdf.metadata.title == "Rechnung"
assert len(pdf.pages) == 1
invoice_text = pdf.pages[0].extract_text()
assert "Nettobetrag 11.24 EUR" in invoice_text
assert "zzgl. 19% MwSt. 2.13 EUR" in invoice_text
assert "Gesamtbetrag 13.37 EUR" in invoice_text
assert "Rechnungs-Nr. R0000001" in invoice_text

assert terms["Content-Disposition"] == 'attachment;\n filename*0="allgemeine_geschaeftsbedingungen.pdf"'
assert terms["Content-Type"] == "application/pdf"
Expand All @@ -87,3 +88,5 @@
assert revocation_policy["Content-Type"] == "application/pdf"
hash = hashlib.sha256(decode_mail_part(revocation_policy)).hexdigest()
assert hash == "046c90a8d66a67acbb6e5154b83a3b61ef3b17ec0f4a91bea189b1c5d1076d74"

assert open("/var/lib/academy/invoices/R0000001.pdf", "rb").read() == invoice_pdf

0 comments on commit ad2a4bf

Please sign in to comment.