diff --git a/services/search/mod.rs b/services/search/mod.rs new file mode 100644 index 0000000..300bc75 --- /dev/null +++ b/services/search/mod.rs @@ -0,0 +1,10 @@ +pub mod service; +pub mod types; + +pub use service::{MerchantIndex, PaymentIndex, RefundIndex, SearchService}; +pub use types::{ + MerchantPage, MerchantRecord, MerchantSearchQuery, MerchantSortField, + PaymentPage, PaymentRecord, PaymentSearchQuery, PaymentSortField, PaymentStatusFilter, + RefundPage, RefundRecord, RefundSearchQuery, RefundSortField, + SortOrder, +}; diff --git a/services/search/service.rs b/services/search/service.rs new file mode 100644 index 0000000..c62232e --- /dev/null +++ b/services/search/service.rs @@ -0,0 +1,276 @@ +/// Advanced Search & Filtering Service — Issue #275 +/// +/// Provides cursor-based paginated search across payments, merchants, and refunds +/// with multi-field sorting and combined filter support. + +use crate::types::{ + MerchantPage, MerchantRecord, MerchantSearchQuery, MerchantSortField, + PaymentPage, PaymentRecord, PaymentSearchQuery, PaymentSortField, + RefundPage, RefundRecord, RefundSearchQuery, RefundSortField, + SortOrder, +}; + +pub const DEFAULT_PAGE_LIMIT: u32 = 20; +pub const MAX_PAGE_LIMIT: u32 = 100; + +// ── Repository traits ───────────────────────────────────────────────────────── + +pub trait PaymentIndex: Send + Sync { + /// Return all payment records (implementations should use DB indexes). + fn all(&self) -> Vec; +} + +pub trait MerchantIndex: Send + Sync { + fn all(&self) -> Vec; +} + +pub trait RefundIndex: Send + Sync { + fn all(&self) -> Vec; +} + +// ── Search service ──────────────────────────────────────────────────────────── + +pub struct SearchService +where + P: PaymentIndex, + M: MerchantIndex, + R: RefundIndex, +{ + payments: P, + merchants: M, + refunds: R, +} + +impl SearchService { + pub fn new(payments: P, merchants: M, refunds: R) -> Self { + Self { payments, merchants, refunds } + } + + // ── Payments ────────────────────────────────────────────────────────────── + + pub fn search_payments(&self, q: PaymentSearchQuery) -> PaymentPage { + let limit = q.limit.unwrap_or(DEFAULT_PAGE_LIMIT).min(MAX_PAGE_LIMIT) as usize; + + let mut records: Vec = self + .payments + .all() + .into_iter() + .filter(|r| { + if let Some(ref a) = q.merchant_address { + if !r.merchant_address.eq_ignore_ascii_case(a) { return false; } + } + if let Some(ref a) = q.payer_address { + if !r.payer_address.eq_ignore_ascii_case(a) { return false; } + } + if let Some(ref t) = q.token_address { + if !r.token_address.eq_ignore_ascii_case(t) { return false; } + } + if let Some(min) = q.amount_min { if r.amount < min { return false; } } + if let Some(max) = q.amount_max { if r.amount > max { return false; } } + if let Some(start) = q.date_start { if r.paid_at < start { return false; } } + if let Some(end) = q.date_end { if r.paid_at > end { return false; } } + if let Some(ref statuses) = q.statuses { + let matched = statuses.iter().any(|s| format!("{:?}", s) == r.status); + if !matched { return false; } + } + true + }) + .collect(); + + // Multi-field sort + let sort_field = q.sort_field.unwrap_or(PaymentSortField::Date); + let asc = q.sort_order.as_ref().map_or(false, |o| *o == SortOrder::Ascending); + records.sort_by(|a, b| { + let ord = match sort_field { + PaymentSortField::Date => a.paid_at.cmp(&b.paid_at), + PaymentSortField::Amount => a.amount.cmp(&b.amount), + PaymentSortField::MerchantAddress => a.merchant_address.cmp(&b.merchant_address), + PaymentSortField::PayerAddress => a.payer_address.cmp(&b.payer_address), + }; + if asc { ord } else { ord.reverse() } + }); + + let total = records.len() as u64; + let (records, next_cursor) = paginate(records, q.cursor.as_deref(), limit, |r| r.order_id.clone()); + PaymentPage { records, next_cursor, total } + } + + // ── Merchants ───────────────────────────────────────────────────────────── + + pub fn search_merchants(&self, q: MerchantSearchQuery) -> MerchantPage { + let limit = q.limit.unwrap_or(DEFAULT_PAGE_LIMIT).min(MAX_PAGE_LIMIT) as usize; + + let mut records: Vec = self + .merchants + .all() + .into_iter() + .filter(|r| { + if let Some(ref name) = q.name_contains { + if !r.name.to_lowercase().contains(&name.to_lowercase()) { return false; } + } + if let Some(ref cat) = q.category { + if !r.category.eq_ignore_ascii_case(cat) { return false; } + } + if let Some(active) = q.active { if r.active != active { return false; } } + if let Some(wl) = q.whitelisted { if r.whitelisted != wl { return false; } } + true + }) + .collect(); + + let sort_field = q.sort_field.unwrap_or(MerchantSortField::Name); + let asc = q.sort_order.as_ref().map_or(true, |o| *o == SortOrder::Ascending); + records.sort_by(|a, b| { + let ord = match sort_field { + MerchantSortField::Name => a.name.cmp(&b.name), + MerchantSortField::RegisteredAt => a.registered_at.cmp(&b.registered_at), + MerchantSortField::Category => a.category.cmp(&b.category), + }; + if asc { ord } else { ord.reverse() } + }); + + let total = records.len() as u64; + let (records, next_cursor) = paginate(records, q.cursor.as_deref(), limit, |r| r.address.clone()); + MerchantPage { records, next_cursor, total } + } + + // ── Refunds ─────────────────────────────────────────────────────────────── + + pub fn search_refunds(&self, q: RefundSearchQuery) -> RefundPage { + let limit = q.limit.unwrap_or(DEFAULT_PAGE_LIMIT).min(MAX_PAGE_LIMIT) as usize; + + let mut records: Vec = self + .refunds + .all() + .into_iter() + .filter(|r| { + if let Some(ref oid) = q.order_id { if r.order_id != *oid { return false; } } + if let Some(ref by) = q.initiated_by { if !r.initiated_by.eq_ignore_ascii_case(by) { return false; } } + if let Some(ref statuses) = q.statuses { + if !statuses.iter().any(|s| s.eq_ignore_ascii_case(&r.status)) { return false; } + } + if let Some(min) = q.amount_min { if r.amount < min { return false; } } + if let Some(max) = q.amount_max { if r.amount > max { return false; } } + if let Some(start) = q.date_start { if r.initiated_at < start { return false; } } + if let Some(end) = q.date_end { if r.initiated_at > end { return false; } } + true + }) + .collect(); + + let sort_field = q.sort_field.unwrap_or(RefundSortField::InitiatedAt); + let asc = q.sort_order.as_ref().map_or(false, |o| *o == SortOrder::Ascending); + records.sort_by(|a, b| { + let ord = match sort_field { + RefundSortField::InitiatedAt => a.initiated_at.cmp(&b.initiated_at), + RefundSortField::Amount => a.amount.cmp(&b.amount), + }; + if asc { ord } else { ord.reverse() } + }); + + let total = records.len() as u64; + let (records, next_cursor) = paginate(records, q.cursor.as_deref(), limit, |r| r.refund_id.clone()); + RefundPage { records, next_cursor, total } + } +} + +// ── Cursor-based pagination ─────────────────────────────────────────────────── + +/// Slices `items` starting after the item whose key matches `cursor`. +/// Returns (page_items, next_cursor). +fn paginate(items: Vec, cursor: Option<&str>, limit: usize, key_fn: F) -> (Vec, Option) +where + F: Fn(&T) -> String, +{ + let start = match cursor { + None => 0, + Some(c) => items.iter().position(|i| key_fn(i) == c).map_or(0, |p| p + 1), + }; + let slice: Vec = items.into_iter().skip(start).take(limit + 1).collect(); + if slice.len() > limit { + let mut page = slice; + let extra = page.pop().unwrap(); + let next = key_fn(&extra); + (page, Some(next)) + } else { + (slice, None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payments() -> Vec { + vec![ + PaymentRecord { order_id: "P1".into(), merchant_address: "M1".into(), payer_address: "A1".into(), token_address: "T1".into(), amount: 500, refunded_amount: 0, status: "Completed".into(), paid_at: 1000 }, + PaymentRecord { order_id: "P2".into(), merchant_address: "M2".into(), payer_address: "A2".into(), token_address: "T1".into(), amount: 200, refunded_amount: 0, status: "Completed".into(), paid_at: 2000 }, + PaymentRecord { order_id: "P3".into(), merchant_address: "M1".into(), payer_address: "A1".into(), token_address: "T1".into(), amount: 800, refunded_amount: 0, status: "PartiallyRefunded".into(), paid_at: 3000 }, + ] + } + + struct StaticPayments(Vec); + struct StaticMerchants(Vec); + struct StaticRefunds(Vec); + impl PaymentIndex for StaticPayments { fn all(&self) -> Vec { self.0.clone() } } + impl MerchantIndex for StaticMerchants { fn all(&self) -> Vec { self.0.clone() } } + impl RefundIndex for StaticRefunds { fn all(&self) -> Vec { self.0.clone() } } + + fn svc() -> SearchService { + SearchService::new( + StaticPayments(payments()), + StaticMerchants(vec![ + MerchantRecord { address: "M1".into(), name: "Coffee House".into(), category: "Food".into(), active: true, whitelisted: false, registered_at: 100 }, + MerchantRecord { address: "M2".into(), name: "Tech Store".into(), category: "Digital".into(), active: false, whitelisted: true, registered_at: 200 }, + ]), + StaticRefunds(vec![ + RefundRecord { refund_id: "R1".into(), order_id: "P1".into(), amount: 100, status: "Pending".into(), initiated_by: "A1".into(), initiated_at: 1500 }, + ]), + ) + } + + #[test] fn filter_by_merchant() { + let page = svc().search_payments(PaymentSearchQuery { merchant_address: Some("M1".into()), ..Default::default() }); + assert_eq!(page.records.len(), 2); + } + + #[test] fn filter_by_amount_range() { + let page = svc().search_payments(PaymentSearchQuery { amount_min: Some(300), amount_max: Some(600), ..Default::default() }); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].order_id, "P1"); + } + + #[test] fn filter_by_date_range() { + let page = svc().search_payments(PaymentSearchQuery { date_start: Some(1500), date_end: Some(2500), ..Default::default() }); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].order_id, "P2"); + } + + #[test] fn sort_by_amount_ascending() { + let page = svc().search_payments(PaymentSearchQuery { sort_field: Some(PaymentSortField::Amount), sort_order: Some(SortOrder::Ascending), ..Default::default() }); + assert_eq!(page.records[0].amount, 200); + } + + #[test] fn cursor_pagination() { + let page1 = svc().search_payments(PaymentSearchQuery { limit: Some(2), sort_field: Some(PaymentSortField::Date), sort_order: Some(SortOrder::Descending), ..Default::default() }); + assert_eq!(page1.records.len(), 2); + assert!(page1.next_cursor.is_some()); + let page2 = svc().search_payments(PaymentSearchQuery { limit: Some(2), cursor: page1.next_cursor, sort_field: Some(PaymentSortField::Date), sort_order: Some(SortOrder::Descending), ..Default::default() }); + assert_eq!(page2.records.len(), 1); + assert!(page2.next_cursor.is_none()); + } + + #[test] fn merchant_text_search() { + let page = svc().search_merchants(MerchantSearchQuery { name_contains: Some("coffee".into()), ..Default::default() }); + assert_eq!(page.records.len(), 1); + } + + #[test] fn merchant_filter_active() { + let page = svc().search_merchants(MerchantSearchQuery { active: Some(false), ..Default::default() }); + assert_eq!(page.records.len(), 1); + assert_eq!(page.records[0].address, "M2"); + } + + #[test] fn refund_filter_by_order() { + let page = svc().search_refunds(RefundSearchQuery { order_id: Some("P1".into()), ..Default::default() }); + assert_eq!(page.records.len(), 1); + } +} diff --git a/services/search/types.rs b/services/search/types.rs new file mode 100644 index 0000000..222cafb --- /dev/null +++ b/services/search/types.rs @@ -0,0 +1,153 @@ +/// Advanced search & filtering types — Issue #275 + +use serde::{Deserialize, Serialize}; + +// ── Sort helpers ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SortOrder { + Ascending, + Descending, +} + +// ── Payment search ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentSortField { + Date, + Amount, + MerchantAddress, + PayerAddress, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentStatusFilter { + Any, + Completed, + PartiallyRefunded, + FullyRefunded, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PaymentSearchQuery { + // text + pub merchant_address: Option, + pub payer_address: Option, + pub token_address: Option, + // amount range + pub amount_min: Option, + pub amount_max: Option, + // date range + pub date_start: Option, + pub date_end: Option, + // status (supports multiple values) + pub statuses: Option>, + // sort + pub sort_field: Option, + pub sort_order: Option, + // pagination + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PaymentRecord { + pub order_id: String, + pub merchant_address: String, + pub payer_address: String, + pub token_address: String, + pub amount: i128, + pub refunded_amount: i128, + pub status: String, + pub paid_at: u64, +} + +#[derive(Debug, Serialize)] +pub struct PaymentPage { + pub records: Vec, + pub next_cursor: Option, + pub total: u64, +} + +// ── Merchant search ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MerchantSortField { + Name, + RegisteredAt, + Category, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MerchantSearchQuery { + pub name_contains: Option, + pub category: Option, + pub active: Option, + pub whitelisted: Option, + pub sort_field: Option, + pub sort_order: Option, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MerchantRecord { + pub address: String, + pub name: String, + pub category: String, + pub active: bool, + pub whitelisted: bool, + pub registered_at: u64, +} + +#[derive(Debug, Serialize)] +pub struct MerchantPage { + pub records: Vec, + pub next_cursor: Option, + pub total: u64, +} + +// ── Refund search ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RefundSortField { + InitiatedAt, + Amount, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct RefundSearchQuery { + pub order_id: Option, + pub initiated_by: Option, + pub statuses: Option>, + pub date_start: Option, + pub date_end: Option, + pub amount_min: Option, + pub amount_max: Option, + pub sort_field: Option, + pub sort_order: Option, + pub cursor: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RefundRecord { + pub refund_id: String, + pub order_id: String, + pub amount: i128, + pub status: String, + pub initiated_by: String, + pub initiated_at: u64, +} + +#[derive(Debug, Serialize)] +pub struct RefundPage { + pub records: Vec, + pub next_cursor: Option, + pub total: u64, +}