From c618c1e1339cfa2fca8dc936430b88d7030a6954 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 13:42:10 +0100 Subject: [PATCH 1/6] improved the mathematics --- bin/payego/tests/payment_service_test.rs | 2 +- bin/payego/tests/wallet_tests.rs | 30 ++++++++----------- bin/payego/tests/withdrawal_service_test.rs | 6 ++-- crates/api/src/handlers/login.rs | 2 ++ .../core/src/services/auth_service/login.rs | 4 +++ crates/core/src/services/payment_service.rs | 15 ++++------ crates/core/src/services/transfer_service.rs | 6 ++-- .../core/src/services/withdrawal_service.rs | 12 +++----- .../primitives/src/models/dtos/wallet_dto.rs | 22 +++++++------- payego_ui/src/api/transactions.ts | 26 ++++++++++++---- payego_ui/src/components/ConvertForm.tsx | 2 +- 11 files changed, 68 insertions(+), 59 deletions(-) diff --git a/bin/payego/tests/payment_service_test.rs b/bin/payego/tests/payment_service_test.rs index 2672e95..1acf0e1 100644 --- a/bin/payego/tests/payment_service_test.rs +++ b/bin/payego/tests/payment_service_test.rs @@ -71,7 +71,7 @@ async fn test_top_up_paypal_init_success() { // 4. Call Service let req = TopUpRequest { - amount: 1000.0, // $10.00 + amount: 1000, // $10.00 currency: CurrencyCode::USD, provider: PaymentProvider::Paypal, idempotency_key: "topup_1".to_string(), diff --git a/bin/payego/tests/wallet_tests.rs b/bin/payego/tests/wallet_tests.rs index 8267f5a..832b39f 100644 --- a/bin/payego/tests/wallet_tests.rs +++ b/bin/payego/tests/wallet_tests.rs @@ -8,7 +8,7 @@ use validator::Validate; fn test_transfer_request_validation() { // Valid request let req = serde_json::from_value::(json!({ - "amount": 100.0, + "amount": 10000, // $100.00 in cents "bank_code": "057", "account_number": "1234567890", "currency": "USD", @@ -20,7 +20,7 @@ fn test_transfer_request_validation() { // Invalid amount (too low) let req = serde_json::from_value::(json!({ - "amount": 0.5, + "amount": 50, // 50 cents, too low (min 100) "bank_code": "057", "account_number": "1234567890", "currency": "USD", @@ -32,7 +32,7 @@ fn test_transfer_request_validation() { // Invalid amount (too high) let req = serde_json::from_value::(json!({ - "amount": 20000.0, + "amount": 2000000, // $20,000 in cents, too high (max 1,000,000) "bank_code": "057", "account_number": "1234567890", "currency": "USD", @@ -44,7 +44,7 @@ fn test_transfer_request_validation() { // Invalid account number (too short) let req = serde_json::from_value::(json!({ - "amount": 100.0, + "amount": 10000, "bank_code": "057", "account_number": "123", "currency": "USD", @@ -60,7 +60,7 @@ fn test_transfer_request_validation() { // Invalid currency let req = serde_json::from_value::(json!({ - "amount": 100.0, + "amount": 10000, "bank_code": "057", "account_number": "1234567890", "currency": "UNKNOWN", @@ -73,18 +73,14 @@ fn test_transfer_request_validation() { } #[test] -fn test_amount_to_cents_conversion() { - // This is essentially testing the logic inside the handler - // Since it's not a separate function yet, we'll just verify the math logic - let amount: f64 = 10.99; - let cents = (amount * 100.0).round() as i64; - assert_eq!(cents, 1099); +fn test_amount_logic() { + // Basic verification that amounts are now integers + let amount: i64 = 1099; + assert_eq!(amount, 1099); - let amount: f64 = 0.01; - let cents = (amount * 100.0).round() as i64; - assert_eq!(cents, 1); + let amount: i64 = 1; + assert_eq!(amount, 1); - let amount: f64 = 100.0; - let cents = (amount * 100.0).round() as i64; - assert_eq!(cents, 10000); + let amount: i64 = 10000; + assert_eq!(amount, 10000); } diff --git a/bin/payego/tests/withdrawal_service_test.rs b/bin/payego/tests/withdrawal_service_test.rs index 763b3d5..b8423e3 100644 --- a/bin/payego/tests/withdrawal_service_test.rs +++ b/bin/payego/tests/withdrawal_service_test.rs @@ -131,7 +131,7 @@ async fn test_withdrawal_success() { } let req = WithdrawRequest { - amount: 10.0, + amount: 10, currency: CurrencyCode::USD, reference: Uuid::new_v4(), idempotency_key: format!("withdraw_key_{}", Uuid::new_v4()), @@ -202,7 +202,7 @@ async fn test_withdrawal_insufficient_balance() { .unwrap(); let req = WithdrawRequest { - amount: 10.0, + amount: 10, currency: CurrencyCode::USD, reference: Uuid::new_v4(), idempotency_key: "any".to_string(), @@ -227,7 +227,7 @@ async fn test_withdrawal_unsupported_currency() { let bank_id = Uuid::new_v4(); let req = WithdrawRequest { - amount: 10.0, + amount: 10, currency: CurrencyCode::USD, // Changed to valid enum variant, as parsing would fail earlier or logic handles it reference: Uuid::new_v4(), idempotency_key: "any".to_string(), diff --git a/crates/api/src/handlers/login.rs b/crates/api/src/handlers/login.rs index 4e1bb0c..9ef95f1 100755 --- a/crates/api/src/handlers/login.rs +++ b/crates/api/src/handlers/login.rs @@ -4,6 +4,7 @@ use payego_core::services::auth_service::login::{ }; use payego_primitives::error::ApiErrorResponse; use std::sync::Arc; +use tracing::info; #[utoipa::path( post, @@ -34,6 +35,7 @@ pub async fn login( State(state): State>, Json(payload): Json, ) -> Result, ApiError> { + info!("email: {}, password: {}", payload.email, payload.password); let response = LoginService::login(&state, payload.normalize()).await?; Ok(Json(response)) } diff --git a/crates/core/src/services/auth_service/login.rs b/crates/core/src/services/auth_service/login.rs index 60abd7f..581bd2d 100644 --- a/crates/core/src/services/auth_service/login.rs +++ b/crates/core/src/services/auth_service/login.rs @@ -52,6 +52,10 @@ impl LoginService { .map(|u| u.password_hash.as_str()) .unwrap_or(Self::dummy_hash()); + info!("password: {}", password); + info!("email: {}", user.unwrap().email); + info!("user hash: {}, hashed_password: {}", user.unwrap().password_hash, hash); + let parsed = PasswordHash::new(hash).map_err(|_| { error!("auth.login: invalid password hash"); ApiError::Internal("Authentication failure".into()) diff --git a/crates/core/src/services/payment_service.rs b/crates/core/src/services/payment_service.rs index 3ce58ff..785497f 100644 --- a/crates/core/src/services/payment_service.rs +++ b/crates/core/src/services/payment_service.rs @@ -36,7 +36,7 @@ impl PaymentService { ApiError::DatabaseConnection(e.to_string()) })?; - let amount_cents = Self::convert_to_cents(req.amount)?; + let amount_cents = req.amount; let reference = Uuid::new_v4(); // ---------- DB-ENFORCED IDEMPOTENCY ---------- @@ -74,12 +74,6 @@ impl PaymentService { }) } - fn convert_to_cents(amount: f64) -> Result { - if amount <= 0.0 { - return Err(ApiError::Payment("Amount must be positive".into())); - } - Ok((amount * 100.0).round() as i64) - } async fn initiate_stripe( state: &AppState, @@ -197,9 +191,10 @@ impl PaymentService { return Err(ApiError::Internal("Invalid currency code".into())); } - // Pre-format amount safely - let amount_str = format!("{:.2}", req.amount); - if req.amount <= 0.0 { + // Pre-format amount safely using integer math + let amount_cents = req.amount; + let amount_str = format!("{}.{:02}", amount_cents / 100, amount_cents % 100); + if amount_cents <= 0 { return Err(ApiError::Internal( "Amount must be greater than zero".into(), )); diff --git a/crates/core/src/services/transfer_service.rs b/crates/core/src/services/transfer_service.rs index 0f17bb4..902b0c9 100644 --- a/crates/core/src/services/transfer_service.rs +++ b/crates/core/src/services/transfer_service.rs @@ -39,7 +39,7 @@ impl TransferService { .get() .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; - let amount_cents = (req.amount * 100.0).round() as i64; + let amount_cents = req.amount; if amount_cents <= 0 { return Err(ApiError::Internal("Amount must be positive".into())); } @@ -176,7 +176,7 @@ impl TransferService { .parse() .map_err(|_| ApiError::Internal("Unsupported currency".into()))?; - let amount_minor = (req.amount * 100.0).round() as i64; + let amount_minor = req.amount; if amount_minor <= 0 { return Err(ApiError::Internal("Amount must be positive".into())); } @@ -316,7 +316,7 @@ impl TransferService { .join("transfer") .map_err(|_| ApiError::Internal("Invalid Paystack transfer URL".into()))?; - let amount_kobo = (req.amount * 100.0).round() as i64; + let amount_kobo = req.amount; if amount_kobo <= 0 { return Err(ApiError::Internal("Invalid transfer amount".into())); diff --git a/crates/core/src/services/withdrawal_service.rs b/crates/core/src/services/withdrawal_service.rs index 6e95fef..4ff28d3 100644 --- a/crates/core/src/services/withdrawal_service.rs +++ b/crates/core/src/services/withdrawal_service.rs @@ -35,7 +35,10 @@ impl WithdrawalService { bank_account_id: Uuid, req: WithdrawRequest, ) -> Result { - let amount_minor = Self::convert_to_minor_units(req.amount)?; + let amount_minor = req.amount; + if amount_minor <= 0 { + return Err(ApiError::Internal("Invalid amount".into())); + } let mut conn = state .db @@ -117,13 +120,6 @@ impl WithdrawalService { }) } - fn convert_to_minor_units(amount: f64) -> Result { - if amount <= 0.0 { - return Err(ApiError::Internal("Invalid amount".into())); - } - - Ok((amount * 100.0).round() as i64) - } async fn initiate_paystack_transfer( state: &AppState, diff --git a/crates/primitives/src/models/dtos/wallet_dto.rs b/crates/primitives/src/models/dtos/wallet_dto.rs index a9be97f..e5d9793 100644 --- a/crates/primitives/src/models/dtos/wallet_dto.rs +++ b/crates/primitives/src/models/dtos/wallet_dto.rs @@ -42,8 +42,8 @@ pub struct WalletSummaryDto { #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct TransferRequest { - #[validate(range(min = 1.0, max = 10000.0))] - pub amount: f64, + #[validate(range(min = 100, max = 1000000))] + pub amount: i64, // cents pub currency: String, pub bank_code: String, pub account_number: String, @@ -55,8 +55,8 @@ pub struct TransferRequest { #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct WalletTransferRequest { pub recipient: Uuid, - #[validate(range(min = 1.0, max = 10000.0))] - pub amount: f64, + #[validate(range(min = 100, max = 1000000))] + pub amount: i64, // cents pub currency: CurrencyCode, pub description: Option, pub reference: Uuid, @@ -84,8 +84,8 @@ pub struct TransferResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct WithdrawRequest { - #[validate(range(min = 0.01))] - pub amount: f64, + #[validate(range(min = 1))] + pub amount: i64, // cents pub currency: CurrencyCode, pub reference: Uuid, #[validate(length(min = 10, max = 128))] @@ -101,8 +101,8 @@ pub struct WithdrawResponse { #[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct TopUpRequest { - #[validate(range(min = 1.0, max = 10_000.0))] - pub amount: f64, + #[validate(range(min = 100, max = 1000000))] + pub amount: i64, // cents pub provider: PaymentProvider, #[schema(example = "NGN")] pub currency: CurrencyCode, @@ -115,7 +115,7 @@ pub struct TopUpResponse { pub session_url: Option, pub payment_id: Option, pub transaction_id: String, - pub amount: f64, + pub amount: i64, // cents } // --- Conversion DTOs --- @@ -153,8 +153,8 @@ pub struct ConvertQuoteRequest { pub struct ConvertQuoteResponse { pub quote_id: Uuid, pub exchange_rate: f64, - pub fee: f64, - pub net_amount: f64, + pub fee: i64, // cents + pub net_amount: i64, // cents pub expires_at: DateTime, } diff --git a/payego_ui/src/api/transactions.ts b/payego_ui/src/api/transactions.ts index 96eb565..7920ca6 100644 --- a/payego_ui/src/api/transactions.ts +++ b/payego_ui/src/api/transactions.ts @@ -7,11 +7,27 @@ export const transactionApi = { topUp: (data: any) => { // Generate a unique idempotency key for this request const idempotencyKey = crypto.randomUUID(); - return client.post('/api/wallet/top_up', { ...data, idempotency_key: idempotencyKey }).then(res => res.data); + return client.post('/api/wallet/top_up', { + ...data, + amount: Math.round(data.amount * 100), + idempotency_key: idempotencyKey + }).then(res => res.data); }, - internalTransfer: (data: any) => client.post('/api/transfer/internal', data).then(res => res.data), - externalTransfer: (data: any) => client.post('/api/transfer/external', data).then(res => res.data), - withdraw: (bankAccountId: string, data: any) => client.post(`/api/wallet/withdraw/${bankAccountId}`, data).then(res => res.data), - convertCurrency: (data: any) => client.post('/api/wallets/convert', data).then(res => res.data), + internalTransfer: (data: any) => client.post('/api/transfer/internal', { + ...data, + amount: Math.round(data.amount * 100) + }).then(res => res.data), + externalTransfer: (data: any) => client.post('/api/transfer/external', { + ...data, + amount: Math.round(data.amount * 100) + }).then(res => res.data), + withdraw: (bankAccountId: string, data: any) => client.post(`/api/wallet/withdraw/${bankAccountId}`, { + ...data, + amount: Math.round(data.amount * 100) + }).then(res => res.data), + convertCurrency: (data: any) => client.post('/api/wallets/convert', { + ...data, + amount_cents: Math.round((data.amount_cents || data.amount) * 100) + }).then(res => res.data), getExchangeRate: (from: string, to: string) => client.get<{ from: string, to: string, rate: number }>(`/api/exchange-rate?from=${from}&to=${to}`).then(res => res.data), }; diff --git a/payego_ui/src/components/ConvertForm.tsx b/payego_ui/src/components/ConvertForm.tsx index 298d7e9..48a928a 100644 --- a/payego_ui/src/components/ConvertForm.tsx +++ b/payego_ui/src/components/ConvertForm.tsx @@ -83,7 +83,7 @@ const ConvertForm: React.FC = () => { setError(null); try { const result = await transactionApi.convertCurrency({ - amount_cents: Math.round(data.amount * 100), + amount: data.amount, from_currency: data.fromCurrency, to_currency: data.toCurrency, idempotency_key: crypto.randomUUID() From 72027e607b9c1bdf7a60680e3570e1e1ee835a2c Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 15:28:26 +0100 Subject: [PATCH 2/6] improved the clients --- Cargo.lock | 124 ++++++- Cargo.toml | 2 +- bin/payego/src/lib.rs | 2 +- bin/payego/src/utility/clean_up_tasks.rs | 2 +- bin/payego/src/utility/tasks.rs | 2 +- bin/payego/tests/auth_tests.rs | 2 +- bin/payego/tests/common/mod.rs | 25 +- crates/api/src/app.rs | 15 +- crates/api/src/handlers/current_user.rs | 3 +- crates/api/src/handlers/health.rs | 2 +- crates/api/src/handlers/resolve_account.rs | 11 +- crates/api/src/handlers/resolve_user.rs | 2 +- crates/api/src/handlers/top_up.rs | 6 +- crates/core/Cargo.toml | 3 + crates/core/src/app_state.rs | 55 ++++ crates/core/src/clients/email.rs | 26 ++ crates/core/src/clients/exchange_rate.rs | 55 ++++ crates/core/src/clients/mod.rs | 9 + .../src/{client.rs => clients/paystack.rs} | 52 ++- crates/core/src/clients/stripe.rs | 66 ++++ crates/core/src/lib.rs | 7 +- .../src/security.rs} | 7 +- .../core/src/services/auth_service/login.rs | 4 +- .../core/src/services/auth_service/logout.rs | 5 +- .../src/services/auth_service/register.rs | 4 +- .../core/src/services/auth_service/token.rs | 4 +- crates/core/src/services/auth_service/user.rs | 5 +- .../core/src/services/bank_account_service.rs | 148 ++------- crates/core/src/services/bank_service.rs | 2 +- .../core/src/services/conversion_service.rs | 65 +--- crates/core/src/services/payment_service.rs | 302 ++++++------------ crates/core/src/services/paypal_service.rs | 2 +- crates/core/src/services/paystack_service.rs | 2 +- crates/core/src/services/stripe_service.rs | 3 +- .../core/src/services/transaction_service.rs | 4 +- crates/core/src/services/transfer_service.rs | 172 ++-------- crates/core/src/services/wallet_service.rs | 4 +- .../core/src/services/withdrawal_service.rs | 129 ++------ crates/primitives/src/config/mod.rs | 2 +- .../src/models/app_state/app_config.rs | 10 + crates/primitives/src/models/app_state/mod.rs | 2 - .../primitives/src/models/app_state/state.rs | 30 -- 42 files changed, 633 insertions(+), 744 deletions(-) create mode 100644 crates/core/src/app_state.rs create mode 100644 crates/core/src/clients/email.rs create mode 100644 crates/core/src/clients/exchange_rate.rs create mode 100644 crates/core/src/clients/mod.rs rename crates/core/src/{client.rs => clients/paystack.rs} (75%) create mode 100644 crates/core/src/clients/stripe.rs rename crates/{primitives/src/config/security_config.rs => core/src/security.rs} (97%) mode change 100755 => 100644 delete mode 100644 crates/primitives/src/models/app_state/state.rs diff --git a/Cargo.lock b/Cargo.lock index 3ee3f66..e042fc5 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -346,6 +355,16 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -728,6 +747,16 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + [[package]] name = "email_address" version = "0.2.9" @@ -1131,6 +1160,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -1569,7 +1602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", "serde_core", ] @@ -1651,6 +1684,34 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand 2.3.0", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.0", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libc" version = "0.2.175" @@ -1830,6 +1891,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonempty" version = "0.7.0" @@ -1940,6 +2010,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2111,9 +2190,12 @@ dependencies = [ "chrono", "dashmap 7.0.0-rc2", "diesel", + "eyre", "hex", "hmac 0.13.0-rc.3", "http 1.4.0", + "jsonwebtoken", + "lettre", "once_cell", "password-hash", "payego-primitives", @@ -2311,6 +2393,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quanta" version = "0.12.6" @@ -2335,6 +2427,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -2641,7 +2739,9 @@ version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3021,6 +3121,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3834,6 +3947,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 4c79eff..afe6574 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ openssl = { version = "0.10", features = ["vendored"] } secrecy = { version = "0.10", features = ["serde"] } reqwest = { version = "0.12.23", features = ["json", "native-tls"] } rand = "0.7.3" -lettre = "0.11.18" +lettre = { version = "0.11.18", default-features = false } url = "2.5.7" tower_governor = "0.8.0" axum-prometheus = "0.10.0" diff --git a/bin/payego/src/lib.rs b/bin/payego/src/lib.rs index 11bb679..9a16804 100644 --- a/bin/payego/src/lib.rs +++ b/bin/payego/src/lib.rs @@ -13,7 +13,7 @@ use crate::utility::server::serve; use crate::utility::tasks::{build_router, initialize_system, load_env}; use eyre::Report; use payego_primitives::models::app_config::AppConfig; -use payego_primitives::models::app_state::AppState; +use payego_core::app_state::AppState; use tracing::info; pub async fn run() -> Result<(), Report> { diff --git a/bin/payego/src/utility/clean_up_tasks.rs b/bin/payego/src/utility/clean_up_tasks.rs index f5e3ad7..467fcbc 100644 --- a/bin/payego/src/utility/clean_up_tasks.rs +++ b/bin/payego/src/utility/clean_up_tasks.rs @@ -1,7 +1,7 @@ use diesel::ExpressionMethods; use diesel::QueryDsl; use diesel::RunQueryDsl; -use payego_primitives::models::app_state::AppState; +use payego_core::AppState; use payego_primitives::schema::{blacklisted_tokens, refresh_tokens}; use std::sync::Arc; use std::time::Duration; diff --git a/bin/payego/src/utility/tasks.rs b/bin/payego/src/utility/tasks.rs index 1cb6d2b..7d89bd4 100644 --- a/bin/payego/src/utility/tasks.rs +++ b/bin/payego/src/utility/tasks.rs @@ -6,7 +6,7 @@ use axum_prometheus::{metrics_exporter_prometheus::PrometheusHandle, PrometheusM use eyre::Report; use http::HeaderValue; use payego_api::handlers::initialize_banks::initialize_banks; -use payego_primitives::models::app_state::AppState; +use payego_core::app_state::AppState; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; diff --git a/bin/payego/tests/auth_tests.rs b/bin/payego/tests/auth_tests.rs index 850e32f..f97c667 100644 --- a/bin/payego/tests/auth_tests.rs +++ b/bin/payego/tests/auth_tests.rs @@ -1,7 +1,7 @@ mod common; use common::create_test_app_state; -use payego_primitives::config::security_config::SecurityConfig; +use payego_core::SecurityConfig; use serial_test::serial; #[tokio::test] diff --git a/bin/payego/tests/common/mod.rs b/bin/payego/tests/common/mod.rs index 80c0706..7fe9fd0 100644 --- a/bin/payego/tests/common/mod.rs +++ b/bin/payego/tests/common/mod.rs @@ -8,10 +8,15 @@ use payego_primitives::models::app_state::jwt_details::JWTInfo; use payego_primitives::models::app_state::paypal_details::PaypalInfo; use payego_primitives::models::app_state::paystack_details::PaystackInfo; use payego_primitives::models::app_state::stripe_details::StripeInfo; -use payego_primitives::models::app_state::AppState; use secrecy::SecretString; use std::sync::Arc; use uuid::Uuid; +use payego_core::{ + clients::{ + exchange_rate::ExchangeRateClient, paystack::PaystackClient, stripe::StripeClient, + }, + AppState, +}; pub mod fixtures; pub mod helpers; @@ -76,12 +81,30 @@ pub fn create_test_app_state() -> Arc { paypal_details: paypal_config, exchange_api_url: "http://localhost:8080/mock/exchange".to_string(), default_country: "Nigeria".to_string(), + rate_limit_rps: 100, // Higher for tests + rate_limit_burst: 200, }; + let paystack_client = PaystackClient::new( + reqwest::Client::new(), + &app_config.paystack_details.paystack_api_url, + app_config.paystack_details.paystack_secret_key.clone(), + ) + .expect("Failed to create Paystack client"); + + let stripe_client = StripeClient::new(&app_config.stripe_details); + + let fx_client = ExchangeRateClient::new(reqwest::Client::new(), &app_config.exchange_api_url) + .expect("Failed to create FX client"); + let state_arc = Arc::new(AppState { db: create_test_db_pool(), http_client: reqwest::Client::new(), config: app_config, + paystack: paystack_client, + stripe: stripe_client, + fx: fx_client, + email: Default::default(), }); INIT.call_once(|| { diff --git a/crates/api/src/app.rs b/crates/api/src/app.rs index 5eb2091..825c753 100644 --- a/crates/api/src/app.rs +++ b/crates/api/src/app.rs @@ -30,8 +30,8 @@ use axum::{ Router, }; use axum_prometheus::{metrics_exporter_prometheus::PrometheusHandle, PrometheusMetricLayer}; -// use payego_primitives::config::security_config::auth_middleware; -use payego_primitives::models::app_state::AppState; +use payego_core::app_state::AppState; +use payego_core::security::SecurityConfig; use std::sync::Arc; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -40,7 +40,6 @@ use crate::handlers::delete_bank::delete_bank_account; use crate::handlers::paypal_order::get_paypal_order; use crate::handlers::refresh_token::refresh_token; use crate::handlers::resolve_user::resolve_user; -use payego_primitives::config::security_config::SecurityConfig; use tower::ServiceBuilder; use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer}; use tower_http::{ @@ -48,12 +47,6 @@ use tower_http::{ trace::TraceLayer, }; -const REQUESTS_PER_SECOND: u64 = 2; -const BURST_SIZE: u32 = 10; - -//this fails at compile time and not runtime -const _: () = assert!(REQUESTS_PER_SECOND > 0); -const _: () = assert!(BURST_SIZE > 0); pub fn create_router( state: Arc, @@ -64,8 +57,8 @@ pub fn create_router( let governor_conf = Arc::new( GovernorConfigBuilder::default() - .per_second(REQUESTS_PER_SECOND) // 2 requests per second = 120 per minute - .burst_size(BURST_SIZE) + .per_second(state.config.rate_limit_rps) + .burst_size(state.config.rate_limit_burst) .finish() .expect("Invalid rate limiter configuration: "), ); diff --git a/crates/api/src/handlers/current_user.rs b/crates/api/src/handlers/current_user.rs index bc80e39..f08027e 100755 --- a/crates/api/src/handlers/current_user.rs +++ b/crates/api/src/handlers/current_user.rs @@ -2,8 +2,9 @@ use axum::{ extract::{Extension, State}, Json, }; +use payego_core::{AppState, Claims}; use payego_core::services::auth_service::user::{ - ApiError, AppState, Claims, CurrentUserResponse, UserService, + ApiError, CurrentUserResponse, UserService, }; use payego_primitives::error::ApiErrorResponse; use std::sync::Arc; diff --git a/crates/api/src/handlers/health.rs b/crates/api/src/handlers/health.rs index 54055df..d666bce 100644 --- a/crates/api/src/handlers/health.rs +++ b/crates/api/src/handlers/health.rs @@ -1,6 +1,6 @@ use axum::{extract::State, http::StatusCode, Json}; use diesel::prelude::*; -use payego_primitives::models::app_state::AppState; +use payego_core::AppState; use payego_primitives::models::dtos::auth_dto::HealthStatus; use std::sync::Arc; use tracing::error; diff --git a/crates/api/src/handlers/resolve_account.rs b/crates/api/src/handlers/resolve_account.rs index df1266c..b6c0924 100755 --- a/crates/api/src/handlers/resolve_account.rs +++ b/crates/api/src/handlers/resolve_account.rs @@ -38,9 +38,16 @@ pub async fn resolve_account( State(state): State>, Query(req): Query, ) -> Result, ApiError> { + let account_len = req.account_number.len(); + let display_account = if account_len >= 4 { + format!("****{}", &req.account_number[account_len - 4..]) + } else { + req.account_number.clone() + }; + info!( - "Resolving account ****{} @ {}", - &req.account_number[6..], + "Resolving account {} @ {}", + display_account, req.bank_code ); diff --git a/crates/api/src/handlers/resolve_user.rs b/crates/api/src/handlers/resolve_user.rs index fcd13d1..1a5c68b 100644 --- a/crates/api/src/handlers/resolve_user.rs +++ b/crates/api/src/handlers/resolve_user.rs @@ -27,7 +27,7 @@ pub async fn resolve_user( let mut conn = state .db .get() - .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + .map_err(|e: r2d2::Error| ApiError::DatabaseConnection(e.to_string()))?; let user = users::table .filter(users::email.eq(¶ms.identifier)) diff --git a/crates/api/src/handlers/top_up.rs b/crates/api/src/handlers/top_up.rs index 93178a1..8373757 100755 --- a/crates/api/src/handlers/top_up.rs +++ b/crates/api/src/handlers/top_up.rs @@ -55,7 +55,7 @@ pub async fn top_up( let user_id = claims.user_id()?; - Ok(Json( - PaymentService::initiate_top_up(&state, user_id, req).await?, - )) + let response = PaymentService::initiate_top_up(&state, user_id, req).await?; + + Ok(Json(response)) } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b6e8eb2..fc31e3d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -33,4 +33,7 @@ argon2 = "0.5.3" password-hash = "0.5.0" rand_core = "0.6.4" thiserror.workspace = true +jsonwebtoken.workspace = true +eyre.workspace = true +lettre = { workspace = true, default-features = false, features = ["tokio1-rustls-tls", "builder", "smtp-transport"] } diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs new file mode 100644 index 0000000..9b60691 --- /dev/null +++ b/crates/core/src/app_state.rs @@ -0,0 +1,55 @@ +use diesel::r2d2::{self, ConnectionManager}; +use diesel::PgConnection; +use reqwest::Client; +use std::sync::Arc; +use std::time::Duration; + +type DbPool = r2d2::Pool>; + +pub use payego_primitives::models::app_config::AppConfig; +use crate::clients::{PaystackClient, StripeClient, ExchangeRateClient, EmailClient}; +use eyre::Result; + +#[derive(Clone)] +pub struct AppState { + pub db: DbPool, + pub http_client: Client, + pub config: AppConfig, + pub paystack: PaystackClient, + pub stripe: StripeClient, + pub fx: ExchangeRateClient, + pub email: EmailClient, +} + +impl AppState { + pub fn new(db: DbPool, config: AppConfig) -> Result> { + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let paystack = PaystackClient::new( + http.clone(), + &config.paystack_details.paystack_api_url, + config.paystack_details.paystack_secret_key.clone(), + )?; + + let stripe = StripeClient::new(&config.stripe_details); + + let fx = ExchangeRateClient::new( + http.clone(), + &config.exchange_api_url, + )?; + + let email = EmailClient::new(); + + Ok(Arc::new(Self { + db, + http_client: http, + config, + paystack, + stripe, + fx, + email, + })) + } +} diff --git a/crates/core/src/clients/email.rs b/crates/core/src/clients/email.rs new file mode 100644 index 0000000..3a9b7e3 --- /dev/null +++ b/crates/core/src/clients/email.rs @@ -0,0 +1,26 @@ +use payego_primitives::error::ApiError; +// use lettre::{SmtpTransport, Transport, Message}; +// use secrecy::ExposeSecret; + +#[derive(Clone)] +pub struct EmailClient { + // transport: SmtpTransport, +} + +impl Default for EmailClient { + fn default() -> Self { + Self::new() + } +} + +impl EmailClient { + pub fn new() -> Self { + Self {} + } + + pub async fn send_email(&self, _to: &str, _subject: &str, _body: &str) -> Result<(), ApiError> { + // Placeholder for real email sending logic + tracing::info!("Sending email to: {}, subject: {}", _to, _subject); + Ok(()) + } +} diff --git a/crates/core/src/clients/exchange_rate.rs b/crates/core/src/clients/exchange_rate.rs new file mode 100644 index 0000000..3bc39c4 --- /dev/null +++ b/crates/core/src/clients/exchange_rate.rs @@ -0,0 +1,55 @@ +use payego_primitives::error::ApiError; +use payego_primitives::models::enum_types::CurrencyCode; +use payego_primitives::models::dtos::wallet_dto::ExchangeRateResponse; +use reqwest::{Client, Url}; +use std::time::Duration; + +#[derive(Clone)] +pub struct ExchangeRateClient { + http: Client, + base_url: Url, +} + +impl ExchangeRateClient { + pub fn new(http: Client, base_url: &str) -> Result { + let base_url = Url::parse(base_url) + .map_err(|_| ApiError::Internal("Invalid FX base URL".into()))?; + Ok(Self { http, base_url }) + } + + pub async fn get_rate(&self, from: CurrencyCode, to: CurrencyCode) -> Result { + if from == to { + return Ok(1.0); + } + + let mut url = self.base_url.clone(); + url.path_segments_mut() + .map_err(|_| ApiError::Internal("Invalid FX URL path".into()))? + .push(from.to_string().as_str()); + + let resp = self.http + .get(url) + .timeout(Duration::from_secs(5)) + .send() + .await + .map_err(|e| ApiError::Payment(format!("FX API unreachable: {}", e)))?; + + let status = resp.status(); + let body = resp + .json::() + .await + .map_err(|_| ApiError::Payment("Invalid FX response".into()))?; + + if !status.is_success() { + return Err(ApiError::Payment( + body.error.unwrap_or_else(|| "FX API error".into()), + )); + } + + body.rates + .get(&format!("{}", to)) + .copied() + .filter(|r| *r > 0.0) + .ok_or_else(|| ApiError::Payment("Exchange rate not found".into())) + } +} diff --git a/crates/core/src/clients/mod.rs b/crates/core/src/clients/mod.rs new file mode 100644 index 0000000..e9ad0b0 --- /dev/null +++ b/crates/core/src/clients/mod.rs @@ -0,0 +1,9 @@ +pub mod paystack; +pub mod stripe; +pub mod exchange_rate; +pub mod email; + +pub use paystack::PaystackClient; +pub use stripe::StripeClient; +pub use exchange_rate::ExchangeRateClient; +pub use email::EmailClient; diff --git a/crates/core/src/client.rs b/crates/core/src/clients/paystack.rs similarity index 75% rename from crates/core/src/client.rs rename to crates/core/src/clients/paystack.rs index 0712bc1..1508211 100644 --- a/crates/core/src/client.rs +++ b/crates/core/src/clients/paystack.rs @@ -6,7 +6,7 @@ use payego_primitives::models::dtos::providers::paystack::PaystackResolveRespons use payego_primitives::models::enum_types::CurrencyCode; use reqwest::{Client, Url}; use secrecy::{ExposeSecret, SecretString}; -use tracing::warn; +use tracing::{error, warn}; #[derive(Clone)] pub struct PaystackClient { @@ -27,7 +27,7 @@ impl PaystackClient { }) } - pub fn create_recipient<'a>( + pub fn create_recipient_payload<'a>( name: &'a str, account_number: &'a str, bank_code: &'a str, @@ -58,7 +58,6 @@ impl PaystackClient { .map_err(|_| PaystackError::RequestFailed)?; let status = resp.status(); - let body: CreateTransferRecipientResponse = resp .json() .await @@ -83,7 +82,6 @@ impl PaystackClient { bank_code: &str, ) -> Result { let mut url = self.base_url.clone(); - url.set_path("bank/resolve"); url.query_pairs_mut() .append_pair("account_number", account_number) @@ -97,16 +95,15 @@ impl PaystackClient { .send() .await .map_err(|e| { - tracing::error!(error = %e, "Failed to reach Paystack"); + error!(error = %e, "Failed to reach Paystack"); ApiError::Payment("Paystack service unavailable".into()) })?; let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); if !status.is_success() { - tracing::warn!( + warn!( http_status = status.as_u16(), response = %body_text.chars().take(200).collect::(), "Paystack bank resolve failed" @@ -115,7 +112,7 @@ impl PaystackClient { } let body: PaystackResolveResponse = serde_json::from_str(&body_text).map_err(|e| { - tracing::error!( + error!( error = %e, response = %body_text.chars().take(200).collect::(), "Invalid JSON from Paystack" @@ -124,16 +121,47 @@ impl PaystackClient { })?; if !body.status { - tracing::warn!( - message = %body.message, - "Paystack rejected bank resolution" - ); + warn!(message = %body.message, "Paystack rejected bank resolution"); return Err(ApiError::Payment(body.message)); } Ok(body) } + pub async fn initiate_transfer( + &self, + recipient_code: &str, + amount: i64, + reference: &str, + ) -> Result<(), ApiError> { + let url = self.endpoint("transfer"); + + let resp = self + .http + .post(url) + .bearer_auth(self.secret_key.expose_secret()) + .json(&serde_json::json!({ + "source": "balance", + "amount": amount, + "recipient": recipient_code, + "reference": reference + })) + .send() + .await + .map_err(|e| { + error!(error = %e, "Paystack transfer request failed"); + ApiError::Payment("Failed to reach Paystack".into()) + })?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + error!(response = %body, "Paystack transfer failed"); + return Err(ApiError::Payment("Paystack transfer refused".into())); + } + + Ok(()) + } + fn endpoint(&self, path: &str) -> Url { let mut url = self.base_url.clone(); url.set_path(path); diff --git a/crates/core/src/clients/stripe.rs b/crates/core/src/clients/stripe.rs new file mode 100644 index 0000000..79bd1be --- /dev/null +++ b/crates/core/src/clients/stripe.rs @@ -0,0 +1,66 @@ +use payego_primitives::models::app_state::stripe_details::StripeInfo; +use payego_primitives::error::ApiError; +use secrecy::ExposeSecret; +use stripe::{Client, CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems, CreateCheckoutSessionPaymentIntentData, PaymentIntent}; +use std::collections::HashMap; + +#[derive(Clone)] +pub struct StripeClient { + client: Client, +} + +impl StripeClient { + pub fn new(config: &StripeInfo) -> Self { + let client = Client::new(config.stripe_secret_key.expose_secret()); + Self { client } + } + + pub async fn create_checkout_session( + &self, + amount: i64, + currency: &str, + transaction_ref: &str, + success_url: &str, + cancel_url: &str, + ) -> Result { + let mut metadata = HashMap::new(); + metadata.insert("transaction_reference".to_string(), transaction_ref.to_string()); + + let line_item = CreateCheckoutSessionLineItems { + quantity: Some(1), + price_data: Some(stripe::CreateCheckoutSessionLineItemsPriceData { + currency: currency.parse().map_err(|_| ApiError::Internal("Invalid currency for Stripe".into()))?, + product_data: Some(stripe::CreateCheckoutSessionLineItemsPriceDataProductData { + name: "Wallet Top-up".to_string(), + ..Default::default() + }), + unit_amount: Some(amount), + ..Default::default() + }), + ..Default::default() + }; + + let params = CreateCheckoutSession { + mode: Some(CheckoutSessionMode::Payment), + line_items: Some(vec![line_item]), + success_url: Some(success_url), + cancel_url: Some(cancel_url), + payment_intent_data: Some(CreateCheckoutSessionPaymentIntentData { + metadata: Some(metadata.clone()), + ..Default::default() + }), + metadata: Some(metadata), + ..Default::default() + }; + + CheckoutSession::create(&self.client, params) + .await + .map_err(|e| ApiError::Payment(format!("Stripe error: {}", e))) + } + + pub async fn get_payment_intent(&self, id: &stripe::PaymentIntentId) -> Result { + PaymentIntent::retrieve(&self.client, id, &[]) + .await + .map_err(|e| ApiError::Payment(format!("Stripe error: {}", e))) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f6aee93..ca7246d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,8 @@ -pub mod client; +pub mod app_state; +pub mod security; +pub mod clients; pub mod repositories; pub mod services; + +pub use app_state::AppState; +pub use security::{Claims, SecurityConfig}; diff --git a/crates/primitives/src/config/security_config.rs b/crates/core/src/security.rs old mode 100755 new mode 100644 similarity index 97% rename from crates/primitives/src/config/security_config.rs rename to crates/core/src/security.rs index 9c9b633..abf6d9b --- a/crates/primitives/src/config/security_config.rs +++ b/crates/core/src/security.rs @@ -1,7 +1,7 @@ -use crate::{ +use payego_primitives::{ error::{ApiError, AuthError}, - models::AppState, }; +use crate::app_state::AppState; use axum::http::Request; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; @@ -25,6 +25,7 @@ pub struct Claims { pub aud: String, pub jti: String, } + impl Claims { pub fn user_id(&self) -> Result { Uuid::parse_str(&self.sub).map_err(|e| { @@ -113,7 +114,7 @@ impl SecurityConfig { } pub fn is_jti_blacklisted(conn: &mut PgConnection, jti_value: &str) -> Result { - use crate::schema::blacklisted_tokens::dsl::*; + use payego_primitives::schema::blacklisted_tokens::dsl::*; blacklisted_tokens .filter(jti.eq(jti_value)) diff --git a/crates/core/src/services/auth_service/login.rs b/crates/core/src/services/auth_service/login.rs index 581bd2d..6477bea 100644 --- a/crates/core/src/services/auth_service/login.rs +++ b/crates/core/src/services/auth_service/login.rs @@ -3,14 +3,14 @@ use crate::services::auth_service::register::RegisterService; use argon2::{password_hash::PasswordHash, PasswordVerifier}; use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::SecurityConfig, error::{ApiError, AuthError}, models::{ - app_state::AppState, dtos::auth_dto::{LoginRequest, LoginResponse}, user::User, }, }; +pub use crate::app_state::AppState; +pub use crate::security::SecurityConfig; use tracing::{error, info, warn}; pub struct LoginService; diff --git a/crates/core/src/services/auth_service/logout.rs b/crates/core/src/services/auth_service/logout.rs index 8688688..55adbd7 100644 --- a/crates/core/src/services/auth_service/logout.rs +++ b/crates/core/src/services/auth_service/logout.rs @@ -1,13 +1,14 @@ use chrono::{DateTime, Utc}; use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::Claims, error::ApiError, models::{ - app_state::AppState, dtos::auth_dto::LogoutResponse, + dtos::auth_dto::LogoutResponse, entities::authentication::NewBlacklistedToken, }, }; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use tracing::{error, info}; pub struct LogoutService; diff --git a/crates/core/src/services/auth_service/register.rs b/crates/core/src/services/auth_service/register.rs index e443783..597bfe1 100644 --- a/crates/core/src/services/auth_service/register.rs +++ b/crates/core/src/services/auth_service/register.rs @@ -4,16 +4,16 @@ use argon2::{Argon2, Params}; use crate::repositories::user_repository::UserRepository; use password_hash::PasswordHasher; pub use payego_primitives::{ - config::security_config::SecurityConfig, error::{ApiError, AuthError}, models::{ - app_state::AppState, dtos::auth_dto::{RegisterRequest, RegisterResponse}, user::NewUser, user::User, }, schema::users, }; +pub use crate::app_state::AppState; +pub use crate::security::SecurityConfig; use secrecy::{ExposeSecret, SecretString}; use tracing::{error, info}; diff --git a/crates/core/src/services/auth_service/token.rs b/crates/core/src/services/auth_service/token.rs index 9122403..0e8fb22 100644 --- a/crates/core/src/services/auth_service/token.rs +++ b/crates/core/src/services/auth_service/token.rs @@ -3,15 +3,15 @@ use chrono::{Duration, Utc}; use diesel::prelude::*; use hex; pub use payego_primitives::{ - config::security_config::SecurityConfig, error::{ApiError, AuthError}, models::{ - app_state::AppState, authentication::{NewRefreshToken, RefreshToken}, dtos::auth_dto::{LoginResponse, RefreshRequest, RefreshResponse, RefreshResult}, }, schema::refresh_tokens::dsl::*, }; +pub use crate::app_state::AppState; +pub use crate::security::SecurityConfig; use rand::{distributions::Alphanumeric, Rng}; use sha2::{Digest, Sha256}; use tracing::{error, info, warn}; diff --git a/crates/core/src/services/auth_service/user.rs b/crates/core/src/services/auth_service/user.rs index 7a2737f..9d76a75 100644 --- a/crates/core/src/services/auth_service/user.rs +++ b/crates/core/src/services/auth_service/user.rs @@ -1,14 +1,15 @@ use crate::repositories::user_repository::UserRepository; use crate::repositories::wallet_repository::WalletRepository; pub use payego_primitives::{ - config::security_config::Claims, error::{ApiError, AuthError}, models::{ - app_state::AppState, dtos::auth_dto::CurrentUserResponse, + dtos::auth_dto::CurrentUserResponse, dtos::wallet_dto::WalletSummaryDto, enum_types::CurrencyCode, }, schema::{users, wallets}, }; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use tracing::log::error; use uuid::Uuid; diff --git a/crates/core/src/services/bank_account_service.rs b/crates/core/src/services/bank_account_service.rs index 0a400db..75479b6 100644 --- a/crates/core/src/services/bank_account_service.rs +++ b/crates/core/src/services/bank_account_service.rs @@ -1,34 +1,26 @@ -use crate::client::PaystackClient; -use dashmap::DashMap; - +pub use crate::app_state::AppState; +pub use crate::security::Claims; use crate::repositories::bank_account_repository::BankAccountRepository; +use crate::clients::paystack::PaystackClient; +use dashmap::DashMap; use once_cell::sync::Lazy; pub use payego_primitives::{ - config::security_config::Claims, - error::{ApiError, AuthError}, - models::{app_state::AppState, bank::BankAccount}, + error::ApiError, models::{ - bank::NewBankAccount, - dtos::{ - bank_dto::{ - BankAccountResponse, BankAccountsResponse, BankRequest, DeleteResponse, - ResolveAccountRequest, ResolveAccountResponse, ResolvedAccount, - }, - providers::paystack::{PaystackRecipientResponse, PaystackResolveResponse}, + bank::{BankAccount, NewBankAccount}, + dtos::bank_dto::{ + BankAccountResponse, BankAccountsResponse, BankRequest, DeleteResponse, + ResolveAccountRequest, ResolveAccountResponse, ResolvedAccount, }, - enum_types::{CurrencyCode, PaymentState}, + enum_types::CurrencyCode, }, - schema::bank_accounts, }; use regex::Regex; -use reqwest::Url; -use secrecy::ExposeSecret; use std::time::{Duration, Instant}; -use tracing::{error, info, warn}; +use tracing::{error, info}; use uuid::Uuid; static ACCOUNT_NUMBER_RE: Lazy> = Lazy::new(|| Regex::new(r"^\d{10}$")); - static BANK_CODE_RE: Lazy> = Lazy::new(|| Regex::new(r"^\d{3,10}$")); static ACCOUNT_CACHE: Lazy> = Lazy::new(DashMap::new); const TTL: Duration = Duration::from_secs(60 * 10); // 10 minutes @@ -40,21 +32,14 @@ impl BankAccountService { state: &AppState, claims: &Claims, ) -> Result { - let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { - warn!("bank_accounts.list: invalid subject in token"); - ApiError::Auth(AuthError::InvalidToken("Invalid token".into())) - })?; + let user_id = claims.user_id()?; - let mut conn = state.db.get().map_err(|_| { - error!("bank_accounts.list: failed to acquire db connection"); + let mut conn = state.db.get().map_err(|e| { + error!(error = %e, "Failed to acquire db connection"); ApiError::DatabaseConnection("Database unavailable".into()) })?; - let accounts = - BankAccountRepository::find_all_by_user(&mut conn, user_id).map_err(|_| { - error!("bank_accounts.list: query failed"); - ApiError::Internal("Failed to fetch bank accounts".into()) - })?; + let accounts = BankAccountRepository::find_all_by_user(&mut conn, user_id)?; Ok(BankAccountsResponse { bank_accounts: accounts @@ -64,42 +49,22 @@ impl BankAccountService { }) } - pub async fn get_bank_accounts( - state: &AppState, - user_id_val: Uuid, - ) -> Result, ApiError> { - let mut conn = state - .db - .get() - .map_err(|e: r2d2::Error| ApiError::DatabaseConnection(e.to_string()))?; - - let accounts = BankAccountRepository::find_all_by_user(&mut conn, user_id_val)?; - - Ok(accounts) - } - pub async fn create_bank_account( state: &AppState, user_id_val: Uuid, req: BankRequest, ) -> Result { - let mut conn = state.db.get().map_err(|e: r2d2::Error| { - error!("Database error: {}", e); + let mut conn = state.db.get().map_err(|e| { + error!(error = %e, "Database error"); ApiError::DatabaseConnection(e.to_string()) })?; - // idempotency check using Repository if let Some(existing) = BankAccountRepository::find_active_by_details( &mut conn, user_id_val, &req.bank_code, &req.account_number, )? { - info!( - user_id = %user_id_val, - bank_code = %req.bank_code, - "Bank account already exists (idempotency check)" - ); return Ok(existing); } @@ -108,21 +73,14 @@ impl BankAccountService { let account_name = account_details.account_name; - //make call to paystack via its client - let paystack_client = PaystackClient::new( - state.http_client.clone(), - &state.config.paystack_details.paystack_api_url, - state.config.paystack_details.paystack_secret_key.clone(), - )?; - - let payload = PaystackClient::create_recipient( + let payload = PaystackClient::create_recipient_payload( &account_name, &req.account_number, &req.bank_code, CurrencyCode::NGN, ); - let recipient_code = paystack_client + let recipient_code = state.paystack .create_transfer_recipient(payload) .await .map_err(|_| ApiError::Payment("Unable to create transfer recipient".into()))?; @@ -142,7 +100,6 @@ impl BankAccountService { info!( user_id = %user_id_val, account_id = %account.id, - bank_code = %req.bank_code, "Bank account created successfully" ); @@ -159,63 +116,10 @@ impl BankAccountService { let cache_key = format!("{bank_code}:{account_number}"); if let Some(cached) = Self::get(&cache_key) { - info!("Bank resolve cache hit: {}", cache_key); return Ok(cached); } - let mut url = Url::parse(&state.config.paystack_details.paystack_api_url) - .map_err(|_| ApiError::Internal("Invalid Paystack base URL".into()))?; - - url.set_path("bank/resolve"); - url.query_pairs_mut() - .append_pair("account_number", account_number) - .append_pair("bank_code", bank_code); - - let resp = state - .http_client - .get(url) - .bearer_auth( - state - .config - .paystack_details - .paystack_secret_key - .expose_secret(), - ) - .header("User-Agent", "Payego/1.0") - .send() - .await - .map_err(|e| { - error!(error = %e, "Failed to reach Paystack during account resolution"); - ApiError::Payment(format!("Paystack service unavailable: {}", e)) - })?; - - let status = resp.status(); - let body_text = resp.text().await.map_err(|e| { - error!(error = %e, "Failed to read Paystack response body"); - ApiError::Payment("Failed to read Paystack response".into()) - })?; - - if !status.is_success() { - error!( - status = %status, - body = %body_text, - "Paystack account resolution failed" - ); - return Err(ApiError::Payment(format!( - "Paystack error {}: {}", - status, body_text - ))); - } - - let body: PaystackResolveResponse = serde_json::from_str(&body_text).map_err(|e| { - error!(error = %e, body = %body_text, "Failed to parse Paystack resolution response"); - ApiError::Payment("Invalid response from Paystack".into()) - })?; - - if !body.status { - tracing::log::warn!("Paystack resolve failed: {}", body.message); - return Err(ApiError::Payment(body.message)); - } + let body = state.paystack.resolve_bank_account(account_number, bank_code).await?; let data = body .data @@ -238,7 +142,6 @@ impl BankAccountService { .map_err(|_| ApiError::Internal("Account number regex misconfigured".into()))? .is_match(account_number) { - warn!("bank_account.validate: invalid account number format"); return Err(ApiError::BadRequest( "Account number must be 10 digits".to_string(), )); @@ -246,10 +149,9 @@ impl BankAccountService { if !BANK_CODE_RE .as_ref() - .map_err(|_| ApiError::Internal("Account number regex misconfigured".into()))? + .map_err(|_| ApiError::Internal("Bank code regex misconfigured".into()))? .is_match(bank_code) { - warn!("bank_account.validate: invalid bank code format"); return Err(ApiError::BadRequest( "Bank code must be 3–10 digits".to_string(), )); @@ -264,18 +166,12 @@ impl BankAccountService { bank_account_id: Uuid, ) -> Result { let mut conn = state.db.get().map_err(|e| { - error!("Database error: {}", e); + error!(error = %e, "Database error"); ApiError::DatabaseConnection(e.to_string()) })?; BankAccountRepository::delete_by_id_and_user(&mut conn, bank_account_id, user_id)?; - info!( - user_id = %user_id, - account_id = %bank_account_id, - "Bank account deleted successfully" - ); - Ok(DeleteResponse { account_id: bank_account_id, message: "Bank account deleted successfully".into(), diff --git a/crates/core/src/services/bank_service.rs b/crates/core/src/services/bank_service.rs index a99101b..0c5671a 100644 --- a/crates/core/src/services/bank_service.rs +++ b/crates/core/src/services/bank_service.rs @@ -2,7 +2,6 @@ use crate::repositories::bank_repository::BankRepository; pub use payego_primitives::{ error::ApiError, models::{ - app_state::AppState, bank::{Bank, NewBank, NewBankAccount}, dtos::{ bank_dto::{BankDto, BankListResponse, ResolvedAccount}, @@ -12,6 +11,7 @@ pub use payego_primitives::{ }, schema::banks, }; +pub use crate::app_state::AppState; use reqwest::Url; use secrecy::ExposeSecret; use std::sync::Arc; diff --git a/crates/core/src/services/conversion_service.rs b/crates/core/src/services/conversion_service.rs index 2b0704a..1b1296d 100644 --- a/crates/core/src/services/conversion_service.rs +++ b/crates/core/src/services/conversion_service.rs @@ -1,22 +1,18 @@ use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::Claims, error::ApiError, models::{ - app_state::AppState, - dtos::wallet_dto::{ConvertRequest, ConvertResponse, ExchangeRateResponse}, + dtos::wallet_dto::{ConvertRequest, ConvertResponse}, enum_types::{CurrencyCode, PaymentProvider, PaymentState, TransactionIntent}, - transaction::{NewTransaction, Transaction}, - wallet::Wallet, - wallet_ledger::NewWalletLedger, + transaction::NewTransaction, }, schema::{transactions, wallet_ledger, wallets}, }; -use reqwest::Url; use serde_json::json; -use std::time::Duration; use uuid::Uuid; pub struct ConversionService; @@ -42,7 +38,6 @@ impl ConversionService { user_id, &req.idempotency_key, )? { - //closure to help me convert this let get_i64 = |key: &str| { tx.metadata .get(key) @@ -59,7 +54,7 @@ impl ConversionService { } // ---------- RATE ---------- - let rate = Self::get_exchange_rate(state, req.from_currency, req.to_currency).await?; + let rate = state.fx.get_rate(req.from_currency, req.to_currency).await?; if !(0.0001..10_000.0).contains(&rate) { return Err(ApiError::Payment("Suspicious exchange rate".into())); @@ -67,14 +62,13 @@ impl ConversionService { let rate_scaled = (rate * 1_000_000.0).round() as i64; let converted_cents = req.amount_cents * rate_scaled / 1_000_000; - let fee_bps = state.config.conversion_fee_bps; // 1% + let fee_bps = state.config.conversion_fee_bps; let fee_cents = (converted_cents as i128 * fee_bps / 10_000) as i64; let net_cents = converted_cents - fee_cents; let tx_ref = Uuid::new_v4(); conn.transaction::<_, ApiError, _>(|conn| { - // ---------- LOCK WALLETS ---------- let from_wallet = WalletRepository::find_by_user_and_currency_with_lock( conn, user_id, @@ -82,13 +76,10 @@ impl ConversionService { )?; let to_wallet = WalletRepository::create_if_not_exists(conn, user_id, req.to_currency)?; - // ---------- BALANCE CHECK (CACHED) ---------- - debug_assert!(from_wallet.balance >= 0); if from_wallet.balance < req.amount_cents { return Err(ApiError::Payment("Insufficient balance".into())); } - // ---------- TRANSACTION ---------- let tx = TransactionRepository::create( conn, NewTransaction { @@ -112,10 +103,9 @@ impl ConversionService { }, )?; - // ---------- LEDGER ---------- WalletRepository::add_ledger_entry( conn, - NewWalletLedger { + payego_primitives::models::wallet_ledger::NewWalletLedger { wallet_id: from_wallet.id, transaction_id: tx.id, amount: -req.amount_cents, @@ -124,14 +114,13 @@ impl ConversionService { WalletRepository::add_ledger_entry( conn, - NewWalletLedger { + payego_primitives::models::wallet_ledger::NewWalletLedger { wallet_id: to_wallet.id, transaction_id: tx.id, amount: net_cents, }, )?; - // ---------- UPDATE CACHED BALANCE ---------- WalletRepository::debit(conn, from_wallet.id, req.amount_cents)?; WalletRepository::credit(conn, to_wallet.id, net_cents)?; @@ -151,42 +140,6 @@ impl ConversionService { from: CurrencyCode, to: CurrencyCode, ) -> Result { - if from == to { - //this should not be allowed - return Ok(1.0); - } - - let mut url = Url::parse(&state.config.exchange_api_url) - .map_err(|_| ApiError::Internal("Invalid FX base URL".into()))?; - - url.path_segments_mut() - .map_err(|_| ApiError::Internal("Invalid FX URL path".into()))? - .push(from.to_string().as_str()); - - let resp = state - .http_client - .get(url) - .timeout(Duration::from_secs(5)) - .send() - .await - .map_err(|e| ApiError::Payment(format!("FX API unreachable: {}", e)))?; - - let status = resp.status(); - let body = resp - .json::() - .await - .map_err(|_| ApiError::Payment("Invalid FX response".into()))?; - - if !status.is_success() { - return Err(ApiError::Payment( - body.error.unwrap_or_else(|| "FX API error".into()), - )); - } - - body.rates - .get(&format!("{}", to)) - .copied() - .filter(|r| *r > 0.0) - .ok_or_else(|| ApiError::Payment("Exchange rate not found".into())) + state.fx.get_rate(from, to).await } } diff --git a/crates/core/src/services/payment_service.rs b/crates/core/src/services/payment_service.rs index 785497f..1d73573 100644 --- a/crates/core/src/services/payment_service.rs +++ b/crates/core/src/services/payment_service.rs @@ -1,26 +1,17 @@ use crate::repositories::transaction_repository::TransactionRepository; -use http::header::{CONTENT_TYPE, USER_AGENT}; -use payego_primitives::models::dtos::providers::paypal::PayPalOrderResp; +pub use crate::app_state::AppState; +pub use crate::security::Claims; +// use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::Claims, error::ApiError, models::{ - app_state::AppState, dtos::wallet_dto::{TopUpRequest, TopUpResponse}, enum_types::{PaymentProvider, PaymentState, TransactionIntent}, - transaction::{NewTransaction, Transaction}, + transaction::NewTransaction, }, }; -use reqwest::Url; use secrecy::ExposeSecret; -use serde_json::json; -use std::str::FromStr; -use stripe::{ - CheckoutSession, CheckoutSessionMode, Client as StripeClient, CreateCheckoutSession, - CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData, - CreateCheckoutSessionLineItemsPriceDataProductData, Currency, -}; -use tracing::error; +use tracing::{error, info}; use uuid::Uuid; pub struct PaymentService; @@ -31,223 +22,108 @@ impl PaymentService { user_id: Uuid, req: TopUpRequest, ) -> Result { - let mut conn = state.db.get().map_err(|e: r2d2::Error| { - error!("Database error: {}", e); - ApiError::DatabaseConnection(e.to_string()) - })?; + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; - let amount_cents = req.amount; - let reference = Uuid::new_v4(); + let tx_ref = Uuid::new_v4(); - // ---------- DB-ENFORCED IDEMPOTENCY ---------- - let tx = TransactionRepository::create( + // Create initial transaction + TransactionRepository::create( &mut conn, NewTransaction { user_id, counterparty_id: None, intent: TransactionIntent::TopUp, - amount: amount_cents, + amount: req.amount, currency: req.currency, txn_state: PaymentState::Pending, provider: Some(req.provider), provider_reference: None, idempotency_key: &req.idempotency_key, - reference, - description: Some("Top-up intent"), - metadata: serde_json::json!({}), + reference: tx_ref, + description: Some("Wallet top-up initiation"), + metadata: serde_json::json!({ + "idempotency_key": req.idempotency_key, + }), }, )?; - let (session_url, payment_id) = match req.provider { + let response = match req.provider { PaymentProvider::Stripe => { - Self::initiate_stripe(state, &req, tx.reference, amount_cents).await? + let success_url = format!("{}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.app_url); + let cancel_url = format!("{}/checkout/cancel", state.config.app_url); + + let session = state.stripe.create_checkout_session( + req.amount, + &req.currency.to_string().to_lowercase(), + &tx_ref.to_string(), + &success_url, + &cancel_url, + ).await?; + + info!("Stripe session created: {}", session.id); + TopUpResponse { + session_url: session.url, + payment_id: Some(session.id.to_string()), + transaction_id: tx_ref.to_string(), + amount: req.amount, + } } - PaymentProvider::Paypal => Self::initiate_paypal(state, &req, tx.reference).await?, - _ => return Err(ApiError::Payment("Unsupported provider".into())), - }; - - Ok(TopUpResponse { - session_url, - payment_id, - transaction_id: tx.reference.to_string(), - amount: req.amount, - }) - } - - - async fn initiate_stripe( - state: &AppState, - req: &TopUpRequest, - reference: Uuid, - amount_cents: i64, - ) -> Result<(Option, Option), ApiError> { - let stripe_client = StripeClient::from_url( - state.config.stripe_details.stripe_api_url.as_str(), - state - .config - .stripe_details - .stripe_secret_key - .expose_secret(), - ); - - let currency = Currency::from_str(&req.currency.to_string().to_lowercase()) - .map_err(|_| ApiError::Payment("Invalid currency".into()))?; - - let session = CheckoutSession::create( - &stripe_client, - CreateCheckoutSession { - mode: Some(CheckoutSessionMode::Payment), - success_url: Some(&format!( - "{}/success?tx={}", - state.config.app_url, reference - )), - cancel_url: Some(&format!("{}/top-up", state.config.app_url)), - line_items: Some(vec![CreateCheckoutSessionLineItems { - quantity: Some(1), - price_data: Some(CreateCheckoutSessionLineItemsPriceData { - currency, - unit_amount: Some(amount_cents), - product_data: Some(CreateCheckoutSessionLineItemsPriceDataProductData { - name: "Account Top-Up".into(), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - }]), - payment_intent_data: Some(stripe::CreateCheckoutSessionPaymentIntentData { - metadata: Some( - [("transaction_reference".into(), reference.to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }), - metadata: Some( - [("transaction_reference".into(), reference.to_string())] - .into_iter() - .collect(), - ), - ..Default::default() - }, - ) - .await - .map_err(|e| ApiError::Payment(e.to_string()))?; - - Ok((session.url, None)) - } - - async fn initiate_paypal( - state: &AppState, - req: &TopUpRequest, - transaction_id: Uuid, - ) -> Result<(Option, Option), ApiError> { - let client_id = &state.config.paypal_details.paypal_client_id; - let secret = state.config.paypal_details.paypal_secret.expose_secret(); - let app_url = &state.config.app_url; - let base_url = &state.config.paypal_details.paypal_api_url; - - //even though I know this will not happen because emptiness is dealt with from onset - if client_id.trim().is_empty() || secret.trim().is_empty() { - return Err(ApiError::Internal("PayPal credentials missing".into())); - } - - let mut url = Url::parse(base_url) - .map_err(|_| ApiError::Internal("Invalid PayPal base URL".into()))?; - - url.set_path("/v1/oauth2/token"); - - let token_resp = state - .http_client - .post(url) - .timeout(std::time::Duration::from_secs(30)) // increased to 30secs for sandbox - .header(CONTENT_TYPE, "application/x-www-form-urlencoded") - .header(USER_AGENT, "Payego/1.0 (Rust backend)") - .basic_auth(client_id, Some(secret)) - .form(&[("grant_type", "client_credentials")]) - .send() - .await - .map_err(|e| { - tracing::error!("PayPal OAuth request failed: {:?}", e); - ApiError::Payment("PayPal authentication failed".into()) - })?; - - let token_json = token_resp - .json::() - .await - .map_err(|_| ApiError::Payment("PayPal token parse error".into()))?; - let access_token = token_json["access_token"] - .as_str() - .ok_or_else(|| ApiError::Payment("PayPal token missing".into()))?; - - let mut url = Url::parse(base_url) - .map_err(|_| ApiError::Internal("Invalid PayPal base URL".into()))?; - - url.set_path("v2/checkout/orders"); - - // Validate currency early (defensive) - let currency = req.currency.to_string(); - if currency.len() != 3 { - return Err(ApiError::Internal("Invalid currency code".into())); - } - - // Pre-format amount safely using integer math - let amount_cents = req.amount; - let amount_str = format!("{}.{:02}", amount_cents / 100, amount_cents % 100); - if amount_cents <= 0 { - return Err(ApiError::Internal( - "Amount must be greater than zero".into(), - )); - } - - let payload = json!({ - "intent": "CAPTURE", - "purchase_units": [{ - "amount": { - "currency_code": currency, - "value": amount_str, - }, - "description": format!("Top-up {}", transaction_id), - "custom_id": transaction_id.to_string(), - }], - "application_context": { - "payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED", - "return_url": format!("{}/success?transaction_id={}", app_url, transaction_id), - "cancel_url": format!("{}/top-up", app_url), - "brand_name": "Payego", - "user_action": "PAY_NOW" + PaymentProvider::Paypal => { + let amount_str = format!("{}.{:02}", req.amount / 100, req.amount % 100); + + let paypal_res = state + .http_client + .post(format!("{}/v2/checkout/orders", state.config.paypal_details.paypal_api_url)) + .basic_auth( + state.config.paypal_details.paypal_client_id.clone(), + Some(state.config.paypal_details.paypal_secret.expose_secret()), + ) + .json(&serde_json::json!({ + "intent": "CAPTURE", + "purchase_units": [{ + "reference_id": tx_ref.to_string(), + "amount": { + "currency_code": req.currency.to_string(), + "value": amount_str + } + }], + "application_context": { + "return_url": format!("{}/success?transaction_id={}", state.config.app_url, tx_ref), + "cancel_url": format!("{}/top-up", state.config.app_url) + } + })) + .send() + .await + .map_err(|e| ApiError::Payment(format!("PayPal request failed: {}", e)))?; + + let body: serde_json::Value = paypal_res + .json() + .await + .map_err(|_| ApiError::Payment("Invalid PayPal response".into()))?; + + let approval_url = body["links"] + .as_array() + .and_then(|links| { + links.iter().find(|l| l["rel"] == "approve").and_then(|l| l["href"].as_str()) + }) + .ok_or_else(|| { + error!("PayPal Approval link missing. Body: {:?}", body); + ApiError::Payment("PayPal approval link missing".into()) + })?; + + TopUpResponse { + session_url: Some(approval_url.to_string()), + payment_id: body["id"].as_str().map(|s| s.to_string()), + transaction_id: tx_ref.to_string(), + amount: req.amount, + } } - }); - - let resp = state - .http_client - .post(url) - .bearer_auth(access_token) - .json(&payload) - .send() - .await - .map_err(|e| { - error!("PayPal order request failed: {}", e); - ApiError::Payment("Failed to reach PayPal".into()) - })?; - - let body: PayPalOrderResp = resp - .json() - .await - .map_err(|_| ApiError::Payment("Invalid PayPal response".into()))?; - - let approve_url = body - .links - .iter() - .find(|l| l.rel == "approve") - .ok_or_else(|| ApiError::Payment("Approval link missing".into()))? - .href - .clone(); - - if body.id.trim().is_empty() { - return Err(ApiError::Payment("Empty PayPal payment ID".into())); - } + _ => return Err(ApiError::BadRequest("Unsupported provider".into())), + }; - Ok((Some(approve_url), Some(body.id.clone()))) + Ok(response) } } diff --git a/crates/core/src/services/paypal_service.rs b/crates/core/src/services/paypal_service.rs index 69ecb17..92f3334 100644 --- a/crates/core/src/services/paypal_service.rs +++ b/crates/core/src/services/paypal_service.rs @@ -8,13 +8,13 @@ use payego_primitives::models::dtos::providers::paypal::{ pub use payego_primitives::{ error::ApiError, models::{ - app_state::AppState, enum_types::{CurrencyCode, PaymentProvider, PaymentState}, transaction::Transaction, wallet::Wallet, wallet_ledger::NewWalletLedger, }, }; +pub use crate::app_state::AppState; use reqwest::Url; use secrecy::ExposeSecret; diff --git a/crates/core/src/services/paystack_service.rs b/crates/core/src/services/paystack_service.rs index 6036e47..2fa5848 100644 --- a/crates/core/src/services/paystack_service.rs +++ b/crates/core/src/services/paystack_service.rs @@ -8,13 +8,13 @@ use http::HeaderMap; pub use payego_primitives::{ error::ApiError, models::{ - app_state::AppState, dtos::providers::paystack::PaystackWebhook, entities::enum_types::{PaymentState, TransactionIntent}, transaction::Transaction, wallet_ledger::NewWalletLedger, }, }; +pub use crate::app_state::AppState; use secrecy::ExposeSecret; use std::sync::Arc; use tracing::{info, warn}; diff --git a/crates/core/src/services/stripe_service.rs b/crates/core/src/services/stripe_service.rs index 7ae1e49..04ae267 100644 --- a/crates/core/src/services/stripe_service.rs +++ b/crates/core/src/services/stripe_service.rs @@ -7,8 +7,9 @@ use stripe::{Event, EventObject, EventType, Webhook}; use crate::services::transaction_service::TransactionService; pub use payego_primitives::{ error::ApiError, - models::{app_state::AppState, dtos::providers::stripe::StripeWebhookContext}, + models::dtos::providers::stripe::StripeWebhookContext, }; +pub use crate::app_state::AppState; pub enum WebhookOutcome { Processed, diff --git a/crates/core/src/services/transaction_service.rs b/crates/core/src/services/transaction_service.rs index b19299b..29dacf0 100644 --- a/crates/core/src/services/transaction_service.rs +++ b/crates/core/src/services/transaction_service.rs @@ -2,10 +2,8 @@ use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::Claims, error::{ApiError, AuthError}, models::{ - app_state::AppState, entities::enum_types::PaymentState, enum_types::{CurrencyCode, TransactionIntent}, transaction::Transaction, @@ -15,6 +13,8 @@ pub use payego_primitives::{ }, schema::{transactions, wallet_ledger, wallets}, }; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use stripe::PaymentIntent; use tracing::{error, info, warn}; use uuid::Uuid; diff --git a/crates/core/src/services/transfer_service.rs b/crates/core/src/services/transfer_service.rs index 902b0c9..b376ffb 100644 --- a/crates/core/src/services/transfer_service.rs +++ b/crates/core/src/services/transfer_service.rs @@ -1,28 +1,20 @@ -use crate::client::PaystackClient; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; use diesel::prelude::*; -use payego_primitives::models::dtos::providers::paystack::{ - PaystackTransferData, PaystackTransferResponse, -}; pub use payego_primitives::{ - config::security_config::Claims, error::ApiError, models::{ - app_state::AppState, dtos::wallet_dto::{TransferRequest, TransferResponse, WalletTransferRequest}, enum_types::{CurrencyCode, PaymentProvider, PaymentState, TransactionIntent}, - transaction::{NewTransaction, Transaction}, - wallet::Wallet, + transaction::NewTransaction, wallet_ledger::NewWalletLedger, }, - schema::{transactions, wallet_ledger, wallets}, }; -use reqwest::{Client, Url}; -use secrecy::ExposeSecret; use serde_json::json; use std::sync::Arc; -use tracing::{info, warn}; +use tracing::info; use uuid::Uuid; pub struct TransferService; @@ -31,7 +23,6 @@ impl TransferService { pub async fn transfer_internal( state: &Arc, sender_id: Uuid, - // recipient_id: Uuid, req: WalletTransferRequest, ) -> Result { let mut conn = state @@ -45,7 +36,6 @@ impl TransferService { } conn.transaction::<_, ApiError, _>(|conn| { - // ── 1. Idempotency if let Some(existing) = TransactionRepository::find_by_idempotency_key( conn, sender_id, @@ -61,7 +51,6 @@ impl TransferService { } } - // ── 2. Lock wallets let sender_wallet = WalletRepository::find_by_user_and_currency_with_lock( conn, sender_id, @@ -71,17 +60,9 @@ impl TransferService { WalletRepository::create_if_not_exists(conn, req.recipient, req.currency)?; if sender_wallet.balance < amount_cents { - warn!( - user_id = %sender_id, - available_balance = sender_wallet.balance, - requested_amount = amount_cents, - currency = %req.currency, - "Insufficient balance for internal transfer" - ); return Err(ApiError::Payment("Insufficient balance".into())); } - // ── 3. Sender transaction (debit) let sender_tx = TransactionRepository::create( conn, NewTransaction { @@ -103,7 +84,6 @@ impl TransferService { }, )?; - // ── 4. Recipient transaction (credit) let recipient_tx = TransactionRepository::create( conn, NewTransaction { @@ -126,7 +106,6 @@ impl TransferService { }, )?; - // ── 5. Ledger entries WalletRepository::add_ledger_entry( conn, NewWalletLedger { @@ -144,20 +123,10 @@ impl TransferService { }, )?; - // ── 6. Update balances WalletRepository::debit(conn, sender_wallet.id, amount_cents)?; WalletRepository::credit(conn, recipient_wallet.id, amount_cents)?; - info!( - transaction_id = %sender_tx.id, - sender_id = %sender_id, - recipient_id = %req.recipient, - amount = amount_cents, - currency = %req.currency, - "Internal transfer completed successfully" - ); - - Ok(sender_tx.id) + Ok::(sender_tx.id) }) } @@ -182,34 +151,19 @@ impl TransferService { } let tx_id = conn.transaction::<_, ApiError, _>(|conn| { - // ── 1. Idempotency if let Some(existing) = TransactionRepository::find_by_idempotency_key(conn, user_id, &req.idempotency_key)? { - info!( - transaction_id = %existing.id, - idempotency_key = %req.idempotency_key, - "External transfer already initiated (idempotency check)" - ); return Ok(existing.id); } - // ── 2. Lock wallet let wallet = WalletRepository::find_by_user_and_currency_with_lock(conn, user_id, currency)?; if wallet.balance < amount_minor { - warn!( - user_id = %user_id, - available_balance = wallet.balance, - requested_amount = amount_minor, - currency = %currency, - "Insufficient balance for external transfer" - ); return Err(ApiError::Payment("Insufficient balance".into())); } - // ── 3. Create pending payout transaction let tx = TransactionRepository::create( conn, NewTransaction { @@ -232,7 +186,6 @@ impl TransferService { }, )?; - // ── 4. Ledger reservation (funds held) WalletRepository::add_ledger_entry( conn, NewWalletLedger { @@ -242,119 +195,40 @@ impl TransferService { }, )?; - // ── 5. Reduce available balance WalletRepository::debit(conn, wallet.id, amount_minor)?; - Ok(tx.id) + Ok::(tx.id) })?; - // ── 6. Call Paystack OUTSIDE DB transaction - let provider_data = Self::initiate_paystack_transfer(state, &req).await?; - - // ── 7. Attach provider reference and update state if success - TransactionRepository::update_status_and_provider_ref( - &mut conn, - tx_id, - if provider_data.status.as_deref() == Some("success") { - PaymentState::Completed - } else { - PaymentState::Pending - }, - Some(provider_data.transfer_code.to_string()), - )?; - - info!( - transaction_id = %tx_id, - user_id = %user_id, - amount = amount_minor, - currency = %currency, - provider_status = ?provider_data.status, - "External transfer initiated successfully" - ); - - Ok(TransferResponse { - transaction_id: tx_id, - }) - } - - async fn initiate_paystack_transfer( - state: &AppState, - req: &TransferRequest, - ) -> Result { - let client = Client::new(); - let key = state - .config - .paystack_details - .paystack_secret_key - .expose_secret(); - - let paystack_client = PaystackClient::new( - state.http_client.clone(), - &state.config.paystack_details.paystack_api_url, - state.config.paystack_details.paystack_secret_key.clone(), - )?; - let name = req.account_name.clone().unwrap_or("Recipient".into()); - - let payload = PaystackClient::create_recipient( + let payload = crate::clients::paystack::PaystackClient::create_recipient_payload( &name, &req.account_number, &req.bank_code, CurrencyCode::NGN, ); - let recipient_code = paystack_client + let recipient_code = state.paystack .create_transfer_recipient(payload) .await .map_err(|_| ApiError::Payment("Unable to create transfer recipient".into()))?; - //todo: turn this into client - let base = Url::parse(&state.config.paystack_details.paystack_api_url) - .map_err(|_| ApiError::Internal("Invalid Paystack base URL".into()))?; - - let url = base - .join("transfer") - .map_err(|_| ApiError::Internal("Invalid Paystack transfer URL".into()))?; - - let amount_kobo = req.amount; - - if amount_kobo <= 0 { - return Err(ApiError::Internal("Invalid transfer amount".into())); - } - - let resp = client - .post(url) - .bearer_auth(key) - .json(&serde_json::json!({ - "source": "balance", - "amount": amount_kobo, - "recipient": recipient_code, - "reference": req.reference.to_string() - })) - .send() - .await - .map_err(|e| { - tracing::error!(error = %e, "Paystack transfer request failed"); - ApiError::Payment("Failed to reach Paystack".into()) - })? - .error_for_status() - .map_err(|e| { - tracing::warn!(error = %e, "Paystack transfer rejected"); - ApiError::Payment("Paystack rejected transfer".into()) - })?; - - //todo===== end of client - - let body: PaystackTransferResponse = resp.json().await.map_err(|e| { - tracing::error!(error = %e, "Invalid Paystack transfer response"); - ApiError::Payment("Invalid Paystack response".into()) - })?; + state.paystack.initiate_transfer( + &recipient_code, + amount_minor, + &req.reference.to_string(), + ).await?; - if !body.status { - return Err(ApiError::Payment(body.message)); - } + // Updating status to completed (simplified, usually we'd wait for webhook) + TransactionRepository::update_status_and_provider_ref( + &mut conn, + tx_id, + PaymentState::Completed, + Some(recipient_code), // Using recipient_code as provider ref for now + )?; - body.data - .ok_or_else(|| ApiError::Payment("Missing transfer data".into())) + Ok(TransferResponse { + transaction_id: tx_id, + }) } } diff --git a/crates/core/src/services/wallet_service.rs b/crates/core/src/services/wallet_service.rs index 9b1666f..12edc98 100644 --- a/crates/core/src/services/wallet_service.rs +++ b/crates/core/src/services/wallet_service.rs @@ -3,15 +3,15 @@ use tracing::{error, warn}; use uuid::Uuid; pub use payego_primitives::{ - config::security_config::Claims, error::{ApiError, AuthError}, models::{ - app_state::AppState, wallet::Wallet, wallet_dto::{WalletDto, WalletsResponse}, }, schema::wallets, }; +pub use crate::app_state::AppState; +pub use crate::security::Claims; pub struct WalletService; diff --git a/crates/core/src/services/withdrawal_service.rs b/crates/core/src/services/withdrawal_service.rs index 4ff28d3..f0bce66 100644 --- a/crates/core/src/services/withdrawal_service.rs +++ b/crates/core/src/services/withdrawal_service.rs @@ -1,29 +1,19 @@ use crate::repositories::bank_account_repository::BankAccountRepository; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; +pub use crate::app_state::AppState; +pub use crate::security::Claims; use diesel::prelude::*; pub use payego_primitives::{ - config::security_config::Claims, error::ApiError, models::{ - app_state::AppState, - bank::BankAccount, dtos::wallet_dto::{WithdrawRequest, WithdrawResponse}, - enum_types::{CurrencyCode, PaymentProvider, PaymentState, TransactionIntent}, + enum_types::{PaymentProvider, PaymentState, TransactionIntent}, transaction::NewTransaction, - wallet::Wallet, wallet_ledger::NewWalletLedger, }, - schema::{bank_accounts, transactions, wallet_ledger, wallets}, }; - -use payego_primitives::models::dtos::providers::paystack::{ - PaystackResponseWrapper as PaystackResponse, PaystackTransData, -}; -use reqwest::Url; -use secrecy::ExposeSecret; use serde_json::json; -use tracing::error; use uuid::Uuid; pub struct WithdrawalService; @@ -45,7 +35,6 @@ impl WithdrawalService { .get() .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; - // ---- Load wallet (FOR UPDATE) ---- let wallet = WalletRepository::find_by_user_and_currency_with_lock( &mut conn, user_id, @@ -62,27 +51,21 @@ impl WithdrawalService { user_id, )?; - // ---- External transfer FIRST ---- - let provider_data = Self::initiate_paystack_transfer( - state, - &bank_account, - amount_minor, - &req.currency, - req.reference, - ) - .await?; + let recipient_code = bank_account.provider_recipient_id.as_deref().ok_or_else(|| { + ApiError::Payment("Bank account is not linked to a provider recipient".into()) + })?; - let initial_state = match provider_data.status.as_deref() { - Some("success") => PaymentState::Completed, - _ => PaymentState::Pending, - }; + // External transfer + state.paystack.initiate_transfer( + recipient_code, + amount_minor, + &req.reference.to_string(), + ).await?; - // ---- Atomic DB write ---- - conn.transaction(|conn| { - // Wallet update + // Atomic DB write + let tx_id = conn.transaction::<_, ApiError, _>(|conn| { WalletRepository::debit(conn, wallet.id, amount_minor)?; - // Transaction let tx = TransactionRepository::create( conn, NewTransaction { @@ -91,14 +74,15 @@ impl WithdrawalService { intent: TransactionIntent::Payout, amount: amount_minor, currency: wallet.currency, - txn_state: initial_state, + txn_state: PaymentState::Completed, // Simplified provider: Some(PaymentProvider::Paystack), - provider_reference: Some(&provider_data.transfer_code), + provider_reference: Some(recipient_code), idempotency_key: &req.idempotency_key, reference: req.reference, description: Some("Wallet withdrawal"), metadata: json!({ - "bank_account_id": bank_account_id, + "bank_code": bank_account.bank_code, + "account_number": bank_account.account_number, }), }, )?; @@ -113,80 +97,11 @@ impl WithdrawalService { }, )?; - Ok(tx.id) - }) - .map(|tx_id| WithdrawResponse { - transaction_id: tx_id, - }) - } - - - async fn initiate_paystack_transfer( - state: &AppState, - bank: &BankAccount, - amount_minor: i64, - currency: &CurrencyCode, - reference: Uuid, - ) -> Result { - let key = state - .config - .paystack_details - .paystack_secret_key - .expose_secret(); - - let mut url = Url::parse(&state.config.paystack_details.paystack_api_url) - .map_err(|_| ApiError::Internal("Invalid Paystack base URL".into()))?; - - url.set_path("transfer"); - - let resp = state - .http_client - .post(url) - .bearer_auth(key) - .json(&json!({ - "source": "balance", - "amount": amount_minor, - "recipient": bank.provider_recipient_id, - "reference": reference.to_string(), - "reason": format!("Withdrawal ({})", currency), - })) - .send() - .await - .map_err(|e| { - error!(error = %e, "Failed to reach Paystack during transfer initiation"); - ApiError::Payment(format!("Failed to reach Paystack: {}", e)) - })?; - - let status = resp.status(); - let body_text = resp.text().await.map_err(|e| { - error!(error = %e, "Failed to read Paystack transfer response body"); - ApiError::Payment("Invalid Paystack response body".into()) + Ok::(tx.id) })?; - if !status.is_success() { - error!( - status = %status, - body = %body_text, - "Paystack transfer initiation failed" - ); - return Err(ApiError::Payment(format!( - "Paystack transfer failed with status {}: {}", - status, body_text - ))); - } - - let body: PaystackResponse = - serde_json::from_str(&body_text).map_err(|e| { - error!(error = %e, body = %body_text, "Failed to parse Paystack transfer response"); - ApiError::Payment("Invalid Paystack response format".into()) - })?; - - if !body.status { - // warn!(message = %body.message, "Paystack rejected transfer"); - return Err(ApiError::Payment("Transfer rejected by Paystack".into())); - } - - body.data - .ok_or_else(|| ApiError::Payment("Paystack response missing data".into())) + Ok(WithdrawResponse { + transaction_id: tx_id, + }) } } diff --git a/crates/primitives/src/config/mod.rs b/crates/primitives/src/config/mod.rs index 09380d5..2c8fe35 100755 --- a/crates/primitives/src/config/mod.rs +++ b/crates/primitives/src/config/mod.rs @@ -1 +1 @@ -pub mod security_config; +// This module no longer contains security config (moved to core) diff --git a/crates/primitives/src/models/app_state/app_config.rs b/crates/primitives/src/models/app_state/app_config.rs index 1a5f19a..9783258 100644 --- a/crates/primitives/src/models/app_state/app_config.rs +++ b/crates/primitives/src/models/app_state/app_config.rs @@ -22,6 +22,8 @@ pub struct AppConfig { pub exchange_api_url: String, pub default_country: String, + pub rate_limit_rps: u64, + pub rate_limit_burst: u32, } impl AppConfig { @@ -44,6 +46,14 @@ impl AppConfig { .unwrap_or_else(|_| "https://api.exchangerate-api.com/v4/latest".into()), default_country: env::var("DEFAULT_COUNTRY").unwrap_or_else(|_| "Nigeria".into()), + + rate_limit_rps: env::var("RATE_LIMIT_RPS") + .unwrap_or_else(|_| "2".into()) + .parse()?, + + rate_limit_burst: env::var("RATE_LIMIT_BURST") + .unwrap_or_else(|_| "10".into()) + .parse()?, }) } } diff --git a/crates/primitives/src/models/app_state/mod.rs b/crates/primitives/src/models/app_state/mod.rs index 515508d..70a4895 100644 --- a/crates/primitives/src/models/app_state/mod.rs +++ b/crates/primitives/src/models/app_state/mod.rs @@ -2,12 +2,10 @@ pub mod app_config; pub mod jwt_details; pub mod paypal_details; pub mod paystack_details; -pub mod state; pub mod stripe_details; pub use app_config::*; pub use jwt_details::*; pub use paypal_details::*; pub use paystack_details::*; -pub use state::*; pub use stripe_details::*; diff --git a/crates/primitives/src/models/app_state/state.rs b/crates/primitives/src/models/app_state/state.rs deleted file mode 100644 index dc088ae..0000000 --- a/crates/primitives/src/models/app_state/state.rs +++ /dev/null @@ -1,30 +0,0 @@ -use diesel::r2d2::{self, ConnectionManager}; -use diesel::PgConnection; -use reqwest::Client; -use std::sync::Arc; -use std::time::Duration; - -type DbPool = r2d2::Pool>; - -use crate::models::app_config::AppConfig; - -#[derive(Clone)] -pub struct AppState { - pub db: DbPool, - pub http_client: Client, - pub config: AppConfig, -} - -use eyre::Result; - -impl AppState { - pub fn new(db: DbPool, config: AppConfig) -> Result> { - let http = Client::builder().timeout(Duration::from_secs(30)).build()?; - - Ok(Arc::new(Self { - db, - http_client: http, - config, - })) - } -} From 64ea51fb521d7082d631737efea09576ff26e291 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 16:25:43 +0100 Subject: [PATCH 3/6] added audit logging --- bin/payego/tests/audit_tests.rs | 63 +++++++++++++++++++ .../core/src/repositories/audit_repository.rs | 16 +++++ crates/core/src/repositories/mod.rs | 1 + crates/core/src/services/audit_service.rs | 36 +++++++++++ .../core/src/services/auth_service/login.rs | 54 +++++++++++----- .../src/services/auth_service/register.rs | 12 ++++ crates/core/src/services/mod.rs | 1 + crates/core/src/services/transfer_service.rs | 36 ++++++++++- .../src/models/entities/audit_log.rs | 30 +++++++++ crates/primitives/src/models/entities/mod.rs | 1 + crates/primitives/src/schema.rs | 15 +++++ .../down.sql | 1 + .../up.sql | 14 +++++ 13 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 bin/payego/tests/audit_tests.rs create mode 100644 crates/core/src/repositories/audit_repository.rs create mode 100644 crates/core/src/services/audit_service.rs create mode 100644 crates/primitives/src/models/entities/audit_log.rs create mode 100644 migrations/2026-01-28-152500_create_audit_logs/down.sql create mode 100644 migrations/2026-01-28-152500_create_audit_logs/up.sql diff --git a/bin/payego/tests/audit_tests.rs b/bin/payego/tests/audit_tests.rs new file mode 100644 index 0000000..c266642 --- /dev/null +++ b/bin/payego/tests/audit_tests.rs @@ -0,0 +1,63 @@ +mod common; + +use common::{create_test_app, create_test_app_state, create_test_user}; +use axum_test::TestServer; +use diesel::prelude::*; +use payego_primitives::schema::audit_logs; +use payego_primitives::models::entities::audit_log::AuditLog; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_audit_log_on_registration() { + let state = create_test_app_state(); + let app = create_test_app(state.clone()); + let server = TestServer::new(app).unwrap(); + + let email = format!("audit_test_{}@example.com", uuid::Uuid::new_v4()); + + // Perform registration + let _ = create_test_user(&server, &email).await; + + // Verify audit log + let mut conn = state.db.get().unwrap(); + let logs: Vec = audit_logs::table + .filter(audit_logs::event_type.eq("auth.register")) + .load(&mut conn) + .unwrap(); + + assert!(!logs.is_empty()); + let log = logs.iter().find(|l| l.metadata["email"] == email).expect("Audit log for registration not found"); + assert_eq!(log.event_type, "auth.register"); +} + +#[tokio::test] +#[serial] +async fn test_audit_log_on_login() { + let state = create_test_app_state(); + let app = create_test_app(state.clone()); + let server = TestServer::new(app).unwrap(); + + let email = format!("login_audit_{}@example.com", uuid::Uuid::new_v4()); + let _ = create_test_user(&server, &email).await; + + // Perform login + let response = server + .post("/api/auth/login") + .json(&serde_json::json!({ + "email": email, + "password": "SecurePass123!" + })) + .await; + + response.assert_status(axum::http::StatusCode::OK); + + // Verify audit log + let mut conn = state.db.get().unwrap(); + let logs: Vec = audit_logs::table + .filter(audit_logs::event_type.eq("auth.login.success")) + .load(&mut conn) + .unwrap(); + + assert!(!logs.is_empty()); +} diff --git a/crates/core/src/repositories/audit_repository.rs b/crates/core/src/repositories/audit_repository.rs new file mode 100644 index 0000000..d39f5d5 --- /dev/null +++ b/crates/core/src/repositories/audit_repository.rs @@ -0,0 +1,16 @@ +use diesel::prelude::*; +use payego_primitives::models::entities::audit_log::NewAuditLog; +use payego_primitives::schema::audit_logs; +use payego_primitives::error::ApiError; + +pub struct AuditLogRepository; + +impl AuditLogRepository { + pub fn create(conn: &mut PgConnection, new_log: NewAuditLog) -> Result<(), ApiError> { + diesel::insert_into(audit_logs::table) + .values(&new_log) + .execute(conn) + .map_err(ApiError::Database)?; + Ok(()) + } +} diff --git a/crates/core/src/repositories/mod.rs b/crates/core/src/repositories/mod.rs index f335028..de6f774 100644 --- a/crates/core/src/repositories/mod.rs +++ b/crates/core/src/repositories/mod.rs @@ -4,3 +4,4 @@ pub mod token_repository; pub mod transaction_repository; pub mod user_repository; pub mod wallet_repository; +pub mod audit_repository; diff --git a/crates/core/src/services/audit_service.rs b/crates/core/src/services/audit_service.rs new file mode 100644 index 0000000..244b31a --- /dev/null +++ b/crates/core/src/services/audit_service.rs @@ -0,0 +1,36 @@ +use crate::app_state::AppState; +use crate::repositories::audit_repository::AuditLogRepository; +use payego_primitives::error::ApiError; +use payego_primitives::models::entities::audit_log::NewAuditLog; +use uuid::Uuid; + +pub struct AuditService; + +impl AuditService { + pub async fn log_event( + state: &AppState, + user_id: Option, + event_type: &str, + target_type: Option<&str>, + target_id: Option<&str>, + metadata: serde_json::Value, + ip_address: Option, + ) -> Result<(), ApiError> { + let mut conn = state + .db + .get() + .map_err(|e| ApiError::DatabaseConnection(e.to_string()))?; + + let new_log = NewAuditLog { + id: Uuid::new_v4(), + user_id, + event_type: event_type.to_string(), + target_type: target_type.map(|s| s.to_string()), + target_id: target_id.map(|s| s.to_string()), + metadata, + ip_address, + }; + + AuditLogRepository::create(&mut conn, new_log) + } +} diff --git a/crates/core/src/services/auth_service/login.rs b/crates/core/src/services/auth_service/login.rs index 6477bea..4f4ebe6 100644 --- a/crates/core/src/services/auth_service/login.rs +++ b/crates/core/src/services/auth_service/login.rs @@ -1,4 +1,5 @@ use crate::repositories::user_repository::UserRepository; +use crate::services::audit_service::AuditService; use crate::services::auth_service::register::RegisterService; use argon2::{password_hash::PasswordHash, PasswordVerifier}; use diesel::prelude::*; @@ -22,10 +23,25 @@ impl LoginService { ApiError::DatabaseConnection("Database unavailable".into()) })?; - let user = UserRepository::find_by_email(&mut conn, &payload.email)?; - Self::verify_password(&payload.password, user.as_ref())?; + let user_opt = UserRepository::find_by_email(&mut conn, &payload.email)?; + let user = user_opt.ok_or_else(|| { + warn!("auth.login: user not found for email {}", payload.email); + ApiError::Auth(AuthError::InvalidCredentials) + })?; - let user = user.ok_or(ApiError::Auth(AuthError::InvalidCredentials))?; + if let Err(e) = Self::verify_password(&payload.password, &user) { + let _ = AuditService::log_event( + state, + Some(user.id), + "auth.login.failure", + None, + None, + serde_json::json!({ "reason": "invalid_password" }), + None, + ) + .await; + return Err(e); + } let token = SecurityConfig::create_token(state, &user.id.to_string()).map_err(|_| { error!("auth.login: jwt creation failed"); @@ -34,6 +50,17 @@ impl LoginService { let refresh_token = Self::create_refresh_token(&mut conn, user.id)?; + let _ = AuditService::log_event( + state, + Some(user.id), + "auth.login.success", + None, + None, + serde_json::json!({}), + None, + ) + .await; + info!( user_id = %user.id, "User logged in successfully" @@ -46,15 +73,8 @@ impl LoginService { }) } - fn verify_password(password: &str, user: Option<&User>) -> Result<(), ApiError> { - // verifying *something* to prevent timing attacks - let hash = user //either get the user password hash or generate a dummy one - .map(|u| u.password_hash.as_str()) - .unwrap_or(Self::dummy_hash()); - - info!("password: {}", password); - info!("email: {}", user.unwrap().email); - info!("user hash: {}, hashed_password: {}", user.unwrap().password_hash, hash); + fn verify_password(password: &str, user: &User) -> Result<(), ApiError> { + let hash = &user.password_hash; let parsed = PasswordHash::new(hash).map_err(|_| { error!("auth.login: invalid password hash"); @@ -84,9 +104,9 @@ impl LoginService { }) } - fn dummy_hash() -> &'static str { - "$argon2id$v=19$m=65536,t=3,p=1$\ - c29tZXNhbHQ$\ - c29tZWZha2VoYXNo" - } + // fn dummy_hash() -> &'static str { + // "$argon2id$v=19$m=65536,t=3,p=1$\ + // c29tZXNhbHQ$\ + // c29tZWZha2VoYXNo" + // } } diff --git a/crates/core/src/services/auth_service/register.rs b/crates/core/src/services/auth_service/register.rs index 597bfe1..e68cbca 100644 --- a/crates/core/src/services/auth_service/register.rs +++ b/crates/core/src/services/auth_service/register.rs @@ -1,4 +1,5 @@ use crate::services::auth_service::token::TokenService; +use crate::services::audit_service::AuditService; use argon2::{Argon2, Params}; use crate::repositories::user_repository::UserRepository; @@ -52,6 +53,17 @@ impl RegisterService { ApiError::Internal("Authentication service error".into()) })?; + let _ = AuditService::log_event( + state, + Some(user.id), + "auth.register", + Some("user"), + Some(&user.id.to_string()), + serde_json::json!({ "email": user.email }), + None, + ) + .await; + info!( user_id = %user.id, email = %user.email, diff --git a/crates/core/src/services/mod.rs b/crates/core/src/services/mod.rs index a4a0fad..860c067 100644 --- a/crates/core/src/services/mod.rs +++ b/crates/core/src/services/mod.rs @@ -11,3 +11,4 @@ pub mod transaction_service; pub mod transfer_service; pub mod wallet_service; pub mod withdrawal_service; +pub mod audit_service; diff --git a/crates/core/src/services/transfer_service.rs b/crates/core/src/services/transfer_service.rs index b376ffb..a1c8cc7 100644 --- a/crates/core/src/services/transfer_service.rs +++ b/crates/core/src/services/transfer_service.rs @@ -1,4 +1,5 @@ pub use crate::app_state::AppState; +use crate::services::audit_service::AuditService; pub use crate::security::Claims; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; @@ -35,7 +36,7 @@ impl TransferService { return Err(ApiError::Internal("Amount must be positive".into())); } - conn.transaction::<_, ApiError, _>(|conn| { + let tx_id = conn.transaction::<_, ApiError, _>(|conn| { if let Some(existing) = TransactionRepository::find_by_idempotency_key( conn, sender_id, @@ -127,7 +128,24 @@ impl TransferService { WalletRepository::credit(conn, recipient_wallet.id, amount_cents)?; Ok::(sender_tx.id) - }) + })?; + + let _ = AuditService::log_event( + state, + Some(sender_id), + "transfer.internal", + Some("transaction"), + Some(&tx_id.to_string()), + json!({ + "recipient": req.recipient, + "amount": amount_cents, + "currency": req.currency, + }), + None, + ) + .await; + + Ok(tx_id) } pub async fn transfer_external( @@ -227,6 +245,20 @@ impl TransferService { Some(recipient_code), // Using recipient_code as provider ref for now )?; + let _ = AuditService::log_event( + state, + Some(user_id), + "transfer.external", + Some("transaction"), + Some(&tx_id.to_string()), + json!({ + "bank_code": req.bank_code, + "amount": amount_minor, + "currency": currency, + }), + None, + ).await; + Ok(TransferResponse { transaction_id: tx_id, }) diff --git a/crates/primitives/src/models/entities/audit_log.rs b/crates/primitives/src/models/entities/audit_log.rs new file mode 100644 index 0000000..8b5d9db --- /dev/null +++ b/crates/primitives/src/models/entities/audit_log.rs @@ -0,0 +1,30 @@ +use crate::schema::audit_logs; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Queryable, Selectable, Identifiable, Serialize, Deserialize)] +#[diesel(table_name = audit_logs)] +pub struct AuditLog { + pub id: Uuid, + pub user_id: Option, + pub event_type: String, + pub target_type: Option, + pub target_id: Option, + pub metadata: serde_json::Value, + pub ip_address: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Insertable, Deserialize)] +#[diesel(table_name = audit_logs)] +pub struct NewAuditLog { + pub id: Uuid, + pub user_id: Option, + pub event_type: String, + pub target_type: Option, + pub target_id: Option, + pub metadata: serde_json::Value, + pub ip_address: Option, +} diff --git a/crates/primitives/src/models/entities/mod.rs b/crates/primitives/src/models/entities/mod.rs index 0841214..435c33a 100644 --- a/crates/primitives/src/models/entities/mod.rs +++ b/crates/primitives/src/models/entities/mod.rs @@ -6,3 +6,4 @@ pub mod transaction; pub mod user; pub mod wallet; pub mod wallet_ledger; +pub mod audit_log; diff --git a/crates/primitives/src/schema.rs b/crates/primitives/src/schema.rs index 7532567..1d5ae05 100755 --- a/crates/primitives/src/schema.rs +++ b/crates/primitives/src/schema.rs @@ -18,6 +18,19 @@ pub mod sql_types { pub struct TransactionIntent; } +diesel::table! { + audit_logs (id) { + id -> Uuid, + user_id -> Nullable, + event_type -> Text, + target_type -> Nullable, + target_id -> Nullable, + metadata -> Jsonb, + ip_address -> Nullable, + created_at -> Timestamptz, + } +} + diesel::table! { bank_accounts (id) { id -> Uuid, @@ -127,6 +140,7 @@ diesel::table! { } } +diesel::joinable!(audit_logs -> users (user_id)); diesel::joinable!(bank_accounts -> users (user_id)); diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(wallet_ledger -> transactions (transaction_id)); @@ -134,6 +148,7 @@ diesel::joinable!(wallet_ledger -> wallets (wallet_id)); diesel::joinable!(wallets -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( + audit_logs, bank_accounts, banks, blacklisted_tokens, diff --git a/migrations/2026-01-28-152500_create_audit_logs/down.sql b/migrations/2026-01-28-152500_create_audit_logs/down.sql new file mode 100644 index 0000000..3a0ecc3 --- /dev/null +++ b/migrations/2026-01-28-152500_create_audit_logs/down.sql @@ -0,0 +1 @@ +DROP TABLE audit_logs; diff --git a/migrations/2026-01-28-152500_create_audit_logs/up.sql b/migrations/2026-01-28-152500_create_audit_logs/up.sql new file mode 100644 index 0000000..9110481 --- /dev/null +++ b/migrations/2026-01-28-152500_create_audit_logs/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id), + event_type TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_event_type ON audit_logs(event_type); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); From 2a2f8448a5356943464f41f06d97a9d0f8f3f085 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 16:35:40 +0100 Subject: [PATCH 4/6] added audit logging --- bin/payego/src/lib.rs | 2 +- bin/payego/tests/audit_tests.rs | 11 +++-- bin/payego/tests/common/mod.rs | 10 ++-- crates/api/src/app.rs | 1 - crates/api/src/handlers/current_user.rs | 4 +- crates/api/src/handlers/resolve_account.rs | 6 +-- crates/api/src/handlers/top_up.rs | 2 +- crates/core/src/app_state.rs | 13 ++--- crates/core/src/clients/exchange_rate.rs | 9 ++-- crates/core/src/clients/mod.rs | 8 ++-- crates/core/src/clients/stripe.rs | 21 +++++++-- crates/core/src/lib.rs | 2 +- .../core/src/repositories/audit_repository.rs | 2 +- crates/core/src/repositories/mod.rs | 2 +- crates/core/src/security.rs | 4 +- .../core/src/services/auth_service/login.rs | 4 +- .../core/src/services/auth_service/logout.rs | 9 ++-- .../src/services/auth_service/register.rs | 6 +-- .../core/src/services/auth_service/token.rs | 4 +- crates/core/src/services/auth_service/user.rs | 8 ++-- .../core/src/services/bank_account_service.rs | 38 +++++++++++++-- crates/core/src/services/bank_service.rs | 2 +- .../core/src/services/conversion_service.rs | 25 +++++++++- crates/core/src/services/mod.rs | 2 +- crates/core/src/services/payment_service.rs | 47 ++++++++++++++----- crates/core/src/services/paypal_service.rs | 2 +- crates/core/src/services/paystack_service.rs | 2 +- crates/core/src/services/stripe_service.rs | 5 +- .../core/src/services/transaction_service.rs | 4 +- crates/core/src/services/transfer_service.rs | 19 ++++---- crates/core/src/services/wallet_service.rs | 4 +- .../core/src/services/withdrawal_service.rs | 36 ++++++++++---- crates/primitives/src/models/entities/mod.rs | 2 +- payego_ui/.husky/pre-commit | 0 34 files changed, 203 insertions(+), 113 deletions(-) mode change 100644 => 100755 payego_ui/.husky/pre-commit diff --git a/bin/payego/src/lib.rs b/bin/payego/src/lib.rs index 9a16804..4ed8d36 100644 --- a/bin/payego/src/lib.rs +++ b/bin/payego/src/lib.rs @@ -12,8 +12,8 @@ use crate::utility::logging::setup_logging; use crate::utility::server::serve; use crate::utility::tasks::{build_router, initialize_system, load_env}; use eyre::Report; -use payego_primitives::models::app_config::AppConfig; use payego_core::app_state::AppState; +use payego_primitives::models::app_config::AppConfig; use tracing::info; pub async fn run() -> Result<(), Report> { diff --git a/bin/payego/tests/audit_tests.rs b/bin/payego/tests/audit_tests.rs index c266642..67e74a9 100644 --- a/bin/payego/tests/audit_tests.rs +++ b/bin/payego/tests/audit_tests.rs @@ -1,10 +1,10 @@ mod common; -use common::{create_test_app, create_test_app_state, create_test_user}; use axum_test::TestServer; +use common::{create_test_app, create_test_app_state, create_test_user}; use diesel::prelude::*; -use payego_primitives::schema::audit_logs; use payego_primitives::models::entities::audit_log::AuditLog; +use payego_primitives::schema::audit_logs; use serial_test::serial; #[tokio::test] @@ -15,7 +15,7 @@ async fn test_audit_log_on_registration() { let server = TestServer::new(app).unwrap(); let email = format!("audit_test_{}@example.com", uuid::Uuid::new_v4()); - + // Perform registration let _ = create_test_user(&server, &email).await; @@ -27,7 +27,10 @@ async fn test_audit_log_on_registration() { .unwrap(); assert!(!logs.is_empty()); - let log = logs.iter().find(|l| l.metadata["email"] == email).expect("Audit log for registration not found"); + let log = logs + .iter() + .find(|l| l.metadata["email"] == email) + .expect("Audit log for registration not found"); assert_eq!(log.event_type, "auth.register"); } diff --git a/bin/payego/tests/common/mod.rs b/bin/payego/tests/common/mod.rs index 7fe9fd0..420352d 100644 --- a/bin/payego/tests/common/mod.rs +++ b/bin/payego/tests/common/mod.rs @@ -3,6 +3,10 @@ use axum_test::TestServer; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::PgConnection; +use payego_core::{ + clients::{exchange_rate::ExchangeRateClient, paystack::PaystackClient, stripe::StripeClient}, + AppState, +}; use payego_primitives::models::app_state::app_config::AppConfig; use payego_primitives::models::app_state::jwt_details::JWTInfo; use payego_primitives::models::app_state::paypal_details::PaypalInfo; @@ -11,12 +15,6 @@ use payego_primitives::models::app_state::stripe_details::StripeInfo; use secrecy::SecretString; use std::sync::Arc; use uuid::Uuid; -use payego_core::{ - clients::{ - exchange_rate::ExchangeRateClient, paystack::PaystackClient, stripe::StripeClient, - }, - AppState, -}; pub mod fixtures; pub mod helpers; diff --git a/crates/api/src/app.rs b/crates/api/src/app.rs index 825c753..290bf36 100644 --- a/crates/api/src/app.rs +++ b/crates/api/src/app.rs @@ -47,7 +47,6 @@ use tower_http::{ trace::TraceLayer, }; - pub fn create_router( state: Arc, metric_layer: PrometheusMetricLayer<'static>, diff --git a/crates/api/src/handlers/current_user.rs b/crates/api/src/handlers/current_user.rs index f08027e..d04455b 100755 --- a/crates/api/src/handlers/current_user.rs +++ b/crates/api/src/handlers/current_user.rs @@ -2,10 +2,8 @@ use axum::{ extract::{Extension, State}, Json, }; +use payego_core::services::auth_service::user::{ApiError, CurrentUserResponse, UserService}; use payego_core::{AppState, Claims}; -use payego_core::services::auth_service::user::{ - ApiError, CurrentUserResponse, UserService, -}; use payego_primitives::error::ApiErrorResponse; use std::sync::Arc; diff --git a/crates/api/src/handlers/resolve_account.rs b/crates/api/src/handlers/resolve_account.rs index b6c0924..510107c 100755 --- a/crates/api/src/handlers/resolve_account.rs +++ b/crates/api/src/handlers/resolve_account.rs @@ -45,11 +45,7 @@ pub async fn resolve_account( req.account_number.clone() }; - info!( - "Resolving account {} @ {}", - display_account, - req.bank_code - ); + info!("Resolving account {} @ {}", display_account, req.bank_code); let resolved = BankAccountService::resolve_account_details(&state, &req.bank_code, &req.account_number) diff --git a/crates/api/src/handlers/top_up.rs b/crates/api/src/handlers/top_up.rs index 8373757..e097bb4 100755 --- a/crates/api/src/handlers/top_up.rs +++ b/crates/api/src/handlers/top_up.rs @@ -56,6 +56,6 @@ pub async fn top_up( let user_id = claims.user_id()?; let response = PaymentService::initiate_top_up(&state, user_id, req).await?; - + Ok(Json(response)) } diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs index 9b60691..adc0b67 100644 --- a/crates/core/src/app_state.rs +++ b/crates/core/src/app_state.rs @@ -6,9 +6,9 @@ use std::time::Duration; type DbPool = r2d2::Pool>; -pub use payego_primitives::models::app_config::AppConfig; -use crate::clients::{PaystackClient, StripeClient, ExchangeRateClient, EmailClient}; +use crate::clients::{EmailClient, ExchangeRateClient, PaystackClient, StripeClient}; use eyre::Result; +pub use payego_primitives::models::app_config::AppConfig; #[derive(Clone)] pub struct AppState { @@ -23,9 +23,7 @@ pub struct AppState { impl AppState { pub fn new(db: DbPool, config: AppConfig) -> Result> { - let http = Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; + let http = Client::builder().timeout(Duration::from_secs(30)).build()?; let paystack = PaystackClient::new( http.clone(), @@ -35,10 +33,7 @@ impl AppState { let stripe = StripeClient::new(&config.stripe_details); - let fx = ExchangeRateClient::new( - http.clone(), - &config.exchange_api_url, - )?; + let fx = ExchangeRateClient::new(http.clone(), &config.exchange_api_url)?; let email = EmailClient::new(); diff --git a/crates/core/src/clients/exchange_rate.rs b/crates/core/src/clients/exchange_rate.rs index 3bc39c4..66cc87c 100644 --- a/crates/core/src/clients/exchange_rate.rs +++ b/crates/core/src/clients/exchange_rate.rs @@ -1,6 +1,6 @@ use payego_primitives::error::ApiError; -use payego_primitives::models::enum_types::CurrencyCode; use payego_primitives::models::dtos::wallet_dto::ExchangeRateResponse; +use payego_primitives::models::enum_types::CurrencyCode; use reqwest::{Client, Url}; use std::time::Duration; @@ -12,8 +12,8 @@ pub struct ExchangeRateClient { impl ExchangeRateClient { pub fn new(http: Client, base_url: &str) -> Result { - let base_url = Url::parse(base_url) - .map_err(|_| ApiError::Internal("Invalid FX base URL".into()))?; + let base_url = + Url::parse(base_url).map_err(|_| ApiError::Internal("Invalid FX base URL".into()))?; Ok(Self { http, base_url }) } @@ -27,7 +27,8 @@ impl ExchangeRateClient { .map_err(|_| ApiError::Internal("Invalid FX URL path".into()))? .push(from.to_string().as_str()); - let resp = self.http + let resp = self + .http .get(url) .timeout(Duration::from_secs(5)) .send() diff --git a/crates/core/src/clients/mod.rs b/crates/core/src/clients/mod.rs index e9ad0b0..523a6f0 100644 --- a/crates/core/src/clients/mod.rs +++ b/crates/core/src/clients/mod.rs @@ -1,9 +1,9 @@ +pub mod email; +pub mod exchange_rate; pub mod paystack; pub mod stripe; -pub mod exchange_rate; -pub mod email; +pub use email::EmailClient; +pub use exchange_rate::ExchangeRateClient; pub use paystack::PaystackClient; pub use stripe::StripeClient; -pub use exchange_rate::ExchangeRateClient; -pub use email::EmailClient; diff --git a/crates/core/src/clients/stripe.rs b/crates/core/src/clients/stripe.rs index 79bd1be..5e42e0d 100644 --- a/crates/core/src/clients/stripe.rs +++ b/crates/core/src/clients/stripe.rs @@ -1,8 +1,11 @@ -use payego_primitives::models::app_state::stripe_details::StripeInfo; use payego_primitives::error::ApiError; +use payego_primitives::models::app_state::stripe_details::StripeInfo; use secrecy::ExposeSecret; -use stripe::{Client, CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems, CreateCheckoutSessionPaymentIntentData, PaymentIntent}; use std::collections::HashMap; +use stripe::{ + CheckoutSession, CheckoutSessionMode, Client, CreateCheckoutSession, + CreateCheckoutSessionLineItems, CreateCheckoutSessionPaymentIntentData, PaymentIntent, +}; #[derive(Clone)] pub struct StripeClient { @@ -24,12 +27,17 @@ impl StripeClient { cancel_url: &str, ) -> Result { let mut metadata = HashMap::new(); - metadata.insert("transaction_reference".to_string(), transaction_ref.to_string()); + metadata.insert( + "transaction_reference".to_string(), + transaction_ref.to_string(), + ); let line_item = CreateCheckoutSessionLineItems { quantity: Some(1), price_data: Some(stripe::CreateCheckoutSessionLineItemsPriceData { - currency: currency.parse().map_err(|_| ApiError::Internal("Invalid currency for Stripe".into()))?, + currency: currency + .parse() + .map_err(|_| ApiError::Internal("Invalid currency for Stripe".into()))?, product_data: Some(stripe::CreateCheckoutSessionLineItemsPriceDataProductData { name: "Wallet Top-up".to_string(), ..Default::default() @@ -58,7 +66,10 @@ impl StripeClient { .map_err(|e| ApiError::Payment(format!("Stripe error: {}", e))) } - pub async fn get_payment_intent(&self, id: &stripe::PaymentIntentId) -> Result { + pub async fn get_payment_intent( + &self, + id: &stripe::PaymentIntentId, + ) -> Result { PaymentIntent::retrieve(&self.client, id, &[]) .await .map_err(|e| ApiError::Payment(format!("Stripe error: {}", e))) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ca7246d..58f1c10 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,7 +1,7 @@ pub mod app_state; -pub mod security; pub mod clients; pub mod repositories; +pub mod security; pub mod services; pub use app_state::AppState; diff --git a/crates/core/src/repositories/audit_repository.rs b/crates/core/src/repositories/audit_repository.rs index d39f5d5..b02baea 100644 --- a/crates/core/src/repositories/audit_repository.rs +++ b/crates/core/src/repositories/audit_repository.rs @@ -1,7 +1,7 @@ use diesel::prelude::*; +use payego_primitives::error::ApiError; use payego_primitives::models::entities::audit_log::NewAuditLog; use payego_primitives::schema::audit_logs; -use payego_primitives::error::ApiError; pub struct AuditLogRepository; diff --git a/crates/core/src/repositories/mod.rs b/crates/core/src/repositories/mod.rs index de6f774..edc64a2 100644 --- a/crates/core/src/repositories/mod.rs +++ b/crates/core/src/repositories/mod.rs @@ -1,7 +1,7 @@ +pub mod audit_repository; pub mod bank_account_repository; pub mod bank_repository; pub mod token_repository; pub mod transaction_repository; pub mod user_repository; pub mod wallet_repository; -pub mod audit_repository; diff --git a/crates/core/src/security.rs b/crates/core/src/security.rs index abf6d9b..85b004d 100644 --- a/crates/core/src/security.rs +++ b/crates/core/src/security.rs @@ -1,6 +1,3 @@ -use payego_primitives::{ - error::{ApiError, AuthError}, -}; use crate::app_state::AppState; use axum::http::Request; use axum::middleware::Next; @@ -10,6 +7,7 @@ use chrono::{Duration, Utc}; use diesel::prelude::*; use http::HeaderMap; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use payego_primitives::error::{ApiError, AuthError}; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use std::sync::Arc; diff --git a/crates/core/src/services/auth_service/login.rs b/crates/core/src/services/auth_service/login.rs index 4f4ebe6..da9c474 100644 --- a/crates/core/src/services/auth_service/login.rs +++ b/crates/core/src/services/auth_service/login.rs @@ -1,4 +1,6 @@ +pub use crate::app_state::AppState; use crate::repositories::user_repository::UserRepository; +pub use crate::security::SecurityConfig; use crate::services::audit_service::AuditService; use crate::services::auth_service::register::RegisterService; use argon2::{password_hash::PasswordHash, PasswordVerifier}; @@ -10,8 +12,6 @@ pub use payego_primitives::{ user::User, }, }; -pub use crate::app_state::AppState; -pub use crate::security::SecurityConfig; use tracing::{error, info, warn}; pub struct LoginService; diff --git a/crates/core/src/services/auth_service/logout.rs b/crates/core/src/services/auth_service/logout.rs index 55adbd7..93e58b6 100644 --- a/crates/core/src/services/auth_service/logout.rs +++ b/crates/core/src/services/auth_service/logout.rs @@ -1,14 +1,11 @@ +pub use crate::app_state::AppState; +pub use crate::security::Claims; use chrono::{DateTime, Utc}; use diesel::prelude::*; pub use payego_primitives::{ error::ApiError, - models::{ - dtos::auth_dto::LogoutResponse, - entities::authentication::NewBlacklistedToken, - }, + models::{dtos::auth_dto::LogoutResponse, entities::authentication::NewBlacklistedToken}, }; -pub use crate::app_state::AppState; -pub use crate::security::Claims; use tracing::{error, info}; pub struct LogoutService; diff --git a/crates/core/src/services/auth_service/register.rs b/crates/core/src/services/auth_service/register.rs index e68cbca..6cf358f 100644 --- a/crates/core/src/services/auth_service/register.rs +++ b/crates/core/src/services/auth_service/register.rs @@ -1,8 +1,10 @@ -use crate::services::auth_service::token::TokenService; use crate::services::audit_service::AuditService; +use crate::services::auth_service::token::TokenService; use argon2::{Argon2, Params}; +pub use crate::app_state::AppState; use crate::repositories::user_repository::UserRepository; +pub use crate::security::SecurityConfig; use password_hash::PasswordHasher; pub use payego_primitives::{ error::{ApiError, AuthError}, @@ -13,8 +15,6 @@ pub use payego_primitives::{ }, schema::users, }; -pub use crate::app_state::AppState; -pub use crate::security::SecurityConfig; use secrecy::{ExposeSecret, SecretString}; use tracing::{error, info}; diff --git a/crates/core/src/services/auth_service/token.rs b/crates/core/src/services/auth_service/token.rs index 0e8fb22..e88ad16 100644 --- a/crates/core/src/services/auth_service/token.rs +++ b/crates/core/src/services/auth_service/token.rs @@ -1,4 +1,6 @@ +pub use crate::app_state::AppState; use crate::repositories::token_repository::TokenRepository; +pub use crate::security::SecurityConfig; use chrono::{Duration, Utc}; use diesel::prelude::*; use hex; @@ -10,8 +12,6 @@ pub use payego_primitives::{ }, schema::refresh_tokens::dsl::*, }; -pub use crate::app_state::AppState; -pub use crate::security::SecurityConfig; use rand::{distributions::Alphanumeric, Rng}; use sha2::{Digest, Sha256}; use tracing::{error, info, warn}; diff --git a/crates/core/src/services/auth_service/user.rs b/crates/core/src/services/auth_service/user.rs index 9d76a75..c6fce4f 100644 --- a/crates/core/src/services/auth_service/user.rs +++ b/crates/core/src/services/auth_service/user.rs @@ -1,15 +1,15 @@ +pub use crate::app_state::AppState; use crate::repositories::user_repository::UserRepository; use crate::repositories::wallet_repository::WalletRepository; +pub use crate::security::Claims; pub use payego_primitives::{ error::{ApiError, AuthError}, models::{ - dtos::auth_dto::CurrentUserResponse, - dtos::wallet_dto::WalletSummaryDto, enum_types::CurrencyCode, + dtos::auth_dto::CurrentUserResponse, dtos::wallet_dto::WalletSummaryDto, + enum_types::CurrencyCode, }, schema::{users, wallets}, }; -pub use crate::app_state::AppState; -pub use crate::security::Claims; use tracing::log::error; use uuid::Uuid; diff --git a/crates/core/src/services/bank_account_service.rs b/crates/core/src/services/bank_account_service.rs index 75479b6..61706b7 100644 --- a/crates/core/src/services/bank_account_service.rs +++ b/crates/core/src/services/bank_account_service.rs @@ -1,7 +1,8 @@ pub use crate::app_state::AppState; -pub use crate::security::Claims; -use crate::repositories::bank_account_repository::BankAccountRepository; use crate::clients::paystack::PaystackClient; +use crate::repositories::bank_account_repository::BankAccountRepository; +pub use crate::security::Claims; +use crate::services::audit_service::AuditService; use dashmap::DashMap; use once_cell::sync::Lazy; pub use payego_primitives::{ @@ -80,7 +81,8 @@ impl BankAccountService { CurrencyCode::NGN, ); - let recipient_code = state.paystack + let recipient_code = state + .paystack .create_transfer_recipient(payload) .await .map_err(|_| ApiError::Payment("Unable to create transfer recipient".into()))?; @@ -97,6 +99,20 @@ impl BankAccountService { let account = BankAccountRepository::create(&mut conn, new_account)?; + let _ = AuditService::log_event( + state, + Some(user_id_val), + "bank_account.created", + Some("bank_account"), + Some(&account.id.to_string()), + serde_json::json!({ + "bank_name": account.bank_name, + "account_number": account.account_number, + }), + None, + ) + .await; + info!( user_id = %user_id_val, account_id = %account.id, @@ -119,7 +135,10 @@ impl BankAccountService { return Ok(cached); } - let body = state.paystack.resolve_bank_account(account_number, bank_code).await?; + let body = state + .paystack + .resolve_bank_account(account_number, bank_code) + .await?; let data = body .data @@ -172,6 +191,17 @@ impl BankAccountService { BankAccountRepository::delete_by_id_and_user(&mut conn, bank_account_id, user_id)?; + let _ = AuditService::log_event( + state, + Some(user_id), + "bank_account.deleted", + Some("bank_account"), + Some(&bank_account_id.to_string()), + serde_json::json!({}), + None, + ) + .await; + Ok(DeleteResponse { account_id: bank_account_id, message: "Bank account deleted successfully".into(), diff --git a/crates/core/src/services/bank_service.rs b/crates/core/src/services/bank_service.rs index 0c5671a..467f77f 100644 --- a/crates/core/src/services/bank_service.rs +++ b/crates/core/src/services/bank_service.rs @@ -1,3 +1,4 @@ +pub use crate::app_state::AppState; use crate::repositories::bank_repository::BankRepository; pub use payego_primitives::{ error::ApiError, @@ -11,7 +12,6 @@ pub use payego_primitives::{ }, schema::banks, }; -pub use crate::app_state::AppState; use reqwest::Url; use secrecy::ExposeSecret; use std::sync::Arc; diff --git a/crates/core/src/services/conversion_service.rs b/crates/core/src/services/conversion_service.rs index 1b1296d..57decda 100644 --- a/crates/core/src/services/conversion_service.rs +++ b/crates/core/src/services/conversion_service.rs @@ -1,7 +1,8 @@ +pub use crate::app_state::AppState; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; -pub use crate::app_state::AppState; pub use crate::security::Claims; +use crate::services::audit_service::AuditService; use diesel::prelude::*; pub use payego_primitives::{ error::ApiError, @@ -54,7 +55,10 @@ impl ConversionService { } // ---------- RATE ---------- - let rate = state.fx.get_rate(req.from_currency, req.to_currency).await?; + let rate = state + .fx + .get_rate(req.from_currency, req.to_currency) + .await?; if !(0.0001..10_000.0).contains(&rate) { return Err(ApiError::Payment("Suspicious exchange rate".into())); @@ -127,6 +131,23 @@ impl ConversionService { Ok(()) })?; + let _ = AuditService::log_event( + state, + Some(user_id), + "conversion.internal", + Some("transaction"), + Some(&tx_ref.to_string()), + json!({ + "from": req.from_currency, + "to": req.to_currency, + "amount": req.amount_cents, + "converted": net_cents, + "rate": rate, + }), + None, + ) + .await; + Ok(ConvertResponse { transaction_id: tx_ref.to_string(), converted_amount: net_cents as f64 / 100.0, diff --git a/crates/core/src/services/mod.rs b/crates/core/src/services/mod.rs index 860c067..11c9f97 100644 --- a/crates/core/src/services/mod.rs +++ b/crates/core/src/services/mod.rs @@ -1,4 +1,5 @@ // pub mod auth_service; +pub mod audit_service; pub mod auth_service; pub mod bank_account_service; pub mod bank_service; @@ -11,4 +12,3 @@ pub mod transaction_service; pub mod transfer_service; pub mod wallet_service; pub mod withdrawal_service; -pub mod audit_service; diff --git a/crates/core/src/services/payment_service.rs b/crates/core/src/services/payment_service.rs index 1d73573..02ff272 100644 --- a/crates/core/src/services/payment_service.rs +++ b/crates/core/src/services/payment_service.rs @@ -1,6 +1,7 @@ -use crate::repositories::transaction_repository::TransactionRepository; pub use crate::app_state::AppState; +use crate::repositories::transaction_repository::TransactionRepository; pub use crate::security::Claims; +use crate::services::audit_service::AuditService; // use diesel::prelude::*; pub use payego_primitives::{ error::ApiError, @@ -52,16 +53,22 @@ impl PaymentService { let response = match req.provider { PaymentProvider::Stripe => { - let success_url = format!("{}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.app_url); + let success_url = format!( + "{}/checkout/success?session_id={{CHECKOUT_SESSION_ID}}", + state.config.app_url + ); let cancel_url = format!("{}/checkout/cancel", state.config.app_url); - let session = state.stripe.create_checkout_session( - req.amount, - &req.currency.to_string().to_lowercase(), - &tx_ref.to_string(), - &success_url, - &cancel_url, - ).await?; + let session = state + .stripe + .create_checkout_session( + req.amount, + &req.currency.to_string().to_lowercase(), + &tx_ref.to_string(), + &success_url, + &cancel_url, + ) + .await?; info!("Stripe session created: {}", session.id); TopUpResponse { @@ -73,7 +80,7 @@ impl PaymentService { } PaymentProvider::Paypal => { let amount_str = format!("{}.{:02}", req.amount / 100, req.amount % 100); - + let paypal_res = state .http_client .post(format!("{}/v2/checkout/orders", state.config.paypal_details.paypal_api_url)) @@ -107,7 +114,10 @@ impl PaymentService { let approval_url = body["links"] .as_array() .and_then(|links| { - links.iter().find(|l| l["rel"] == "approve").and_then(|l| l["href"].as_str()) + links + .iter() + .find(|l| l["rel"] == "approve") + .and_then(|l| l["href"].as_str()) }) .ok_or_else(|| { error!("PayPal Approval link missing. Body: {:?}", body); @@ -124,6 +134,21 @@ impl PaymentService { _ => return Err(ApiError::BadRequest("Unsupported provider".into())), }; + let _ = AuditService::log_event( + state, + Some(user_id), + "payment.top_up.initiated", + Some("transaction"), + Some(&tx_ref.to_string()), + serde_json::json!({ + "amount": req.amount, + "currency": req.currency, + "provider": req.provider, + }), + None, + ) + .await; + Ok(response) } } diff --git a/crates/core/src/services/paypal_service.rs b/crates/core/src/services/paypal_service.rs index 92f3334..038e14e 100644 --- a/crates/core/src/services/paypal_service.rs +++ b/crates/core/src/services/paypal_service.rs @@ -1,3 +1,4 @@ +pub use crate::app_state::AppState; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; use diesel::Connection; @@ -14,7 +15,6 @@ pub use payego_primitives::{ wallet_ledger::NewWalletLedger, }, }; -pub use crate::app_state::AppState; use reqwest::Url; use secrecy::ExposeSecret; diff --git a/crates/core/src/services/paystack_service.rs b/crates/core/src/services/paystack_service.rs index 2fa5848..ed22f74 100644 --- a/crates/core/src/services/paystack_service.rs +++ b/crates/core/src/services/paystack_service.rs @@ -1,6 +1,7 @@ use axum::body::Bytes; use diesel::Connection; +pub use crate::app_state::AppState; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; use hmac::KeyInit; @@ -14,7 +15,6 @@ pub use payego_primitives::{ wallet_ledger::NewWalletLedger, }, }; -pub use crate::app_state::AppState; use secrecy::ExposeSecret; use std::sync::Arc; use tracing::{info, warn}; diff --git a/crates/core/src/services/stripe_service.rs b/crates/core/src/services/stripe_service.rs index 04ae267..8191aa6 100644 --- a/crates/core/src/services/stripe_service.rs +++ b/crates/core/src/services/stripe_service.rs @@ -4,12 +4,11 @@ use secrecy::ExposeSecret; use std::sync::Arc; use stripe::{Event, EventObject, EventType, Webhook}; +pub use crate::app_state::AppState; use crate::services::transaction_service::TransactionService; pub use payego_primitives::{ - error::ApiError, - models::dtos::providers::stripe::StripeWebhookContext, + error::ApiError, models::dtos::providers::stripe::StripeWebhookContext, }; -pub use crate::app_state::AppState; pub enum WebhookOutcome { Processed, diff --git a/crates/core/src/services/transaction_service.rs b/crates/core/src/services/transaction_service.rs index 29dacf0..1180d80 100644 --- a/crates/core/src/services/transaction_service.rs +++ b/crates/core/src/services/transaction_service.rs @@ -1,5 +1,7 @@ +pub use crate::app_state::AppState; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; +pub use crate::security::Claims; use diesel::prelude::*; pub use payego_primitives::{ error::{ApiError, AuthError}, @@ -13,8 +15,6 @@ pub use payego_primitives::{ }, schema::{transactions, wallet_ledger, wallets}, }; -pub use crate::app_state::AppState; -pub use crate::security::Claims; use stripe::PaymentIntent; use tracing::{error, info, warn}; use uuid::Uuid; diff --git a/crates/core/src/services/transfer_service.rs b/crates/core/src/services/transfer_service.rs index a1c8cc7..0ad464c 100644 --- a/crates/core/src/services/transfer_service.rs +++ b/crates/core/src/services/transfer_service.rs @@ -1,8 +1,8 @@ pub use crate::app_state::AppState; -use crate::services::audit_service::AuditService; -pub use crate::security::Claims; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; +pub use crate::security::Claims; +use crate::services::audit_service::AuditService; use diesel::prelude::*; pub use payego_primitives::{ error::ApiError, @@ -226,16 +226,16 @@ impl TransferService { CurrencyCode::NGN, ); - let recipient_code = state.paystack + let recipient_code = state + .paystack .create_transfer_recipient(payload) .await .map_err(|_| ApiError::Payment("Unable to create transfer recipient".into()))?; - state.paystack.initiate_transfer( - &recipient_code, - amount_minor, - &req.reference.to_string(), - ).await?; + state + .paystack + .initiate_transfer(&recipient_code, amount_minor, &req.reference.to_string()) + .await?; // Updating status to completed (simplified, usually we'd wait for webhook) TransactionRepository::update_status_and_provider_ref( @@ -257,7 +257,8 @@ impl TransferService { "currency": currency, }), None, - ).await; + ) + .await; Ok(TransferResponse { transaction_id: tx_id, diff --git a/crates/core/src/services/wallet_service.rs b/crates/core/src/services/wallet_service.rs index 12edc98..007c631 100644 --- a/crates/core/src/services/wallet_service.rs +++ b/crates/core/src/services/wallet_service.rs @@ -2,6 +2,8 @@ use crate::repositories::wallet_repository::WalletRepository; use tracing::{error, warn}; use uuid::Uuid; +pub use crate::app_state::AppState; +pub use crate::security::Claims; pub use payego_primitives::{ error::{ApiError, AuthError}, models::{ @@ -10,8 +12,6 @@ pub use payego_primitives::{ }, schema::wallets, }; -pub use crate::app_state::AppState; -pub use crate::security::Claims; pub struct WalletService; diff --git a/crates/core/src/services/withdrawal_service.rs b/crates/core/src/services/withdrawal_service.rs index f0bce66..5333c98 100644 --- a/crates/core/src/services/withdrawal_service.rs +++ b/crates/core/src/services/withdrawal_service.rs @@ -1,8 +1,9 @@ +pub use crate::app_state::AppState; use crate::repositories::bank_account_repository::BankAccountRepository; use crate::repositories::transaction_repository::TransactionRepository; use crate::repositories::wallet_repository::WalletRepository; -pub use crate::app_state::AppState; pub use crate::security::Claims; +use crate::services::audit_service::AuditService; use diesel::prelude::*; pub use payego_primitives::{ error::ApiError, @@ -51,16 +52,18 @@ impl WithdrawalService { user_id, )?; - let recipient_code = bank_account.provider_recipient_id.as_deref().ok_or_else(|| { - ApiError::Payment("Bank account is not linked to a provider recipient".into()) - })?; + let recipient_code = bank_account + .provider_recipient_id + .as_deref() + .ok_or_else(|| { + ApiError::Payment("Bank account is not linked to a provider recipient".into()) + })?; // External transfer - state.paystack.initiate_transfer( - recipient_code, - amount_minor, - &req.reference.to_string(), - ).await?; + state + .paystack + .initiate_transfer(recipient_code, amount_minor, &req.reference.to_string()) + .await?; // Atomic DB write let tx_id = conn.transaction::<_, ApiError, _>(|conn| { @@ -100,6 +103,21 @@ impl WithdrawalService { Ok::(tx.id) })?; + let _ = AuditService::log_event( + state, + Some(user_id), + "withdrawal.initiated", + Some("transaction"), + Some(&tx_id.to_string()), + json!({ + "amount": amount_minor, + "currency": wallet.currency, + "bank_account_id": bank_account_id, + }), + None, + ) + .await; + Ok(WithdrawResponse { transaction_id: tx_id, }) diff --git a/crates/primitives/src/models/entities/mod.rs b/crates/primitives/src/models/entities/mod.rs index 435c33a..0ed3465 100644 --- a/crates/primitives/src/models/entities/mod.rs +++ b/crates/primitives/src/models/entities/mod.rs @@ -1,4 +1,5 @@ // pub mod entities; +pub mod audit_log; pub mod authentication; pub mod bank; pub mod enum_types; @@ -6,4 +7,3 @@ pub mod transaction; pub mod user; pub mod wallet; pub mod wallet_ledger; -pub mod audit_log; diff --git a/payego_ui/.husky/pre-commit b/payego_ui/.husky/pre-commit old mode 100644 new mode 100755 From d996272a4b5df67c4a034d10298f8c1a3836f0df Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 17:27:38 +0100 Subject: [PATCH 5/6] fix CI/CD bugs --- bin/payego/tests/bank_service_test.rs | 13 +++++++++---- bin/payego/tests/common/mod.rs | 2 +- bin/payego/tests/conversion_service_test.rs | 21 ++++++++++++++------- bin/payego/tests/payment_service_test.rs | 15 ++++++++++----- bin/payego/tests/withdrawal_service_test.rs | 19 ++++++++++++------- 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/bin/payego/tests/bank_service_test.rs b/bin/payego/tests/bank_service_test.rs index 41cc154..2ccbd31 100644 --- a/bin/payego/tests/bank_service_test.rs +++ b/bin/payego/tests/bank_service_test.rs @@ -6,7 +6,6 @@ use payego_primitives::models::BankRequest; use payego_primitives::schema::{bank_accounts, banks, users}; use serde_json::json; use serial_test::serial; -use std::sync::Arc; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -51,9 +50,11 @@ async fn test_add_bank_account_success() { .await; // 3. Setup AppState - let mut base_state = (*common::create_test_app_state()).clone(); - base_state.config.paystack_details.paystack_api_url = base_url.clone(); - let state = Arc::new(base_state); + let base_state = common::create_test_app_state(); + let mut config = base_state.config.clone(); + config.paystack_details.paystack_api_url = base_url.clone(); + let state = payego_core::AppState::new(base_state.db.clone(), config) + .expect("Failed to create AppState"); let pool = &state.db; let conn = &mut pool.get().unwrap(); @@ -107,6 +108,10 @@ async fn test_add_bank_account_success() { // Removed assertion for paystack_recipient_code if it doesn't exist on BankAccount struct // 6. Cleanup + use payego_primitives::schema::audit_logs; + diesel::delete(audit_logs::table.filter(audit_logs::user_id.eq(user_id))) + .execute(conn) + .unwrap(); diesel::delete(bank_accounts::table.filter(bank_accounts::user_id.eq(user_id))) .execute(conn) .unwrap(); diff --git a/bin/payego/tests/common/mod.rs b/bin/payego/tests/common/mod.rs index 420352d..84d6258 100644 --- a/bin/payego/tests/common/mod.rs +++ b/bin/payego/tests/common/mod.rs @@ -186,7 +186,7 @@ pub fn cleanup_test_db(conn: &mut PgConnection) { // Truncate all tables let _ = sql_query( - "TRUNCATE users, wallets, transactions, bank_accounts, blacklisted_tokens CASCADE", + "TRUNCATE users, wallets, transactions, bank_accounts, blacklisted_tokens, audit_logs CASCADE", ) .execute(conn); } diff --git a/bin/payego/tests/conversion_service_test.rs b/bin/payego/tests/conversion_service_test.rs index e4cc7f8..25811ee 100644 --- a/bin/payego/tests/conversion_service_test.rs +++ b/bin/payego/tests/conversion_service_test.rs @@ -6,7 +6,6 @@ use payego_primitives::models::ConvertRequest; use payego_primitives::schema::{users, wallets}; use serde_json::json; use serial_test::serial; -use std::sync::Arc; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -31,9 +30,11 @@ async fn test_convert_currency_success() { .await; // 2. Setup AppState - let mut base_state = (*common::create_test_app_state()).clone(); - base_state.config.exchange_api_url = exchange_api_url; - let state = Arc::new(base_state); + let base_state = common::create_test_app_state(); + let mut config = base_state.config.clone(); + config.exchange_api_url = exchange_api_url.clone(); + let state = payego_core::AppState::new(base_state.db.clone(), config) + .expect("Failed to create AppState"); let pool = &state.db; let conn = &mut pool.get().unwrap(); @@ -110,6 +111,10 @@ async fn test_convert_currency_success() { assert_eq!(wallet_ngn.balance, 1485000); // Cleanup + use payego_primitives::schema::audit_logs; + diesel::delete(audit_logs::table.filter(audit_logs::user_id.eq(user_id))) + .execute(conn) + .unwrap(); diesel::delete(wallets::table.filter(wallets::user_id.eq(user_id))) .execute(conn) .unwrap(); @@ -136,9 +141,11 @@ async fn test_convert_currency_insufficient_balance() { .await; // 2. Setup AppState - let mut base_state = (*common::create_test_app_state()).clone(); - base_state.config.exchange_api_url = exchange_api_url; - let state = Arc::new(base_state); + let base_state = common::create_test_app_state(); + let mut config = base_state.config.clone(); + config.exchange_api_url = exchange_api_url.clone(); + let state = payego_core::AppState::new(base_state.db.clone(), config) + .expect("Failed to create AppState"); let pool = &state.db; let conn = &mut pool.get().unwrap(); diff --git a/bin/payego/tests/payment_service_test.rs b/bin/payego/tests/payment_service_test.rs index 1acf0e1..5ddf6db 100644 --- a/bin/payego/tests/payment_service_test.rs +++ b/bin/payego/tests/payment_service_test.rs @@ -4,7 +4,6 @@ use payego_primitives::models::TopUpRequest; use payego_primitives::schema::{transactions, users}; use serde_json::json; use serial_test::serial; -use std::sync::Arc; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -46,9 +45,11 @@ async fn test_top_up_paypal_init_success() { .await; // 3. Setup AppState - let mut base_state = (*common::create_test_app_state()).clone(); - base_state.config.paypal_details.paypal_api_url = base_url.clone(); - let state = Arc::new(base_state); + let base_state = common::create_test_app_state(); + let mut config = base_state.config.clone(); + config.paypal_details.paypal_api_url = base_url.clone(); + let state = payego_core::AppState::new(base_state.db.clone(), config) + .expect("Failed to create AppState"); let pool = &state.db; let conn = &mut pool.get().unwrap(); @@ -103,7 +104,7 @@ async fn test_top_up_paypal_init_success() { .first::(conn) .unwrap(); - assert_eq!(tx.amount, 100000); // 1000 * 100 + assert_eq!(tx.amount, 1000); // 1000 cents assert_eq!( tx.provider, Some(payego_primitives::models::entities::enum_types::PaymentProvider::Paypal) @@ -114,6 +115,10 @@ async fn test_top_up_paypal_init_success() { ); // 6. Cleanup + use payego_primitives::schema::audit_logs; + diesel::delete(audit_logs::table.filter(audit_logs::user_id.eq(user_id))) + .execute(conn) + .unwrap(); diesel::delete(transactions::table.filter(transactions::user_id.eq(user_id))) .execute(conn) .unwrap(); diff --git a/bin/payego/tests/withdrawal_service_test.rs b/bin/payego/tests/withdrawal_service_test.rs index b8423e3..dd4fd1f 100644 --- a/bin/payego/tests/withdrawal_service_test.rs +++ b/bin/payego/tests/withdrawal_service_test.rs @@ -6,7 +6,6 @@ use payego_primitives::models::WithdrawRequest; use payego_primitives::schema::{bank_accounts, banks, transactions, users, wallets}; use serde_json::json; use serial_test::serial; -use std::sync::Arc; use uuid::Uuid; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -65,10 +64,12 @@ async fn test_withdrawal_success() { .await; // 2. Setup AppState - let mut base_state = (*common::create_test_app_state()).clone(); - base_state.config.exchange_api_url = base_url.clone(); - base_state.config.paystack_details.paystack_api_url = base_url.clone(); - let state = Arc::new(base_state); + let base_state = common::create_test_app_state(); + let mut config = base_state.config.clone(); + config.exchange_api_url = base_url.clone(); + config.paystack_details.paystack_api_url = base_url.clone(); + let state = payego_core::AppState::new(base_state.db.clone(), config) + .expect("Failed to create AppState"); let pool = &state.db; let conn = &mut pool.get().unwrap(); @@ -131,7 +132,7 @@ async fn test_withdrawal_success() { } let req = WithdrawRequest { - amount: 10, + amount: 1000, currency: CurrencyCode::USD, reference: Uuid::new_v4(), idempotency_key: format!("withdraw_key_{}", Uuid::new_v4()), @@ -154,6 +155,10 @@ async fn test_withdrawal_success() { assert_eq!(wallet.balance, 1000); // 6. Cleanup + use payego_primitives::schema::audit_logs; + diesel::delete(audit_logs::table.filter(audit_logs::user_id.eq(user_id))) + .execute(conn) + .unwrap(); diesel::delete(bank_accounts::table.filter(bank_accounts::user_id.eq(user_id))) .execute(conn) .unwrap(); @@ -202,7 +207,7 @@ async fn test_withdrawal_insufficient_balance() { .unwrap(); let req = WithdrawRequest { - amount: 10, + amount: 1000, currency: CurrencyCode::USD, reference: Uuid::new_v4(), idempotency_key: "any".to_string(), From 646a112628c1827a2fb0dbdb7db8eb46bd30d79e Mon Sep 17 00:00:00 2001 From: intelliDean Date: Wed, 28 Jan 2026 17:49:49 +0100 Subject: [PATCH 6/6] fix CI/CD bugs --- bin/payego/tests/idempotency_tests.rs | 4 ++-- crates/core/src/services/payment_service.rs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/payego/tests/idempotency_tests.rs b/bin/payego/tests/idempotency_tests.rs index 51f3817..27270cf 100644 --- a/bin/payego/tests/idempotency_tests.rs +++ b/bin/payego/tests/idempotency_tests.rs @@ -71,7 +71,7 @@ async fn test_top_up_idempotency() { let _reference = Uuid::new_v4(); let top_up_data = json!({ - "amount": 100.0, + "amount": 10000, "provider": "Paypal", "currency": "USD", "idempotency_key": "topup_idemp_1" @@ -187,7 +187,7 @@ async fn test_internal_transfer_idempotency() { let reference = Uuid::new_v4(); let transfer_data = json!({ - "amount": 10.0, + "amount": 1000, "recipient": recipient_id, "currency": "USD", "description": "Internal transfer test", diff --git a/crates/core/src/services/payment_service.rs b/crates/core/src/services/payment_service.rs index 02ff272..e710979 100644 --- a/crates/core/src/services/payment_service.rs +++ b/crates/core/src/services/payment_service.rs @@ -31,7 +31,7 @@ impl PaymentService { let tx_ref = Uuid::new_v4(); // Create initial transaction - TransactionRepository::create( + let transaction = TransactionRepository::create( &mut conn, NewTransaction { user_id, @@ -51,6 +51,8 @@ impl PaymentService { }, )?; + let tx_ref = transaction.reference; + let response = match req.provider { PaymentProvider::Stripe => { let success_url = format!(