|
1 | | -use axum::{Extension, Json, extract::{Path, State}}; |
| 1 | +use axum::{Extension, Json, extract::{Path, Query, State}}; |
2 | 2 | use bigdecimal::{BigDecimal, ToPrimitive}; |
| 3 | +use chrono::{DateTime, NaiveDate, Utc}; |
3 | 4 | use diesel::prelude::*; |
4 | 5 | use serde::{Deserialize, Serialize}; |
5 | | -use utoipa::ToSchema; |
| 6 | +use utoipa::{IntoParams, ToSchema}; |
6 | 7 | use uuid::Uuid; |
7 | 8 |
|
8 | 9 | use crate::{ |
@@ -57,6 +58,50 @@ pub struct WithdrawalRequest { |
57 | 58 | pub reason: Option<String>, |
58 | 59 | } |
59 | 60 |
|
| 61 | +#[derive(Deserialize, IntoParams, ToSchema)] |
| 62 | +pub struct TransactionFilters { |
| 63 | + /// Optional text search against reference, description, or provider |
| 64 | + pub search: Option<String>, |
| 65 | + |
| 66 | + /// Filter by transaction status (pending, successful, failed, reversed) |
| 67 | + pub status: Option<WalletTransactionStatusEnum>, |
| 68 | + |
| 69 | + /// Filter by transaction type (deposit, withdrawal, transfer, refund, reward) |
| 70 | + pub transaction_type: Option<WalletTransactionTypeEnum>, |
| 71 | + |
| 72 | + /// Filter by start date (inclusive, YYYY-MM-DD) |
| 73 | + pub date_from: Option<NaiveDate>, |
| 74 | + |
| 75 | + /// Filter by end date (inclusive, YYYY-MM-DD) |
| 76 | + pub date_to: Option<NaiveDate>, |
| 77 | + |
| 78 | + /// Minimum amount filter, e.g. "500.00" |
| 79 | + pub amount_min: Option<String>, |
| 80 | + |
| 81 | + /// Maximum amount filter, e.g. "10000.00" |
| 82 | + pub amount_max: Option<String>, |
| 83 | + |
| 84 | + /// Page number (starts from 1) |
| 85 | + #[serde(default = "default_page")] |
| 86 | + pub page: i64, |
| 87 | + |
| 88 | + /// Items per page (default: 20, max: 100) |
| 89 | + #[serde(default = "default_page_size")] |
| 90 | + pub page_size: i64, |
| 91 | +} |
| 92 | + |
| 93 | +fn default_page() -> i64 { 1 } |
| 94 | +fn default_page_size() -> i64 { 20 } |
| 95 | + |
| 96 | +#[derive(Serialize, ToSchema)] |
| 97 | +pub struct TransactionListResponse { |
| 98 | + pub data: Vec<WalletTransaction>, |
| 99 | + pub page: i64, |
| 100 | + pub page_size: i64, |
| 101 | + pub total: i64, |
| 102 | + pub total_pages: i64, |
| 103 | +} |
| 104 | + |
60 | 105 | /// Get wallet balance and transaction history |
61 | 106 | #[utoipa::path( |
62 | 107 | get, |
@@ -368,6 +413,155 @@ pub async fn withdraw_funds( |
368 | 413 | Ok(ApiResponse::success_with_message("Withdrawal initiated successfully", transaction)) |
369 | 414 | } |
370 | 415 |
|
| 416 | +/// List wallet transaction history |
| 417 | +/// |
| 418 | +/// Returns a paginated list of the authenticated user's wallet transactions. |
| 419 | +/// Supports filtering by status, type, date range, amount range, and text search. |
| 420 | +#[utoipa::path( |
| 421 | + get, |
| 422 | + path = "/api/wallets/transactions", |
| 423 | + params(TransactionFilters), |
| 424 | + responses( |
| 425 | + (status = 200, body = ApiResponse<TransactionListResponse>), |
| 426 | + (status = 401), |
| 427 | + (status = 500) |
| 428 | + ), |
| 429 | + tag = "wallets", |
| 430 | + security(("bearer_auth" = [])) |
| 431 | +)] |
| 432 | +pub async fn list_transactions( |
| 433 | + State(state): State<AppState>, |
| 434 | + Extension(current_user): Extension<User>, |
| 435 | + Query(filters): Query<TransactionFilters>, |
| 436 | +) -> Result<ApiResponse<TransactionListResponse>, AppError> { |
| 437 | + let mut conn = state.pool.get()?; |
| 438 | + |
| 439 | + let wallet = get_or_create_wallet(&mut conn, current_user.id)?; |
| 440 | + |
| 441 | + let page = filters.page.max(1); |
| 442 | + let page_size = filters.page_size.min(100).max(1); |
| 443 | + let offset = (page - 1) * page_size; |
| 444 | + |
| 445 | + // ---- Build base query ---- |
| 446 | + let mut query = wallet_transactions::table |
| 447 | + .filter(wallet_transactions::wallet_id.eq(wallet.id)) |
| 448 | + .into_boxed(); |
| 449 | + |
| 450 | + // Status filter |
| 451 | + if let Some(status) = filters.status { |
| 452 | + query = query.filter(wallet_transactions::status.eq(status)); |
| 453 | + } |
| 454 | + |
| 455 | + // Type filter |
| 456 | + if let Some(tx_type) = filters.transaction_type { |
| 457 | + query = query.filter(wallet_transactions::transaction_type.eq(tx_type)); |
| 458 | + } |
| 459 | + |
| 460 | + // Date range filters — convert NaiveDate to start/end of day DateTime<Utc> |
| 461 | + if let Some(date_from) = filters.date_from { |
| 462 | + let start = date_from |
| 463 | + .and_hms_opt(0, 0, 0) |
| 464 | + .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc)) |
| 465 | + .unwrap(); |
| 466 | + query = query.filter(wallet_transactions::created_at.ge(start)); |
| 467 | + } |
| 468 | + |
| 469 | + if let Some(date_to) = filters.date_to { |
| 470 | + let end = date_to |
| 471 | + .and_hms_opt(23, 59, 59) |
| 472 | + .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc)) |
| 473 | + .unwrap(); |
| 474 | + query = query.filter(wallet_transactions::created_at.le(end)); |
| 475 | + } |
| 476 | + |
| 477 | + // Amount range filters — parse from string |
| 478 | + let amount_min = filters.amount_min |
| 479 | + .as_deref() |
| 480 | + .map(|s| s.parse::<BigDecimal>().ok()) |
| 481 | + .flatten(); |
| 482 | + |
| 483 | + let amount_max = filters.amount_max |
| 484 | + .as_deref() |
| 485 | + .map(|s| s.parse::<BigDecimal>().ok()) |
| 486 | + .flatten(); |
| 487 | + |
| 488 | + if let Some(ref min) = amount_min { |
| 489 | + query = query.filter(wallet_transactions::amount.ge(min.clone())); |
| 490 | + } |
| 491 | + |
| 492 | + if let Some(ref max) = amount_max { |
| 493 | + query = query.filter(wallet_transactions::amount.le(max.clone())); |
| 494 | + } |
| 495 | + |
| 496 | + // Text search — reference, description, or provider |
| 497 | + if let Some(ref search) = filters.search { |
| 498 | + let pattern = format!("%{}%", search.to_lowercase()); |
| 499 | + query = query.filter( |
| 500 | + wallet_transactions::reference.ilike(pattern.clone()) |
| 501 | + .or(wallet_transactions::description.ilike(pattern.clone())) |
| 502 | + .or(wallet_transactions::provider.ilike(pattern)) |
| 503 | + ); |
| 504 | + } |
| 505 | + |
| 506 | + // ---- Count total (clone the predicate) ---- |
| 507 | + let mut count_query = wallet_transactions::table |
| 508 | + .filter(wallet_transactions::wallet_id.eq(wallet.id)) |
| 509 | + .into_boxed(); |
| 510 | + |
| 511 | + if let Some(status) = filters.status { |
| 512 | + count_query = count_query.filter(wallet_transactions::status.eq(status)); |
| 513 | + } |
| 514 | + if let Some(tx_type) = filters.transaction_type { |
| 515 | + count_query = count_query.filter(wallet_transactions::transaction_type.eq(tx_type)); |
| 516 | + } |
| 517 | + if let Some(date_from) = filters.date_from { |
| 518 | + let start = date_from.and_hms_opt(0, 0, 0) |
| 519 | + .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc)) |
| 520 | + .unwrap(); |
| 521 | + count_query = count_query.filter(wallet_transactions::created_at.ge(start)); |
| 522 | + } |
| 523 | + if let Some(date_to) = filters.date_to { |
| 524 | + let end = date_to.and_hms_opt(23, 59, 59) |
| 525 | + .map(|ndt| DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc)) |
| 526 | + .unwrap(); |
| 527 | + count_query = count_query.filter(wallet_transactions::created_at.le(end)); |
| 528 | + } |
| 529 | + if let Some(ref min) = amount_min { |
| 530 | + count_query = count_query.filter(wallet_transactions::amount.ge(min.clone())); |
| 531 | + } |
| 532 | + if let Some(ref max) = amount_max { |
| 533 | + count_query = count_query.filter(wallet_transactions::amount.le(max.clone())); |
| 534 | + } |
| 535 | + if let Some(ref search) = filters.search { |
| 536 | + let pattern = format!("%{}%", search.to_lowercase()); |
| 537 | + count_query = count_query.filter( |
| 538 | + wallet_transactions::reference.ilike(pattern.clone()) |
| 539 | + .or(wallet_transactions::description.ilike(pattern.clone())) |
| 540 | + .or(wallet_transactions::provider.ilike(pattern)) |
| 541 | + ); |
| 542 | + } |
| 543 | + |
| 544 | + let total: i64 = count_query |
| 545 | + .count() |
| 546 | + .get_result(&mut conn)?; |
| 547 | + |
| 548 | + let transactions = query |
| 549 | + .order(wallet_transactions::created_at.desc()) |
| 550 | + .limit(page_size) |
| 551 | + .offset(offset) |
| 552 | + .load::<WalletTransaction>(&mut conn)?; |
| 553 | + |
| 554 | + let total_pages = (total + page_size - 1) / page_size; |
| 555 | + |
| 556 | + Ok(ApiResponse::success(TransactionListResponse { |
| 557 | + data: transactions, |
| 558 | + page, |
| 559 | + page_size, |
| 560 | + total, |
| 561 | + total_pages, |
| 562 | + })) |
| 563 | +} |
| 564 | + |
371 | 565 | // Utility function to get or create a wallet for a user |
372 | 566 | fn get_or_create_wallet(conn: &mut PgConnection, user_id_val: Uuid) -> Result<Wallet, AppError> { |
373 | 567 | let wallet = wallets::table |
|
0 commit comments