From 75f666494dd5afd5bc3b56a0189b03a2e74d4b0b Mon Sep 17 00:00:00 2001 From: Ndifreke000 Date: Sun, 26 Jul 2026 18:58:15 +0100 Subject: [PATCH] Add refund claim flow, stream pause/resume tests, period-reset event, and SDK pre-auth wrapper - feat(refund): add claim_refund entry point letting the original requester self-serve an operator-approved refund; adds Error::RefundNotApproved. approve_refund/process_refund already existed and needed no changes. Closes #450 - test(stream): pause_stream/resume_stream were already implemented on PaymentProcessor but untested; add coverage for checkpointing, double-pause, resume timing, and unauthorized access. Closes #452 - feat(merchant-auth): emit MERCHANT_AUTH/PERIOD_RESET when pull_payment rolls over into a new billing period; add tests for period reset, limit enforcement, and multi-period pulls (period-reset logic itself already existed and was correct). Closes #455 - feat(sdk): expose preAuthorizeMerchant, pullFromAuthorization, revokeAuthorization, and getAuthorization on FluxapayClient, plus MerchantAuthorization/MerchantAuthError types and a README section. Closes #454 --- fluxapay/src/lib.rs | 28 ++++ fluxapay/src/merchant_auth.rs | 129 +++++++++++++++++ fluxapay/src/test.rs | 260 ++++++++++++++++++++++++++++++++++ sdk/README.md | 37 +++++ sdk/src/index.ts | 120 ++++++++++++++++ 5 files changed, 574 insertions(+) diff --git a/fluxapay/src/lib.rs b/fluxapay/src/lib.rs index 96ed62e..e7cd3ea 100644 --- a/fluxapay/src/lib.rs +++ b/fluxapay/src/lib.rs @@ -350,6 +350,8 @@ pub enum Error { RateDeviationExceeded = 55, /// Issue #505: Invalid payment status transition attempted. InvalidStatusTransition = 54, + /// Issue #450: Customer called `claim_refund` before an operator approved it. + RefundNotApproved = 56, } #[contracttype] @@ -1803,6 +1805,32 @@ impl RefundManager { Ok(()) } + /// Issue #450: Customer self-serves an operator-approved refund. + /// + /// Callable only by the original refund requester, and only once an + /// operator has called `approve_refund`. `process_refund` remains + /// available for operators/oracles who need to execute a refund + /// directly without waiting for the customer to claim it. + pub fn claim_refund(env: Env, requester: Address, refund_id: String) -> Result<(), Error> { + requester.require_auth(); + Self::require_not_paused(&env)?; + Self::require_not_blacklisted(&env, &requester)?; + + let refund = Self::get_refund_internal(&env, &refund_id)?; + + if refund.requester != requester { + return Err(Error::Unauthorized); + } + if refund.status != RefundStatus::Pending { + return Err(Error::RefundAlreadyProcessed); + } + if !refund.approved { + return Err(Error::RefundNotApproved); + } + + Self::process_refund_internal(&env, &requester, refund_id) + } + /// Cancel a pending refund. Caller must be the refund requester (merchant) or contract admin. /// Removes the refund from the payment's pending list and emits REFUND/CANCELLED. /// Instantly refund a payment without operator approval. diff --git a/fluxapay/src/merchant_auth.rs b/fluxapay/src/merchant_auth.rs index 3241b76..397b3a1 100644 --- a/fluxapay/src/merchant_auth.rs +++ b/fluxapay/src/merchant_auth.rs @@ -237,6 +237,14 @@ impl MerchantPreAuth { .period_start .saturating_add(periods_elapsed * auth.period_secs); auth.pulled_this_period = 0; + + env.events().publish( + ( + Symbol::new(&env, "MERCHANT_AUTH"), + Symbol::new(&env, "PERIOD_RESET"), + ), + (customer.clone(), merchant.clone(), auth.period_start), + ); } // ── Limit check ─────────────────────────────────────────────────────── @@ -316,3 +324,124 @@ impl MerchantPreAuth { Ok(auth.limit_per_period.saturating_sub(pulled).max(0)) } } + +#[cfg(test)] +mod period_reset_tests { + use crate::{PaymentProcessor, PaymentProcessorClient}; + use soroban_sdk::{ + testutils::{Address as _, Events as _, Ledger as _}, + token, Address, Env, + }; + + fn setup(env: &Env) -> (Address, PaymentProcessorClient<'_>) { + let contract_id = env.register(PaymentProcessor, ()); + let client = PaymentProcessorClient::new(env, &contract_id); + let admin = Address::generate(env); + client.initialize_payment_processor(&admin); + (admin, client) + } + + fn setup_authorization( + env: &Env, + client: &PaymentProcessorClient<'_>, + limit_per_period: i128, + period_secs: u64, + ) -> (Address, Address, Address) { + let customer = Address::generate(env); + let merchant = Address::generate(env); + let token_admin = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + token::StellarAssetClient::new(env, &token).mint(&customer, &1_000_000_000i128); + + client.pre_authorize_merchant( + &customer, + &merchant, + &token, + &limit_per_period, + &period_secs, + ); + + (customer, merchant, token) + } + + #[test] + fn test_period_reset_on_first_pull_of_new_period() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup(&env); + let (customer, merchant, _token) = setup_authorization(&env, &client, 1_000i128, 100u64); + + client.pull_payment(&merchant, &customer, &1_000i128); + // Fully spent for this period. + let result = client.try_pull_payment(&merchant, &customer, &1i128); + assert!(result.is_err()); + + // Advance past the period boundary — the first pull of the new + // period must reset `pulled_this_period` before applying the limit. + env.ledger().with_mut(|li| li.timestamp += 101); + let pulled = client.pull_payment(&merchant, &customer, &500i128); + assert_eq!(pulled, 500i128); + + let auth = client.get_merchant_authorization(&customer, &merchant); + assert_eq!(auth.pulled_this_period, 500i128); + } + + #[test] + fn test_limit_enforced_within_period() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup(&env); + let (customer, merchant, _token) = setup_authorization(&env, &client, 1_000i128, 100u64); + + client.pull_payment(&merchant, &customer, &700i128); + let result = client.try_pull_payment(&merchant, &customer, &400i128); + assert_eq!( + result, + Err(Ok(crate::merchant_auth::MerchantAuthError::LimitExceeded)) + ); + + // Remaining budget in the same period is still pullable. + let pulled = client.pull_payment(&merchant, &customer, &300i128); + assert_eq!(pulled, 1_000i128); + } + + #[test] + fn test_multi_period_pulls_all_succeed() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup(&env); + let (customer, merchant, _token) = setup_authorization(&env, &client, 1_000i128, 100u64); + + for _ in 0..3 { + let pulled = client.pull_payment(&merchant, &customer, &1_000i128); + assert_eq!(pulled, 1_000i128); + env.ledger().with_mut(|li| li.timestamp += 101); + } + } + + #[test] + fn test_period_reset_event_emitted_on_rollover() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup(&env); + let (customer, merchant, _token) = setup_authorization(&env, &client, 1_000i128, 100u64); + + // Pull within the same period — no reset, no PERIOD_RESET event. + client.pull_payment(&merchant, &customer, &500i128); + let events_before = env.events().all().len(); + + // Roll over into a new period — this pull must emit PERIOD_RESET + // in addition to the usual CHARGED event. + env.ledger().with_mut(|li| li.timestamp += 101); + client.pull_payment(&merchant, &customer, &200i128); + let events_after = env.events().all().len(); + + assert!( + events_after > events_before + 1, + "expected an extra MERCHANT_AUTH/PERIOD_RESET event on period rollover" + ); + } +} diff --git a/fluxapay/src/test.rs b/fluxapay/src/test.rs index 58848b3..d4bba6f 100644 --- a/fluxapay/src/test.rs +++ b/fluxapay/src/test.rs @@ -328,6 +328,130 @@ fn test_create_stream_fails_for_blacklisted_sender() { assert_eq!(result, Err(Ok(StreamError::Unauthorized))); } +#[test] +fn test_pause_stream_checkpoints_accrual_and_sets_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_payment_processor(&env); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let stream_id = String::from_str(&env, "pause_stream_1"); + + token::StellarAssetClient::new(&env, &token).mint(&sender, &1_000_000i128); + client.create_stream(&sender, &recipient, &token, &10i128, &1_000i128, &stream_id); + + env.ledger().with_mut(|li| li.timestamp += 50); + + client.pause_stream(&sender, &stream_id); + + let stream = client.get_stream(&stream_id); + assert_eq!(stream.status, StreamStatus::Paused); + // 50 seconds at rate 10/s should have been checkpointed. + assert_eq!(stream.accrued_at_checkpoint, 500i128); + assert_eq!(stream.last_checkpoint_at, env.ledger().timestamp()); +} + +#[test] +fn test_double_pause_stream_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_payment_processor(&env); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let stream_id = String::from_str(&env, "double_pause_stream_1"); + + token::StellarAssetClient::new(&env, &token).mint(&sender, &1_000_000i128); + client.create_stream(&sender, &recipient, &token, &10i128, &1_000i128, &stream_id); + client.pause_stream(&sender, &stream_id); + + let result = client.try_pause_stream(&sender, &stream_id); + assert_eq!(result, Err(Ok(StreamError::StreamNotActive))); +} + +#[test] +fn test_resume_stream_restarts_accrual_from_correct_point() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_payment_processor(&env); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let stream_id = String::from_str(&env, "resume_stream_1"); + + token::StellarAssetClient::new(&env, &token).mint(&sender, &1_000_000i128); + client.create_stream(&sender, &recipient, &token, &10i128, &1_000i128, &stream_id); + + env.ledger().with_mut(|li| li.timestamp += 50); + client.pause_stream(&sender, &stream_id); + + // Time passes while paused — must not accrue. + env.ledger().with_mut(|li| li.timestamp += 200); + client.resume_stream(&sender, &stream_id); + + let stream = client.get_stream(&stream_id); + assert_eq!(stream.status, StreamStatus::Active); + // Accrual while paused must not be counted; only the pre-pause 50s * 10/s. + assert_eq!(stream.accrued_at_checkpoint, 500i128); + assert_eq!(stream.last_checkpoint_at, env.ledger().timestamp()); +} + +#[test] +fn test_resume_non_paused_stream_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_payment_processor(&env); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let stream_id = String::from_str(&env, "resume_active_stream_1"); + + token::StellarAssetClient::new(&env, &token).mint(&sender, &1_000_000i128); + client.create_stream(&sender, &recipient, &token, &10i128, &1_000i128, &stream_id); + + let result = client.try_resume_stream(&sender, &stream_id); + assert_eq!(result, Err(Ok(StreamError::StreamNotPaused))); +} + +#[test] +fn test_pause_resume_stream_unauthorized_for_non_sender() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_payment_processor(&env); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let stranger = Address::generate(&env); + let stream_id = String::from_str(&env, "pause_unauthorized_stream_1"); + + token::StellarAssetClient::new(&env, &token).mint(&sender, &1_000_000i128); + client.create_stream(&sender, &recipient, &token, &10i128, &1_000i128, &stream_id); + + let result = client.try_pause_stream(&stranger, &stream_id); + assert_eq!(result, Err(Ok(StreamError::Unauthorized))); +} + #[test] fn test_batch_withdraw_to_custom_routing() { let env = Env::default(); @@ -856,6 +980,142 @@ fn test_process_refund() { assert_eq!(refund.status, RefundStatus::Completed); } +#[test] +fn test_approve_then_claim_refund_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_refund_manager(&env); + + let payment_id = String::from_str(&env, "payment_claim_1"); + let merchant_id = Address::generate(&env); + let refund_amount = 1000i128; + let requester = Address::generate(&env); + + client.register_payment( + &payment_id, + &merchant_id, + &5000i128, + &Symbol::new(&env, "USDC"), + ); + + let refund_id = client.create_refund( + &payment_id, + &refund_amount, + &String::from_str(&env, "Reason"), + &requester, + ); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + + client.approve_refund(&operator, &refund_id); + let refund = client.get_refund(&refund_id); + assert!(refund.approved); + assert_eq!(refund.status, RefundStatus::Pending); + + client.claim_refund(&requester, &refund_id); + + let refund = client.get_refund(&refund_id); + assert_eq!(refund.status, RefundStatus::Completed); +} + +#[test] +fn test_claim_refund_before_approval_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client) = setup_refund_manager(&env); + + let payment_id = String::from_str(&env, "payment_claim_2"); + let merchant_id = Address::generate(&env); + let refund_amount = 1000i128; + let requester = Address::generate(&env); + + client.register_payment( + &payment_id, + &merchant_id, + &5000i128, + &Symbol::new(&env, "USDC"), + ); + + let refund_id = client.create_refund( + &payment_id, + &refund_amount, + &String::from_str(&env, "Reason"), + &requester, + ); + + let result = client.try_claim_refund(&requester, &refund_id); + assert_eq!(result, Err(Ok(Error::RefundNotApproved))); +} + +#[test] +fn test_claim_refund_by_non_requester_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_refund_manager(&env); + + let payment_id = String::from_str(&env, "payment_claim_3"); + let merchant_id = Address::generate(&env); + let refund_amount = 1000i128; + let requester = Address::generate(&env); + let stranger = Address::generate(&env); + + client.register_payment( + &payment_id, + &merchant_id, + &5000i128, + &Symbol::new(&env, "USDC"), + ); + + let refund_id = client.create_refund( + &payment_id, + &refund_amount, + &String::from_str(&env, "Reason"), + &requester, + ); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + client.approve_refund(&operator, &refund_id); + + let result = client.try_claim_refund(&stranger, &refund_id); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_double_claim_refund_blocked() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client) = setup_refund_manager(&env); + + let payment_id = String::from_str(&env, "payment_claim_4"); + let merchant_id = Address::generate(&env); + let refund_amount = 1000i128; + let requester = Address::generate(&env); + + client.register_payment( + &payment_id, + &merchant_id, + &5000i128, + &Symbol::new(&env, "USDC"), + ); + + let refund_id = client.create_refund( + &payment_id, + &refund_amount, + &String::from_str(&env, "Reason"), + &requester, + ); + + let operator = Address::generate(&env); + client.grant_role(&admin, &role_settlement_operator(&env), &operator); + client.approve_refund(&operator, &refund_id); + client.claim_refund(&requester, &refund_id); + + let result = client.try_claim_refund(&requester, &refund_id); + assert_eq!(result, Err(Ok(Error::RefundAlreadyProcessed))); +} + #[test] fn test_process_refund_accumulates_treasury_and_withdraws() { let env = Env::default(); diff --git a/sdk/README.md b/sdk/README.md index ed85113..d1c9c5b 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -195,6 +195,43 @@ const dispute = await client.getDispute("dispute_001"); const paymentDisputes = await client.getPaymentDisputes("pay_123"); ``` +## Merchant Pre-Authorization (Pull Billing) + +`MerchantPreAuth` lets a customer grant a merchant permission to pull up to a +fixed amount per billing period — useful for SaaS-style recurring charges +without requiring a fresh signature on every charge. + +```typescript +// Customer grants the merchant a $50/30-day pull allowance. +const auth = await client.preAuthorizeMerchant({ + customer: "GCUSTOMER...", + merchant: "GMERCHANT...", + token: "CUSDC...", + limitPerPeriod: 50_000_000n, // 50 USDC (7 decimals) + periodSecs: 2_592_000n, // 30 days +}); + +// Merchant pulls a charge against the authorization. Returns the +// cumulative amount pulled so far in the current period. +const pulledThisPeriod = await client.pullFromAuthorization( + "GMERCHANT...", + "GCUSTOMER...", + 10_000_000n, // 10 USDC +); + +// Look up the current authorization (null if none exists). +const current = await client.getAuthorization("GCUSTOMER...", "GMERCHANT..."); + +// Customer revokes the authorization at any time. +await client.revokeAuthorization("GCUSTOMER...", "GMERCHANT..."); +``` + +Billing periods reset automatically: once `now >= period_start + period_secs`, +the next `pullFromAuthorization` call resets `pulled_this_period` to 0 and +emits a `MERCHANT_AUTH/PERIOD_RESET` event before applying the pull, so a new +period always starts with the full `limitPerPeriod` available regardless of +how many periods were skipped with no activity. + ## RefundManagerClient The `RefundManagerClient` provides methods for managing refunds on a dedicated RefundManager contract: diff --git a/sdk/src/index.ts b/sdk/src/index.ts index f1708cb..73f9be6 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -90,6 +90,32 @@ export interface RegisterMerchantParams { feeConfig?: FeeConfig; } +/** + * A customer's pre-authorization for a merchant to pull recurring payments, + * mirroring `MerchantAuthorization` in `fluxapay/src/merchant_auth.rs`. + */ +export interface MerchantAuthorization { + customer: string; + merchant: string; + token: string; + limit_per_period: bigint; + period_secs: bigint; + period_start: bigint; + pulled_this_period: bigint; + active: boolean; + created_at: bigint; +} + +/** Error codes from `fluxapay/src/merchant_auth.rs::MerchantAuthError`. */ +export const MerchantAuthError = { + 1: { message: "AuthorizationNotFound" }, + 2: { message: "AuthorizationInactive" }, + 3: { message: "LimitExceeded" }, + 4: { message: "InvalidAmount" }, + 5: { message: "Unauthorized" }, + 6: { message: "AuthorizationAlreadyExists" }, +} as const; + export interface UpdateMerchantParams { merchantId: string; businessName?: string; @@ -555,6 +581,100 @@ export class FluxapayClient { ); } + // ── Merchant pre-authorization (pull billing, #454) ───────────────────────── + // + // These delegate to `pre_authorize_merchant` / `pull_payment` / + // `revoke_merchant_authorization` / `get_merchant_authorization`, entry + // points already exposed on `PaymentProcessor` (see + // `fluxapay/src/lib.rs`). They're invoked via a loose cast because the + // checked-in `contracts/fluxapay` bindings predate these entry points; + // regenerating bindings with `npm run generate` (see `scripts/generate-sdk.sh`) + // against a freshly built contract will pick up proper typings, at which + // point the `as any` casts below can be removed. + + /** + * Customer grants a merchant permission to pull up to `limitPerPeriod` + * tokens per `periodSecs`-second billing window. + */ + async preAuthorizeMerchant(params: { + customer: string; + merchant: string; + token: string; + limitPerPeriod: bigint; + periodSecs: bigint; + }): Promise { + return withMappedContractError(async () => { + const tx = await (this.contract as any).pre_authorize_merchant({ + customer: params.customer, + merchant: params.merchant, + token: params.token, + limit_per_period: params.limitPerPeriod, + period_secs: params.periodSecs, + }); + return tx.result; + }); + } + + /** + * Merchant pulls `amount` tokens from `customer` against an existing + * pre-authorization. Returns the cumulative amount pulled this period. + */ + async pullFromAuthorization( + merchant: string, + customer: string, + amount: bigint, + ): Promise { + return withMappedContractError(async () => { + const tx = await (this.contract as any).pull_payment({ + merchant, + customer, + amount, + }); + return tx.result; + }); + } + + /** + * Customer revokes a previously granted merchant authorization. + */ + async revokeAuthorization(customer: string, merchant: string): Promise { + return withMappedContractError(async () => { + const tx = await (this.contract as any).revoke_merchant_authorization({ + customer, + merchant, + }); + return tx.result; + }); + } + + /** + * Fetch the stored authorization for a (customer, merchant) pair, or + * `null` if none exists. + */ + async getAuthorization( + customer: string, + merchant: string, + ): Promise { + try { + return await withMappedContractError(async () => { + const tx = await (this.contract as any).get_merchant_authorization({ + customer, + merchant, + }); + return tx.result; + }); + } catch (error) { + // Note: `FLUXAPAY_CONTRACT_ERROR_MAP` only covers the main `Error` + // enum, whose code space overlaps with `MerchantAuthError`'s — code 1 + // means `AuthorizationNotFound` here, not the mapped "Unauthorized" + // name (see docs/error-codes.md). Check the raw code, not the name. + if (error instanceof FluxapayError && error.code === 1) { + return null; + } + throw error; + } + } + /** * Get all refunds for a payment */