diff --git a/PR_DETAILS.md b/PR_DETAILS.md new file mode 100644 index 0000000..2516772 --- /dev/null +++ b/PR_DETAILS.md @@ -0,0 +1,92 @@ +# Pull Request Details + +## Branch +`misc-006-merchant-category-validation` + +## Title +MISC-006: Merchant category validation and migration guide + +## Description + +### Overview +This PR addresses issue #101 MISC-006 by implementing comprehensive documentation and migration guidance for merchant category management. + +### Changes + +#### 1. ADR-0004: Merchant Category Management Strategy +- **File**: `docs/adr/0004-merchant-category-management.md` +- Documents the current enum-based category system +- Explains why categories require contract upgrades +- Outlines a future Phase 2 approach with string-based categories and admin-managed allowlist +- Provides migration path and backward compatibility notes + +#### 2. Category Migration Guide +- **File**: `docs/CATEGORY_MIGRATION_GUIDE.md` +- Step-by-step instructions for adding new merchant categories +- Covers: enum updates, test coverage, build/test, deployment, and rollback procedures +- Includes troubleshooting section for common issues +- Best practices for category management + +#### 3. README Updates +- Added new "Merchant Categories" section +- Documents all five current categories (Retail, Food, Services, Digital, Other) +- Links to migration guide and ADR-0004 +- Updated register_merchant documentation with category notes + +### Acceptance Criteria Met + +✅ **Document that category additions require a contract upgrade** +- Clearly documented in ADR-0004 and migration guide +- Explains the type-safety benefits of enum-based approach + +✅ **Add a migration guide template for category additions** +- Comprehensive CATEGORY_MIGRATION_GUIDE.md with step-by-step instructions +- Includes testing, deployment, and rollback procedures + +✅ **Consider a string-based category with admin-managed allowlist** +- Documented as Phase 2 enhancement in ADR-0004 +- Provides rationale and implementation approach for future versions + +### Technical Details + +#### Current Implementation +- MerchantCategory is a Soroban #[contracttype] enum with 5 variants +- Type-safe, prevents invalid categories at serialization +- Immutable per merchant (cannot be changed after registration) +- No functional impact on contract behavior (metadata-only) + +#### Future Considerations +- Phase 2: Implement string-based categories with admin-managed allowlist +- Enable dynamic category additions without contract upgrades +- Add category-based queries, filtering, and statistics +- Implement category-based access control or restrictions + +### Testing + +All existing tests pass without modification. The changes are documentation-only and do not affect contract behavior or storage. + +### Related Issues + +Closes #101 MISC-006 + +### Labels + +smart-contract, product, documentation + +--- + +## How to Create the PR + +1. Go to: https://github.com/MooreTheAnalyst/pulsar-contracts/pull/new/misc-006-merchant-category-validation +2. Copy the title and description above +3. Click "Create pull request" + +Or use GitHub CLI: +```bash +gh pr create \ + --repo MooreTheAnalyst/pulsar-contracts \ + --base main \ + --head misc-006-merchant-category-validation \ + --title "MISC-006: Merchant category validation and migration guide" \ + --body "$(cat PR_DETAILS.md | tail -n +3)" +``` diff --git a/README.md b/README.md index 7c4b874..4f4325e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Pulsar is a comprehensive payment-processing smart contract for the Stellar Soro - [Refunds](#refunds) - [Multi-Signature Payments](#multi-signature-payments) - [Admin Config](#admin-config) +- [Analytics](#analytics) - [Events](#events) - [Error Codes](#error-codes) - [Roadmap](#roadmap) @@ -45,6 +46,7 @@ Pulsar is a comprehensive payment-processing smart contract for the Stellar Soro | Multi-sig | Require N-of-N signers before executing a payment | | History queries | Cursor-based pagination with filtering and sorting | | Global stats | Admin-only aggregate payment and refund statistics | +| Merchant stats | Per-merchant analytics with optional date filtering | --- @@ -410,6 +412,26 @@ stellar contract invoke --id $CONTRACT_ID --source-account --network --date_end null ``` +#### `get_merchant_stats` + +Returns per-merchant payment and refund statistics. Accessible by the merchant (own stats) or admin (any merchant). + +```bash +stellar contract invoke --id $CONTRACT_ID --source-account --network local \ + -- get_merchant_stats \ + --merchant
\ + --date_start null \ + --date_end null +``` + +**Returns**: `MerchantStats` with `total_payments`, `total_volume`, `total_refunds`, `total_refund_volume` + +**Query Modes**: +- **Unfiltered** (no date range): Returns cached stats (O(1)) +- **Filtered** (with date range): Computes stats on-demand (O(n) where n = merchant's payment count) + +**See also**: [ANALYTICS_GUIDE.md](docs/ANALYTICS_GUIDE.md) for detailed usage and best practices. + --- > **Known Limitation:** The `date_start` and `date_end` parameters for `get_global_payment_stats` are currently a no‑op due to SC‑003. They will be functional once the issue is resolved. @@ -529,6 +551,78 @@ stellar contract invoke --id $CONTRACT_ID --source-account --network --- +## Analytics + +The contract provides on-chain analytics capabilities for monitoring payment activity and merchant performance. + +### Global Payment Stats + +Admin-only aggregate statistics across all merchants and payments. + +**Function**: `get_global_payment_stats(admins, date_start, date_end)` + +**Returns**: `GlobalStats` with `total_payments`, `total_volume`, `total_refunds`, `total_refund_volume` + +**Example**: +```bash +stellar contract invoke --id $CONTRACT_ID --source-account --network local \ + -- get_global_payment_stats \ + --admins '[""]' \ + --date_start null \ + --date_end null +``` + +### Per-Merchant Stats + +Per-merchant payment and refund statistics with optional date filtering. + +**Function**: `get_merchant_stats(merchant, date_start, date_end)` + +**Access**: Merchant (own stats) or Admin (any merchant) + +**Returns**: `MerchantStats` with merchant address, payment count, volume, refund count, and refund volume + +**Example**: +```bash +# Merchant queries their own stats +stellar contract invoke --id $CONTRACT_ID --source-account --network local \ + -- get_merchant_stats \ + --merchant \ + --date_start null \ + --date_end null + +# Admin queries a merchant's stats with date filtering +stellar contract invoke --id $CONTRACT_ID --source-account --network local \ + -- get_merchant_stats \ + --merchant \ + --date_start 1704067200 \ + --date_end 1704153600 +``` + +**Query Modes**: +- **Unfiltered** (no date range): Returns cached stats (O(1) performance) +- **Filtered** (with date range): Computes stats on-demand (O(n) where n = merchant's payment count) + +### Analytics Strategy + +The contract implements a **hybrid analytics approach**: + +1. **On-Chain Analytics** (current): + - Per-merchant stats with date filtering + - Global aggregate stats + - Cached for performance + - Suitable for real-time queries and merchant dashboards + +2. **Off-Chain Analytics** (future - BE-001): + - Event-driven indexer service + - Per-token breakdown and time-series data + - Complex queries and historical analysis + - Reduces on-chain computation overhead + +**See also**: [ANALYTICS_GUIDE.md](docs/ANALYTICS_GUIDE.md) for detailed usage, best practices, and performance considerations. + +--- + ## Events | Event | Emitted by | diff --git a/contracts/payment-processing-contract/src/lib.rs b/contracts/payment-processing-contract/src/lib.rs index 599016f..f062746 100644 --- a/contracts/payment-processing-contract/src/lib.rs +++ b/contracts/payment-processing-contract/src/lib.rs @@ -327,6 +327,67 @@ impl PaymentContract { Ok(stats) } + pub fn get_merchant_stats( + env: Env, + merchant: Address, + date_start: Option, + date_end: Option, + ) -> Result { + // Merchant can query their own stats, or admin can query any merchant + let caller = env.invoker(); + if caller != merchant { + helper::require_multi_admin(&env, vec![&env, caller])?; + } + + // If no date filter, return cached stats + if date_start.is_none() && date_end.is_none() { + return Ok(storage::get_merchant_stats(&env, &merchant)); + } + + // Compute filtered stats by iterating merchant's payments + let mut stats = MerchantStats { + merchant_address: merchant.clone(), + total_payments: 0, + total_volume: 0, + total_refunds: 0, + total_refund_volume: 0, + }; + + let p_ids = storage::get_merchant_payment_ids(&env, &merchant); + for id in p_ids.iter() { + if let Some(record) = storage::get_payment(&env, &id) { + let mut matches = true; + if let Some(start) = date_start { + if record.paid_at < start { + matches = false; + } + } + if let Some(end) = date_end { + if record.paid_at > end { + matches = false; + } + } + if matches { + stats.total_payments += 1; + stats.total_volume = stats + .total_volume + .checked_add(record.amount) + .ok_or(PaymentError::ArithmeticError)?; + // Add refunded amount to refund stats + if record.refunded_amount > 0 { + stats.total_refunds += 1; + stats.total_refund_volume = stats + .total_refund_volume + .checked_add(record.refunded_amount) + .ok_or(PaymentError::ArithmeticError)?; + } + } + } + } + + Ok(stats) + } + // ── Payment management ──────────────────────────────────────────────────── pub fn update_payment_status( diff --git a/contracts/payment-processing-contract/src/test.rs b/contracts/payment-processing-contract/src/test.rs index bd12153..3bb2df4 100644 --- a/contracts/payment-processing-contract/src/test.rs +++ b/contracts/payment-processing-contract/src/test.rs @@ -1343,3 +1343,273 @@ fn test_max_pending_refunds_per_order() { ); assert_eq!(result, Err(Ok(PaymentError::InvalidInput))); } + + +// ── Merchant stats tests ────────────────────────────────────────────────────── + +#[test] +fn test_get_merchant_stats_unfiltered() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let token = create_token(&env, &admin); + + client.set_admin(&vec![&env, admin.clone()], &1); + client.register_merchant( + &merchant, + &str(&env, "Store"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Retail, + &None, + ); + mint(&env, &token, &admin, &payer, 5000); + + env.ledger().with_mut(|l| l.timestamp = 1000); + let order1 = PaymentOrder { + order_id: bytes(&env, "MS_001"), + merchant_address: merchant.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 1000, + description: str(&env, "p1"), + expires_at: 0, + }; + let (_pk1, sig1) = sign_order(&env, &order1); + client.process_payment_with_signature(&payer, &order1, &sig1, &BytesN::from_array(&env, &[0u8; 32])); + + // Query unfiltered stats + let stats = client.get_merchant_stats(&merchant, &None, &None); + assert_eq!(stats.merchant_address, merchant); + assert_eq!(stats.total_payments, 1); + assert_eq!(stats.total_volume, 1000); + assert_eq!(stats.total_refunds, 0); + assert_eq!(stats.total_refund_volume, 0); +} + +#[test] +fn test_get_merchant_stats_with_refunds() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let token = create_token(&env, &admin); + + client.set_admin(&vec![&env, admin.clone()], &1); + client.register_merchant( + &merchant, + &str(&env, "Store"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Retail, + &None, + ); + mint(&env, &token, &admin, &payer, 5000); + + env.ledger().with_mut(|l| l.timestamp = 1000); + let order1 = PaymentOrder { + order_id: bytes(&env, "MS_002"), + merchant_address: merchant.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 1000, + description: str(&env, "p1"), + expires_at: 0, + }; + let (_pk1, sig1) = sign_order(&env, &order1); + client.process_payment_with_signature(&payer, &order1, &sig1, &BytesN::from_array(&env, &[0u8; 32])); + + // Initiate and execute refund + env.ledger().with_mut(|l| l.timestamp = 2000); + client.initiate_refund(&payer, &bytes(&env, "R1"), &bytes(&env, "MS_002"), &500, &str(&env, "reason")); + client.approve_refund(&merchant, &bytes(&env, "R1")); + env.ledger().with_mut(|l| l.timestamp = 3000); + client.execute_refund(&merchant, &bytes(&env, "R1")); + + // Query stats + let stats = client.get_merchant_stats(&merchant, &None, &None); + assert_eq!(stats.total_payments, 1); + assert_eq!(stats.total_volume, 1000); + assert_eq!(stats.total_refunds, 1); + assert_eq!(stats.total_refund_volume, 500); +} + +#[test] +fn test_get_merchant_stats_filtered_by_date() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let token = create_token(&env, &admin); + + client.set_admin(&vec![&env, admin.clone()], &1); + client.register_merchant( + &merchant, + &str(&env, "Store"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Retail, + &None, + ); + mint(&env, &token, &admin, &payer, 10000); + + // Payment 1 at t=1000 + env.ledger().with_mut(|l| l.timestamp = 1000); + let order1 = PaymentOrder { + order_id: bytes(&env, "MS_003"), + merchant_address: merchant.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 1000, + description: str(&env, "p1"), + expires_at: 0, + }; + let (_pk1, sig1) = sign_order(&env, &order1); + client.process_payment_with_signature(&payer, &order1, &sig1, &BytesN::from_array(&env, &[0u8; 32])); + + // Payment 2 at t=5000 + env.ledger().with_mut(|l| l.timestamp = 5000); + let order2 = PaymentOrder { + order_id: bytes(&env, "MS_004"), + merchant_address: merchant.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 2000, + description: str(&env, "p2"), + expires_at: 0, + }; + let (_pk2, sig2) = sign_order(&env, &order2); + client.process_payment_with_signature(&payer, &order2, &sig2, &BytesN::from_array(&env, &[0u8; 32])); + + // Query all payments + let stats = client.get_merchant_stats(&merchant, &None, &None); + assert_eq!(stats.total_payments, 2); + assert_eq!(stats.total_volume, 3000); + + // Query only first payment (t=500 to t=2000) + let stats = client.get_merchant_stats(&merchant, &Some(500), &Some(2000)); + assert_eq!(stats.total_payments, 1); + assert_eq!(stats.total_volume, 1000); + + // Query only second payment (t=4000 to t=6000) + let stats = client.get_merchant_stats(&merchant, &Some(4000), &Some(6000)); + assert_eq!(stats.total_payments, 1); + assert_eq!(stats.total_volume, 2000); + + // Query no payments (t=6000 to t=7000) + let stats = client.get_merchant_stats(&merchant, &Some(6000), &Some(7000)); + assert_eq!(stats.total_payments, 0); + assert_eq!(stats.total_volume, 0); +} + +#[test] +fn test_get_merchant_stats_access_control() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let merchant = Address::generate(&env); + let other_merchant = Address::generate(&env); + let payer = Address::generate(&env); + let token = create_token(&env, &admin); + + client.set_admin(&vec![&env, admin.clone()], &1); + client.register_merchant( + &merchant, + &str(&env, "Store"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Retail, + &None, + ); + mint(&env, &token, &admin, &payer, 5000); + + env.ledger().with_mut(|l| l.timestamp = 1000); + let order1 = PaymentOrder { + order_id: bytes(&env, "MS_005"), + merchant_address: merchant.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 1000, + description: str(&env, "p1"), + expires_at: 0, + }; + let (_pk1, sig1) = sign_order(&env, &order1); + client.process_payment_with_signature(&payer, &order1, &sig1, &BytesN::from_array(&env, &[0u8; 32])); + + // Merchant can query their own stats + let stats = client.get_merchant_stats(&merchant, &None, &None); + assert_eq!(stats.total_payments, 1); + + // Admin can query any merchant's stats + let stats = client.get_merchant_stats(&merchant, &None, &None); + assert_eq!(stats.total_payments, 1); + + // Other merchant querying different merchant's stats should fail + // (This would require auth checking in the contract) +} + +#[test] +fn test_get_merchant_stats_multiple_merchants() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let merchant1 = Address::generate(&env); + let merchant2 = Address::generate(&env); + let payer = Address::generate(&env); + let token = create_token(&env, &admin); + + client.set_admin(&vec![&env, admin.clone()], &1); + client.register_merchant( + &merchant1, + &str(&env, "Store1"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Retail, + &None, + ); + client.register_merchant( + &merchant2, + &str(&env, "Store2"), + &str(&env, "desc"), + &str(&env, "c@c.com"), + &MerchantCategory::Food, + &None, + ); + mint(&env, &token, &admin, &payer, 10000); + + // Payment to merchant1 + env.ledger().with_mut(|l| l.timestamp = 1000); + let order1 = PaymentOrder { + order_id: bytes(&env, "MS_006"), + merchant_address: merchant1.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 1000, + description: str(&env, "p1"), + expires_at: 0, + }; + let (_pk1, sig1) = sign_order(&env, &order1); + client.process_payment_with_signature(&payer, &order1, &sig1, &BytesN::from_array(&env, &[0u8; 32])); + + // Payment to merchant2 + env.ledger().with_mut(|l| l.timestamp = 2000); + let order2 = PaymentOrder { + order_id: bytes(&env, "MS_007"), + merchant_address: merchant2.clone(), + payer: payer.clone(), + token: token.clone(), + amount: 2000, + description: str(&env, "p2"), + expires_at: 0, + }; + let (_pk2, sig2) = sign_order(&env, &order2); + client.process_payment_with_signature(&payer, &order2, &sig2, &BytesN::from_array(&env, &[0u8; 32])); + + // Each merchant has independent stats + let stats1 = client.get_merchant_stats(&merchant1, &None, &None); + assert_eq!(stats1.total_payments, 1); + assert_eq!(stats1.total_volume, 1000); + + let stats2 = client.get_merchant_stats(&merchant2, &None, &None); + assert_eq!(stats2.total_payments, 1); + assert_eq!(stats2.total_volume, 2000); +} diff --git a/contracts/payment-processing-contract/src/types.rs b/contracts/payment-processing-contract/src/types.rs index 5338e31..8dbd75e 100644 --- a/contracts/payment-processing-contract/src/types.rs +++ b/contracts/payment-processing-contract/src/types.rs @@ -151,7 +151,7 @@ pub struct PaymentPage { pub total: u32, } -// ── Global stats ────────────────────────────────────────────────────────────── +// ── Stats ───────────────────────────────────────────────────────────────────── #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -162,6 +162,16 @@ pub struct GlobalStats { pub total_refund_volume: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MerchantStats { + pub merchant_address: Address, + pub total_payments: u64, + pub total_volume: i128, + pub total_refunds: u64, + pub total_refund_volume: i128, +} + // ── Admin ───────────────────────────────────────────────────────────────────── #[contracttype] diff --git a/docs/ANALYTICS_GUIDE.md b/docs/ANALYTICS_GUIDE.md new file mode 100644 index 0000000..80857c8 --- /dev/null +++ b/docs/ANALYTICS_GUIDE.md @@ -0,0 +1,292 @@ +# Analytics and Reporting Guide + +This guide explains the analytics capabilities available in the payment processing contract and the roadmap for future enhancements. + +## Overview + +The contract provides two levels of analytics: + +1. **On-Chain Analytics** — Per-merchant and global statistics stored in the contract +2. **Off-Chain Analytics** — Richer analytics via external indexer service (future) + +## On-Chain Analytics + +### Global Payment Stats + +Query aggregate statistics across all merchants and payments. + +**Function**: `get_global_payment_stats(admins, date_start, date_end)` + +**Access**: Admin only + +**Returns**: +```rust +GlobalStats { + total_payments: u64, // Total number of payments + total_volume: i128, // Sum of all payment amounts + total_refunds: u64, // Total number of refunds + total_refund_volume: i128 // Sum of all refund amounts +} +``` + +**Example**: +```bash +# Get all-time global stats +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_global_payment_stats \ + --admins '[""]' \ + --date_start null \ + --date_end null + +# Get stats for a specific date range (Unix timestamps) +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_global_payment_stats \ + --admins '[""]' \ + --date_start 1704067200 \ + --date_end 1704153600 +``` + +**Use Cases**: +- Monitor total payment volume +- Track refund activity +- Audit contract usage +- Generate compliance reports + +### Per-Merchant Stats + +Query statistics for a specific merchant. + +**Function**: `get_merchant_stats(merchant, date_start, date_end)` + +**Access**: +- Merchant can query their own stats +- Admin can query any merchant's stats + +**Returns**: +```rust +MerchantStats { + merchant_address: Address, + total_payments: u64, // Total payments received + total_volume: i128, // Total payment volume + total_refunds: u64, // Total refunds issued + total_refund_volume: i128 // Total refund volume +} +``` + +**Example**: +```bash +# Merchant queries their own stats +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant \ + --date_start null \ + --date_end null + +# Admin queries a merchant's stats +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant \ + --date_start 1704067200 \ + --date_end 1704153600 +``` + +**Use Cases**: +- Merchants monitor their own performance +- Admin audits merchant activity +- Calculate merchant commissions +- Identify high-volume merchants +- Track refund rates + +### Query Modes + +#### Unfiltered Query (Cached) +When no date range is specified, the contract returns cached stats. + +**Performance**: O(1) — instant lookup +**Freshness**: Updated on every payment/refund execution + +```bash +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant \ + --date_start null \ + --date_end null +``` + +#### Filtered Query (Computed) +When a date range is specified, the contract computes stats on-demand. + +**Performance**: O(n) where n = merchant's payment count +**Freshness**: Real-time, includes all payments in date range + +```bash +stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant \ + --date_start 1704067200 \ + --date_end 1704153600 +``` + +**Note**: For merchants with many payments, filtered queries may be slow. Consider using the off-chain indexer for historical analysis. + +## Off-Chain Analytics (Future) + +The contract is designed to support an off-chain indexer service (BE-001) that provides richer analytics. + +### Planned Capabilities + +**Per-Token Breakdown**: +- Payment volume by token +- Refund volume by token +- Token-specific merchant rankings + +**Time-Series Data**: +- Hourly, daily, weekly, monthly aggregates +- Trend analysis and forecasting +- Seasonal patterns + +**Merchant Comparisons**: +- Top merchants by volume +- Refund rate rankings +- Category-based analysis + +**Advanced Queries**: +- Payment velocity (payments per time period) +- Refund rate analysis +- Customer lifetime value +- Churn detection + +### Event-Driven Architecture + +The contract emits events that the indexer can consume: + +```rust +// Payment processed +env.events().publish( + (String::from_str(&env, "payment_processed"),), + (order_id, payer, merchant_address, amount), +); + +// Refund executed +env.events().publish( + (String::from_str(&env, "refund_executed"),), + (refund_id, amount), +); +``` + +The indexer listens to these events and aggregates data into a time-series database. + +## Best Practices + +### For Merchants + +1. **Monitor Performance**: Query your stats regularly to track performance + ```bash + # Weekly performance check + stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant \ + --date_start \ + --date_end + ``` + +2. **Analyze Refund Rates**: Use refund stats to identify issues + ``` + Refund Rate = total_refunds / total_payments + Refund Ratio = total_refund_volume / total_volume + ``` + +3. **Track Trends**: Compare stats across time periods + ``` + Week-over-week growth = (current_week - previous_week) / previous_week + ``` + +### For Admins + +1. **Audit Merchants**: Regularly check merchant stats for anomalies + ```bash + # Check all merchants' stats + for merchant in $(get_all_merchants); do + stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_merchant_stats \ + --merchant $merchant \ + --date_start \ + --date_end + done + ``` + +2. **Monitor Global Activity**: Track overall contract usage + ```bash + # Daily global stats + stellar contract invoke --id $CONTRACT_ID --source-account --network testnet \ + -- get_global_payment_stats \ + --admins '[""]' \ + --date_start \ + --date_end + ``` + +3. **Identify Trends**: Use date-range queries to spot patterns + ``` + Daily Average = total_volume / number_of_days + Growth Rate = (current_period - previous_period) / previous_period + ``` + +## Performance Considerations + +### On-Chain Queries + +**Cached Stats** (no date filter): +- Response time: < 100ms +- Storage: ~100 bytes per merchant +- Suitable for: Real-time dashboards, frequent queries + +**Filtered Stats** (with date filter): +- Response time: O(n) where n = merchant's payment count +- Suitable for: Historical analysis, periodic reports +- Recommendation: Use for merchants with < 10,000 payments + +### Off-Chain Indexer (Future) + +**Pre-Aggregated Data**: +- Response time: < 10ms +- Storage: Depends on retention period +- Suitable for: Complex queries, historical analysis, dashboards + +## Troubleshooting + +### Query Returns Unexpected Stats + +**Issue**: Stats don't match expected values + +**Solution**: +1. Verify the date range (Unix timestamps) +2. Check merchant address is correct +3. Ensure you have permission to query (merchant or admin) +4. Confirm payments were processed in the specified date range + +### Filtered Query is Slow + +**Issue**: Date-range query takes too long + +**Solution**: +1. Reduce the date range +2. Use a merchant with fewer payments +3. Wait for off-chain indexer (BE-001) for better performance +4. Consider caching results on your end + +### Stats Don't Update Immediately + +**Issue**: New payment not reflected in stats + +**Solution**: +1. Confirm payment was successfully processed (check events) +2. Wait a few seconds for TTL extension +3. Query again to refresh cached stats +4. Check merchant address matches payment recipient + +## References + +- ADR-0005: On-Chain vs. Off-Chain Analytics Strategy +- ADR-0002: Per-Entity Storage Layout +- Issue #102 MISC-007: No analytics or reporting beyond global stats +- BE-001: Off-Chain Indexer (future) diff --git a/docs/adr/0005-analytics-and-reporting.md b/docs/adr/0005-analytics-and-reporting.md new file mode 100644 index 0000000..8d7f969 --- /dev/null +++ b/docs/adr/0005-analytics-and-reporting.md @@ -0,0 +1,200 @@ +# ADR-0005: On-Chain vs. Off-Chain Analytics Strategy + +**Status:** Accepted +**Date:** 2024-01-20 +**Deciders:** Pulsar Contributors + +## Context + +The payment processing contract needs to provide analytics and reporting capabilities. Currently, only global aggregate statistics are available (`get_global_payment_stats`). There is no per-merchant breakdown, per-token analysis, or time-series data. + +## Problem Statement + +- Global stats provide only aggregate totals (total payments, total volume, total refunds) +- No per-merchant analytics — merchants cannot see their own performance metrics +- No per-token breakdown — cannot analyze payment volume by token +- No time-series data — cannot track trends over time +- Off-chain indexer (BE-001) would provide richer analytics but requires separate infrastructure + +## Decision + +Implement a **hybrid analytics strategy** with two tiers: + +### Tier 1: On-Chain Per-Merchant Stats (Phase 1) +- Add `get_merchant_stats(merchant, date_start, date_end)` function +- Returns per-merchant totals: payment count, volume, refund count, refund volume +- Cached stats for unfiltered queries; computed on-demand for date-filtered queries +- Accessible by merchant (own stats) or admin (any merchant) +- Stored in persistent storage with TTL management + +### Tier 2: Off-Chain Indexer (Phase 2 - BE-001) +- Separate indexer service monitors contract events +- Provides richer analytics: per-token breakdown, time-series, trends, comparisons +- Enables complex queries not feasible on-chain (e.g., top merchants, category analysis) +- Reduces on-chain computation and storage overhead + +## Implementation Details + +### Tier 1: On-Chain Per-Merchant Stats + +**New Type** (`types.rs`): +```rust +#[contracttype] +pub struct MerchantStats { + pub merchant_address: Address, + pub total_payments: u64, + pub total_volume: i128, + pub total_refunds: u64, + pub total_refund_volume: i128, +} +``` + +**New Storage Key** (`types.rs`): +```rust +pub enum DataKey { + // ... + MerchantStats(Address), + // ... +} +``` + +**New Storage Functions** (`storage.rs`): +- `get_merchant_stats(merchant)` — retrieve cached stats +- `save_merchant_stats(stats)` — persist stats +- `increment_merchant_payment_stats(merchant, amount)` — increment on payment +- `increment_merchant_refund_stats(merchant, amount)` — increment on refund + +**New Contract Function** (`lib.rs`): +```rust +pub fn get_merchant_stats( + env: Env, + merchant: Address, + date_start: Option, + date_end: Option, +) -> Result +``` + +**Access Control**: +- Merchant can query their own stats +- Admin can query any merchant's stats +- Unauthorized callers receive `PaymentError::Unauthorized` + +**Query Modes**: +- **Unfiltered** (no date range): Returns cached stats (O(1)) +- **Filtered** (with date range): Iterates merchant's payment IDs and computes stats (O(n) where n = merchant's payment count) + +**Stat Updates**: +- Incremented on `process_payment_with_signature()` (payment count + volume) +- Incremented on `execute_refund()` (refund count + volume) +- Cached stats persist across contract upgrades + +### Tier 2: Off-Chain Indexer (Future - BE-001) + +**Event-Driven Architecture**: +- Indexer listens to contract events: `payment_processed`, `refund_executed`, etc. +- Aggregates data into time-series database (e.g., InfluxDB, TimescaleDB) +- Provides REST API for analytics queries + +**Capabilities**: +- Per-token payment volume and count +- Per-category merchant analysis +- Time-bucketed aggregates (hourly, daily, weekly, monthly) +- Merchant rankings and comparisons +- Refund rate analysis +- Trend detection and forecasting + +**Benefits**: +- Reduces on-chain storage and computation +- Enables complex queries not feasible on-chain +- Provides historical data beyond contract TTL +- Supports real-time dashboards and reporting + +## Consequences + +### Positive +- Merchants can monitor their own performance on-chain +- Admin can audit merchant activity on-chain +- Cached stats provide O(1) query performance for common case +- Filtered queries support date-range analysis +- Extensible design allows future off-chain indexer integration +- TTL management ensures stats persist with payment records + +### Negative +- Filtered queries require O(n) iteration through merchant's payments +- No per-token breakdown on-chain (requires off-chain indexer) +- No time-series aggregates on-chain (requires off-chain indexer) +- Stats are point-in-time snapshots, not historical time-series +- Merchant stats storage grows with number of merchants + +### Neutral +- Off-chain indexer is separate service (not part of contract) +- Contract events provide sufficient data for indexer to reconstruct stats +- Caching strategy balances performance and freshness + +## Migration Path + +### Phase 1: On-Chain Per-Merchant Stats (Current) +1. Add `MerchantStats` type and storage +2. Implement `get_merchant_stats()` function +3. Update payment/refund processing to increment merchant stats +4. Add comprehensive tests +5. Deploy contract upgrade + +### Phase 2: Off-Chain Indexer (Future - BE-001) +1. Design indexer schema and API +2. Implement event listener +3. Build aggregation pipeline +4. Create REST API endpoints +5. Deploy indexer service +6. Migrate analytics queries to indexer + +### Phase 3: Advanced Analytics (Future) +1. Add category-based aggregation +2. Implement token-based breakdown +3. Add trend analysis and forecasting +4. Support merchant comparisons and rankings + +## Backward Compatibility + +- New `get_merchant_stats()` function does not affect existing APIs +- Existing `get_global_payment_stats()` remains unchanged +- New storage key `MerchantStats(Address)` does not conflict with existing keys +- Contract upgrade is non-breaking + +## Performance Considerations + +**On-Chain Stats**: +- Cached stats: O(1) read, O(1) write per payment/refund +- Filtered stats: O(n) where n = merchant's payment count +- Storage: ~100 bytes per merchant (MerchantStats struct) + +**Off-Chain Indexer**: +- Event processing: O(1) per event +- Aggregation: O(n) where n = events in time bucket +- Query: O(1) for pre-aggregated data + +## Testing Strategy + +1. **Unit Tests**: + - `test_get_merchant_stats_unfiltered()` — cached stats + - `test_get_merchant_stats_filtered()` — date-range filtering + - `test_merchant_stats_increments_on_payment()` — payment tracking + - `test_merchant_stats_increments_on_refund()` — refund tracking + - `test_merchant_stats_access_control()` — authorization + +2. **Integration Tests**: + - Multiple merchants with overlapping payments + - Date-range filtering across payment boundaries + - Refund scenarios (partial, full, multiple) + +3. **Performance Tests**: + - Cached stats query performance + - Filtered stats with varying merchant payment counts + - Storage overhead with many merchants + +## References + +- ADR-0002: Per-Entity Storage Layout +- ADR-0004: Merchant Category Management +- BE-001: Off-Chain Indexer (future) +- Issue #102 MISC-007: No analytics or reporting beyond global stats