diff --git a/config/development.toml b/config/development.toml index 9348a0c4..169be168 100644 --- a/config/development.toml +++ b/config/development.toml @@ -119,6 +119,21 @@ aws_region = "" # `openssl rand -hex 32`. On rotation add e.g. `v2 = "..."` and set current = "v2" above. v1 = "0000000000000000000000000000000000000000000000000000000000000000" +[volume_commitment] +# Expose the scheduler on its own port in this process. Prod: only the deployment that owns it. +enabled = true +# How often a merchant's plan is rebuilt — the only scheduled job. prod default: 3600 (1 h) +default_forecast_interval_secs = 60 +# Where the scheduler calls when a forecast comes due. Normally this same process's [server]. +main_server_url = "http://127.0.0.1:8080" +# How often the scheduler wakes. Keep well under the shortest cadence above. +tick_secs = 10 + +# The scheduler's own listener. GET /schedule shows the timing; GET /health is a liveness probe. +[volume_commitment.server] +host = "127.0.0.1" +port = 9095 + [hypersense] base_url = "https://eu.hyperswitch.io/cost-observability/router" username = "hypersense_user" diff --git a/src/analytics/clickhouse/common.rs b/src/analytics/clickhouse/common.rs index b5fb5f5e..2d0c7951 100644 --- a/src/analytics/clickhouse/common.rs +++ b/src/analytics/clickhouse/common.rs @@ -43,6 +43,11 @@ pub const PAYMENT_AUDIT_DYNAMIC_FLOW_TYPES: &[FlowType] = &[ FlowType::UpdateScoreLegacyError, ]; +/// Payment amount on a decide event, inside the `details` JSON. Shared by every metric that +/// sums volume so the request shape has one place to move. +pub const PAYMENT_AMOUNT_EXPR: &str = + "JSONExtractFloat(assumeNotNull(details), 'request', 'paymentInfo', 'amount')"; + pub async fn fetch_all(query: Query) -> Result, ApiError> where T: Row + for<'de> Deserialize<'de>, diff --git a/src/analytics/clickhouse/metrics/cost_savings.rs b/src/analytics/clickhouse/metrics/cost_savings.rs index 417b9e6e..7799f2e6 100644 --- a/src/analytics/clickhouse/metrics/cost_savings.rs +++ b/src/analytics/clickhouse/metrics/cost_savings.rs @@ -17,8 +17,7 @@ const COST_SAVED_BPS_EXPR: &str = "JSONExtractFloat(assumeNotNull(details), 'response', 'multi_objective_info', 'costSavedBps')"; const MO_OUTCOME_EXPR: &str = "JSONExtractString(assumeNotNull(details), 'response', 'multi_objective_info', 'outcome')"; -const PAYMENT_AMOUNT_EXPR: &str = - "JSONExtractFloat(assumeNotNull(details), 'request', 'paymentInfo', 'amount')"; +use crate::analytics::clickhouse::common::PAYMENT_AMOUNT_EXPR; // Currency lives in the request JSON, not the top-level `currency` column (which is NULL for // /decide-gateway events). const PAYMENT_CURRENCY_EXPR: &str = diff --git a/src/analytics/flow.rs b/src/analytics/flow.rs index 85889427..cb6e6535 100644 --- a/src/analytics/flow.rs +++ b/src/analytics/flow.rs @@ -91,6 +91,8 @@ pub enum FlowType { RoutingCreateVolumeContract, RoutingEvaluateVolumeContract, AutopilotCalibration, + /// A volume-commitment forecast run: the plan snapshot, including eliminations. + VolumeCommitmentForecast, } impl FlowType { @@ -160,6 +162,7 @@ impl FlowType { Self::RoutingCreateVolumeContract => "routing_create_volume_contract", Self::RoutingEvaluateVolumeContract => "routing_evaluate_volume_contract", Self::AutopilotCalibration => "autopilot_calibration", + Self::VolumeCommitmentForecast => "volume_commitment_forecast", } } } diff --git a/src/app.rs b/src/app.rs index df2413c7..412b24ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -547,6 +547,30 @@ where "/merchant-account/:merchant-id/cost-coverage", get(routes::cost_coverage::get_cost_coverage), ) + .route( + "/merchant-account/:merchant-id/volume-commitment", + get(routes::volume_commitment::get_volume_commitment), + ) + .route( + "/merchant-account/:merchant-id/volume-commitment/series", + get(routes::volume_commitment::get_series), + ) + .route( + "/merchant-account/:merchant-id/volume-commitment/audit", + get(routes::volume_commitment::get_audit), + ) + .route( + "/merchant-account/:merchant-id/volume-commitment/impact", + get(routes::volume_commitment::get_impact), + ) + // Called by the volume-commitment scheduler when a merchant's forecast comes due. Behind + // the same auth as any other write, and the handler narrows further: the admin secret + // (which the scheduler presents) may run anything, a session or api key only its own + // merchant, and the every-merchant sweep is admin-only. + .route( + "/volume-commitment/run-forecast", + post(routes::volume_commitment::run_forecast), + ) .route( "/merchant-account/:merchant-id/cost-ingestions", get(routes::report_upload::list_ingestions), diff --git a/src/bin/open_router.rs b/src/bin/open_router.rs index 8bf8035e..5943dffb 100644 --- a/src/bin/open_router.rs +++ b/src/bin/open_router.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_in_result)] use masking::PeekInterface; +use open_router::decider::gatewaydecider::volume_commitment; use open_router::{logger, tenant::GlobalAppState}; #[allow(clippy::expect_used)] @@ -32,7 +33,19 @@ async fn main() -> Result<(), Box> { .await .expect("Failed while configuring global application state"); - // Run both servers concurrently using tokio::spawn + // Volume commitment routing: install the shared dependencies before anything binds, so the + // routing path and the controller's own port see the same plan and the same counters. + let volume_commitment_admin_secret = global_config.admin_secret.secret.peek().clone(); + let volume_commitment_deps = std::sync::Arc::new( + volume_commitment::build_deps( + &global_config.volume_commitment, + &global_config.analytics.clickhouse, + ) + .await, + ); + volume_commitment::init_deps(volume_commitment_deps.clone()); + + // Run the servers concurrently using tokio::spawn let main_server_handle = tokio::spawn(async move { open_router::app::server_builder(global_app_state) .await @@ -45,8 +58,23 @@ async fn main() -> Result<(), Box> { .expect("Failed while building the metrics server") }); - // Wait for both servers to complete (they should run indefinitely) - tokio::try_join!(main_server_handle, metrics_server_handle)?; + // The pacing scheduler, on a port of its own. It owns the clock: when a merchant's forecast + // comes due it calls the main server, which does the work. + let volume_commitment_server_handle = tokio::spawn(async move { + volume_commitment::server::volume_commitment_server_builder( + volume_commitment_deps, + volume_commitment_admin_secret, + ) + .await + .expect("Failed while building the volume commitment scheduler server") + }); + + // Wait for the servers to complete (they should run indefinitely) + tokio::try_join!( + main_server_handle, + metrics_server_handle, + volume_commitment_server_handle + )?; Ok(()) } diff --git a/src/config.rs b/src/config.rs index 184ebd72..f373b2d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,8 @@ pub struct GlobalConfig { #[serde(default)] pub cost_ingestion: CostIngestionConfig, #[serde(default)] + pub volume_commitment: VolumeCommitmentConfig, + #[serde(default)] pub sr_auto_calibration: SrAutoCalibrationConfig, #[serde(default)] pub card_info_service: CardInfoServiceConfig, @@ -394,9 +396,43 @@ pub struct TenantConfig { pub cache_config: CacheConfig, pub hypersense: HypersenseConfig, pub cost_ingestion: CostIngestionConfig, + pub volume_commitment: VolumeCommitmentConfig, pub card_info_service: CardInfoServiceConfig, } +/// Deployment-level mechanics for volume commitment routing; everything per-merchant arrives +/// from the contract source instead. Nothing here is a contract term. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(default)] +pub struct VolumeCommitmentConfig { + /// Expose the scheduler in this process, on its own port. + pub enabled: bool, + /// Where that port binds — its own listener, exactly as `[metrics]` is. + pub server: Server, + /// Where the scheduler sends run calls; normally this same process's `[server]`. + pub main_server_url: String, + /// How often the scheduler wakes. Bounds how late a due job fires; keep well under the + /// shortest cadence. + pub tick_secs: u64, + /// How often a merchant's plan is rebuilt — the only scheduled job. Per-merchant overridable. + pub default_forecast_interval_secs: u64, +} + +impl Default for VolumeCommitmentConfig { + fn default() -> Self { + Self { + enabled: false, + server: Server { + host: "127.0.0.1".to_string(), + port: 9095, + }, + main_server_url: "http://127.0.0.1:8080".to_string(), + tick_secs: 30, + default_forecast_interval_secs: 3600, + } + } +} + /// Configuration for the in-house cost-estimation settlement ingestion pipeline /// (see `scratch/inhouse-cost-architecture.md` §7). #[derive(Clone, Debug, serde::Deserialize)] @@ -553,6 +589,7 @@ impl TenantConfig { cache_config: global_config.cache_config.clone(), hypersense: global_config.hypersense.clone(), cost_ingestion: global_config.cost_ingestion.clone(), + volume_commitment: global_config.volume_commitment.clone(), card_info_service: global_config.card_info_service.clone(), } } diff --git a/src/decider/gatewaydecider.rs b/src/decider/gatewaydecider.rs index 4b792b50..571fa415 100644 --- a/src/decider/gatewaydecider.rs +++ b/src/decider/gatewaydecider.rs @@ -12,3 +12,4 @@ pub mod runner; pub mod types; pub mod utils; pub mod validators; +pub mod volume_commitment; diff --git a/src/decider/gatewaydecider/ab_test/interceptor.rs b/src/decider/gatewaydecider/ab_test/interceptor.rs index c52484fa..5e82eae8 100644 --- a/src/decider/gatewaydecider/ab_test/interceptor.rs +++ b/src/decider/gatewaydecider/ab_test/interceptor.rs @@ -162,6 +162,7 @@ pub async fn intercept(dreq: &DomainDeciderRequestForApiCallV2) -> AbTestInterce is_rust_based_decider: true, latency: None, multi_objective_info: None, + volume_steer_info: None, }), experiment_id, variant_arm: arm.to_string(), diff --git a/src/decider/gatewaydecider/flow_new.rs b/src/decider/gatewaydecider/flow_new.rs index f966f282..3558cc58 100644 --- a/src/decider/gatewaydecider/flow_new.rs +++ b/src/decider/gatewaydecider/flow_new.rs @@ -20,6 +20,7 @@ use super::runner::handle_fallback_logic; use super::types as T; use super::types::PriorityLogicFailure; use super::utils as Utils; +use super::volume_commitment; // use optics_core::{preview, review}; use crate::decider::gatewaydecider::constants as C; use crate::feedback::constants::kvRedis; @@ -457,6 +458,7 @@ pub async fn run_decider_flow( is_rust_based_decider: true, latency: Some(cpu_time), multi_objective_info: None, + volume_steer_info: None, }) } else { decider_flow @@ -719,6 +721,39 @@ pub async fn run_decider_flow( decider_flow.writer.multi_objective_info = Some(outcome.info); } + // Volume-commitment nudge runs last on its own flag; fails open when flag, + // deps or plan is absent. Under hedging the flag is not even read. + let volume_commitment_on = !hedging_on + && is_feature_enabled( + volume_commitment::FEATURE_FLAG.to_string(), + merchant_id_text.clone(), + kvRedis(), + ) + .await; + if volume_commitment_on { + if let Some(vc_deps) = volume_commitment::deps() { + if let Some(plan) = vc_deps.state.load_plan(&merchant_id_text).await { + // Sampling, not counting: the forecast already decided each PSP's + // share of the eligible flow, so this payment only has to roll. + let mut roll = || rand::random::(); + let outcome = volume_commitment::nudge::choose( + ¤tGatewayScoreMap, + &plan, + chrono::Utc::now(), + &mut roll, + ); + // Only an actual diversion relabels the approach. + if let Some(chosen) = outcome.chosen { + decidedGateway = Some(chosen); + cost_fallbacks_override = Some(outcome.fallbacks); + decider_flow.writer.gwDeciderApproach = + T::GatewayDeciderApproach::SrSelectionVolumeCommitment; + } + decider_flow.writer.volume_steer_info = Some(outcome.info); + } + } + } + let stateBindings = ( decider_flow.writer.srElminiationApproachInfo.clone(), decider_flow.writer.isOptimizedBasedOnSRMetricEnabled, @@ -864,6 +899,7 @@ pub async fn run_decider_flow( .writer .multi_objective_info .clone(), + volume_steer_info: decider_flow.writer.volume_steer_info.clone(), }) } None => Err(( diff --git a/src/decider/gatewaydecider/flows.rs b/src/decider/gatewaydecider/flows.rs index 89cee164..033491bf 100644 --- a/src/decider/gatewaydecider/flows.rs +++ b/src/decider/gatewaydecider/flows.rs @@ -404,6 +404,7 @@ pub async fn run_decider_flow( is_rust_based_decider: deciderParams.dpShouldConsumeResult.unwrap_or(false), latency: None, multi_objective_info: None, + volume_steer_info: None, }) } else { decider_flow @@ -734,6 +735,7 @@ pub async fn run_decider_flow( .unwrap_or(false), latency: None, multi_objective_info: None, + volume_steer_info: None, }) } None => Err(( diff --git a/src/decider/gatewaydecider/types.rs b/src/decider/gatewaydecider/types.rs index 683f556d..ea3e025f 100644 --- a/src/decider/gatewaydecider/types.rs +++ b/src/decider/gatewaydecider/types.rs @@ -477,6 +477,8 @@ pub struct DeciderState { /// overrides the merchant's elimination rule. Absent for control arm and non-tuning experiments. pub ab_test_sr_override: Option, pub multi_objective_info: Option, + /// Why the volume-commitment nudge did or did not move this payment. + pub volume_steer_info: Option, } pub fn initial_decider_state(date_created: String) -> DeciderState { @@ -522,6 +524,7 @@ pub fn initial_decider_state(date_created: String) -> DeciderState { sr_v3_hedging_percent: None, gateway_reference_id: None, multi_objective_info: None, + volume_steer_info: None, gateway_scoring_data: GatewayScoringData { merchantId: String::new(), paymentMethodType: String::new(), @@ -655,6 +658,9 @@ pub enum GatewayDeciderApproach { NtwBasedRouting, AbTestStaticAlgorithm, SrSelectionMultiObjective, + /// A volume-contract nudge moved the payment off the SR head — the volume-driven sibling of + /// [`Self::SrSelectionMultiObjective`]. + SrSelectionVolumeCommitment, } #[derive(Debug, Serialize, Deserialize, PartialEq)] @@ -1378,6 +1384,7 @@ pub struct DecidedGateway { pub is_rust_based_decider: bool, pub latency: Option, pub multi_objective_info: Option, + pub volume_steer_info: Option, } #[derive(Debug, Serialize, Clone, Deserialize)] @@ -1593,6 +1600,9 @@ impl fmt::Display for GatewayDeciderApproach { Self::SrSelectionMultiObjective => { write!(f, "SR_SELECTION_MULTI_OBJECTIVE") } + Self::SrSelectionVolumeCommitment => { + write!(f, "SR_SELECTION_VOLUME_COMMITMENT") + } } } } diff --git a/src/decider/gatewaydecider/volume_commitment/controller.rs b/src/decider/gatewaydecider/volume_commitment/controller.rs new file mode 100644 index 00000000..ba9c3755 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/controller.rs @@ -0,0 +1,455 @@ +//! Builds and stores the steering plan on demand (HTTP-triggered); never touches a live payment. + +use chrono::Utc; +use futures::FutureExt; +use serde::{Deserialize, Serialize}; + +use super::inputs::CommitmentInputs; +use super::math::{self, PACE_WINDOW_DAYS}; +use super::plan::{self, PspPlan, SteeringPlan}; +use super::volume::VolumeError; +use super::Deps; +use crate::config::VolumeCommitmentConfig; +use crate::logger; + +/// This merchant's forecast cadence: its own override, else the config default. +pub fn interval_secs(inputs: &CommitmentInputs, config: &VolumeCommitmentConfig) -> u64 { + inputs + .forecast_interval_secs + .unwrap_or(config.default_forecast_interval_secs) + .max(1) +} + +/// The cadence to fall back on when no merchant ran. +pub fn default_interval_secs(config: &VolumeCommitmentConfig) -> u64 { + config.default_forecast_interval_secs.max(1) +} + +/// A plan may steer for this many forecast intervals before going stale: one missed run is +/// tolerated, three means the scheduler is gone and steering must stop. +const PLAN_FRESH_FOR_INTERVALS: u64 = 3; + +/// What one merchant's run produced. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MerchantRun { + pub merchant_id: String, + /// Commitments still being chased. + pub psps_tracked: usize, + /// Of those, the ones normal routing is not feeding enough. + pub psps_steering: usize, + /// Commitments given up on. + pub psps_dropped: usize, + /// When this merchant wants forecasting run again. + pub next_run_in_secs: u64, +} + +/// What a whole run produced — handed back so the scheduler learns when to come back without +/// holding cadence configuration of its own. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunReport { + pub merchants_processed: usize, + /// Merchants with no commitments we could use. Not an error. + pub merchants_skipped: usize, + /// Merchants whose pass panicked. The rest of the run still completed. + pub merchants_failed: usize, + /// The soonest any merchant wants a run again, so one sweep can serve them all. + pub next_run_in_secs: u64, + pub merchants: Vec, +} + +/// Forecast every active merchant; a panicking merchant is counted and the sweep continues. +pub async fn run_all(deps: &Deps) -> RunReport { + let mut report = RunReport { + merchants_processed: 0, + merchants_skipped: 0, + merchants_failed: 0, + next_run_in_secs: default_interval_secs(&deps.config), + merchants: Vec::new(), + }; + + for merchant_id in deps.inputs.list_active().await { + match std::panic::AssertUnwindSafe(run_for_merchant(deps, &merchant_id)) + .catch_unwind() + .await + { + Ok(Ok(Some(run))) => { + report.merchants_processed += 1; + report.merchants.push(run); + } + Ok(Ok(None)) => report.merchants_skipped += 1, + // Already logged where it happened; the previous plan stands. + Ok(Err(_)) => report.merchants_failed += 1, + Err(_) => { + report.merchants_failed += 1; + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id.as_str(), + "panic in volume commitment pass; the rest of the run continues" + ); + } + } + } + + // Serve the most impatient merchant; the rest are simply run early. + if let Some(soonest) = report.merchants.iter().map(|m| m.next_run_in_secs).min() { + report.next_run_in_secs = soonest; + } + report +} + +/// Run a forecast for one merchant. `Ok(None)` means it has no commitments we can use; `Err` +/// means delivery could not be measured, so no plan was written. +pub async fn run_for_merchant( + deps: &Deps, + merchant_id: &str, +) -> Result, VolumeError> { + let Some(inputs) = deps.inputs.load(merchant_id).await else { + return Ok(None); + }; + let plan = build_plan(deps, &inputs).await?; + + Ok(Some(MerchantRun { + psps_tracked: plan.psps.len(), + psps_steering: plan.needing_steering().count(), + psps_dropped: plan.dropped.len(), + next_run_in_secs: interval_secs(&inputs, &deps.config), + merchant_id: inputs.merchant_id, + })) +} + +/// Measure, position, choose what to chase, mark who is behind — from scratch every run — then store. +/// +/// An unmeasurable merchant gets no plan at all: the previous one keeps steering until it goes +/// stale. Building on "nothing delivered" would steer every PSP at its maximum rate and, late in +/// a cycle, eliminate commitments that are in fact on pace. +pub async fn build_plan( + deps: &Deps, + inputs: &CommitmentInputs, +) -> Result { + let now = Utc::now(); + let measured = deps + .volume + .measure(&inputs.merchant_id, &inputs.commitments, PACE_WINDOW_DAYS) + .await + .map_err(|error| { + logger::error!( + tag = "volume_commitment", + merchant_id = inputs.merchant_id.as_str(), + "forecast skipped, the previous plan stands: {error}" + ); + error + })?; + + // Where each PSP stands, and the longest horizon any commitment still runs for. + let starting_pace = + math::starting_pace(inputs.expected_daily_traffic, inputs.commitments.len()); + let mut psps = Vec::with_capacity(inputs.commitments.len()); + let mut longest_period = 0.0_f64; + for commitment in &inputs.commitments { + let (psp, days_left) = position(commitment, &measured, starting_pace, now, inputs); + longest_period = longest_period.max(days_left); + psps.push(psp); + } + + // One run per cycle. Commitments in a document normally share a cycle; where they differ, the + // earliest opening is the run this plan belongs to. + let cycle_start_ms = inputs + .commitments + .iter() + .map(|c| c.period_start_ms) + .min() + .unwrap_or(0); + let day_secs = inputs.day_secs(); + + // First contract day: drop only the unreachable; afterwards the reward-ranked budget pass + // (see `plan::drop_unreachable`). + let traffic_left = math::traffic_left(inputs.expected_daily_traffic, longest_period); + let first_day = math::day_index(cycle_start_ms, now.timestamp_millis(), day_secs) < 1; + let (mut kept, dropped) = if first_day { + plan::drop_unreachable(psps, inputs.expected_daily_traffic) + } else { + plan::choose_commitments_to_keep(psps, traffic_left, inputs.expected_daily_traffic) + }; + + plan::mark_who_needs_steering(&mut kept); + // Then set each behind-pace PSP's share of the eligible flow, which is what the payment path + // samples instead of counting. + for psp in kept.iter_mut().filter(|p| p.needs_steering) { + psp.steer_rate = rate_for(psp, &measured, inputs, now); + } + + let computed_at = now.timestamp(); + let fresh_for = PLAN_FRESH_FOR_INTERVALS.saturating_mul(interval_secs(inputs, &deps.config)); + // Cap staleness at the soonest cycle end (over all commitments, kept or dropped) so no plan + // outlives its period. + let soonest_cycle_end = inputs + .commitments + .iter() + .map(|c| c.period_end_ms / 1000) + .min() + .unwrap_or(i64::MAX); + + let plan = SteeringPlan { + merchant_id: inputs.merchant_id.clone(), + run_id: math::run_id(cycle_start_ms), + contract_anchor_ms: inputs.contract_anchor_ms, + computed_at_epoch_secs: computed_at, + stale_after_epoch_secs: computed_at + .saturating_add(i64::try_from(fresh_for).unwrap_or(i64::MAX)) + .min(soonest_cycle_end), + tolerance: inputs.tolerance, + psps: kept, + dropped, + }; + + log_plan(&plan, &measured); + deps.state.store_plan(&inputs.merchant_id, &plan).await; + audit_plan(&plan); + + Ok(plan) +} + +/// Record the run as a domain analytics event so the audit trail survives this process. +fn audit_plan(plan: &SteeringPlan) { + let steering: Vec<&str> = plan + .needing_steering() + .map(|p| p.connector.as_str()) + .collect(); + let details = serde_json::json!({ + "runId": plan.run_id, + "tracked": plan.psps.len(), + "steering": steering, + "dropped": plan.dropped.iter().map(|d| serde_json::json!({ + "connector": d.connector, + "reason": d.reason, + "remaining": d.remaining, + "reward": d.reward, + })).collect::>(), + }); + + crate::analytics::DomainAnalyticsEvent::record_operation( + crate::analytics::AnalyticsFlowContext::new( + crate::analytics::ApiFlow::DynamicRouting, + crate::analytics::FlowType::VolumeCommitmentForecast, + ), + // Reusing an existing route, as the SR auto-calibrator does for its retune events. + crate::analytics::AnalyticsRoute::UpdateGatewayScore, + Some(plan.merchant_id.clone()), + None, + None, + None, + None, + Some("success".to_string()), + Some(details.to_string()), + None, + ); +} + +/// Steer rate = (today's shortfall − already steered today) / traffic expected for the rest of +/// the contract day. +fn rate_for( + psp: &PspPlan, + measured: &super::inputs::MeasuredVolume, + inputs: &CommitmentInputs, + now: chrono::DateTime, +) -> f64 { + let shortfall = math::daily_shortfall(psp.needed_daily, psp.routing_gives_daily); + let already = measured.steered_today_for(&psp.connector); + + let day_ms = math::day_ms(psp.day_secs) as f64; + let elapsed_ms = ((now.timestamp_millis() - psp.period_start_ms).max(0) as f64) % day_ms; + let day_remaining = ((day_ms - elapsed_ms) / day_ms).clamp(0.0, 1.0); + + math::steer_rate( + (shortfall - already).max(0.0), + inputs.expected_daily_traffic * day_remaining, + ) +} + +/// One commitment's position: what is owed, what each remaining day must bring, and what routing +/// delivers unaided. A "day" is the contract's own — a calendar day, or a minute on a test cycle. +fn position( + commitment: &super::inputs::Commitment, + measured: &super::inputs::MeasuredVolume, + starting_pace: f64, + now: chrono::DateTime, + inputs: &CommitmentInputs, +) -> (PspPlan, f64) { + let days_left = math::days_left( + commitment.period_end_ms, + now.timestamp_millis(), + commitment.day_secs, + ); + + let achieved = measured.achieved_for(&commitment.connector); + let pace = measured + .pace_for(&commitment.connector) + .unwrap_or(starting_pace); + let routing_gives_daily = measured.routing_gives_daily_for(&commitment.connector); + let remaining = math::remaining(commitment.goal, achieved); + + logger::debug!( + tag = "volume_commitment", + merchant_id = inputs.merchant_id.as_str(), + connector = commitment.connector.as_str(), + "goal={:.0} achieved={:.0} pace={:.0} forecast={:.0} remaining={:.0} days_left={:.2}", + commitment.goal, + achieved, + pace, + math::forecast(achieved, pace, days_left), + remaining, + days_left, + ); + + ( + PspPlan { + connector: commitment.connector.clone(), + reward: commitment.reward, + remaining, + needed_daily: math::needed_daily(remaining, days_left), + routing_gives_daily, + needs_steering: false, // set by mark_who_needs_steering + steer_rate: 0.0, // set once we know who is behind + period_start_ms: commitment.period_start_ms, + period_end_ms: commitment.period_end_ms, + day_secs: commitment.day_secs, + }, + days_left, + ) +} + +/// One log line per PSP, so the merchant can be shown why volume went where it did. +fn log_plan(plan: &SteeringPlan, measured: &super::inputs::MeasuredVolume) { + for psp in &plan.psps { + logger::info!( + tag = "volume_commitment", + merchant_id = plan.merchant_id.as_str(), + connector = psp.connector.as_str(), + "achieved={:.0} remaining={:.0} needed_daily={:.0} routing_gives_daily={:.0} \ + needs_steering={} reward={:.0}", + measured.achieved_for(&psp.connector), + psp.remaining, + psp.needed_daily, + psp.routing_gives_daily, + psp.needs_steering, + psp.reward, + ); + } + + for psp in &plan.dropped { + logger::info!( + tag = "volume_commitment_dropped", + merchant_id = plan.merchant_id.as_str(), + connector = psp.connector.as_str(), + "remaining={:.0} reward={:.0} reason={}", + psp.remaining, + psp.reward, + psp.reason, + ); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + use async_trait::async_trait; + + use super::*; + use crate::decider::gatewaydecider::volume_commitment::inputs::Commitment; + use crate::decider::gatewaydecider::volume_commitment::state::StateStore; + use crate::decider::gatewaydecider::volume_commitment::volume::FixtureVolumeSource; + use crate::decider::gatewaydecider::volume_commitment::DslInputSource; + + /// Records whether a plan was ever written; every other operation is inert. + #[derive(Default)] + struct RecordingStore { + stored: AtomicBool, + } + + #[async_trait] + impl StateStore for RecordingStore { + async fn load_plan(&self, _merchant_id: &str) -> Option { + None + } + async fn store_plan(&self, _merchant_id: &str, _plan: &SteeringPlan) { + self.stored.store(true, Ordering::SeqCst); + } + async fn clear_plan(&self, _merchant_id: &str) {} + async fn try_acquire_run_lease(&self, _merchant_id: &str, _ttl_secs: u64) -> bool { + true + } + async fn release_run_lease(&self, _merchant_id: &str) {} + async fn last_run_started_at(&self, _merchant_id: &str) -> Option { + None + } + } + + fn inputs() -> CommitmentInputs { + let now_ms = Utc::now().timestamp_millis(); + CommitmentInputs { + merchant_id: "m1".to_string(), + contract_anchor_ms: now_ms, + contract_rule_id: "rule".to_string(), + tolerance: 0.05, + expected_daily_traffic: 1_000.0, + forecast_interval_secs: None, + currency: None, + commitments: vec![Commitment { + connector: "adyen".to_string(), + goal: 10_000.0, + reward: 100.0, + reward_note: "lump sum".to_string(), + period_start_ms: now_ms, + period_end_ms: now_ms + 30 * math::SECS_PER_DAY as i64 * 1000, + day_secs: math::SECS_PER_DAY, + timezone: "UTC".to_string(), + }], + } + } + + /// A ClickHouse outage (or no ClickHouse at all) must leave the previous plan in place, not + /// replace it with one that reads every PSP as owing its whole goal. + #[tokio::test] + async fn an_unmeasurable_merchant_gets_no_plan() { + let store = Arc::new(RecordingStore::default()); + let deps = Deps { + config: VolumeCommitmentConfig::default(), + inputs: Arc::new(DslInputSource), + state: store.clone(), + volume: Arc::new(FixtureVolumeSource::new(HashMap::new())), + }; + + let outcome = build_plan(&deps, &inputs()).await; + + assert!(matches!(outcome, Err(VolumeError::Unavailable))); + assert!( + !store.stored.load(Ordering::SeqCst), + "no plan may be written" + ); + } + + /// The same inputs with a measurement behind them do produce and store a plan. + #[tokio::test] + async fn a_measured_merchant_gets_a_plan() { + let store = Arc::new(RecordingStore::default()); + let deps = Deps { + config: VolumeCommitmentConfig::default(), + inputs: Arc::new(DslInputSource), + state: store.clone(), + volume: Arc::new(FixtureVolumeSource::new(HashMap::from([( + "m1".to_string(), + super::super::inputs::MeasuredVolume::default(), + )]))), + }; + + let plan = build_plan(&deps, &inputs()).await.expect("measured"); + + assert_eq!(plan.psps.len() + plan.dropped.len(), 1); + assert!(store.stored.load(Ordering::SeqCst)); + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/dsl.rs b/src/decider/gatewaydecider/volume_commitment/dsl.rs new file mode 100644 index 00000000..b2fe870f --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/dsl.rs @@ -0,0 +1,572 @@ +//! Flattens the stored volume-contract document (a routing-rule row activated in the +//! `volume_commitment` slot) into `CommitmentInputs`; nothing downstream knows the DSL exists. + +use async_trait::async_trait; +use chrono::{DateTime, Datelike, Months, NaiveDate, Utc}; +use chrono_tz::Tz; +use diesel::associations::HasTable; +use diesel::{BoolExpressionMethods, ExpressionMethods}; + +use super::inputs::{Commitment, CommitmentInputs, InputSource}; +use super::math::{MIN_TEST_CYCLE_MINUTES, SECS_PER_DAY, TEST_DAY_SECS}; +use super::FEATURE_FLAG; +use crate::app::get_tenant_app_state; +use crate::euclid::types::StaticRoutingAlgorithm; +use crate::euclid::types::{AlgorithmType, RoutingAlgorithm, RoutingAlgorithmMapper}; +use crate::euclid::volume_contract::{ + Amount, BillingCycle, BillingCycleType, CommitmentMetric, ContractStatus, ContractTerms, + Reward, RoutingMode, TierRate, VolumeContract, VolumeContractConfig, +}; +use crate::feedback::constants::kvRedis; +use crate::logger; +use crate::redis::feature::is_feature_enabled; +#[cfg(feature = "mysql")] +use crate::storage::schema::routing_algorithm::dsl as algo_dsl; +#[cfg(feature = "mysql")] +use crate::storage::schema::routing_algorithm_mapper::dsl as mapper_dsl; +#[cfg(feature = "postgres")] +use crate::storage::schema_pg::routing_algorithm::dsl as algo_dsl; +#[cfg(feature = "postgres")] +use crate::storage::schema_pg::routing_algorithm_mapper::dsl as mapper_dsl; + +/// One basis point is one ten-thousandth. Rewards and tolerance both arrive in bps. +const BPS: f64 = 10_000.0; + +/// Reads commitments from the merchant's active volume-contract document. +pub struct DslInputSource; + +#[async_trait] +impl InputSource for DslInputSource { + async fn load(&self, merchant_id: &str) -> Option { + if !feature_on(merchant_id).await { + return None; + } + let (config, anchor_ms, rule_id) = active_config(merchant_id).await?; + to_commitment_inputs(merchant_id, &config, anchor_ms, rule_id) + } + + /// Every merchant with a contract activated; `load` is where the feature flag is applied. + async fn list_active(&self) -> Vec { + active_merchant_ids().await + } +} + +/// Per-merchant feature flag; without it no payment is steered, so nothing is forecast either. +async fn feature_on(merchant_id: &str) -> bool { + is_feature_enabled(FEATURE_FLAG.to_string(), merchant_id.to_string(), kvRedis()).await +} + +/// Active contract document, its `modified_at` (the anchor for `test_minutes` cycles, stamped on +/// activation and on edit — see `routing_rules::stamp_contract_activation`), and its rule id. +async fn active_config(merchant_id: &str) -> Option<(VolumeContractConfig, i64, String)> { + let state = get_tenant_app_state().await; + + // A merchant holds at most one mapper row per slot; the volume_commitment slot is ours. + let mapper = crate::generics::generic_find_one_optional::< + ::Table, + _, + RoutingAlgorithmMapper, + >( + &state.db, + mapper_dsl::created_by + .eq(merchant_id.to_string()) + .and(mapper_dsl::algorithm_for.eq(AlgorithmType::VolumeCommitment.to_string())), + ) + .await + .ok() + .flatten()?; + + let algorithm = crate::generics::generic_find_one_optional::< + ::Table, + _, + RoutingAlgorithm, + >( + &state.db, + algo_dsl::id.eq(mapper.routing_algorithm_id.clone()), + ) + .await + .ok() + .flatten()?; + + let anchor_ms = algorithm.modified_at.assume_utc().unix_timestamp() * 1000; + + match serde_json::from_str::(&algorithm.algorithm_data) { + Ok(StaticRoutingAlgorithm::VolumeContract(config)) => { + Some((*config, anchor_ms, algorithm.id.clone())) + } + Ok(_) => { + // The write path enforces that this slot only ever holds a contract document, so this + // means the row was written by something that bypassed it. + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "the volume_commitment slot holds a non-contract algorithm; ignoring it" + ); + None + } + Err(error) => { + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not parse the active volume contract: {error}" + ); + None + } + } +} + +/// Every merchant with a contract document activated. Read on each pass of the schedule. +async fn active_merchant_ids() -> Vec { + let state = get_tenant_app_state().await; + let rows: Vec = crate::generics::generic_find_all::< + ::Table, + _, + RoutingAlgorithmMapper, + >( + &state.db, + mapper_dsl::algorithm_for.eq(AlgorithmType::VolumeCommitment.to_string()), + ) + .await + .unwrap_or_default(); + + rows.into_iter().map(|row| row.created_by).collect() +} + +/// Flatten a document into controller inputs; `None` when nothing is paceable (empty, inactive, +/// or an unimplemented routing mode). +fn to_commitment_inputs( + merchant_id: &str, + config: &VolumeContractConfig, + anchor_ms: i64, + rule_id: String, +) -> Option { + // Mode 2 puts commitments ahead of approval rate; this engine only implements Mode 1, where + // routing stays in charge and steering is a nudge within tolerance. + if config.routing_mode != RoutingMode::PaceGuarded { + logger::warn!( + tag = "volume_commitment", + merchant_id = merchant_id, + "routing_mode {:?} is not implemented; this merchant is not steered", + config.routing_mode + ); + return None; + } + + let expected_daily_traffic = canonical(&config.expected_daily_traffic)?; + + let commitments: Vec = config + .volume_contracts + .iter() + .filter(|contract| contract.status == ContractStatus::Active) + .filter_map(|contract| to_commitment(merchant_id, contract, anchor_ms)) + .collect(); + + if commitments.is_empty() { + return None; + } + + Some(CommitmentInputs { + merchant_id: merchant_id.to_string(), + contract_anchor_ms: anchor_ms, + contract_rule_id: rule_id, + tolerance: f64::from(config.tolerance_bps.0) / BPS, + expected_daily_traffic, + forecast_interval_secs: config.forecast_interval_secs.map(u64::from), + // Counts are not money: no currency, so the dashboard shows plain numbers. + currency: matches!(config.metric, CommitmentMetric::Gmv) + .then(|| config.currency.denomination.to_string()), + commitments, + }) +} + +/// One contract into one commitment. `None` for anything this engine cannot price. +fn to_commitment( + merchant_id: &str, + contract: &VolumeContract, + anchor_ms: i64, +) -> Option { + let skip = |why: &str| { + logger::warn!( + tag = "volume_commitment", + merchant_id = merchant_id, + connector = contract.connector.as_str(), + "contract {} is not being paced: {why}", + contract.id + ); + None:: + }; + + let (goal, reward, reward_note) = match &contract.terms { + ContractTerms::Lumpsum(terms) => { + let goal = canonical(&terms.target)?; + ( + goal, + reward_amount(&terms.reward, goal)?, + reward_note(&terms.reward), + ) + } + ContractTerms::Tiered(terms) => { + // Validation guarantees exactly one targeted tier, and that it is retroactive — a + // marginal tier pays nothing at its own threshold, so it could not name a reward. + let tier = terms.tiers.iter().find(|tier| tier.targeted)?; + let goal = canonical(&tier.threshold)?; + match &tier.rate { + TierRate::Retroactive(rate) => ( + goal, + goal * f64::from(rate.rebate_bps) / BPS, + format!("{} rebate, tier", pct_label(f64::from(rate.rebate_bps))), + ), + TierRate::Marginal(_) => return skip("its targeted tier is marginal"), + } + } + // Parses so the wire format stays frozen, but v1 validation rejects it, so a stored + // document should never contain one. + ContractTerms::MinCommitment(_) => return skip("min_commitment terms are not supported"), + }; + + let window = match cycle_window(&contract.billing_cycle, Utc::now(), anchor_ms) { + Some(window) => window, + None => return skip("its billing cycle could not be resolved"), + }; + + Some(Commitment { + connector: contract.connector.clone(), + goal, + reward, + reward_note, + period_start_ms: window.start_ms, + period_end_ms: window.end_ms, + day_secs: window.day_secs, + timezone: contract.billing_cycle.timezone.clone(), + }) +} + +/// The reward's terms in words, for the contract card: "0.25% rebate" or "lump sum". +fn reward_note(reward: &Reward) -> String { + match reward { + Reward::Flat(_) => "lump sum".to_string(), + Reward::Percentage(pct) => format!("{} rebate", pct_label(f64::from(pct.rebate_bps))), + } +} + +/// Basis points as a short percentage — 25 → "0.25%", 150 → "1.5%", 200 → "2%". +fn pct_label(bps: f64) -> String { + let pct = bps / 100.0; + let text = format!("{pct:.2}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + format!("{text}%") +} + +/// What the merchant earns for landing `goal`. +fn reward_amount(reward: &Reward, goal: f64) -> Option { + match reward { + Reward::Flat(flat) => canonical(&flat.flat_amount), + Reward::Percentage(pct) => Some(goal * f64::from(pct.rebate_bps) / BPS), + } +} + +/// Amounts are canonicalized to integer minor units before storage, so a decimal here means the +/// document was written by something that skipped canonicalization. +fn canonical(amount: &Amount) -> Option { + amount.as_canonical().map(|value| value as f64) +} + +/// One resolved billing cycle: when it opened, when it closes, and how long a contract "day" +/// lasts inside it. +pub struct CycleWindow { + pub start_ms: i64, + pub end_ms: i64, + pub day_secs: u64, +} + +/// Current billing window in the contract's timezone; a `test_minutes` cycle repeats from +/// `anchor_ms` with one contract day per minute. +fn cycle_window(cycle: &BillingCycle, now: DateTime, anchor_ms: i64) -> Option { + if cycle.cycle_type == BillingCycleType::TestMinutes { + let minutes = u32::from(cycle.anchor).max(MIN_TEST_CYCLE_MINUTES); + let span_ms = i64::from(minutes) * i64::try_from(TEST_DAY_SECS).unwrap_or(60) * 1000; + // Anchored to activation, not the epoch, so a fresh contract always gets a whole first cycle. + let elapsed = now.timestamp_millis().saturating_sub(anchor_ms).max(0); + let start_ms = anchor_ms + (elapsed / span_ms) * span_ms; + return Some(CycleWindow { + start_ms, + end_ms: start_ms + span_ms, + day_secs: TEST_DAY_SECS, + }); + } + + let tz: Tz = cycle.timezone.parse().ok()?; + let today = now.with_timezone(&tz).date_naive(); + let anchor = u32::from(cycle.anchor); + + let (start, span) = match cycle.cycle_type { + BillingCycleType::CalendarMonth => { + // `anchor` is a day of the month, 1..=30. + let this_month = clamped(today.year(), today.month(), anchor)?; + let start = if today >= this_month { + this_month + } else { + let previous = first_of_month(today)?.checked_sub_months(Months::new(1))?; + clamped(previous.year(), previous.month(), anchor)? + }; + (start, Months::new(1)) + } + BillingCycleType::CalendarQuarter => { + // `anchor` is which month of the quarter the cycle opens on, 1..=3. Boundaries fall + // every three months from there. + let month_index = i64::from(today.year()) * 12 + i64::from(today.month()) - 1; + let offset = (month_index - (i64::from(anchor) - 1)).rem_euclid(3); + (from_month_index(month_index - offset)?, Months::new(3)) + } + BillingCycleType::CalendarYear => { + // `anchor` is the opening month, 1..=12. + let year = if today.month() >= anchor { + today.year() + } else { + today.year() - 1 + }; + (NaiveDate::from_ymd_opt(year, anchor, 1)?, Months::new(12)) + } + // Returned above; `None` here rather than a panic, so a future variant degrades safely. + BillingCycleType::TestMinutes => return None, + }; + + let end = start.checked_add_months(span)?; + Some(CycleWindow { + start_ms: start_of_day_ms(start, &tz), + end_ms: start_of_day_ms(end, &tz), + day_secs: SECS_PER_DAY, + }) +} + +/// Epoch ms at local midnight on `date` in `tz`; a DST gap resolves to the earliest valid instant. +fn start_of_day_ms(date: NaiveDate, tz: &Tz) -> i64 { + use chrono::TimeZone; + let Some(midnight) = date.and_hms_opt(0, 0, 0) else { + return 0; + }; + tz.from_local_datetime(&midnight) + .earliest() + .map(|dt| dt.timestamp_millis()) + .unwrap_or_else(|| midnight.and_utc().timestamp_millis()) +} + +/// `day` in the given month, pulled back to the last day when the month is shorter — a cycle +/// anchored to the 30th still turns over in February. +fn clamped(year: i32, month: u32, day: u32) -> Option { + let first = NaiveDate::from_ymd_opt(year, month, 1)?; + let last_day = first.checked_add_months(Months::new(1))?.pred_opt()?.day(); + NaiveDate::from_ymd_opt(year, month, day.min(last_day)) +} + +fn first_of_month(date: NaiveDate) -> Option { + NaiveDate::from_ymd_opt(date.year(), date.month(), 1) +} + +/// Turn a `year * 12 + (month - 1)` index back into the first of that month. +fn from_month_index(index: i64) -> Option { + let year = i32::try_from(index.div_euclid(12)).ok()?; + let month = u32::try_from(index.rem_euclid(12)).ok()? + 1; + NaiveDate::from_ymd_opt(year, month, 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::euclid::volume_contract::Proration; + + fn cycle(cycle_type: BillingCycleType, anchor: u8, timezone: &str) -> BillingCycle { + BillingCycle { + cycle_type, + anchor, + timezone: timezone.to_string(), + proration: Proration::FullPeriod, + } + } + + fn at(text: &str) -> DateTime { + text.parse().expect("valid RFC 3339 instant") + } + + fn ymd(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") + } + + /// The window's boundaries read back as dates in the contract's zone, for readable assertions. + fn window_dates(c: &BillingCycle, now: DateTime) -> Option<(NaiveDate, NaiveDate)> { + let tz: Tz = c.timezone.parse().ok()?; + let w = cycle_window(c, now, 0)?; + let as_date = |ms: i64| { + DateTime::from_timestamp_millis(ms).map(|dt| dt.with_timezone(&tz).date_naive()) + }; + Some((as_date(w.start_ms)?, as_date(w.end_ms)?)) + } + + #[test] + fn monthly_cycle_runs_anchor_to_anchor() { + let c = cycle(BillingCycleType::CalendarMonth, 1, "UTC"); + assert_eq!( + window_dates(&c, at("2026-08-20T12:00:00Z")), + Some((ymd(2026, 8, 1), ymd(2026, 9, 1))) + ); + } + + /// Before the anchor day, the merchant is still inside the cycle that opened last month. + #[test] + fn monthly_cycle_before_the_anchor_belongs_to_the_previous_month() { + let c = cycle(BillingCycleType::CalendarMonth, 15, "UTC"); + assert_eq!( + window_dates(&c, at("2026-08-03T12:00:00Z")), + Some((ymd(2026, 7, 15), ymd(2026, 8, 15))) + ); + } + + /// An anchor later than February's last day pulls back rather than failing. + #[test] + fn monthly_anchor_clamps_in_a_short_month() { + let c = cycle(BillingCycleType::CalendarMonth, 30, "UTC"); + let (start, _) = window_dates(&c, at("2026-02-28T12:00:00Z")).expect("resolves"); + assert_eq!(start, ymd(2026, 2, 28)); + } + + /// The cycle turns over at midnight in the contract's zone, not in UTC. 03:00 UTC on the 1st + /// is still 23:00 on the previous day in New York, so the previous cycle is still open. + #[test] + fn cycle_turns_over_in_the_contracts_timezone() { + let c = cycle(BillingCycleType::CalendarMonth, 1, "America/New_York"); + assert_eq!( + window_dates(&c, at("2026-08-01T03:00:00Z")), + Some((ymd(2026, 7, 1), ymd(2026, 8, 1))) + ); + // Same instant read in UTC has already rolled into August. + let utc = cycle(BillingCycleType::CalendarMonth, 1, "UTC"); + assert_eq!( + window_dates(&utc, at("2026-08-01T03:00:00Z")), + Some((ymd(2026, 8, 1), ymd(2026, 9, 1))) + ); + } + + /// `anchor` is the month within the quarter: 1 gives Jan/Apr/Jul/Oct. + #[test] + fn quarterly_cycle_opens_on_the_anchor_month_of_the_quarter() { + let c = cycle(BillingCycleType::CalendarQuarter, 1, "UTC"); + assert_eq!( + window_dates(&c, at("2026-08-20T12:00:00Z")), + Some((ymd(2026, 7, 1), ymd(2026, 10, 1))) + ); + } + + /// anchor 2 shifts the ladder to Feb/May/Aug/Nov. + #[test] + fn quarterly_anchor_shifts_the_whole_ladder() { + let c = cycle(BillingCycleType::CalendarQuarter, 2, "UTC"); + assert_eq!( + window_dates(&c, at("2026-08-20T12:00:00Z")), + Some((ymd(2026, 8, 1), ymd(2026, 11, 1))) + ); + // January falls in the quarter that opened the previous November. + assert_eq!( + window_dates(&c, at("2026-01-10T12:00:00Z")), + Some((ymd(2025, 11, 1), ymd(2026, 2, 1))) + ); + } + + #[test] + fn yearly_cycle_opens_on_the_anchor_month() { + let c = cycle(BillingCycleType::CalendarYear, 4, "UTC"); + assert_eq!( + window_dates(&c, at("2026-08-20T12:00:00Z")), + Some((ymd(2026, 4, 1), ymd(2027, 4, 1))) + ); + // Before April, the open cycle is the one that started the previous April. + assert_eq!( + window_dates(&c, at("2026-02-20T12:00:00Z")), + Some((ymd(2025, 4, 1), ymd(2026, 4, 1))) + ); + } + + #[test] + fn an_unknown_timezone_resolves_to_nothing_rather_than_guessing() { + let c = cycle(BillingCycleType::CalendarMonth, 1, "Mars/Olympus_Mons"); + assert!(cycle_window(&c, at("2026-08-20T12:00:00Z"), 0).is_none()); + } +} + +#[cfg(test)] +mod test_cycle_tests { + use super::*; + use crate::euclid::volume_contract::Proration; + + fn at(text: &str) -> DateTime { + text.parse().expect("valid instant") + } + + /// A 30-minute test cycle: thirty one-minute contract days, anchored to activation. + #[test] + fn a_test_cycle_lasts_its_minutes_with_one_day_per_minute() { + let cycle = BillingCycle { + cycle_type: BillingCycleType::TestMinutes, + anchor: 30, + timezone: "UTC".to_string(), + proration: Proration::FullPeriod, + }; + // Contract written at 10:05; cycles run from there, not from a wall-clock grid. + let anchor = at("2026-08-24T10:05:00Z").timestamp_millis(); + let now = at("2026-08-24T10:12:00Z"); + let w = cycle_window(&cycle, now, anchor).expect("resolves"); + + assert_eq!(w.day_secs, 60); + assert_eq!(w.end_ms - w.start_ms, 30 * 60_000); + // Seven minutes after it was written, still inside the first cycle. + assert_eq!(w.start_ms, anchor); + // Twenty-three contract days remain. + assert_eq!( + super::super::math::days_left(w.end_ms, now.timestamp_millis(), w.day_secs), + 23.0 + ); + } +} + +#[cfg(test)] +mod anchor_tests { + use super::*; + use crate::euclid::volume_contract::Proration; + + fn at(text: &str) -> DateTime { + text.parse().expect("valid instant") + } + + fn two_minute_cycle() -> BillingCycle { + BillingCycle { + cycle_type: BillingCycleType::TestMinutes, + anchor: 2, + timezone: "UTC".to_string(), + proration: Proration::FullPeriod, + } + } + + /// A contract activated seconds before a grid boundary must still get a whole first cycle. + #[test] + fn a_fresh_contract_gets_a_whole_first_cycle() { + let written = at("2026-08-24T10:01:58Z"); + let w = cycle_window(&two_minute_cycle(), written, written.timestamp_millis()) + .expect("resolves"); + + assert_eq!(w.start_ms, written.timestamp_millis()); + assert_eq!( + super::super::math::days_left(w.end_ms, written.timestamp_millis(), w.day_secs), + 2.0 + ); + } + + /// It still repeats: five minutes into a two-minute contract is the third cycle. + #[test] + fn cycles_repeat_from_the_anchor() { + let anchor = at("2026-08-24T10:00:00Z").timestamp_millis(); + let now = at("2026-08-24T10:05:00Z"); + let w = cycle_window(&two_minute_cycle(), now, anchor).expect("resolves"); + + assert_eq!(w.start_ms, at("2026-08-24T10:04:00Z").timestamp_millis()); + assert_eq!(w.end_ms, at("2026-08-24T10:06:00Z").timestamp_millis()); + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/inputs.rs b/src/decider/gatewaydecider/volume_commitment/inputs.rs new file mode 100644 index 00000000..9ff8ac30 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/inputs.rs @@ -0,0 +1,103 @@ +//! The flat shape the controller works in. [`super::dsl`] flattens the contract DSL into it, so +//! nothing downstream depends on how contracts are stored. + +use std::collections::HashMap; + +use async_trait::async_trait; + +/// Everything we need to know about one merchant for one billing cycle. +#[derive(Debug, Clone)] +pub struct CommitmentInputs { + pub merchant_id: String, + /// When the active contract document was written — its identity for our purposes. A plan built + /// from a different document is describing a contract that is no longer in force. + pub contract_anchor_ms: i64, + /// The routing-rule id holding the document, so a caller can act on the contract itself. + pub contract_rule_id: String, + /// How much approval rate we may give up to win volume, as a fraction (5pp = 0.05). + pub tolerance: f64, + /// Total payment volume we expect per day across all PSPs. + pub expected_daily_traffic: f64, + /// Forecast cadence override; None means the config default. + pub forecast_interval_secs: Option, + /// ISO-4217 code every amount in the document is denominated in, for display. + pub currency: Option, + pub commitments: Vec, +} + +impl CommitmentInputs { + /// Contract-day length shared by the document's commitments (a calendar day when empty). + pub fn day_secs(&self) -> u64 { + self.commitments + .first() + .map(|c| c.day_secs) + .unwrap_or(super::math::SECS_PER_DAY) + } +} + +/// One promise the merchant made to one PSP. +#[derive(Debug, Clone)] +pub struct Commitment { + pub connector: String, + /// Volume promised for the period. + pub goal: f64, + /// What the merchant earns if the goal is met. + pub reward: f64, + /// How the reward is earned, in words — "0.25% rebate", "lump sum" — for the contract card. + pub reward_note: String, + /// Instant the current cycle opened — the lower bound of the window we measure. + pub period_start_ms: i64, + /// Instant it closes (the next cycle's start). + pub period_end_ms: i64, + /// How long one contract "day" lasts. A calendar cycle counts real days; a `test_minutes` + /// cycle counts minutes, so the same pacing maths plays out in minutes instead of a month. + pub day_secs: u64, + /// IANA zone the billing cycle runs in, kept for display. + pub timezone: String, +} + +/// Volume actually measured, per PSP. +#[derive(Debug, Clone, Default)] +pub struct MeasuredVolume { + /// Total sent to each PSP so far this cycle. + pub achieved: HashMap, + /// Recent average volume per day. + pub pace: HashMap, + /// Volume normal routing sends each PSP per day, without any help. + pub routing_gives_daily: HashMap, + /// Volume the nudge has already steered here this contract day; subtracted from the shortfall to close the loop. + pub steered_today: HashMap, +} + +impl MeasuredVolume { + /// A connector's value in one of the maps, zero when it had no traffic. + fn of(map: &HashMap, connector: &str) -> f64 { + map.get(connector).copied().unwrap_or(0.0) + } + + pub fn achieved_for(&self, connector: &str) -> f64 { + Self::of(&self.achieved, connector) + } + + /// `None` when unmeasured, so the caller can fall back to a starting guess. + pub fn pace_for(&self, connector: &str) -> Option { + self.pace.get(connector).copied() + } + + pub fn routing_gives_daily_for(&self, connector: &str) -> f64 { + Self::of(&self.routing_gives_daily, connector) + } + + pub fn steered_today_for(&self, connector: &str) -> f64 { + Self::of(&self.steered_today, connector) + } +} + +/// Where commitments come from — the contract DSL in production, a stub in tests. +#[async_trait] +pub trait InputSource: Send + Sync { + /// One merchant's commitments, or None if it has none we can use. + async fn load(&self, merchant_id: &str) -> Option; + /// Every merchant with commitments, checked on each pass of the background loop. + async fn list_active(&self) -> Vec; +} diff --git a/src/decider/gatewaydecider/volume_commitment/math.rs b/src/decider/gatewaydecider/volume_commitment/math.rs new file mode 100644 index 00000000..766a020f --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/math.rs @@ -0,0 +1,158 @@ +//! The arithmetic. Plain numbers in, plain numbers out. + +/// How many days of history we average to work out a PSP's recent daily volume. +pub const PACE_WINDOW_DAYS: u32 = 7; + +/// Seconds in a calendar day — the contract-day length for every non-test billing cycle. +pub const SECS_PER_DAY: u64 = 86_400; + +/// Contract-day length on a `test_minutes` cycle: one minute per contract day. +pub const TEST_DAY_SECS: u64 = 60; + +/// Shortest `test_minutes` cycle, so a test contract always has at least two contract days. +pub const MIN_TEST_CYCLE_MINUTES: u32 = 2; + +/// Prefix of every run id; the rest is the cycle's opening instant in epoch ms. +pub const RUN_ID_PREFIX: &str = "vcr_"; + +/// Floor for days-left (~1s) so an ended cycle yields a huge `needed_daily` instead of a division by zero. +const MIN_DAYS_LEFT: f64 = 1.0 / SECS_PER_DAY as f64; + +/// Fractional contract days left; flooring would overstate the needed rate and understate the traffic left. +pub fn days_left(period_end_ms: i64, now_ms: i64, day_secs: u64) -> f64 { + let remaining_ms = (period_end_ms - now_ms).max(0) as f64; + (remaining_ms / day_ms(day_secs) as f64).max(MIN_DAYS_LEFT) +} + +/// One contract day in milliseconds. +pub fn day_ms(day_secs: u64) -> i64 { + (day_secs.max(1) as i64).saturating_mul(1000) +} + +/// Which contract day of the cycle `now` falls in, counting from 0. +pub fn day_index(period_start_ms: i64, now_ms: i64, day_secs: u64) -> i64 { + (now_ms - period_start_ms).max(0) / day_ms(day_secs) +} + +/// Whole contract days in a cycle, never below one — the x-axis span a promise line runs to. +pub fn days_total(period_start_ms: i64, period_end_ms: i64, day_secs: u64) -> u32 { + days_left(period_end_ms, period_start_ms, day_secs) + .round() + .max(1.0) as u32 +} + +/// Run id for one billing cycle, keyed on its opening instant so runs sort chronologically. +pub fn run_id(cycle_start_ms: i64) -> String { + format!("{RUN_ID_PREFIX}{cycle_start_ms}") +} + +/// The cycle-opening instant a run id names, or None for anything that is not a run id. +pub fn run_start_ms(run_id: &str) -> Option { + run_id.strip_prefix(RUN_ID_PREFIX)?.parse().ok() +} + +/// Volume still owed on a commitment. Never negative — an over-delivered PSP owes nothing. +pub fn remaining(goal: f64, achieved: f64) -> f64 { + (goal - achieved).max(0.0) +} + +/// Volume a PSP must receive each day from now on to still hit its goal. +pub fn needed_daily(remaining: f64, days_left: f64) -> f64 { + remaining / days_left.max(MIN_DAYS_LEFT) +} + +/// Total traffic we expect for the rest of the period — the budget every commitment competes for. +pub fn traffic_left(expected_daily_traffic: f64, days_left: f64) -> f64 { + expected_daily_traffic * days_left +} + +/// Where a PSP lands at its current rate. Informational only; does not drive steering. +pub fn forecast(achieved: f64, pace: f64, days_left: f64) -> f64 { + achieved + pace * days_left +} + +/// Day-0 pace guess: the day's traffic split evenly across the PSPs. +pub fn starting_pace(expected_daily_traffic: f64, psp_count: usize) -> f64 { + if psp_count == 0 { + 0.0 + } else { + expected_daily_traffic / psp_count as f64 + } +} + +/// Volume steering must *add* each day: the daily target less what routing already delivers, +/// never negative. Capping on `needed_daily` instead would overshoot by routing's own share. +pub fn daily_shortfall(needed_daily: f64, routing_gives_daily: f64) -> f64 { + (needed_daily - routing_gives_daily).max(0.0) +} + +/// Share of remaining traffic to divert (0..=1) = shortfall / expected remaining traffic; a rate +/// needs no shared counter and self-paces. +pub fn steer_rate(remaining_shortfall: f64, expected_remaining_traffic: f64) -> f64 { + if expected_remaining_traffic <= 0.0 { + // No traffic left to steer into; whatever is owed cannot be delivered this cycle. + return 0.0; + } + (remaining_shortfall / expected_remaining_traffic).clamp(0.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortfall_is_the_target_less_what_routing_already_gives() { + // The fixture's psp_b: needs ~192k/day, routing supplies 158k. + assert!((daily_shortfall(191_667.0, 158_000.0) - 33_667.0).abs() < 1.0); + } + + #[test] + fn a_psp_routing_already_feeds_needs_no_help() { + assert_eq!(daily_shortfall(100_000.0, 158_000.0), 0.0); + } + + /// The rate is simply what is owed over what is still coming. + #[test] + fn the_rate_is_the_share_of_remaining_traffic_still_owed() { + assert_eq!(steer_rate(25_000.0, 100_000.0), 0.25); + assert_eq!(steer_rate(0.0, 100_000.0), 0.0); + } + + /// Owing more than the traffic that remains means taking all of it, never more. + #[test] + fn the_rate_never_exceeds_all_of_the_traffic() { + assert_eq!(steer_rate(500_000.0, 100_000.0), 1.0); + } + + /// A cycle with no traffic left cannot deliver, and must not divide by zero. + #[test] + fn no_remaining_traffic_yields_no_steering() { + assert_eq!(steer_rate(50_000.0, 0.0), 0.0); + assert_eq!(steer_rate(50_000.0, -1.0), 0.0); + } + + #[test] + fn days_left_counts_contract_days_not_calendar_days() { + let day_ms = 60_000; // one-minute contract days + // Ten minutes of cycle remaining = ten contract days. + assert_eq!(days_left(10 * day_ms, 0, 60), 10.0); + // Past the end nothing is left, but the value stays safe to divide by. + assert!(days_left(0, 10 * day_ms, 60) > 0.0); + assert!(days_left(0, 10 * day_ms, 60) < 0.001); + } + + /// Half a contract day is half a day, not a whole one. Flooring here understated the traffic + /// left to fund a commitment and overstated the rate it needed, writing off reachable goals. + #[test] + fn a_part_day_is_counted_as_a_fraction() { + let day_ms = 60_000; + assert_eq!(days_left(3 * day_ms / 2, 0, 60), 1.5); + + // The case that misfired: a 2-day cycle, 30s in, one PSP owing 600k against 500k/day. + let remaining_ms = 2 * day_ms - 30_000; + let left = days_left(remaining_ms, 0, 60); + assert_eq!(left, 1.5); + assert_eq!(needed_daily(600_000.0, left), 400_000.0); // was 600_000 when floored + assert_eq!(traffic_left(500_000.0, left), 750_000.0); // was 500_000 when floored + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/mod.rs b/src/decider/gatewaydecider/volume_commitment/mod.rs new file mode 100644 index 00000000..7014203a --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/mod.rs @@ -0,0 +1,83 @@ +//! Volume-commitment routing: nudge payments toward a behind-pace PSP within an approval +//! tolerance. `controller` writes the plan, `scheduler` owns the clock, `nudge` reads it per payment. + +pub mod controller; +pub mod dsl; +pub mod inputs; +pub mod math; +pub mod nudge; +pub mod plan; +pub mod scheduler; +pub mod server; +pub mod state; +pub mod volume; + +use std::collections::HashMap; +use std::sync::Arc; + +use once_cell::sync::OnceCell; + +pub use dsl::DslInputSource; +pub use inputs::{Commitment, CommitmentInputs, InputSource, MeasuredVolume}; +pub use nudge::{NudgeOutcome, VolumeSteerInfo, VolumeSteerOutcome}; +pub use plan::{DroppedPsp, PspPlan, SteeringPlan}; +pub use state::{RedisStateStore, StateStore}; +pub use volume::{ClickHouseVolumeSource, FixtureVolumeSource, VolumeError, VolumeSource}; + +/// Per-merchant feature flag; the routing path, the forecaster and the dashboard toggle all read it. +pub const FEATURE_FLAG: &str = "volume_commitment_routing_enabled"; + +/// What this feature needs from outside: the promise, the delivery, and somewhere to keep state. +pub struct Deps { + /// Deployment settings — cadence defaults, where the main server is. + pub config: crate::config::VolumeCommitmentConfig, + /// What the merchant committed to. From the contract DSL. + pub inputs: Arc, + /// Where the plan and the steered-today counters live. + pub state: Arc, + /// What each PSP was actually sent. From the traffic. + pub volume: Arc, +} + +/// Build the shared dependencies at startup, before any server binds. +pub async fn build_deps( + config: &crate::config::VolumeCommitmentConfig, + clickhouse: &crate::config::ClickHouseAnalyticsConfig, +) -> Deps { + // Without ClickHouse nothing can be measured, so no plan is ever built and nothing is steered. + let volume: Arc = if clickhouse.enabled { + crate::logger::info!( + tag = "volume_commitment", + "measuring delivered volume from clickhouse" + ); + Arc::new(ClickHouseVolumeSource::new(clickhouse)) + } else { + crate::logger::warn!( + tag = "volume_commitment", + "clickhouse analytics is disabled; no delivered volume can be measured, so no \ + commitment will be paced" + ); + Arc::new(FixtureVolumeSource::new(HashMap::new())) + }; + + Deps { + config: config.clone(), + inputs: Arc::new(DslInputSource), + // Redis, always: a process-local plan would be invisible to other replicas and lost on restart. + state: Arc::new(RedisStateStore), + volume, + } +} + +/// Set once at startup, so the routing path can reach these without being passed them. +static DEPS: OnceCell> = OnceCell::new(); + +/// Store the dependencies. Calling it twice does nothing. +pub fn init_deps(deps: Arc) { + let _ = DEPS.set(deps); +} + +/// The shared dependencies, or None before startup has run — routing treats that as "feature off". +pub fn deps() -> Option<&'static Arc> { + DEPS.get() +} diff --git a/src/decider/gatewaydecider/volume_commitment/nudge.rs b/src/decider/gatewaydecider/volume_commitment/nudge.rs new file mode 100644 index 00000000..435f785e --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/nudge.rs @@ -0,0 +1,415 @@ +//! Per-payment decision: a behind-pace PSP takes the payment if the plan is fresh, its cycle is +//! open, it is within tolerance, and it wins a roll against its steer rate — stateless by design. +//! A behind-pace PSP that is already the routing head cannot be steered to (it has the payment), +//! but it can be steered *from*: it rolls first, and a win keeps the payment out of the reach of +//! every lower-reward commitment behind it in the plan. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::plan::SteeringPlan; + +/// Whether this payment was moved. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VolumeSteerOutcome { + /// A PSP behind on its commitment took the payment. + Steered, + /// Normal routing kept the payment. + SrPrevailed, +} + +/// Why this payment was or was not moved. Shown on the decide response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VolumeSteerInfo { + pub outcome: VolumeSteerOutcome, + pub reason: String, + /// The PSP normal routing picked. + pub sr_head: Option, + /// The PSP we actually chose. Same as `sr_head` when nothing moved. + pub chosen: Option, + /// Approval rate given up to win the volume, as a fraction. None when nothing moved. + pub sr_gap_conceded: Option, + /// The share of eligible payments this PSP was set to receive when the roll happened. + /// None when nothing moved. + pub steer_rate: Option, + /// How many PSPs needed extra volume when this payment arrived. + pub steering_count: usize, + /// The contract execution this steer belongs to, so it files under the right run. + pub run_id: Option, +} + +/// The decision for one payment. +#[derive(Debug, Clone)] +pub struct NudgeOutcome { + /// Set only when we moved the payment. None means routing keeps its own choice. + pub chosen: Option, + /// Backup PSPs to try, best-approving first. + pub fallbacks: Vec, + pub info: VolumeSteerInfo, +} + +/// Decide whether a PSP behind on its commitment should take this payment. `now` is injected so +/// the windowing is testable; callers pass `Utc::now()`. +pub fn choose( + scores: &HashMap, + plan: &SteeringPlan, + now: DateTime, + roll: &mut impl FnMut() -> f64, +) -> NudgeOutcome { + let steering_count = plan.needing_steering().count(); + + let Some((best_psp, best_score)) = highest_scoring(scores) else { + return keep_routing_choice(None, "No PSPs to choose from.".to_string(), steering_count); + }; + + // A dead scheduler fails safe: the plan expires and normal routing carries on unaided. + if plan.is_stale(now.timestamp()) { + return keep_routing_choice( + Some(best_psp), + "The pacing plan has expired without a fresh forecast; normal routing keeps this \ + payment." + .to_string(), + steering_count, + ); + } + + // The list is ordered by reward, so the first PSP that passes every check is the best one. + for psp in plan.needing_steering() { + // Not in the score map means routing already ruled it out for this payment. + let Some(&score) = scores.get(&psp.connector) else { + continue; + }; + + // Routing already picked it, so there is nothing to move *to* it. But it is behind, and + // every PSP after it in the plan is worth less: let it roll for its share first, and on a + // win keep the payment here rather than let a cheaper commitment take it away. On a loss + // the rest of the list gets its turn, which is what shares the flow between them. + if psp.connector == best_psp { + if now.timestamp_millis() < psp.period_end_ms && roll() < psp.steer_rate { + return keep_routing_choice( + Some(best_psp.clone()), + format!( + "Kept with {}, which is behind on its own volume commitment (worth {:.0}) \ + and is taking {:.1}% of eligible payments; no lower-reward commitment may \ + steer this payment away from it.", + psp.connector, + psp.reward, + psp.steer_rate * 100.0 + ), + steering_count, + ); + } + continue; + } + + // The cycle this commitment was owed to has closed; volume sent now counts toward the + // next one, so there is nothing left to rescue here. + if now.timestamp_millis() >= psp.period_end_ms { + continue; + } + + // Approves too much worse than the best PSP. + let approval_given_up = (best_score - score).max(0.0); + if approval_given_up > plan.tolerance { + continue; + } + + // The forecast already decided how much of the eligible flow this PSP should take. All + // that is left is to roll for it — no counter to read, nothing to write back. + if roll() >= psp.steer_rate { + continue; + } + + return NudgeOutcome { + chosen: Some(psp.connector.clone()), + fallbacks: others_by_score(scores, &psp.connector), + info: VolumeSteerInfo { + outcome: VolumeSteerOutcome::Steered, + reason: format!( + "Sent to {} to help meet its volume commitment (worth {:.0}). It is taking \ + {:.1}% of eligible payments; gave up {:.4} approval rate versus {}, within \ + the {:.4} allowed.", + psp.connector, + psp.reward, + psp.steer_rate * 100.0, + approval_given_up, + best_psp, + plan.tolerance + ), + sr_head: Some(best_psp), + chosen: Some(psp.connector.clone()), + sr_gap_conceded: Some(approval_given_up), + steer_rate: Some(psp.steer_rate), + steering_count, + run_id: Some(plan.run_id.clone()), + }, + }; + } + + let reason = if steering_count == 0 { + "Every commitment is on track; normal routing keeps this payment.".to_string() + } else { + "No PSP behind on its commitment was both close enough on approval and selected by its \ + steering rate; normal routing keeps this payment." + .to_string() + }; + keep_routing_choice(Some(best_psp), reason, steering_count) +} + +/// Leave the payment where normal routing put it. +fn keep_routing_choice( + best_psp: Option, + reason: String, + steering_count: usize, +) -> NudgeOutcome { + NudgeOutcome { + chosen: None, + fallbacks: Vec::new(), + info: VolumeSteerInfo { + outcome: VolumeSteerOutcome::SrPrevailed, + reason, + sr_head: best_psp.clone(), + chosen: best_psp, + sr_gap_conceded: None, + steer_rate: None, + steering_count, + run_id: None, + }, + } +} + +/// Highest score first; equal scores fall back to name, so the order is deterministic. +fn by_score_desc(a: &(&String, &f64), b: &(&String, &f64)) -> std::cmp::Ordering { + b.1.partial_cmp(a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) +} + +/// The best-approving PSP, ties broken by name for determinism. +fn highest_scoring(scores: &HashMap) -> Option<(String, f64)> { + scores + .iter() + .filter(|(_, score)| score.is_finite()) + .min_by(by_score_desc) + .map(|(psp, score)| (psp.clone(), *score)) +} + +/// The remaining PSPs, best-approving first. +fn others_by_score(scores: &HashMap, chosen: &str) -> Vec { + let mut rest: Vec<(&String, &f64)> = scores.iter().filter(|(psp, _)| *psp != chosen).collect(); + rest.sort_by(by_score_desc); + rest.into_iter().map(|(psp, _)| psp.clone()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decider::gatewaydecider::volume_commitment::math; + use crate::decider::gatewaydecider::volume_commitment::plan::PspPlan; + + /// A PSP behind pace, taking `steer_rate` of the eligible flow. + fn psp(connector: &str, reward: f64, steer_rate: f64) -> PspPlan { + PspPlan { + connector: connector.to_string(), + reward, + remaining: 1_000.0, + needed_daily: 200.0, + routing_gives_daily: 50.0, + needs_steering: true, + steer_rate, + period_start_ms: 0, + period_end_ms: i64::MAX, + day_secs: math::SECS_PER_DAY, + } + } + + fn fresh_plan(psps: Vec, tolerance: f64, now: DateTime) -> SteeringPlan { + SteeringPlan { + merchant_id: "m1".to_string(), + run_id: "vcr_test".to_string(), + contract_anchor_ms: 0, + computed_at_epoch_secs: now.timestamp(), + stale_after_epoch_secs: now.timestamp() + 3_600, + tolerance, + psps, + dropped: Vec::new(), + } + } + + fn scores(entries: &[(&str, f64)]) -> HashMap { + entries + .iter() + .map(|(name, score)| (name.to_string(), *score)) + .collect() + } + + fn noon() -> DateTime { + "2026-08-20T12:00:00Z".parse().expect("valid instant") + } + + /// Rolls are injected rather than random, so every test below is deterministic. `always()` + /// rolls 0.0 — under any positive rate; `never()` rolls 1.0 — under none. + fn always() -> impl FnMut() -> f64 { + || 0.0 + } + fn never() -> impl FnMut() -> f64 { + || 1.0 + } + + #[test] + fn steers_a_behind_psp_that_approves_within_tolerance() { + let plan = fresh_plan(vec![psp("behind", 1_000.0, 0.5)], 0.05, noon()); + let scores = scores(&[("best", 0.95), ("behind", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen.as_deref(), Some("behind")); + assert_eq!(outcome.info.outcome, VolumeSteerOutcome::Steered); + assert_eq!(outcome.info.sr_head.as_deref(), Some("best")); + assert_eq!(outcome.info.steer_rate, Some(0.5)); + } + + #[test] + fn respects_the_tolerance() { + let plan = fresh_plan(vec![psp("behind", 1_000.0, 1.0)], 0.05, noon()); + let scores = scores(&[("best", 0.95), ("behind", 0.89)]); // gap 0.06 > 0.05 + + // Even at a rate of 1.0, the approval gap keeps this payment where routing put it. + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + assert_eq!(outcome.info.outcome, VolumeSteerOutcome::SrPrevailed); + } + + /// The roll is what replaces the old daily counter: a payment that loses it stays put. + #[test] + fn a_losing_roll_leaves_the_payment_alone() { + let plan = fresh_plan(vec![psp("behind", 1_000.0, 0.2)], 0.05, noon()); + let scores = scores(&[("best", 0.95), ("behind", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut never()); + assert_eq!(outcome.chosen, None); + } + + /// A rate of zero means the forecast decided this PSP needs nothing more. + #[test] + fn a_zero_rate_never_steers() { + let plan = fresh_plan(vec![psp("behind", 1_000.0, 0.0)], 0.05, noon()); + let scores = scores(&[("best", 0.95), ("behind", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + } + + #[test] + fn an_expired_plan_steers_nothing() { + let mut plan = fresh_plan(vec![psp("behind", 1_000.0, 1.0)], 0.05, noon()); + plan.stale_after_epoch_secs = noon().timestamp() - 1; + let scores = scores(&[("best", 0.95), ("behind", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + assert!(outcome.info.reason.contains("expired")); + } + + /// Past its cycle end a commitment is settled: steering there would credit the next period. + #[test] + fn a_closed_cycle_steers_nothing() { + let mut behind = psp("behind", 1_000.0, 1.0); + behind.period_end_ms = noon().timestamp_millis() - 1; + let plan = fresh_plan(vec![behind], 0.05, noon()); + let scores = scores(&[("best", 0.95), ("behind", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + } + + /// The plan is reward-ordered; the first PSP passing every check wins a contested payment. + #[test] + fn the_higher_reward_wins_a_contested_payment() { + let plan = fresh_plan( + vec![psp("rich", 5_000.0, 1.0), psp("poor", 1_000.0, 1.0)], + 0.05, + noon(), + ); + let scores = scores(&[("best", 0.95), ("rich", 0.92), ("poor", 0.93)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen.as_deref(), Some("rich")); + } + + /// A PSP routing already picked needs no nudge, even when it is behind. + #[test] + fn the_sr_head_itself_is_never_nudged() { + let plan = fresh_plan(vec![psp("behind", 1_000.0, 1.0)], 0.05, noon()); + let scores = scores(&[("behind", 0.95), ("other", 0.90)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + assert_eq!(outcome.info.sr_head.as_deref(), Some("behind")); + } + + /// The head is behind on the richer commitment and wins its roll: the payment stays with it, + /// and the cheaper commitment further down the plan may not take it away. + #[test] + fn a_behind_head_that_wins_its_roll_keeps_the_payment_from_cheaper_commitments() { + let plan = fresh_plan( + vec![psp("adyen", 12_000.0, 0.6), psp("stripe", 2_000.0, 1.0)], + 0.05, + noon(), + ); + let scores = scores(&[("adyen", 0.91), ("stripe", 0.91)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen, None); + assert_eq!(outcome.info.outcome, VolumeSteerOutcome::SrPrevailed); + assert_eq!(outcome.info.chosen.as_deref(), Some("adyen")); + assert!(outcome.info.reason.contains("Kept with adyen")); + } + + /// The head is behind but loses its roll: the cheaper commitment gets its usual turn. + #[test] + fn a_behind_head_that_loses_its_roll_lets_the_next_commitment_steer() { + let plan = fresh_plan( + vec![psp("adyen", 12_000.0, 0.6), psp("stripe", 2_000.0, 1.0)], + 0.05, + noon(), + ); + let scores = scores(&[("adyen", 0.91), ("stripe", 0.91)]); + + // First roll (adyen's, the head by name on a tie) loses at 0.7 ≥ 0.6; the second (stripe's) wins at 0.0 < 1.0. + let mut rolls = [0.7, 0.0].into_iter(); + let mut roll = || rolls.next().expect("two rolls"); + let outcome = choose(&scores, &plan, noon(), &mut roll); + assert_eq!(outcome.chosen.as_deref(), Some("stripe")); + } + + /// A richer commitment that is *not* the head still outranks the head's own claim: the plan + /// order decides, and the head only defends against commitments below it. + #[test] + fn a_richer_non_head_commitment_still_wins_over_a_behind_head() { + let plan = fresh_plan( + vec![psp("richest", 20_000.0, 1.0), psp("head", 12_000.0, 1.0)], + 0.05, + noon(), + ); + let scores = scores(&[("head", 0.95), ("richest", 0.92)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen.as_deref(), Some("richest")); + } + + /// A behind head whose cycle has closed has nothing to defend; it does not consume a roll. + #[test] + fn a_behind_head_past_its_cycle_end_does_not_hold_the_payment() { + let mut head = psp("head", 12_000.0, 1.0); + head.period_end_ms = noon().timestamp_millis() - 1; + let plan = fresh_plan(vec![head, psp("poor", 2_000.0, 1.0)], 0.05, noon()); + let scores = scores(&[("head", 0.91), ("poor", 0.91)]); + + let outcome = choose(&scores, &plan, noon(), &mut always()); + assert_eq!(outcome.chosen.as_deref(), Some("poor")); + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/plan.rs b/src/decider/gatewaydecider/volume_commitment/plan.rs new file mode 100644 index 00000000..370ddfef --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/plan.rs @@ -0,0 +1,278 @@ +//! Works out which commitments we still chase, and which PSPs need extra volume. + +use serde::{Deserialize, Serialize}; + +/// One PSP's position against its commitment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PspPlan { + pub connector: String, + /// What the merchant earns if this commitment is met. + pub reward: f64, + /// Volume still owed. + pub remaining: f64, + /// Volume needed each day from now on. + pub needed_daily: f64, + /// Volume normal routing already sends here each day. + pub routing_gives_daily: f64, + /// True when normal routing is not sending enough — only these PSPs get extra volume. + pub needs_steering: bool, + /// Share of eligible payments to divert here, 0..=1. Recomputed every forecast from what has + /// actually been delivered, which is what lets the payment path decide without counting. + pub steer_rate: f64, + /// Instant this commitment's cycle opened — contract days are counted from here. + pub period_start_ms: i64, + /// Instant it closes. Past this the commitment is settled, and no payment may be steered to + /// it: the volume would land in the next cycle, not the one it was owed to. + pub period_end_ms: i64, + /// How long one contract day lasts, so the nudge paces on the same unit the plan does. + pub day_secs: u64, +} + +/// A commitment we stopped chasing, and why. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DroppedPsp { + pub connector: String, + pub remaining: f64, + pub reward: f64, + pub reason: String, +} + +/// What the background loop hands to the routing path. Rebuilt each tick, read on every payment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SteeringPlan { + pub merchant_id: String, + /// Which execution of the contract this plan belongs to — one cycle, start to close. Every + /// forecast, steer and elimination it produces is filed under this. + pub run_id: String, + /// Anchor of the contract this plan was built from; a mismatch means the plan is for a replaced contract. + pub contract_anchor_ms: i64, + pub computed_at_epoch_secs: i64, + /// Epoch second after which this plan may no longer steer — a dead scheduler fails safe. + pub stale_after_epoch_secs: i64, + /// How much approval rate we are willing to give up to win volume, as a fraction. + pub tolerance: f64, + /// Commitments we are still chasing, best reward first. + pub psps: Vec, + /// Commitments we gave up on. + pub dropped: Vec, +} + +impl SteeringPlan { + /// The PSPs that currently need extra volume. + pub fn needing_steering(&self) -> impl Iterator { + self.psps.iter().filter(|p| p.needs_steering) + } + + /// True once the plan has outlived its forecast cadence and must stop steering. + pub fn is_stale(&self, now_epoch_secs: i64) -> bool { + now_epoch_secs > self.stale_after_epoch_secs + } +} + +/// Drop the unreachable, then rank by reward and shed the tail until period and daily budgets fit; +/// dropped PSPs still get normal traffic. +pub fn choose_commitments_to_keep( + psps: Vec, + traffic_left: f64, + daily_traffic: f64, +) -> (Vec, Vec) { + // Pass 1: the certifiably lost. + let (mut kept, mut dropped) = drop_unreachable(psps, daily_traffic); + + // Pass 2: reward-ranked, giving up the tail until the period and daily budgets both fit. + kept.sort_by(by_reward_desc); + let mut total_remaining: f64 = kept.iter().map(|p| p.remaining).sum(); + let mut total_daily: f64 = kept.iter().map(|p| p.needed_daily).sum(); + + let mut budget_dropped = Vec::new(); + while total_remaining > traffic_left || total_daily > daily_traffic { + let Some(psp) = kept.pop() else { break }; + total_remaining -= psp.remaining; + total_daily -= psp.needed_daily; + budget_dropped.push(DroppedPsp { + reason: format!( + "needs {:.0} more volume for a reward of only {:.0} — the lowest-ranked \ + commitment still standing when the traffic ran short, so it was given up to \ + leave room for the ones worth more", + psp.remaining, psp.reward + ), + connector: psp.connector, + remaining: psp.remaining, + reward: psp.reward, + }); + } + + // Popped cheapest-first; report best-reward-first, matching the kept list. + budget_dropped.reverse(); + dropped.extend(budget_dropped); + (kept, dropped) +} + +/// Drop only commitments whose daily need exceeds total daily traffic; used alone through the +/// first contract day, when the budget pass would be noise. +pub fn drop_unreachable(psps: Vec, daily_traffic: f64) -> (Vec, Vec) { + let (unreachable, kept): (Vec, Vec) = psps + .into_iter() + .partition(|psp| psp.needed_daily > daily_traffic); + let dropped: Vec = unreachable + .into_iter() + .map(|psp| DroppedPsp { + reason: format!( + "needs {:.0} a day but the merchant only expects {:.0} a day in total, \ + so this commitment cannot be met and is not chased", + psp.needed_daily, daily_traffic + ), + connector: psp.connector, + remaining: psp.remaining, + reward: psp.reward, + }) + .collect(); + (kept, dropped) +} + +/// Biggest reward first. Same reward falls back to name, so the order never changes randomly. +fn by_reward_desc(a: &PspPlan, b: &PspPlan) -> std::cmp::Ordering { + b.reward + .partial_cmp(&a.reward) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.connector.cmp(&b.connector)) +} + +/// Mark who is short, and order by reward so the biggest wins a contested payment. +pub fn mark_who_needs_steering(psps: &mut [PspPlan]) { + for psp in psps.iter_mut() { + psp.needs_steering = psp.routing_gives_daily < psp.needed_daily; + } + + psps.sort_by(by_reward_desc); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn psp(connector: &str, reward: f64, remaining: f64, needed_daily: f64) -> PspPlan { + PspPlan { + connector: connector.to_string(), + reward, + remaining, + needed_daily, + routing_gives_daily: 0.0, + needs_steering: false, + steer_rate: 0.0, + period_start_ms: 0, + period_end_ms: i64::MAX, + day_secs: crate::decider::gatewaydecider::volume_commitment::math::SECS_PER_DAY, + } + } + + fn names(psps: &[PspPlan]) -> Vec<&str> { + psps.iter().map(|p| p.connector.as_str()).collect() + } + + /// The first-day pass never trades on reward: an over-promised pair both survive it, and only + /// a commitment that is unreachable on its own is dropped. + #[test] + fn the_first_day_pass_drops_only_the_unreachable() { + let psps = vec![ + psp("adyen", 13_000.0, 900_000.0, 300_000.0), + psp("stripe", 5_000.0, 1_150_000.0, 380_000.0), + psp("checkout", 1_000.0, 3_000_000.0, 750_000.0), + ]; + let (kept, dropped) = drop_unreachable(psps, 500_000.0); + assert_eq!(names(&kept), vec!["adyen", "stripe"]); + assert_eq!(names_dropped(&dropped), vec!["checkout"]); + } + + fn names_dropped(psps: &[DroppedPsp]) -> Vec<&str> { + psps.iter().map(|p| p.connector.as_str()).collect() + } + + #[test] + fn keeps_everything_when_it_all_fits() { + let psps = vec![ + psp("psp_a", 20_000.0, 4_400_000.0, 244_444.0), + psp("psp_b", 12_000.0, 3_450_000.0, 191_667.0), + ]; + let (kept, dropped) = choose_commitments_to_keep(psps, 50_000_000.0, 1_000_000.0); + assert_eq!(names(&kept), ["psp_a", "psp_b"]); + assert!(dropped.is_empty()); + } + + /// Four PSPs with 18 days left at 620k/day: 11.16M of traffic against 13.4M of commitments. + /// The weakest reward is the one to go. + #[test] + fn drops_the_weakest_reward_first() { + let psps = vec![ + psp("psp_a", 20_000.0, 4_400_000.0, 244_444.0), + psp("psp_b", 12_000.0, 3_450_000.0, 191_667.0), + psp("psp_c", 6_000.0, 3_050_000.0, 169_444.0), + psp("psp_d", 15_000.0, 2_500_000.0, 138_889.0), + ]; + let (kept, dropped) = choose_commitments_to_keep(psps, 11_160_000.0, 620_000.0); + + assert_eq!(names(&kept), ["psp_a", "psp_d", "psp_b"]); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].connector, "psp_c"); + assert_eq!(dropped[0].reward, 6_000.0); + } + + /// Only the day's traffic is short — the period total fits. That alone must force a drop. + #[test] + fn daily_traffic_alone_can_force_a_drop() { + let psps = vec![ + psp("psp_a", 20_000.0, 100.0, 400_000.0), + psp("psp_b", 5_000.0, 100.0, 400_000.0), + ]; + let (kept, dropped) = choose_commitments_to_keep(psps, 10_000_000.0, 620_000.0); + assert_eq!(names(&kept), ["psp_a"]); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].connector, "psp_b"); + } + + /// Equal rewards must not resolve differently from one run to the next. + #[test] + fn equal_rewards_break_by_name() { + let psps = vec![ + psp("zeta", 10_000.0, 6_000_000.0, 100.0), + psp("alpha", 10_000.0, 6_000_000.0, 100.0), + ]; + let (kept, dropped) = choose_commitments_to_keep(psps, 6_000_000.0, 1_000_000.0); + assert_eq!(names(&kept), ["alpha"]); + assert_eq!(dropped[0].connector, "zeta"); + } + + /// Nothing fits at all: everything is given up rather than looping forever. + #[test] + fn drops_everything_when_nothing_fits() { + let psps = vec![psp("psp_a", 20_000.0, 9_000_000.0, 500_000.0)]; + let (kept, dropped) = choose_commitments_to_keep(psps, 1_000.0, 1_000.0); + assert!(kept.is_empty()); + assert_eq!(dropped.len(), 1); + } + + /// An impossible commitment must not ride its reward rank: it is lost either way, and keeping + /// it would push out commitments that can still be landed. + #[test] + fn an_unreachable_commitment_cannot_crowd_out_achievable_ones() { + let psps = vec![ + // Highest reward, but needs 900k/day against 620k/day of total traffic: hopeless. + psp("psp_doomed", 50_000.0, 9_000_000.0, 900_000.0), + psp("psp_b", 12_000.0, 3_450_000.0, 191_667.0), + psp("psp_c", 6_000.0, 3_050_000.0, 169_444.0), + ]; + let (kept, dropped) = choose_commitments_to_keep(psps, 11_160_000.0, 620_000.0); + + // Without the pre-pass, psp_doomed's reward rank would keep it and evict both others. + assert_eq!(names(&kept), ["psp_b", "psp_c"]); + assert_eq!(dropped.len(), 1); + assert_eq!(dropped[0].connector, "psp_doomed"); + assert!(dropped[0].reason.contains("cannot be met")); + } + + #[test] + fn empty_input_is_no_op() { + let (kept, dropped) = choose_commitments_to_keep(Vec::new(), 0.0, 0.0); + assert!(kept.is_empty() && dropped.is_empty()); + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/scheduler.rs b/src/decider/gatewaydecider/volume_commitment/scheduler.rs new file mode 100644 index 00000000..993c0ab1 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/scheduler.rs @@ -0,0 +1,287 @@ +//! Owns the clock only: POSTs the main server's run endpoint when each merchant's cadence comes +//! due — or its cycle rolls over, whichever is sooner, since the plan (and its Redis key) dies at +//! the cycle boundary and waiting out the cadence would leave the fresh cycle unsteered and +//! "Forecast pending" for up to a whole interval. Every replica may run one; a Redis lease per +//! merchant and interval makes sure only one does. + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use futures::FutureExt; +use serde::Serialize; + +use super::controller::{self, RunReport}; +use super::Deps; +use crate::logger; + +/// Watches the clock for every merchant with commitments. +pub struct Scheduler { + deps: Arc, + http: reqwest::Client, + /// Presented to the main server's run endpoint, which sits behind the same auth as any write. + admin_secret: String, +} + +/// One row of the schedule, served by `GET /schedule` for inspection. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleEntry { + pub merchant_id: String, + /// This merchant's forecast cadence. + pub every_secs: u64, + /// Soonest instant any of this merchant's commitments closes its cycle. The plan expires + /// there, so the forecast is re-run at rollover rather than waiting out the cadence. + pub period_end_epoch_secs: i64, + /// When the current interval's run was claimed, by any replica. `None` until this merchant's + /// first run, or once the last one has expired. + pub last_notified_at_epoch_secs: Option, + /// Zero means it fires on the next tick. + pub due_in_secs: u64, +} + +/// Seconds until a merchant is due again: its cadence less what has elapsed since the last run +/// was claimed, but never later than its cycle's rollover — the plan dies there. Never run, or +/// the claim expired, means now. +fn due_in_secs( + every_secs: u64, + last_started_at: Option, + now_epoch_secs: i64, + until_rollover_secs: u64, +) -> u64 { + let cadence = match last_started_at { + None => 0, + Some(at) => { + let elapsed = u64::try_from(now_epoch_secs - at).unwrap_or(0); + every_secs.saturating_sub(elapsed) + } + }; + cadence.min(until_rollover_secs) +} + +/// Seconds from `now` until the soonest cycle end, zero once it has passed. +fn until_rollover_secs(period_end_epoch_secs: i64, now_epoch_secs: i64) -> u64 { + u64::try_from(period_end_epoch_secs.saturating_sub(now_epoch_secs)).unwrap_or(0) +} + +impl Scheduler { + pub fn new(deps: Arc, admin_secret: String) -> Self { + Self { + deps, + admin_secret, + // Short timeout so a wedged run cannot pin the loop; the next tick retries. + http: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_default(), + } + } + + /// Start the loop. Wakes on a fixed tick; each merchant comes due on its own cadence. + pub fn spawn(self: Arc) { + let tick = Duration::from_secs(self.deps.config.tick_secs.max(1)); + + tokio::spawn(async move { + logger::info!( + tag = "volume_commitment", + "volume commitment scheduler started; wakes every {:?}, notifies {}", + tick, + self.deps.config.main_server_url + ); + let mut ticker = tokio::time::interval(tick); + // A slow pass should not queue up the wake-ups it missed and fire them back to back. + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + ticker.tick().await; + // Contain a panic so one bad pass cannot take the schedule down with it. + if std::panic::AssertUnwindSafe(self.tick_once()) + .catch_unwind() + .await + .is_err() + { + logger::error!( + tag = "volume_commitment", + "panic in scheduler pass; the loop continues" + ); + } + } + }); + } + + /// One pass: claim and notify every job that has come due. The lease lives in Redis for one + /// interval — capped at the merchant's cycle end, so it expires with the plan and the fresh + /// cycle is forecast on the next tick — and it doubles as the last-run record: a restart does + /// not make everyone due, and a second replica finds the interval already taken. + async fn tick_once(&self) { + for entry in self.due_now().await { + let ttl = entry + .every_secs + .min(until_rollover_secs( + entry.period_end_epoch_secs, + Utc::now().timestamp(), + )) + .max(1); + if !self + .deps + .state + .try_acquire_run_lease(&entry.merchant_id, ttl) + .await + { + continue; + } + if !self.notify(&entry.merchant_id).await { + // Give the interval back so the next tick retries rather than waiting it out. + self.deps.state.release_run_lease(&entry.merchant_id).await; + } + } + } + + /// Everything currently due. A never-run merchant is due immediately, so one added mid-cycle + /// gets a plan on the next tick. + async fn due_now(&self) -> Vec { + self.schedule() + .await + .into_iter() + .filter(|entry| entry.due_in_secs == 0) + .collect() + } + + /// Every merchant's cadence and how long until its next forecast fires. + pub async fn schedule(&self) -> Vec { + let now = Utc::now().timestamp(); + let mut entries = Vec::new(); + + for merchant_id in self.deps.inputs.list_active().await { + let Some(inputs) = self.deps.inputs.load(&merchant_id).await else { + continue; + }; + let every_secs = controller::interval_secs(&inputs, &self.deps.config); + // `load` recomputes cycle windows from "now", so right after a rollover this is + // already the new cycle's end — the boundary itself is enforced by the lease TTL. + let period_end_epoch_secs = inputs + .commitments + .iter() + .map(|c| c.period_end_ms / 1000) + .min() + .unwrap_or(i64::MAX); + let last_started_at = self.deps.state.last_run_started_at(&merchant_id).await; + entries.push(ScheduleEntry { + every_secs, + period_end_epoch_secs, + last_notified_at_epoch_secs: last_started_at, + due_in_secs: due_in_secs( + every_secs, + last_started_at, + now, + until_rollover_secs(period_end_epoch_secs, now), + ), + merchant_id, + }); + } + entries + } + + /// Tell the main server it is time. True only when the run happened: a rejected call, an + /// unreachable server, or a run that failed to measure all hand the lease back so the next + /// tick retries rather than waiting out a whole interval. + async fn notify(&self, merchant_id: &str) -> bool { + let url = format!( + "{}/volume-commitment/run-forecast", + self.deps.config.main_server_url.trim_end_matches('/'), + ); + + let request = self + .http + .post(&url) + .header("x-admin-secret", &self.admin_secret) + .query(&[("merchant_id", merchant_id)]); + match request.send().await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(report) => { + logger::info!( + tag = "volume_commitment", + merchant_id = merchant_id, + "forecast run done: processed={} skipped={} failed={}", + report.merchants_processed, + report.merchants_skipped, + report.merchants_failed, + ); + report.merchants_failed == 0 + } + // The run happened; only the reply was unreadable, so it still counts as done. + Err(error) => { + logger::warn!( + tag = "volume_commitment", + merchant_id = merchant_id, + "forecast run accepted but its reply could not be read: {error}" + ); + true + } + } + } + Ok(response) => { + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "forecast run rejected by the main server: HTTP {}", + response.status() + ); + false + } + Err(error) => { + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not reach the main server at {url} for a forecast run: {error}" + ); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{due_in_secs, until_rollover_secs}; + + /// No rollover in sight — cadence alone decides. + const FAR: u64 = u64::MAX; + + #[test] + fn a_merchant_never_run_is_due_now() { + assert_eq!(due_in_secs(3600, None, 1_000_000, FAR), 0); + } + + #[test] + fn a_fresh_lease_waits_out_the_rest_of_the_interval() { + assert_eq!(due_in_secs(3600, Some(1_000_000), 1_000_600, FAR), 3000); + } + + /// Past the interval the lease has expired anyway; the entry reads as due, never negative. + #[test] + fn an_old_lease_reads_as_due() { + assert_eq!(due_in_secs(60, Some(1_000_000), 1_000_600, FAR), 0); + } + + /// Clock skew between replicas cannot push a merchant into the future. + #[test] + fn a_lease_from_the_future_is_treated_as_just_taken() { + assert_eq!(due_in_secs(60, Some(1_000_100), 1_000_000, FAR), 60); + } + + /// The plan dies at cycle end, so due-ness is capped there: a lease with 3000 s of cadence + /// left still comes due at a rollover 100 s away. + #[test] + fn a_rollover_overrides_the_cadence() { + assert_eq!(due_in_secs(3600, Some(1_000_000), 1_000_600, 100), 100); + assert_eq!(due_in_secs(3600, Some(1_000_000), 1_000_600, 0), 0); + } + + #[test] + fn time_to_rollover_never_goes_negative() { + assert_eq!(until_rollover_secs(1_000_100, 1_000_000), 100); + assert_eq!(until_rollover_secs(1_000_000, 1_000_100), 0); + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/server.rs b/src/decider/gatewaydecider/volume_commitment/server.rs new file mode 100644 index 00000000..49e01fd5 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/server.rs @@ -0,0 +1,63 @@ +//! The scheduler's own axum listener (like the metrics server): `/health`, `/schedule`, and the loop. + +use std::sync::Arc; + +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use tokio::signal::unix::{signal, SignalKind}; + +use super::scheduler::{ScheduleEntry, Scheduler}; +use super::Deps; +use crate::logger; +use crate::metrics::ConfigurationError; + +/// Serve the scheduler's port, and run its loop, until SIGTERM. A no-op unless +/// `volume_commitment.enabled` — two schedulers would double every run. +pub async fn volume_commitment_server_builder( + deps: Arc, + admin_secret: String, +) -> Result<(), ConfigurationError> { + if !deps.config.enabled { + logger::info!( + tag = "volume_commitment", + "volume commitment scheduler disabled; not binding its port" + ); + return Ok(()); + } + + let bind_address = format!("{}:{}", deps.config.server.host, deps.config.server.port); + let listener = tokio::net::TcpListener::bind(&bind_address).await?; + logger::info!( + tag = "volume_commitment", + "volume commitment scheduler listening on {}", + bind_address + ); + + let scheduler = Arc::new(Scheduler::new(deps, admin_secret)); + Arc::clone(&scheduler).spawn(); + + let router = Router::new() + .route("/health", get(|| async { "ok" })) + .route("/schedule", get(schedule)) + .with_state(scheduler); + + let mut sigterm = signal(SignalKind::terminate())?; + + axum::serve(listener, router.into_make_service()) + .with_graceful_shutdown(async move { + let _ = sigterm.recv().await; + logger::info!( + tag = "volume_commitment", + "volume commitment scheduler shutting down gracefully" + ); + }) + .await?; + + Ok(()) +} + +/// `GET /schedule` — every merchant's cadence, last run, and time until the next one fires. +async fn schedule(State(scheduler): State>) -> Json> { + Json(scheduler.schedule().await) +} diff --git a/src/decider/gatewaydecider/volume_commitment/state.rs b/src/decider/gatewaydecider/volume_commitment/state.rs new file mode 100644 index 00000000..6d2df112 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/state.rs @@ -0,0 +1,187 @@ +//! Redis-backed store for the plan (with a short process-local cache) and the scheduler's +//! per-merchant run lease. The plan is derived state; the lease only says who runs next. + +use async_trait::async_trait; + +use super::plan::SteeringPlan; + +#[async_trait] +pub trait StateStore: Send + Sync { + /// The latest plan, or None before the background loop has produced one. + async fn load_plan(&self, merchant_id: &str) -> Option; + + async fn store_plan(&self, merchant_id: &str, plan: &SteeringPlan); + + /// Forget the plan everywhere: a deactivated or replaced contract must stop steering now, + /// not when the plan's TTL runs out. + async fn clear_plan(&self, merchant_id: &str); + + /// Claim this merchant's next forecast for `ttl_secs`. False when another replica already + /// holds it (or Redis cannot say), so K schedulers produce one run per interval, not K. + async fn try_acquire_run_lease(&self, merchant_id: &str, ttl_secs: u64) -> bool; + + /// Give a lease back after a run that did not happen, so the next tick retries. + async fn release_run_lease(&self, merchant_id: &str); + + /// Epoch seconds the live lease was taken at — when this merchant's last run started — or + /// None once it has expired or was released. + async fn last_run_started_at(&self, merchant_id: &str) -> Option; +} + +/// Local cache TTL; short so pods notice a new forecast quickly — freshness itself is enforced by +/// `stale_after_epoch_secs`, not by this. +const PLAN_CACHE_TTL_MS: u64 = 5_000; + +/// At most this many merchants' plans are held locally. Well above any realistic active-contract +/// count, so eviction is a safety valve rather than something the hot path meets. +const PLAN_CACHE_MAX: usize = 10_000; + +/// Process-local front for the Redis plans, holding misses too (`None`): a flag-on merchant with +/// no plan — every rollover gap — would otherwise cost one Redis GET per payment. `try_lock` +/// inside means contention costs a Redis read rather than blocking a payment. +static PLAN_CACHE: once_cell::sync::Lazy< + crate::redis::mem_cache::TypedCache>, +> = once_cell::sync::Lazy::new(|| { + crate::redis::mem_cache::TypedCache::new(PLAN_CACHE_TTL_MS, PLAN_CACHE_MAX) +}); + +fn plan_key(merchant_id: &str) -> String { + format!("vc_plan_{merchant_id}") +} + +fn lease_key(merchant_id: &str) -> String { + format!("vc_run_lease_{merchant_id}") +} + +/// Redis is the source of truth (survives restarts, shared by all pods); the local cache only +/// spares a round trip per payment. A plan is derived state, so losing it costs one forecast. +pub struct RedisStateStore; + +#[async_trait] +impl StateStore for RedisStateStore { + async fn load_plan(&self, merchant_id: &str) -> Option { + let key = plan_key(merchant_id); + if let Some(cached) = PLAN_CACHE.get(&key) { + return cached; + } + + let state = crate::app::get_tenant_app_state().await; + // A Redis failure is not cached: it is transient, and the next payment may get through. + let raw = state.redis_conn.get_key_string(&key).await.ok()?; + + // An absent key comes back as an empty string rather than an error. + let plan = if raw.is_empty() { + None + } else { + match serde_json::from_str::(&raw) { + Ok(plan) => Some(plan), + Err(error) => { + // A plan written by an incompatible build. Steering simply stops until the + // next forecast overwrites it, which is the safe direction. + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not parse the stored plan: {error}" + ); + None + } + } + }; + // Misses are cached as well, so the absent-plan case stays off Redis for the TTL. + PLAN_CACHE.store(key, plan.clone()); + plan + } + + async fn store_plan(&self, merchant_id: &str, plan: &SteeringPlan) { + let key = plan_key(merchant_id); + + // TTL = time until the nudge would refuse the plan anyway, floored at 1s. + let ttl_secs = (plan.stale_after_epoch_secs - chrono::Utc::now().timestamp()).max(1); + + let state = crate::app::get_tenant_app_state().await; + match serde_json::to_string(plan) { + Ok(raw) => { + if let Err(error) = state.redis_conn.set_key_with_ttl(&key, raw, ttl_secs).await { + // Not cached locally on failure: a plan Redis never saw must not steer on one pod only. + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not store the plan in redis: {error:?}" + ); + return; + } + } + Err(error) => { + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not serialize the plan: {error}" + ); + return; + } + } + + // Populate locally only after Redis accepted it, so the cache can never be ahead of truth. + PLAN_CACHE.store(key, Some(plan.clone())); + } + + async fn clear_plan(&self, merchant_id: &str) { + let key = plan_key(merchant_id); + // This pod stops at once; the others notice within the local cache TTL. + PLAN_CACHE.store(key.clone(), None); + + let state = crate::app::get_tenant_app_state().await; + if let Err(error) = state.redis_conn.delete_key(&key).await { + // Loud: until this succeeds, other pods keep steering on a contract that is gone. + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not delete the stored plan from redis: {error:?}" + ); + } + } + + async fn try_acquire_run_lease(&self, merchant_id: &str, ttl_secs: u64) -> bool { + let state = crate::app::get_tenant_app_state().await; + let started_at = chrono::Utc::now().timestamp().to_string(); + let ttl = i64::try_from(ttl_secs.max(1)).unwrap_or(i64::MAX); + match state + .redis_conn + .set_key_if_not_exists(&lease_key(merchant_id), &started_at, ttl) + .await + { + Ok(acquired) => acquired, + Err(error) => { + // Without Redis nobody can tell who holds the lease; running anyway would put + // every replica to work at once, so no one runs. + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not take the forecast lease in redis: {error:?}" + ); + false + } + } + } + + async fn release_run_lease(&self, merchant_id: &str) { + let state = crate::app::get_tenant_app_state().await; + if let Err(error) = state.redis_conn.delete_key(&lease_key(merchant_id)).await { + crate::logger::warn!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not release the forecast lease; the merchant waits out the interval: {error:?}" + ); + } + } + + async fn last_run_started_at(&self, merchant_id: &str) -> Option { + let state = crate::app::get_tenant_app_state().await; + let raw = state + .redis_conn + .get_key_string(&lease_key(merchant_id)) + .await + .ok()?; + raw.parse().ok() + } +} diff --git a/src/decider/gatewaydecider/volume_commitment/volume.rs b/src/decider/gatewaydecider/volume_commitment/volume.rs new file mode 100644 index 00000000..f196b427 --- /dev/null +++ b/src/decider/gatewaydecider/volume_commitment/volume.rs @@ -0,0 +1,749 @@ +//! What each PSP was actually *delivered*, read from the traffic — deliberately separate from +//! what the contract *promised*. + +use std::collections::HashMap; + +use async_trait::async_trait; +use clickhouse::{Client, Row}; +use masking::PeekInterface; +use serde::Deserialize; + +use super::inputs::{Commitment, MeasuredVolume}; +use super::math; +use crate::analytics::clickhouse::common::{ + fetch_all, DOMAIN_TABLE, PAYMENT_AMOUNT_EXPR as AMOUNT_EXPR, +}; +use crate::analytics::clickhouse::query::{BoundQueryBuilder, FilterClause, OrderClause}; +use crate::analytics::flow::FlowType; +use crate::config::ClickHouseAnalyticsConfig; +use crate::decider::gatewaydecider::types::GatewayDeciderApproach; +use crate::logger; + +/// Approach stamped on nudged decisions; steered volume counts toward the goal, not toward what +/// routing provides unaided. +static STEERED_APPROACH: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| GatewayDeciderApproach::SrSelectionVolumeCommitment.to_string()); + +/// SQL predicates for steered / unaided decide events. +static STEERED_PRED: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| format!("routing_approach = '{}'", *STEERED_APPROACH)); +static UNAIDED_PRED: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + format!( + "(routing_approach IS NULL OR routing_approach != '{}')", + *STEERED_APPROACH + ) +}); + +/// The filters every query here starts from: one merchant, one flow type. +fn base_filters(builder: &mut BoundQueryBuilder, merchant_id: &str, flow: FlowType) { + builder.add_filter(FilterClause::eq("merchant_id", merchant_id.to_string())); + builder.add_filter(FilterClause::raw(format!( + "flow_type = '{}'", + flow.as_str() + ))); +} + +/// Restrict to the contract's connectors; a no-op on an empty list (callers never pass one). +fn connector_filter(builder: &mut BoundQueryBuilder, connectors: &[String]) { + if let Some(filter) = FilterClause::in_list("gateway", connectors) { + builder.add_filter(filter); + } +} + +/// One PSP's volume on one day of its cycle, for the pacing chart. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DayVolume { + pub connector: String, + /// Days since the PSP's cycle started (0 = the first day). + pub day_index: u32, + /// Where in the cycle this bucket starts, in (fractional) contract days — `day_index` with + /// the sub-day resolution the caller asked for, so an intraday chart can plot it. + pub day: f64, + /// Everything delivered that day. + pub total: f64, + /// Of that, what the nudge moved. + pub steered: f64, + /// Payments behind `total`. + pub payments: u64, + /// Payments behind `steered`. + pub steered_payments: u64, +} + +/// What kind of audit entry an event is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditKind { + Forecast, + Steered, + Eliminated, +} + +/// One entry in the audit trail, reconstructed from the analytics events in ClickHouse — so it +/// covers real payments and survives restarts. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditEvent { + pub at_epoch_ms: i64, + pub kind: AuditKind, + /// The contract execution this entry belongs to. `None` on events written before runs were + /// named, which group under an "earlier activity" bucket rather than being dropped. + #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub connector: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, +} + +/// Per-PSP window totals: `steered_*` moved *to* it by the nudge, `ceded_*` moved *away* from it. +#[derive(Debug, Clone, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowTotals { + pub connector: String, + pub payments: u64, + pub volume: f64, + pub steered_payments: u64, + pub steered_volume: f64, + pub ceded_payments: u64, + pub ceded_volume: f64, +} + +/// Why delivered volume could not be measured. Distinct from "nothing was delivered": a plan +/// built on an empty measurement would read every PSP as owing its whole goal. +#[derive(Debug, thiserror::Error)] +pub enum VolumeError { + #[error("no volume source is configured (clickhouse analytics is disabled)")] + Unavailable, + #[error("could not read routed volume from clickhouse: {0}")] + Read(String), +} + +/// Where observed traffic is read from. +#[async_trait] +pub trait VolumeSource: Send + Sync { + /// What each PSP in `commitments` has been sent. Connectors with no traffic are simply absent, + /// which the controller reads as zero; a source that cannot answer says so instead. + async fn measure( + &self, + merchant_id: &str, + commitments: &[Commitment], + pace_window_days: u32, + ) -> Result; + + /// Volume for each PSP since its cycle started, in `per_day` buckets per contract day (1 = + /// whole days), ordered by `day`. Empty when nothing can be measured. + async fn daily_series( + &self, + merchant_id: &str, + commitments: &[Commitment], + per_day: u32, + ) -> Vec; + + /// The audit trail, newest first: forecasts and eliminations from the controller's events, + /// steer chunks from the decide events themselves. + async fn audit_events(&self, merchant_id: &str, limit: u64) -> Vec; + + /// Per-connector totals in `[start_ms, end_ms)`, split into unaided / steered-in / ceded; + /// absent when no traffic. + async fn window_totals( + &self, + merchant_id: &str, + connectors: &[String], + start_ms: i64, + end_ms: i64, + ) -> Vec; +} + +/// Reads routed volume out of the analytics events in ClickHouse. +pub struct ClickHouseVolumeSource { + client: Client, +} + +impl ClickHouseVolumeSource { + /// Build a client against the analytics ClickHouse; no probe — an unreachable ClickHouse + /// logs and measures nothing rather than failing startup. + pub fn new(config: &ClickHouseAnalyticsConfig) -> Self { + let mut client = Client::default() + .with_url(config.url.clone()) + .with_database(config.database.clone()) + .with_user(config.user.clone()); + if let Some(password) = &config.password { + client = client.with_password(password.peek().clone()); + } + Self { client } + } + + /// One aggregate per (cycle start, timezone) group — usually a single round trip, since PSP + /// contracts tend to share a cycle. Window boundaries are midnights in the contract's zone. + async fn measure_group( + &self, + merchant_id: &str, + connectors: &[String], + cycle_start_ms: i64, + day_secs: u64, + pace_window_days: u32, + into: &mut MeasuredVolume, + ) -> Result<(), VolumeError> { + let now_ms = chrono::Utc::now().timestamp_millis(); + let day_ms = math::day_ms(day_secs); + let pace_days = i64::from(pace_window_days.max(1)); + // The pace window is the recent slice of the cycle, never wider than the cycle itself — + // averaging over days before the cycle began would understate a young commitment's rate. + let start_of_today_ms = + cycle_start_ms + math::day_index(cycle_start_ms, now_ms, day_secs) * day_ms; + let pace_start_ms = start_of_today_ms + .saturating_sub(pace_days.saturating_sub(1) * day_ms) + .max(cycle_start_ms); + let unaided = &*UNAIDED_PRED; + let steered = &*STEERED_PRED; + + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects([ + "gateway".to_string(), + format!("sum({AMOUNT_EXPR}) AS achieved"), + format!("sumIf({AMOUNT_EXPR}, created_at_ms >= {pace_start_ms}) AS pace_total"), + format!( + "sumIf({AMOUNT_EXPR}, created_at_ms >= {pace_start_ms} AND {unaided}) \ + AS unaided_total" + ), + // Today's running steered total, measured from the start of the contract day. + format!( + "sumIf({AMOUNT_EXPR}, created_at_ms >= {start_of_today_ms} AND {steered}) \ + AS steered_today" + ), + ]); + base_filters(&mut builder, merchant_id, FlowType::DecideGatewayDecision); + builder.add_filter(FilterClause::raw(format!( + "created_at_ms >= {cycle_start_ms}" + ))); + connector_filter(&mut builder, connectors); + builder.extend_group_bys(["gateway"]); + + // Not zeroes: an unreadable ClickHouse must not read as "nothing delivered". + let rows = fetch_all::(builder.build(&self.client)) + .await + .map_err(|error| VolumeError::Read(format!("{error:?}")))?; + + // Divide by the days actually queried — a fixed 7 would understate the rate early in a + // cycle and mid-day, and an understated routing rate reads as a phantom shortfall. + let window_days = elapsed_window_days(now_ms, pace_start_ms, pace_days, day_ms); + for row in rows { + let Some(gateway) = row.gateway else { continue }; + into.achieved + .insert(gateway.clone(), nan_to_zero(row.achieved)); + into.pace + .insert(gateway.clone(), nan_to_zero(row.pace_total) / window_days); + into.routing_gives_daily.insert( + gateway.clone(), + nan_to_zero(row.unaided_total) / window_days, + ); + // Not divided: this is today's running total, not a rate. + into.steered_today + .insert(gateway, nan_to_zero(row.steered_today)); + } + Ok(()) + } +} + +#[async_trait] +impl VolumeSource for ClickHouseVolumeSource { + async fn measure( + &self, + merchant_id: &str, + commitments: &[Commitment], + pace_window_days: u32, + ) -> Result { + let mut measured = MeasuredVolume::default(); + + let mut by_cycle: HashMap<(i64, u64), Vec> = HashMap::new(); + for commitment in commitments { + by_cycle + .entry((commitment.period_start_ms, commitment.day_secs)) + .or_default() + .push(commitment.connector.clone()); + } + + for ((cycle_start_ms, day_secs), connectors) in by_cycle { + self.measure_group( + merchant_id, + &connectors, + cycle_start_ms, + day_secs, + pace_window_days, + &mut measured, + ) + .await?; + } + Ok(measured) + } + + async fn daily_series( + &self, + merchant_id: &str, + commitments: &[Commitment], + per_day: u32, + ) -> Vec { + let mut series = Vec::new(); + let per_day = i64::from(per_day.max(1)); + + let mut by_cycle: HashMap<(i64, i64, u64), Vec> = HashMap::new(); + for commitment in commitments { + by_cycle + .entry(( + commitment.period_start_ms, + commitment.period_end_ms, + commitment.day_secs, + )) + .or_default() + .push(commitment.connector.clone()); + } + + for ((cycle_start_ms, cycle_end_ms, day_secs), connectors) in by_cycle { + let day_ms = math::day_ms(day_secs); + // Buckets of a contract day (or a slice of one); the bucket index is turned back into + // whole days and a fractional position below. + let bucket_ms = (day_ms / per_day).max(1); + let steered = &*STEERED_PRED; + + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects([ + "gateway".to_string(), + format!( + "toInt64(intDiv(created_at_ms - {cycle_start_ms}, {bucket_ms})) AS day_index" + ), + format!("sum({AMOUNT_EXPR}) AS total"), + format!("sumIf({AMOUNT_EXPR}, {steered}) AS steered"), + "toUInt64(count()) AS payments".to_string(), + format!("toUInt64(countIf({steered})) AS steered_payments"), + ]); + base_filters(&mut builder, merchant_id, FlowType::DecideGatewayDecision); + builder.add_filter(FilterClause::raw(format!( + "created_at_ms >= {cycle_start_ms}" + ))); + // Bounded above too, or later cycles' traffic lands in a past cycle's last bucket. + builder.add_filter(FilterClause::raw(format!("created_at_ms < {cycle_end_ms}"))); + connector_filter(&mut builder, &connectors); + builder.extend_group_bys(["gateway", "day_index"]); + + match fetch_all::(builder.build(&self.client)).await { + Ok(rows) => { + for row in rows { + let Some(gateway) = row.gateway else { continue }; + let Ok(day_index) = u32::try_from(row.day_index / per_day) else { + continue; + }; + series.push(DayVolume { + connector: gateway, + day_index, + day: row.day_index as f64 / per_day as f64, + total: nan_to_zero(row.total), + steered: nan_to_zero(row.steered), + payments: row.payments, + steered_payments: row.steered_payments, + }); + } + } + Err(error) => { + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not read the daily volume series from clickhouse: {error:?}" + ); + } + } + } + series.sort_by(|a, b| a.day.total_cmp(&b.day)); + series + } + + async fn audit_events(&self, merchant_id: &str, limit: u64) -> Vec { + let mut events = Vec::new(); + + // Forecast runs (which carry the eliminations) — one event per controller run. + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects(["created_at_ms".to_string(), "details".to_string()]); + base_filters( + &mut builder, + merchant_id, + FlowType::VolumeCommitmentForecast, + ); + builder.add_order_by(OrderClause::desc("created_at_ms")); + builder.set_limit(Some(limit)); + match fetch_all::(builder.build(&self.client)).await { + Ok(rows) => { + for row in rows { + events.extend(forecast_row_to_events(&row)); + } + } + Err(error) => logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not read forecast audit events from clickhouse: {error:?}" + ), + } + + // Steer chunks — the decide events the nudge diverted, with the reason it recorded. + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects([ + "created_at_ms".to_string(), + "gateway".to_string(), + format!("{AMOUNT_EXPR} AS amount"), + "JSONExtractString(assumeNotNull(details), 'response', 'volume_steer_info', 'reason') \ + AS reason" + .to_string(), + "JSONExtractString(assumeNotNull(details), 'response', 'volume_steer_info', 'runId') \ + AS run_id" + .to_string(), + ]); + base_filters(&mut builder, merchant_id, FlowType::DecideGatewayDecision); + builder.add_filter(FilterClause::raw(STEERED_PRED.clone())); + builder.add_order_by(OrderClause::desc("created_at_ms")); + builder.set_limit(Some(limit)); + match fetch_all::(builder.build(&self.client)).await { + Ok(rows) => { + for row in rows { + events.push(AuditEvent { + at_epoch_ms: row.created_at_ms, + kind: AuditKind::Steered, + run_id: (!row.run_id.is_empty()).then(|| row.run_id.clone()), + message: if row.reason.is_empty() { + format!( + "Steered a payment of {:.0} to {}.", + row.amount, + row.gateway.as_deref().unwrap_or("?") + ) + } else { + row.reason + }, + connector: row.gateway, + amount: Some(row.amount), + }); + } + } + Err(error) => logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not read steer audit events from clickhouse: {error:?}" + ), + } + + events.sort_by_key(|e| std::cmp::Reverse(e.at_epoch_ms)); + events.truncate(usize::try_from(limit).unwrap_or(usize::MAX)); + events + } + + async fn window_totals( + &self, + merchant_id: &str, + connectors: &[String], + start_ms: i64, + end_ms: i64, + ) -> Vec { + if connectors.is_empty() || end_ms <= start_ms { + return Vec::new(); + } + let steered = &*STEERED_PRED; + let window_filters = |builder: &mut BoundQueryBuilder| { + base_filters(builder, merchant_id, FlowType::DecideGatewayDecision); + builder.add_filter(FilterClause::raw(format!("created_at_ms >= {start_ms}"))); + builder.add_filter(FilterClause::raw(format!("created_at_ms < {end_ms}"))); + }; + + let mut by_connector: HashMap = HashMap::new(); + + // What landed on each PSP, and how much of it the nudge put there. + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects([ + "gateway".to_string(), + "toUInt64(count()) AS payments".to_string(), + format!("sum({AMOUNT_EXPR}) AS volume"), + format!("toUInt64(countIf({steered})) AS steered_payments"), + format!("sumIf({AMOUNT_EXPR}, {steered}) AS steered_volume"), + ]); + window_filters(&mut builder); + connector_filter(&mut builder, connectors); + builder.extend_group_bys(["gateway"]); + match fetch_all::(builder.build(&self.client)).await { + Ok(rows) => { + for row in rows { + let Some(gateway) = row.gateway else { continue }; + let entry = by_connector.entry(gateway.clone()).or_default(); + entry.connector = gateway; + entry.payments = row.payments; + entry.volume = nan_to_zero(row.volume); + entry.steered_payments = row.steered_payments; + entry.steered_volume = nan_to_zero(row.steered_volume); + } + } + Err(error) => { + logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not read window totals from clickhouse: {error:?}" + ); + return Vec::new(); + } + } + + // What each PSP gave up: steered decisions name the PSP routing had picked (`srHead`). + let mut builder = BoundQueryBuilder::new(DOMAIN_TABLE); + builder.extend_selects([ + "JSONExtractString(assumeNotNull(details), 'response', 'volume_steer_info', 'srHead') \ + AS sr_head" + .to_string(), + "toUInt64(count()) AS payments".to_string(), + format!("sum({AMOUNT_EXPR}) AS volume"), + ]); + window_filters(&mut builder); + builder.add_filter(FilterClause::raw(steered.clone())); + builder.extend_group_bys(["sr_head"]); + match fetch_all::(builder.build(&self.client)).await { + Ok(rows) => { + for row in rows { + if row.sr_head.is_empty() || !connectors.contains(&row.sr_head) { + continue; + } + let entry = by_connector.entry(row.sr_head.clone()).or_default(); + entry.connector = row.sr_head; + entry.ceded_payments = row.payments; + entry.ceded_volume = nan_to_zero(row.volume); + } + } + Err(error) => logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id, + "could not read ceded volume from clickhouse: {error:?}" + ), + } + + let mut totals: Vec = by_connector.into_values().collect(); + totals.sort_by(|a, b| a.connector.cmp(&b.connector)); + totals + } +} + +/// Float aggregates are non-nullable and can come back NaN/inf; every consumer wants zero. +fn nan_to_zero(value: f64) -> f64 { + if value.is_finite() { + value + } else { + 0.0 + } +} + +/// One stored forecast event into audit entries: the run itself, then one entry per elimination. +fn forecast_row_to_events(row: &ForecastEventRow) -> Vec { + let details: serde_json::Value = row + .details + .as_deref() + .and_then(|d| serde_json::from_str(d).ok()) + .unwrap_or_default(); + let tracked = details["tracked"].as_u64().unwrap_or(0); + let steering: Vec<&str> = details["steering"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let run_id = details["runId"].as_str().map(str::to_string); + + let mut events = vec![AuditEvent { + at_epoch_ms: row.created_at_ms, + kind: AuditKind::Forecast, + run_id: run_id.clone(), + connector: None, + message: if steering.is_empty() { + format!("Forecast: {tracked} commitment(s) tracked, all on pace — nothing to steer.") + } else { + format!( + "Forecast: {tracked} commitment(s) tracked; {} behind pace and steering ({}).", + steering.len(), + steering.join(", ") + ) + }, + amount: None, + }]; + + for dropped in details["dropped"].as_array().into_iter().flatten() { + let connector = dropped["connector"].as_str().unwrap_or("?"); + events.push(AuditEvent { + at_epoch_ms: row.created_at_ms, + kind: AuditKind::Eliminated, + run_id: run_id.clone(), + connector: Some(connector.to_string()), + message: format!( + "{connector} eliminated: {}", + dropped["reason"].as_str().unwrap_or("no reason recorded") + ), + amount: None, + }); + } + events +} + +/// One row of the forecast audit query. +#[derive(Debug, Deserialize, Row)] +struct ForecastEventRow { + created_at_ms: i64, + details: Option, +} + +/// One row of the steer audit query. +#[derive(Debug, Deserialize, Row)] +struct SteerEventRow { + created_at_ms: i64, + gateway: Option, + amount: f64, + reason: String, + run_id: String, +} + +/// One row of the daily series query. +#[derive(Debug, Deserialize, Row)] +struct DayVolumeRow { + gateway: Option, + day_index: i64, + total: f64, + steered: f64, + payments: u64, + steered_payments: u64, +} + +/// `sum`/`sumIf` over `JSONExtractFloat` are non-nullable Float64 (NaN when nothing matched), so +/// these must be `f64` — `Option` fails to decode and takes the whole query down. +#[derive(Debug, Deserialize, Row)] +struct VolumeRow { + gateway: Option, + achieved: f64, + pace_total: f64, + unaided_total: f64, + steered_today: f64, +} + +/// One row of the window-totals query. +#[derive(Debug, Deserialize, Row)] +struct WindowRow { + gateway: Option, + payments: u64, + volume: f64, + steered_payments: u64, + steered_volume: f64, +} + +/// One row of the ceded-volume query: what a PSP lost to steering, keyed by routing's own pick. +#[derive(Debug, Deserialize, Row)] +struct CededRow { + sr_head: String, + payments: u64, + volume: f64, +} + +/// Contract days the query covered, floored at one (sub-day rates are noise) and capped at the pace window. +fn elapsed_window_days(now_ms: i64, window_start_ms: i64, pace_days: i64, day_ms: i64) -> f64 { + ((now_ms.saturating_sub(window_start_ms)) as f64 / day_ms.max(1) as f64) + .clamp(1.0, pace_days as f64) +} + +/// Serves volume handed to it up front; stands in for ClickHouse when analytics is off. A +/// merchant it was given nothing for is *unmeasurable*, not at zero — no plan is built from it. +pub struct FixtureVolumeSource { + merchants: HashMap, +} + +impl FixtureVolumeSource { + pub fn new(merchants: HashMap) -> Self { + Self { merchants } + } +} + +#[async_trait] +impl VolumeSource for FixtureVolumeSource { + async fn measure( + &self, + merchant_id: &str, + _commitments: &[Commitment], + _pace_window_days: u32, + ) -> Result { + self.merchants + .get(merchant_id) + .cloned() + .ok_or(VolumeError::Unavailable) + } + + async fn daily_series( + &self, + _merchant_id: &str, + _commitments: &[Commitment], + _per_day: u32, + ) -> Vec { + Vec::new() + } + + async fn audit_events(&self, _merchant_id: &str, _limit: u64) -> Vec { + Vec::new() + } + + async fn window_totals( + &self, + _merchant_id: &str, + _connectors: &[String], + _start_ms: i64, + _end_ms: i64, + ) -> Vec { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY_MS: i64 = math::SECS_PER_DAY as i64 * 1000; + + /// Mid-cycle at noon: six full days plus half of today. + #[test] + fn a_mature_window_divides_by_the_days_actually_covered() { + let now = 6 * DAY_MS + DAY_MS / 2; + assert!((elapsed_window_days(now, 0, 7, DAY_MS) - 6.5).abs() < 1e-9); + } + + /// A cycle two days old must not divide by seven — that understated the rate ~3.5x. + #[test] + fn a_young_cycle_divides_by_its_own_age() { + let now = 2 * DAY_MS; + assert!((elapsed_window_days(now, 0, 7, DAY_MS) - 2.0).abs() < 1e-9); + } + + #[test] + fn the_window_never_shrinks_below_a_day_or_grows_past_the_pace_window() { + assert_eq!(elapsed_window_days(DAY_MS / 4, 0, 7, DAY_MS), 1.0); + assert_eq!(elapsed_window_days(30 * DAY_MS, 0, 7, DAY_MS), 7.0); + } + + /// Compressed days divide by the compressed day length, not the calendar one. + #[test] + fn a_simulated_window_divides_by_virtual_days() { + let day_ms = 120_000; // 120s contract days + assert!((elapsed_window_days(3 * day_ms, 0, 7, day_ms) - 3.0).abs() < 1e-9); + } + + /// With analytics off the fixture stands in for ClickHouse; it must refuse rather than + /// report zero delivery, or every PSP would be steered at its maximum rate. + #[tokio::test] + async fn the_fixture_refuses_a_merchant_it_has_no_volume_for() { + let mut known = MeasuredVolume::default(); + known.achieved.insert("stripe".to_string(), 42.0); + let source = FixtureVolumeSource::new(HashMap::from([("m1".to_string(), known)])); + + let measured = source + .measure("m1", &[], 7) + .await + .expect("fixture merchant"); + assert_eq!(measured.achieved_for("stripe"), 42.0); + assert!(matches!( + source.measure("m2", &[], 7).await, + Err(VolumeError::Unavailable) + )); + } +} diff --git a/src/decider/network_decider/debit_routing.rs b/src/decider/network_decider/debit_routing.rs index d6be9875..b72dba19 100644 --- a/src/decider/network_decider/debit_routing.rs +++ b/src/decider/network_decider/debit_routing.rs @@ -51,6 +51,7 @@ pub async fn perform_debit_routing( is_rust_based_decider: true, latency: None, multi_objective_info: None, + volume_steer_info: None, }); } } diff --git a/src/euclid/handlers/routing_rules.rs b/src/euclid/handlers/routing_rules.rs index 93613c83..72a3f368 100644 --- a/src/euclid/handlers/routing_rules.rs +++ b/src/euclid/handlers/routing_rules.rs @@ -537,32 +537,23 @@ async fn fetch_algorithm_from_db_and_cache( #[cfg(feature = "postgres")] use crate::storage::schema_pg::routing_algorithm_mapper::dsl as db_mapper_dsl; - let mapper_result = match algorithm_for { - Some(algorithm_for) => { - crate::generics::generic_find_one::< - ::Table, - _, - RoutingAlgorithmMapper, - >( - &state.db, - db_mapper_dsl::created_by - .eq(merchant_id.to_string()) - .and(db_mapper_dsl::algorithm_for.eq(algorithm_for.to_string())), - ) - .await - } - None => { - crate::generics::generic_find_one::< - ::Table, - _, - RoutingAlgorithmMapper, - >( - &state.db, - db_mapper_dsl::created_by.eq(merchant_id.to_string()), - ) - .await - } - }; + // The merchant can hold one mapper row per algorithm_for slot (payment, payout, 3DS, + // volume_commitment). Callers that omit `algorithm_for` are on the legacy payment flow and + // must only ever see the payment row. + let mapper_algorithm_for = algorithm_for + .map(str::to_string) + .unwrap_or_else(|| AlgorithmType::Payment.to_string()); + let mapper_result = crate::generics::generic_find_one::< + ::Table, + _, + RoutingAlgorithmMapper, + >( + &state.db, + db_mapper_dsl::created_by + .eq(merchant_id.to_string()) + .and(db_mapper_dsl::algorithm_for.eq(mapper_algorithm_for)), + ) + .await; // Only a missing mapper row means no rule is active. Any other storage error is the engine // failing to answer, and reporting it as ActiveRoutingAlgorithmNotFound would let the @@ -1555,6 +1546,61 @@ use crate::storage::schema::routing_algorithm_mapper::dsl as mapper_dsl; #[cfg(feature = "postgres")] use crate::storage::schema_pg::routing_algorithm_mapper::dsl as mapper_dsl; +/// Bump `modified_at` on activation of a volume contract — it anchors `test_minutes` cycles (see +/// `volume_commitment::dsl::active_config`); other rule types keep it as a plain edit timestamp. +async fn stamp_contract_activation( + #[cfg(feature = "mysql")] conn: &crate::storage::MysqlPoolConn, + #[cfg(feature = "postgres")] conn: &crate::storage::PgPoolConn, + algorithm: &RoutingAlgorithm, +) -> Result<(), ContainerError> { + if algorithm.algorithm_for != AlgorithmType::VolumeCommitment.to_string() { + return Ok(()); + } + let now = time::OffsetDateTime::now_utc(); + let timestamp = time::PrimitiveDateTime::new(now.date(), now.time()); + crate::generics::generic_update::<::Table, _, _>( + conn, + dsl::id.eq(algorithm.id.clone()), + dsl::modified_at.eq(timestamp), + ) + .await + .change_context(EuclidErrors::StorageError)?; + Ok(()) +} + +/// Drop the stored steering plan when a volume contract is deactivated or replaced. The decide +/// path steers on flag + plan alone, so a plan left behind would keep diverting payments (for up +/// to its TTL) on a contract every dashboard says is gone. Awaited: the caller's 200 means it. +async fn clear_volume_commitment_plan(algorithm_for: &str, merchant_id: &str) { + if algorithm_for != AlgorithmType::VolumeCommitment.to_string() { + return; + } + if let Some(deps) = crate::decider::gatewaydecider::volume_commitment::deps() { + deps.state.clear_plan(merchant_id).await; + } +} + +/// Rebuild the plan now (spawned, so activation does not wait on ClickHouse) so a newly activated +/// contract does not inherit the previous plan's verdicts until the next scheduler tick. +fn refresh_volume_commitment_plan(algorithm_for: &str, merchant_id: &str) { + if algorithm_for != AlgorithmType::VolumeCommitment.to_string() { + return; + } + let merchant_id = merchant_id.to_string(); + tokio::spawn(async move { + let Some(deps) = crate::decider::gatewaydecider::volume_commitment::deps() else { + return; + }; + // A failure is logged where it happens; the merchant simply has no plan until the + // scheduler's next successful run. + let _ = crate::decider::gatewaydecider::volume_commitment::controller::run_for_merchant( + deps, + &merchant_id, + ) + .await; + }); +} + pub async fn activate_routing_rule( Json(payload): Json, ) -> Result<(), ContainerError> { @@ -1641,7 +1687,16 @@ pub async fn activate_routing_rule( .change_context(EuclidErrors::StorageError) { Ok(_) => { + if let Err(e) = stamp_contract_activation(&conn, &algorithm).await { + update_failure_metrics(); + timer.observe_duration(); + return Err(e); + } cache_routing_algorithm(&state, &payload.created_by, &algorithm).await; + // The old document's plan must not steer for the new one. + clear_volume_commitment_plan(&algorithm.algorithm_for, &payload.created_by) + .await; + refresh_volume_commitment_plan(&algorithm.algorithm_for, &payload.created_by); API_REQUEST_COUNTER .with_label_values(&["activate_routing_rule", "success"]) .inc(); @@ -1657,6 +1712,7 @@ pub async fn activate_routing_rule( } // Already active with the same algorithm — refresh the cache TTL cache_routing_algorithm(&state, &payload.created_by, &algorithm).await; + refresh_volume_commitment_plan(&algorithm.algorithm_for, &payload.created_by); API_REQUEST_COUNTER .with_label_values(&["activate_routing_rule", "success"]) .inc(); @@ -1677,7 +1733,14 @@ pub async fn activate_routing_rule( .change_context(EuclidErrors::StorageError) { Ok(_) => { + if let Err(e) = stamp_contract_activation(&conn, &algorithm).await { + update_failure_metrics(); + timer.observe_duration(); + return Err(e); + } cache_routing_algorithm(&state, &merchant_id_for_cache, &algorithm).await; + clear_volume_commitment_plan(&algorithm.algorithm_for, &merchant_id_for_cache).await; + refresh_volume_commitment_plan(&algorithm.algorithm_for, &merchant_id_for_cache); API_REQUEST_COUNTER .with_label_values(&["activate_routing_rule", "success"]) .inc(); @@ -1773,6 +1836,7 @@ pub async fn deactivate_routing_rule( payload.created_by ); invalidate_routing_algorithm_cache(&state, &payload.created_by).await; + clear_volume_commitment_plan(&algorithm_for, &payload.created_by).await; API_REQUEST_COUNTER .with_label_values(&["deactivate_routing_rule", "success"]) .inc(); diff --git a/src/euclid/volume_contract.rs b/src/euclid/volume_contract.rs index 7a3149ef..bcee2dc7 100644 --- a/src/euclid/volume_contract.rs +++ b/src/euclid/volume_contract.rs @@ -50,7 +50,9 @@ const MAX_TIERS: usize = 20; const MAX_TOLERANCE_BPS: u16 = 2000; const MAX_RATE_BPS: u32 = 10_000; const MAX_REBATE_LAG_DAYS: u16 = 365; -const MIN_INTERVAL_SECS: u32 = 60; +/// Low enough that a `test_minutes` cycle can still forecast and release in chunks; production +/// contracts simply set sane values. +const MIN_INTERVAL_SECS: u32 = 5; const MAX_INTERVAL_SECS: u32 = 604_800; // one week // ── Document root ───────────────────────────────────────────────────────────── @@ -240,7 +242,8 @@ pub struct BillingCycle { #[serde(rename = "type")] pub cycle_type: BillingCycleType, /// `calendar_month`: day-of-month 1–30; `calendar_quarter`: month-in-quarter 1–3; - /// `calendar_year`: start month 1–12. Range-validated per cycle type on write. + /// `calendar_year`: start month 1–12; `test_minutes`: cycle length in minutes 2–240. + /// Range-validated per cycle type on write. pub anchor: u8, /// IANA zone name, e.g. `"America/New_York"`. Validated against the tz database on write. pub timezone: String, @@ -255,6 +258,11 @@ pub enum BillingCycleType { CalendarMonth, CalendarQuarter, CalendarYear, + /// TESTING: the cycle lasts `anchor` minutes and repeats from the contract's activation + /// instant (its stamped anchor), so a fresh contract always plays out a whole period while + /// you watch. Each minute counts as one contract "day", so pacing, elimination and steering + /// behave exactly as on a calendar cycle — only faster. + TestMinutes, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -832,6 +840,8 @@ fn validate_billing_cycle( BillingCycleType::CalendarMonth => 1..=30u8, BillingCycleType::CalendarQuarter => 1..=3u8, BillingCycleType::CalendarYear => 1..=12u8, + // At least two minutes, so the cycle spans more than a single contract day to pace across. + BillingCycleType::TestMinutes => 2..=240u8, }; if !anchor_range.contains(&cycle.anchor) { errors.push(ValidationErrorDetails::new( diff --git a/src/redis/commands.rs b/src/redis/commands.rs index d17f5d99..0535129f 100644 --- a/src/redis/commands.rs +++ b/src/redis/commands.rs @@ -545,6 +545,23 @@ impl RedisConnectionWrapper { .change_context(errors::RedisError::IncrementHashFieldFailed) } + /// `INCRBYFLOAT` — atomically add `delta` and return the resulting total. + /// + /// The returned total is what makes this usable as a shared cap: subtracting `delta` from it + /// gives the value the caller would have observed had it held a lock, so concurrent callers + /// each see a distinct "before" and only one can be the one that crossed the line. + pub async fn increment_key_by_float( + &self, + key: &str, + delta: f64, + ) -> Result { + self.conn + .pool + .incr_by_float(key, delta) + .await + .change_context(errors::RedisError::IncrementHashFieldFailed) + } + pub async fn decrement_key(&self, key: &str) -> Result { self.conn .pool diff --git a/src/routes.rs b/src/routes.rs index 310618a4..70202fd8 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -22,3 +22,4 @@ pub mod seed_costs; pub mod settlement_webhook; pub mod update_gateway_score; pub mod update_score; +pub mod volume_commitment; diff --git a/src/routes/merchant_account_config.rs b/src/routes/merchant_account_config.rs index 1a736b49..6f2803be 100644 --- a/src/routes/merchant_account_config.rs +++ b/src/routes/merchant_account_config.rs @@ -53,6 +53,8 @@ pub enum KnownFeature { #[serde(rename = "auto-calibration")] SrAutoCalibration, Autopilot, + /// Volume-commitment steering — a secondary objective that runs alongside cost savings. + VolumeContracts, } impl KnownFeature { @@ -65,6 +67,7 @@ impl KnownFeature { Self::Elimination, Self::SrAutoCalibration, Self::Autopilot, + Self::VolumeContracts, ] } @@ -77,6 +80,7 @@ impl KnownFeature { "elimination" => Some(Self::Elimination), "auto-calibration" => Some(Self::SrAutoCalibration), "autopilot" => Some(Self::Autopilot), + "volume-contracts" => Some(Self::VolumeContracts), _ => None, } } @@ -92,6 +96,10 @@ impl KnownFeature { Self::Elimination => "enable_gateway_level_sr_elimination", Self::SrAutoCalibration => "sr_auto_calibration_enabled", Self::Autopilot => "autopilot_enabled", + // The same key `flow_new` checks, so this toggle drives the routing gate directly. + Self::VolumeContracts => { + crate::decider::gatewaydecider::volume_commitment::FEATURE_FLAG + } } } diff --git a/src/routes/volume_commitment.rs b/src/routes/volume_commitment.rs new file mode 100644 index 00000000..30f19d2e --- /dev/null +++ b/src/routes/volume_commitment.rs @@ -0,0 +1,843 @@ +//! Read-only pacing/series/audit/impact views plus the scheduler-called run endpoint. + +use std::collections::HashMap; + +use axum::extract::{Path, Query}; +use axum::http::{HeaderMap, StatusCode}; +use axum::{Extension, Json}; +use futures::FutureExt; +use masking::PeekInterface; +use serde::{Deserialize, Serialize}; + +use crate::auth::AuthContext; +use crate::decider::gatewaydecider::volume_commitment; +use volume_commitment::controller::{self, RunReport}; +use volume_commitment::volume::{AuditEvent, AuditKind, DayVolume}; +use volume_commitment::{math, Commitment, CommitmentInputs, Deps, SteeringPlan}; + +/// Newest audit events read per request; runs and their counters are summarised from them. +const AUDIT_EVENT_WINDOW: u64 = 500; +/// Finest series resolution: one bucket per minute of a contract day. +const MAX_BUCKETS_PER_DAY: u32 = 1440; +/// Pseudo run id for events written before runs were named. +const UNNAMED_RUN: &str = "earlier"; + +/// `?merchant_id=` narrows a run to one merchant; omitted, the run sweeps everyone. +#[derive(Debug, Deserialize)] +pub struct RunQuery { + #[serde(default)] + pub merchant_id: Option, +} + +/// True when the request carries the deployment's admin secret — what the scheduler presents. +/// Checked here as well as in the middleware because the middleware attaches no session to such +/// a caller, and in api-key compat mode a request with no credentials at all reaches the handler. +fn presents_admin_secret(headers: &HeaderMap) -> bool { + let Some(state) = crate::app::APP_STATE.get() else { + return false; + }; + let expected = state.global_config.admin_secret.secret.peek(); + let provided = headers + .get("x-admin-secret") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + !expected.is_empty() && provided == expected +} + +/// Who may run what: the admin secret may run anything, a session or api key only its own +/// merchant, and the sweep of every merchant (1 + 2N database reads and N ClickHouse queries, +/// serial) is the scheduler's alone. +fn authorize_run( + admin: bool, + session: Option<&AuthContext>, + merchant_id: Option<&str>, +) -> Result<(), (StatusCode, String)> { + if admin { + return Ok(()); + } + match (session, merchant_id) { + (_, None) => Err(( + StatusCode::FORBIDDEN, + "running a forecast for every merchant needs the admin secret".to_string(), + )), + (Some(context), Some(merchant_id)) if context.merchant_id == merchant_id => Ok(()), + (Some(_), Some(_)) => Err(( + StatusCode::FORBIDDEN, + "this session may only run a forecast for its own merchant".to_string(), + )), + (None, Some(_)) => Err(( + StatusCode::UNAUTHORIZED, + "running a forecast needs a session, an api key, or the admin secret".to_string(), + )), + } +} + +/// `POST /volume-commitment/run-forecast` — re-measure, re-decide what to chase, re-mark who is +/// behind. A merchant with no usable commitments counts as skipped; one whose delivery could not +/// be measured, or whose pass panicked, as failed — its previous plan stands. +pub async fn run_forecast( + headers: HeaderMap, + session: Option>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + authorize_run( + presents_admin_secret(&headers), + session.as_ref().map(|Extension(context)| context), + query.merchant_id.as_deref(), + )?; + + let Some(deps) = volume_commitment::deps() else { + // Startup wiring has not run. Worth a real error here: unlike the read view, a caller + // asking for a run needs to know it did not happen. + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "volume commitment is not configured in this process".to_string(), + )); + }; + + let Some(merchant_id) = query.merchant_id else { + return Ok(Json(controller::run_all(deps).await)); + }; + + let outcome = std::panic::AssertUnwindSafe(controller::run_for_merchant(deps, &merchant_id)) + .catch_unwind() + .await; + + let (processed, skipped, failed, merchants) = match outcome { + Ok(Ok(Some(run))) => (1, 0, 0, vec![run]), + Ok(Ok(None)) => (0, 1, 0, Vec::new()), + // Logged where it happened. + Ok(Err(_)) => (0, 0, 1, Vec::new()), + Err(_) => { + crate::logger::error!( + tag = "volume_commitment", + merchant_id = merchant_id.as_str(), + "panic in volume commitment pass" + ); + (0, 0, 1, Vec::new()) + } + }; + + Ok(Json(RunReport { + merchants_processed: processed, + merchants_skipped: skipped, + merchants_failed: failed, + next_run_in_secs: merchants + .first() + .map(|run| run.next_run_in_secs) + .unwrap_or_else(|| controller::default_interval_secs(&deps.config)), + merchants, + })) +} + +/// One PSP's pacing state. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PspPacing { + pub connector: String, + /// Target volume/GMV for the period. + pub goal: f64, + /// Volume sent so far this cycle. + pub achieved: f64, + /// Still outstanding (`goal - achieved`). + pub gap: f64, + /// Recent average daily volume. + pub pace: f64, + /// What regular SR routing delivers per day, unaided. + pub sr_volume: f64, + /// Volume per day needed from here on to still land the commitment. + pub floor_per_day: f64, + /// Volume the nudge has moved here so far today. + pub steered_today: f64, + /// Share of eligible payments currently being diverted here, 0..=1. + pub steer_rate: f64, + /// Reward captured if the commitment lands. + pub reward: f64, + /// `true` when SR alone is not delivering the floor. + pub steering: bool, +} + +/// A commitment the controller stopped chasing. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EliminatedPspView { + pub connector: String, + /// Volume sent so far this cycle — it keeps counting after the drop, since normal routing + /// still sends the PSP whatever it would have anyway. + pub achieved: f64, + pub gap: f64, + pub reward: f64, + pub reason: String, +} + +/// `GET /merchant-account/:merchant_id/volume-commitment` +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VolumeCommitmentView { + pub merchant_id: String, + /// `true` whenever a contract document is live for the merchant — even between the previous + /// cycle's plan expiring and the next forecast, when `psps` is empty and nothing is steered. + pub active: bool, + /// Epoch seconds of the last controller tick. + pub computed_at_epoch_secs: Option, + /// How far a nudge may stray from the best-approving PSP. + pub tolerance: Option, + /// Total volume the merchant expects per day, from the contract document. + pub expected_daily_traffic: Option, + /// Contract-day length in seconds (`SECS_PER_DAY`, or 60 on a test cycle). + pub day_secs: Option, + /// The routing rule holding the active contract, so the dashboard can act on it. + pub rule_id: Option, + /// Reward still reachable across surviving commitments. + pub reward_at_stake: f64, + pub psps: Vec, + pub eliminated: Vec, +} + +pub async fn get_volume_commitment( + Path(merchant_id): Path, +) -> Result, (StatusCode, String)> { + let Some(deps) = volume_commitment::deps() else { + // Startup wiring has not run — report inactive rather than failing the card. + return Ok(Json(inactive(merchant_id))); + }; + + // Load inputs once for goals and measurement; `None` = no usable contract or feature off. + let Some(inputs) = deps.inputs.load(&merchant_id).await else { + return Ok(Json(inactive(merchant_id))); + }; + + // No plan for *this* contract yet (first forecast pending, cycle just rolled, or the stored + // plan belongs to a replaced document): the contract is live, nothing is paced. + let Some(plan) = current_plan(deps, &inputs).await else { + return Ok(Json(pending(merchant_id, &inputs))); + }; + + let goals: HashMap<&str, f64> = inputs + .commitments + .iter() + .map(|c| (c.connector.as_str(), c.goal)) + .collect(); + // Unmeasurable is an error, not zeros: a card reading "0 delivered" against a live plan + // would be a lie the merchant acts on. + let measured = deps + .volume + .measure(&merchant_id, &inputs.commitments, math::PACE_WINDOW_DAYS) + .await + .map_err(|error| { + ( + StatusCode::SERVICE_UNAVAILABLE, + format!("delivered volume cannot be measured right now: {error}"), + ) + })?; + + let psps = plan + .psps + .iter() + .map(|entry| PspPacing { + goal: goals.get(entry.connector.as_str()).copied().unwrap_or(0.0), + achieved: measured.achieved_for(&entry.connector), + gap: entry.remaining, + pace: measured.pace_for(&entry.connector).unwrap_or(0.0), + sr_volume: entry.routing_gives_daily, + floor_per_day: entry.needed_daily, + steer_rate: entry.steer_rate, + steered_today: measured.steered_today_for(&entry.connector), + reward: entry.reward, + steering: entry.needs_steering, + connector: entry.connector.clone(), + }) + .collect(); + + Ok(Json(VolumeCommitmentView { + merchant_id, + active: true, + computed_at_epoch_secs: Some(plan.computed_at_epoch_secs), + tolerance: Some(plan.tolerance), + expected_daily_traffic: Some(inputs.expected_daily_traffic), + day_secs: Some(inputs.day_secs()), + rule_id: Some(inputs.contract_rule_id.clone()), + reward_at_stake: plan.psps.iter().map(|p| p.reward).sum(), + psps, + eliminated: plan + .dropped + .iter() + .map(|p| EliminatedPspView { + connector: p.connector.clone(), + achieved: measured.achieved_for(&p.connector), + gap: p.remaining, + reward: p.reward, + reason: p.reason.clone(), + }) + .collect(), + })) +} + +/// The stored plan, only if it was built from the contract now active — a plan left behind by a +/// replaced document must not lend its verdicts to the new one. +async fn current_plan(deps: &Deps, inputs: &CommitmentInputs) -> Option { + deps.state + .load_plan(&inputs.merchant_id) + .await + .filter(|plan| plan.contract_anchor_ms == inputs.contract_anchor_ms) +} + +/// Whether the stored plan describes the run being viewed. No `run_id` means the live cycle, +/// and a string that is not a run id falls back to the live cycle exactly as +/// `commitments_for_run` does — only a real, different run id disqualifies the plan. +fn plan_covers_run(plan_run_id: &str, run_id: Option<&str>) -> bool { + match run_id.filter(|r| math::run_start_ms(r).is_some()) { + Some(wanted) => plan_run_id == wanted, + None => true, + } +} + +/// The stored plan, only when it belongs to the run being viewed: a past run must not borrow the +/// live plan's eliminated/steering verdicts — they describe a different cycle. +async fn plan_for_run( + deps: &Deps, + inputs: &CommitmentInputs, + run_id: Option<&str>, +) -> Option { + current_plan(deps, inputs) + .await + .filter(|plan| plan_covers_run(&plan.run_id, run_id)) +} + +/// A live contract with no plan for it yet: active, nothing paced. +fn pending(merchant_id: String, inputs: &CommitmentInputs) -> VolumeCommitmentView { + VolumeCommitmentView { + active: true, + expected_daily_traffic: Some(inputs.expected_daily_traffic), + day_secs: Some(inputs.day_secs()), + rule_id: Some(inputs.contract_rule_id.clone()), + ..inactive(merchant_id) + } +} + +/// Epoch ms as RFC 3339, or empty for an instant chrono cannot represent. +fn rfc3339(epoch_ms: i64) -> String { + chrono::DateTime::from_timestamp_millis(epoch_ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default() +} + +fn inactive(merchant_id: String) -> VolumeCommitmentView { + VolumeCommitmentView { + merchant_id, + active: false, + computed_at_epoch_secs: None, + tolerance: None, + expected_daily_traffic: None, + day_secs: None, + rule_id: None, + reward_at_stake: 0.0, + psps: Vec::new(), + eliminated: Vec::new(), + } +} + +/// One PSP's chart data: its promise and its per-day delivery. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorSeries { + pub connector: String, + pub goal: f64, + pub reward: f64, + /// How the reward is earned — "0.25% rebate", "lump sum". + pub reward_note: String, + /// Instant the cycle opened, RFC 3339 in UTC (the contract's zone shapes the boundary, + /// not the rendering). + pub cycle_start: String, + /// Cycle close (the next cycle's start), for countdowns and sizing a simulated run. + pub cycle_end: String, + /// Length of the cycle in days — the x-axis span the promise line runs to. + pub days_total: u32, + /// True when the current plan has given this commitment up. + pub eliminated: bool, + pub points: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeriesResponse { + pub merchant_id: String, + /// ISO-4217 code the amounts are in, when the contract states one. + pub currency: Option, + /// How long one contract day lasts, in seconds — the unit `points[].day` counts in. + pub day_secs: Option, + pub connectors: Vec, +} + +/// `?run_id=` renders a past execution instead of the one in flight; `?per_day=` asks for that +/// many buckets per contract day (default 1) so a live chart can move within a day. +#[derive(Debug, Deserialize)] +pub struct SeriesQuery { + #[serde(default)] + pub run_id: Option, + #[serde(default)] + pub per_day: Option, +} + +/// Every commitment re-aimed at `[start_ms, end_ms)` — a past run, or the baseline before one. +fn rewindow(commitments: &[Commitment], start_ms: i64, end_ms: i64) -> Vec { + commitments + .iter() + .map(|c| Commitment { + period_start_ms: start_ms, + period_end_ms: end_ms, + ..c.clone() + }) + .collect() +} + +/// The commitments as they stood in the run `run_id` names: the current cycle's length, starting +/// where that run opened. The live cycle for anything that is not a run id. +fn commitments_for_run(commitments: &[Commitment], run_id: Option<&str>) -> Vec { + match run_id.and_then(math::run_start_ms) { + Some(start_ms) => commitments + .iter() + .map(|c| Commitment { + period_start_ms: start_ms, + period_end_ms: start_ms.saturating_add(c.period_end_ms - c.period_start_ms), + ..c.clone() + }) + .collect(), + None => commitments.to_vec(), + } +} + +/// `GET /merchant-account/:merchant_id/volume-commitment/series` — per-bucket delivered volume per +/// PSP and the promise each races; `?run_id=` renders a past cycle. +pub async fn get_series( + Path(merchant_id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let empty = || SeriesResponse { + merchant_id: merchant_id.clone(), + currency: None, + day_secs: None, + connectors: Vec::new(), + }; + let Some(deps) = volume_commitment::deps() else { + return Ok(Json(empty())); + }; + let Some(inputs) = deps.inputs.load(&merchant_id).await else { + return Ok(Json(empty())); + }; + + let eliminated: Vec = plan_for_run(deps, &inputs, query.run_id.as_deref()) + .await + .map(|plan| plan.dropped.iter().map(|d| d.connector.clone()).collect()) + .unwrap_or_default(); + + let commitments = commitments_for_run(&inputs.commitments, query.run_id.as_deref()); + let per_day = query.per_day.unwrap_or(1).clamp(1, MAX_BUCKETS_PER_DAY); + let points = deps + .volume + .daily_series(&merchant_id, &commitments, per_day) + .await; + + let connectors = commitments + .iter() + .map(|commitment| ConnectorSeries { + connector: commitment.connector.clone(), + goal: commitment.goal, + reward: commitment.reward, + reward_note: commitment.reward_note.clone(), + cycle_start: rfc3339(commitment.period_start_ms), + cycle_end: rfc3339(commitment.period_end_ms), + days_total: math::days_total( + commitment.period_start_ms, + commitment.period_end_ms, + commitment.day_secs, + ), + eliminated: eliminated.contains(&commitment.connector), + points: points + .iter() + .filter(|p| p.connector == commitment.connector) + .cloned() + .collect(), + }) + .collect(); + + Ok(Json(SeriesResponse { + merchant_id, + currency: inputs.currency.clone(), + day_secs: commitments.first().map(|c| c.day_secs), + connectors, + })) +} + +/// One execution of the contract, summarised for a picker. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunSummary { + pub run_id: String, + /// When the cycle this run covers opened, from the id itself. + pub started_at_epoch_ms: i64, + /// When the run was last heard from — its most recent forecast or steer. + pub last_activity_epoch_ms: i64, + pub forecasts: usize, + pub steers: usize, + pub eliminations: usize, + /// True for the run whose cycle is still open. + pub is_current: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditResponse { + pub merchant_id: String, + /// Every execution of this merchant's contract, newest first. + pub runs: Vec, + /// Newest first. Narrowed to `?run_id=` when one is given. + pub events: Vec, +} + +/// `?run_id=` narrows a view to one execution of the contract instead of the one in flight. +#[derive(Debug, Deserialize)] +pub struct RunScopedQuery { + #[serde(default)] + pub run_id: Option, +} + +/// `GET /merchant-account/:merchant_id/volume-commitment/audit` — forecasts, steers and +/// eliminations reconstructed from analytics events, grouped by run. +pub async fn get_audit( + Path(merchant_id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let Some(deps) = volume_commitment::deps() else { + return Ok(Json(AuditResponse { + merchant_id, + runs: Vec::new(), + events: Vec::new(), + })); + }; + + // One read, two views: the run list and the filtered events come from the same window. + let all = deps + .volume + .audit_events(&merchant_id, AUDIT_EVENT_WINDOW) + .await; + let current_run = deps + .state + .load_plan(&merchant_id) + .await + .map(|plan| plan.run_id); + + let mut order: Vec = Vec::new(); + let mut summaries: HashMap = HashMap::new(); + for event in &all { + // Events written before runs were named still deserve a home rather than vanishing. + let run_id = event + .run_id + .clone() + .unwrap_or_else(|| UNNAMED_RUN.to_string()); + let entry = summaries.entry(run_id.clone()).or_insert_with(|| { + order.push(run_id.clone()); + RunSummary { + started_at_epoch_ms: math::run_start_ms(&run_id).unwrap_or(event.at_epoch_ms), + last_activity_epoch_ms: event.at_epoch_ms, + is_current: current_run.as_deref() == Some(run_id.as_str()), + run_id, + forecasts: 0, + steers: 0, + eliminations: 0, + } + }); + entry.last_activity_epoch_ms = entry.last_activity_epoch_ms.max(event.at_epoch_ms); + match event.kind { + AuditKind::Forecast => entry.forecasts += 1, + AuditKind::Steered => entry.steers += 1, + AuditKind::Eliminated => entry.eliminations += 1, + } + } + + let mut runs: Vec = order + .into_iter() + .filter_map(|id| summaries.remove(&id)) + .collect(); + runs.sort_by_key(|run| std::cmp::Reverse(run.started_at_epoch_ms)); + + let events = match &query.run_id { + Some(wanted) => all + .into_iter() + .filter(|e| e.run_id.as_deref().unwrap_or(UNNAMED_RUN) == wanted) + .collect(), + None => all, + }; + + Ok(Json(AuditResponse { + merchant_id, + runs, + events, + })) +} + +/// What one PSP received in a window — payments and volume. +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ImpactSlice { + pub payments: u64, + pub volume: f64, +} + +/// One PSP's before-and-after: what it got without the contract, and what it got with it — +/// split into what routing sent on its own, what was steered in, and what it gave up. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectorImpact { + pub connector: String, + pub goal: f64, + pub reward: f64, + /// True when the plan for this cycle has given the commitment up. + pub eliminated: bool, + /// True when the live plan is currently diverting extra payments here. + pub steering: bool, + /// Everything this PSP received in the previous cycle. + pub before: ImpactSlice, + /// Everything this PSP received in the cycle (`unaided + steered`). + pub with_contract: ImpactSlice, + /// The part normal routing sent here by itself. + pub unaided: ImpactSlice, + /// The part the nudge moved here to meet the commitment. + pub steered: ImpactSlice, + /// What routing would have sent here but the nudge moved to a PSP behind on its commitment. + pub ceded: ImpactSlice, +} + +/// A window of time the impact view compares. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImpactWindow { + pub start_ms: i64, + pub end_ms: i64, +} + +/// `GET /merchant-account/:merchant_id/volume-commitment/impact` +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImpactResponse { + pub merchant_id: String, + /// When the active contract document went live. + pub contract_since_ms: i64, + /// The cycle being reported: the one in flight, or the run `?run_id=` asked for. + pub cycle: ImpactWindow, + /// Length of that cycle in contract days. + pub days_total: u32, + /// How long one contract day lasts, in seconds. + pub day_secs: u64, + /// The cycle immediately before `cycle` — what `before` and `baseline_days` are measured over. + pub baseline: ImpactWindow, + pub connectors: Vec, + /// Day-by-day delivery per PSP across the previous cycle, `day_index` counted from its start. + pub baseline_days: Vec, + /// The same across the cycle, `day_index` counted from the cycle's start. + pub cycle_days: Vec, +} + +/// `GET /merchant-account/:merchant_id/volume-commitment/impact` — each PSP's traffic in the +/// previous cycle (same length, ending where this one starts) vs this one, split by who sent it. +pub async fn get_impact( + Path(merchant_id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let not_active = || { + ( + StatusCode::NOT_FOUND, + format!("no active volume contract for merchant {merchant_id}"), + ) + }; + let Some(deps) = volume_commitment::deps() else { + return Err(not_active()); + }; + let Some(inputs) = deps.inputs.load(&merchant_id).await else { + return Err(not_active()); + }; + + let (eliminated, steering): (Vec, Vec) = + plan_for_run(deps, &inputs, query.run_id.as_deref()) + .await + .map(|plan| { + ( + plan.dropped.iter().map(|d| d.connector.clone()).collect(), + plan.needing_steering() + .map(|p| p.connector.clone()) + .collect(), + ) + }) + .unwrap_or_default(); + + let commitments = commitments_for_run(&inputs.commitments, query.run_id.as_deref()); + let Some(first) = commitments.first() else { + return Err(not_active()); + }; + // PSP contracts share a cycle in practice; the response reports the widest span so every + // connector's traffic is inside it. + let cycle_start_ms = commitments + .iter() + .map(|c| c.period_start_ms) + .min() + .unwrap_or(first.period_start_ms); + let cycle_end_ms = commitments + .iter() + .map(|c| c.period_end_ms) + .max() + .unwrap_or(first.period_end_ms); + let cycle_len_ms = (cycle_end_ms - cycle_start_ms).max(1); + let day_secs = first.day_secs; + let days_total = math::days_total(cycle_start_ms, cycle_end_ms, day_secs); + + let baseline_end_ms = cycle_start_ms; + let baseline_start_ms = baseline_end_ms.saturating_sub(cycle_len_ms); + + let connectors: Vec = commitments.iter().map(|c| c.connector.clone()).collect(); + let before = deps + .volume + .window_totals( + &merchant_id, + &connectors, + baseline_start_ms, + baseline_end_ms, + ) + .await; + let during = deps + .volume + .window_totals(&merchant_id, &connectors, cycle_start_ms, cycle_end_ms) + .await; + + // Day-by-day for both windows: hand the series reader the baseline as if it were a cycle. + let baseline_days = deps + .volume + .daily_series( + &merchant_id, + &rewindow(&commitments, baseline_start_ms, baseline_end_ms), + 1, + ) + .await; + let cycle_days = deps + .volume + .daily_series( + &merchant_id, + &rewindow(&commitments, cycle_start_ms, cycle_end_ms), + 1, + ) + .await; + + let slice = |payments: u64, volume: f64| ImpactSlice { payments, volume }; + let connectors = commitments + .iter() + .map(|commitment| { + let b = before.iter().find(|t| t.connector == commitment.connector); + let d = during.iter().find(|t| t.connector == commitment.connector); + ConnectorImpact { + connector: commitment.connector.clone(), + goal: commitment.goal, + reward: commitment.reward, + eliminated: eliminated.contains(&commitment.connector), + steering: steering.contains(&commitment.connector), + before: b.map(|t| slice(t.payments, t.volume)).unwrap_or_default(), + with_contract: d.map(|t| slice(t.payments, t.volume)).unwrap_or_default(), + unaided: d + .map(|t| { + slice( + t.payments.saturating_sub(t.steered_payments), + (t.volume - t.steered_volume).max(0.0), + ) + }) + .unwrap_or_default(), + steered: d + .map(|t| slice(t.steered_payments, t.steered_volume)) + .unwrap_or_default(), + ceded: d + .map(|t| slice(t.ceded_payments, t.ceded_volume)) + .unwrap_or_default(), + } + }) + .collect(); + + Ok(Json(ImpactResponse { + merchant_id, + contract_since_ms: inputs.contract_anchor_ms, + cycle: ImpactWindow { + start_ms: cycle_start_ms, + end_ms: cycle_end_ms, + }, + days_total, + day_secs, + baseline: ImpactWindow { + start_ms: baseline_start_ms, + end_ms: baseline_end_ms, + }, + connectors, + baseline_days, + cycle_days, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthKind; + + fn session(merchant_id: &str) -> AuthContext { + AuthContext { + merchant_id: merchant_id.to_string(), + auth_kind: AuthKind::Jwt, + user_id: None, + email: None, + role: None, + permissions: None, + } + } + + #[test] + fn the_admin_secret_runs_anything() { + assert!(authorize_run(true, None, None).is_ok()); + assert!(authorize_run(true, None, Some("m1")).is_ok()); + assert!(authorize_run(true, Some(&session("m2")), Some("m1")).is_ok()); + } + + /// The sweep is the scheduler's: a dashboard user with a write permission must not be able + /// to put every merchant's forecast on the main server at once. + #[test] + fn the_sweep_needs_the_admin_secret() { + let refused = authorize_run(false, Some(&session("m1")), None).unwrap_err(); + assert_eq!(refused.0, StatusCode::FORBIDDEN); + let refused = authorize_run(false, None, None).unwrap_err(); + assert_eq!(refused.0, StatusCode::FORBIDDEN); + } + + #[test] + fn a_session_runs_only_its_own_merchant() { + assert!(authorize_run(false, Some(&session("m1")), Some("m1")).is_ok()); + let refused = authorize_run(false, Some(&session("m1")), Some("m2")).unwrap_err(); + assert_eq!(refused.0, StatusCode::FORBIDDEN); + } + + /// Api-key compat mode lets an unauthenticated request through the middleware; here it + /// still needs to say who it is. + #[test] + fn no_credentials_at_all_is_refused() { + let refused = authorize_run(false, None, Some("m1")).unwrap_err(); + assert_eq!(refused.0, StatusCode::UNAUTHORIZED); + } + + /// A past run must not borrow the live plan's verdicts; the live view and a request for the + /// live run itself keep them; garbage run ids fall back to the live view like everywhere else. + #[test] + fn the_live_plan_speaks_only_for_its_own_run() { + assert!(plan_covers_run("vcr_1788189990000", None)); + assert!(plan_covers_run( + "vcr_1788189990000", + Some("vcr_1788189990000") + )); + assert!(!plan_covers_run( + "vcr_1788189990000", + Some("vcr_1788189810000") + )); + assert!(plan_covers_run("vcr_1788189990000", Some("abc"))); + } +} diff --git a/website/src/App.tsx b/website/src/App.tsx index 874d2054..e324064c 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -5,7 +5,6 @@ import { DecisionExplorerPage } from './components/pages/DecisionExplorerPage' import { DecisionSimulatorPage } from './components/pages/DecisionSimulatorPage' import { DebitRoutingPage } from './components/pages/DebitRoutingPage' import { EuclidRulesPage } from './components/pages/EuclidRulesPage' -import { VolumeContractsPage } from './components/pages/VolumeContractsPage' import { EuclidRuleBuilderPage } from './components/pages/EuclidRuleBuilderPage' import { VolumeSplitBuilderPage } from './components/pages/VolumeSplitBuilderPage' import { OverviewPage } from './components/pages/OverviewPage' @@ -147,7 +146,7 @@ export default function App() { } /> } /> } /> - } /> + } /> {/* Cost Estimation moved into the Multi Objective page as a tab; keep the old path working for bookmarks/links. */} } /> diff --git a/website/src/components/layout/Sidebar.tsx b/website/src/components/layout/Sidebar.tsx index 05469424..294461e8 100644 --- a/website/src/components/layout/Sidebar.tsx +++ b/website/src/components/layout/Sidebar.tsx @@ -7,7 +7,6 @@ import { BookOpen, PieChart, Network, - Target, BarChart3, Activity, BellRing, @@ -147,7 +146,6 @@ export function Sidebar() { Rule-Based Volume Split Debit Routing - Volume Contracts A/B Testing {simulatorEnabled ? ( diff --git a/website/src/components/pages/AnalyticsPage.tsx b/website/src/components/pages/AnalyticsPage.tsx index 8d640638..a563f530 100644 --- a/website/src/components/pages/AnalyticsPage.tsx +++ b/website/src/components/pages/AnalyticsPage.tsx @@ -58,6 +58,7 @@ import { Card, CardBody, CardHeader } from '../ui/Card' import { Badge } from '../ui/Badge' import { Spinner } from '../ui/Spinner' import { ErrorMessage } from '../ui/ErrorMessage' +import { VolumeCommitmentAnalytics } from './VolumeCommitmentAnalytics' import { TimeRangeFilter } from '../ui/TimeRangeFilter' import { TimeWindow, @@ -72,11 +73,12 @@ type RoutingFilters = { gateways: string[] } -type AnalyticsView = 'transactions' | 'rule_based' -const ANALYTICS_VIEWS: readonly AnalyticsView[] = ['transactions', 'rule_based'] +type AnalyticsView = 'transactions' | 'rule_based' | 'volume_commitments' +const ANALYTICS_VIEWS: readonly AnalyticsView[] = ['transactions', 'rule_based', 'volume_commitments'] const ANALYTICS_VIEW_LABELS: Record = { transactions: 'Multi-objective', rule_based: 'Rule based / Volume based', + volume_commitments: 'Volume commitments', } type PreviewTraceKey = readonly [ @@ -1641,6 +1643,14 @@ export function AnalyticsPage() { > {ANALYTICS_VIEW_LABELS.rule_based} +
@@ -2033,6 +2043,8 @@ export function AnalyticsPage() {
+ ) : view === 'volume_commitments' ? ( + ) : (
diff --git a/website/src/components/pages/CommitmentPacingChart.tsx b/website/src/components/pages/CommitmentPacingChart.tsx new file mode 100644 index 00000000..25f03517 --- /dev/null +++ b/website/src/components/pages/CommitmentPacingChart.tsx @@ -0,0 +1,798 @@ +import { cloneElement, useEffect, useMemo, useState, type ReactElement } from 'react' +import { + CartesianGrid, + ComposedChart, + Customized, + Line, + ReferenceArea, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { CHART_TOOLTIP_LABEL_STYLE, CHART_TOOLTIP_STYLE } from '../../lib/chartStyles' +import { CommitmentConnectorSeries } from '../../types/api' +import { NEUTRAL_INK, SECS_PER_DAY, bucketsPerDay, dayUnit, formatAchieved, formatMoney, formatMoneyExact, pctOfGoal } from './volumeCommitmentChartBits' + +/** Where a PSP stands against its promise, as the chart marks it. */ +export type PacingStatus = 'met' | 'steering' | 'on_pace' | 'eliminated' | 'missed' | 'pending' + +/** The cumulative volume a PSP must have cleared by the end of the contract day `day` falls in — + * the promise split evenly across the cycle's days. Day 0.4 is inside day 1, so its target is + * one day's share; the chart draws these as a step ladder rather than a continuous ramp. */ +function dayTarget(goal: number, day: number, daysTotal: number) { + return (goal * Math.min(daysTotal, Math.floor(day + 1e-9) + 1)) / daysTotal +} + +const DROP_COLOR = '#dc2626' +const STEER_COLOR = '#d97706' +const MET_COLOR = '#059669' + +type Scale = (value: number) => number | undefined +type AxisEntry = { scale: Scale } +type Offset = { top: number; left: number; width: number; height: number } + +type ConnectorMeta = { + name: string + color: string + goal: number + reward: number + status: PacingStatus + /** Contract day the engine dropped it on, when it did. */ + dropDay?: number + /** Last day its running total is drawn to: the end of the last bucket the series has reported + * on a live run (never "now" — what has not been measured yet is not drawn), the cycle end on + * a finished one. */ + endDay: number + endTotal: number + /** Recent delivery per contract day, for the tentative tail drawn from `endDay` to "now". */ + pace: number +} + +/** One steered bucket: where the stretch starts and ends on the line, and how much moved. */ +type Steer = { name: string; day: number; endDay: number; amount: number; total: number; startTotal: number; color: string } +type Drop = { day: number; names: string[]; reason?: string } + +/** Non-line annotations (drop captions, steer triangles, promise labels), positioned via `` axis scales. */ +function PacingOverlay(props: { + metas: ConnectorMeta[] + steers: Steer[] + drops: Drop[] + currency?: string | null + daysTotal: number + dayLabel: string + /** The stretch of the cycle on screen — the whole cycle, or a window that follows the run. */ + xLo: number + xHi: number + xAxisMap?: Record + yAxisMap?: Record + offset?: Offset +}) { + const { metas, steers, drops, currency, daysTotal, dayLabel, xLo, xHi, xAxisMap, yAxisMap, offset } = props + const xAxis = xAxisMap ? Object.values(xAxisMap)[0] : undefined + const yAxis = yAxisMap ? Object.values(yAxisMap)[0] : undefined + if (!xAxis || !yAxis || !offset) return null + const x = (v: number) => xAxis.scale(v) + const y = (v: number) => yAxis.scale(v) + const rightEdge = x(xHi) + if (rightEdge == null) return null + const inView = (day: number) => day >= xLo - 1e-9 && day <= xHi + 1e-9 + + // Promise labels: one per PSP where its target ladder meets the right edge of the view (its + // goal when the whole cycle is on screen), nudged apart when they sit close. + const LABEL_GAP = 30 + const labels = metas + .map((m) => ({ m, y: y(dayTarget(m.goal, xHi, daysTotal)) ?? offset.top })) + .sort((a, b) => a.y - b.y) + for (let i = 1; i < labels.length; i += 1) { + if (labels[i].y - labels[i - 1].y < LABEL_GAP) labels[i].y = labels[i - 1].y + LABEL_GAP + } + const bottom = offset.top + offset.height - 6 + for (let i = labels.length - 1; i >= 0; i -= 1) { + if (labels[i].y > bottom) labels[i].y = bottom + if (i < labels.length - 1 && labels[i + 1].y - labels[i].y < LABEL_GAP) { + labels[i].y = labels[i + 1].y - LABEL_GAP + } + } + + const biggestSteer = steers.reduce((best, s) => (best && best.amount >= s.amount ? best : s), null) + + // One marker per PSP per contract day, on the ladder where that day's target is cleared — + // thinned when the days are too dense for the markers to stay apart. + const dayStep = Math.max(1, Math.ceil(daysTotal / 30)) + + return ( + + {/* Day-end target markers along each ladder. */} + {metas.map((m) => + Array.from({ length: daysTotal }, (_, i) => i + 1) + .filter((k) => k % dayStep === 0 && k >= xLo - 1e-9 && k <= xHi + 1e-9) + .map((k) => { + const tx = x(k) + const ty = y((m.goal * k) / daysTotal) + if (tx == null || ty == null) return null + return ( + + ) + }), + )} + {/* Drop captions along the top of the plot. */} + {drops.map((d) => { + if (!inView(d.day)) return null + const dx = x(d.day) + if (dx == null) return null + const label = `${dayLabel} ${Math.round(d.day)} · ${d.names.join(', ')} dropped` + const width = label.length * 6 + 10 + const anchorLeft = dx + width > offset.left + offset.width + return ( + + + + {label} + + + ) + })} + + {/* Steer chunks: a triangle on the line where the engine pushed volume across; the biggest + chunk gets a label. */} + {steers.map((s, i) => { + if (!inView(s.day)) return null + const sx = x(s.day) + const sy = y(s.total) + if (sx == null || sy == null) return null + const isBiggest = biggestSteer === s + return ( + + + {isBiggest && ( + + steer +{formatMoney(s.amount, currency)} + + )} + + ) + })} + + {/* "dropped — natural traffic only" beside the gray tail, and "× missed" at its end. */} + {metas + .filter((m) => m.status === 'eliminated' || m.status === 'missed') + .map((m) => { + if (m.endDay < xLo) return null + const ex = x(Math.min(m.endDay, xHi)) + const ey = y(m.endTotal) + if (ex == null || ey == null) return null + const tailStart = Math.max(m.dropDay ?? m.endDay, xLo) + const tailEnd = Math.min(m.endDay, xHi) + const midDay = (tailStart + tailEnd) / 2 + const mx = x(midDay) + return ( + + {m.dropDay != null && mx != null && tailEnd - tailStart > (xHi - xLo) * 0.12 && ( + + dropped — natural traffic only + + )} + {m.status === 'missed' && m.endDay <= xHi + 1e-9 && ( + + × missed + + )} + + ) + })} + + {/* Promise labels down the right edge. */} + {labels.map(({ m, y: ly }) => { + const color = m.color + const prefix = m.status === 'met' ? '✓ ' : m.status === 'steering' ? '↗ ' : '' + return ( + + {m.status === 'met' && ( + + ✓ + + )} + + {m.status === 'met' ? '' : prefix} + {m.name} promise · {formatMoney(m.goal, currency)} + + + → {formatMoneyExact(m.reward, currency)} + {m.status === 'steering' ? ' · steering' : ''} + + + ) + })} + + ) +} + +/** `ResponsiveContainer` normally; a fixed width renders to markup for SSR previews and tests. */ +function Sized({ width, height, children }: { width?: number; height: number; children: ReactElement }) { + if (width) return cloneElement(children, { width, height }) + return {children} +} + +function PacingTooltip({ + active, + payload, + label, + metas, + currency, + dayLabel, +}: { + active?: boolean + payload?: Array<{ payload?: Record }> + label?: number + metas: ConnectorMeta[] + currency?: string | null + dayLabel: string +}) { + if (!active || !payload?.length) return null + const row = payload[0]?.payload + if (!row) return null + return ( +
+

+ {dayLabel} {typeof label === 'number' ? label.toFixed(label % 1 === 0 ? 0 : 1) : label} +

+ {metas.map((m) => { + const measured = row[m.name] ?? row[`${m.name}__after`] + const pending = measured == null ? row[`${m.name}__pending`] : undefined + const total = measured ?? pending + const promise = row[`${m.name}__promise`] + return ( +
+ + {m.name} + + {total != null ? `${pending != null ? '~' : ''}${formatMoney(total, currency)}` : '—'} + + {' '}/ {promise != null ? formatMoney(promise, currency) : '—'} by {dayLabel.toLowerCase()}'s end + + +
+ ) + })} +
+ ) +} + +/** Running total per PSP vs its per-day target ladder (the promise split across the cycle's + * days, drawn as dashed steps), with steer triangles and drop markers; dropped PSPs keep their color. */ +export function CommitmentPacingChart({ + connectors, + currency, + daySecs, + colorFor, + statusFor, + eliminatedAtMs, + eliminationReasons, + isPastRun, + height = 460, + showLegend = true, + fixedWidth, + perDay, +}: { + connectors: CommitmentConnectorSeries[] + currency?: string | null + daySecs?: number | null + colorFor: (connector: string, index: number) => string + statusFor: (connector: string) => PacingStatus + /** When the engine dropped each PSP, from the audit trail. */ + eliminatedAtMs?: Map + eliminationReasons?: Map + isPastRun?: boolean + height?: number + showLegend?: boolean + /** Render at a fixed pixel width instead of filling the container — for server-side previews. */ + fixedWidth?: number + /** Buckets per contract day the series was fetched with; defaults to what the pages use. */ + perDay?: number +}) { + const dayMs = Math.max(1, (daySecs ?? SECS_PER_DAY) * 1000) + const bucketDays = 1 / Math.max(1, perDay ?? bucketsPerDay(daySecs)) + + // A live run re-renders on its own clock so the tentative tail keeps pace with "now" between + // polls; a finished run has nothing to advance. + const [, setTick] = useState(0) + useEffect(() => { + if (isPastRun) return undefined + const id = window.setInterval(() => setTick((t) => t + 1), 500) + return () => window.clearInterval(id) + }, [isPastRun]) + const dayLabel = dayUnit(daySecs).short + const daysTotal = Math.max(1, ...connectors.map((c) => c.daysTotal)) + const cycleStartMs = Math.min(...connectors.map((c) => Date.parse(c.cycleStart) || Number.POSITIVE_INFINITY)) + const nowDay = Number.isFinite(cycleStartMs) + ? Math.min(daysTotal, Math.max(0, (Date.now() - cycleStartMs) / dayMs)) + : daysTotal + + const { rows, metas, steers, drops, yMax, yTicks } = useMemo(() => { + const clampDay = (day: number) => Math.min(daysTotal, day) + const dayOf = (ms: number) => clampDay(Math.max(0, (ms - cycleStartMs) / dayMs)) + // The run's right edge: the cycle end once it is over, else this instant. Nothing is drawn + // past it, and a bucket still in progress is cut off at it. + const edge = isPastRun ? daysTotal : nowDay + + // Each PSP's line as nodes: the running total at the *end* of every reported bucket (so a + // bucket's delivery is drawn across the time it happened in), with a hold node where reported + // buckets are not adjacent — the series omits empty buckets, and a gap with data on both + // sides is a genuinely quiet stretch, which is flat, not a slope. Nothing is added after the + // last reported bucket: a silent tail is either ingestion lag or quiet, and drawing it flat + // to "now" would have to be redrawn as a slope once the late buckets arrived. + type Node = { at: number; total: number; steered: number } + const PACE_BUCKETS = 3 + const nodesFor = (c: CommitmentConnectorSeries): { nodes: Node[]; pace: number } => { + const pts = [...c.points].sort((a, b) => a.day - b.day) + const nodes: Node[] = [] + let running = 0 + let prevDay: number | null = null + for (const p of pts) { + const day = clampDay(p.day) + if (prevDay != null && day - prevDay > bucketDays * 1.5) { + nodes.push({ at: Math.min(day, edge), total: running, steered: 0 }) + } + running += p.total + nodes.push({ at: Math.min(day + bucketDays, edge), total: running, steered: p.steered }) + prevDay = day + } + // Recent pace from the last few *complete* buckets — one still in progress (cut at the + // edge) would understate it. Measured over the span they cover, so a quiet bucket counts. + const complete = pts.filter((p) => clampDay(p.day) + bucketDays <= edge + 1e-9) + const recent = complete.slice(-PACE_BUCKETS) + const span = recent.length ? clampDay(recent[recent.length - 1].day) + bucketDays - clampDay(recent[0].day) : 0 + const pace = span > 0 ? recent.reduce((sum, p) => sum + p.total, 0) / span : 0 + return { nodes, pace } + } + const nodesByName = new Map(connectors.map((c) => [c.connector, nodesFor(c)])) + + const metas: ConnectorMeta[] = connectors.map((c, i) => { + const status = statusFor(c.connector) + const droppedAt = eliminatedAtMs?.get(c.connector) + const dropDay = + status === 'eliminated' || status === 'missed' + ? droppedAt != null && Number.isFinite(cycleStartMs) + ? dayOf(droppedAt) + : undefined + : undefined + const { nodes, pace } = nodesByName.get(c.connector) ?? { nodes: [], pace: 0 } + return { + name: c.connector, + color: colorFor(c.connector, i), + goal: c.goal, + reward: c.reward, + status, + dropDay, + endDay: isPastRun ? daysTotal : nodes.length ? nodes[nodes.length - 1].at : 0, + endTotal: 0, + pace, + } + }) + + // Every instant any PSP's line bends at, plus the ends of the promise lines and the drops. + const dayset = new Set([0, daysTotal, ...(isPastRun ? [] : [nowDay])]) + for (const { nodes } of nodesByName.values()) for (const n of nodes) dayset.add(n.at) + for (const m of metas) if (m.dropDay != null) dayset.add(m.dropDay) + // A row on every day boundary, so the target ladder steps exactly where a day ends. + for (let k = 1; k <= daysTotal; k += 1) dayset.add(k) + const days = [...dayset].sort((a, b) => a - b) + + const rows: Record[] = [] + const running: Record = {} + const pointer: Record = {} + const steers: Steer[] = [] + for (const day of days) { + const row: Record = { day } + for (const m of metas) { + const nodes = nodesByName.get(m.name)?.nodes ?? [] + let idx = pointer[m.name] ?? 0 + while (idx < nodes.length && nodes[idx].at <= day + 1e-9) { + const startTotal = running[m.name] ?? 0 + running[m.name] = nodes[idx].total + if (nodes[idx].steered > 0) { + // The triangle sits on the line where the steered bucket ends. + steers.push({ + name: m.name, + day: nodes[idx].at, + endDay: nodes[idx].at, + amount: nodes[idx].steered, + total: nodes[idx].total, + startTotal, + color: m.color, + }) + } + idx += 1 + } + pointer[m.name] = idx + const total = running[m.name] ?? 0 + if (day <= m.endDay + 1e-9) { + if (m.dropDay != null && day >= m.dropDay) { + row[`${m.name}__after`] = total + // Share the drop point so the colored line hands over to the gray one without a gap. + if (day === m.dropDay) row[m.name] = total + } else { + row[m.name] = total + } + m.endTotal = total + } + // The stretch not yet measured: from the last bucket to "now", projected at the recent + // pace and drawn tentatively. Shares its first point with the line so there is no gap. + if (!isPastRun && nowDay > m.endDay + 1e-9 && (day === nowDay || Math.abs(day - m.endDay) < 1e-9)) { + row[`${m.name}__pending`] = m.endTotal + m.pace * (day - m.endDay) + } + row[`${m.name}__promise`] = dayTarget(m.goal, clampDay(day), daysTotal) + } + rows.push(row) + } + + // Steer triangles: one per bucket is noise on a long cycle — keep the largest few per PSP. + const MAX_STEERS_PER_PSP = 6 + const keptSteers = metas.flatMap((m) => + steers + .filter((s) => s.name === m.name) + .sort((a, b) => b.amount - a.amount) + .slice(0, MAX_STEERS_PER_PSP), + ) + + const dropsByDay = new Map() + for (const m of metas) { + if (m.dropDay == null) continue + const key = Math.round(m.dropDay * 100) / 100 + const drop = dropsByDay.get(key) ?? { day: m.dropDay, names: [], reason: eliminationReasons?.get(m.name) } + drop.names.push(m.name) + dropsByDay.set(key, drop) + } + const drops = [...dropsByDay.values()].sort((a, b) => a.day - b.day) + + // Round the axis up to a clean step so the ticks read $2.5M / $5M rather than $8.7M. + const projected = (m: ConnectorMeta) => (isPastRun ? m.endTotal : m.endTotal + m.pace * Math.max(0, nowDay - m.endDay)) + const raw = Math.max(1, ...metas.map((m) => Math.max(m.goal, m.endTotal, projected(m)))) * 1.05 + const magnitude = 10 ** Math.floor(Math.log10(raw)) + const unit = raw / magnitude + const step = (unit <= 2 ? 0.5 : unit <= 5 ? 1 : 2.5) * magnitude + const yMax = Math.ceil(raw / step) * step + const yTicks: number[] = [] + for (let v = 0; v <= yMax + 1e-9; v += step) yTicks.push(v) + return { rows, metas, steers: keptSteers, drops, yMax, yTicks } + }, [connectors, colorFor, statusFor, eliminatedAtMs, eliminationReasons, isPastRun, daysTotal, nowDay, cycleStartMs, dayMs, bucketDays]) + + // ── The window: a slice of the cycle that follows the run, or the whole cycle ("All"). ────── + // Opens on the first contract day so an early run is legible instead of a sliver in the + // bottom-left of a whole-cycle axis, then slides forward with the run; "All" unzooms. + const windowOptions = useMemo( + () => (daysTotal <= 8 ? [1, 2] : [7, 14]).filter((w) => w < daysTotal), + [daysTotal], + ) + const [windowSel, setWindowSel] = useState(daysTotal <= 8 ? 1 : 7) + const win = windowSel === 'all' || !windowOptions.includes(windowSel) ? null : windowSel + const runEdge = isPastRun ? daysTotal : nowDay + const xHi = win == null ? daysTotal : Math.min(daysTotal, Math.max(win, runEdge)) + const xLo = win == null ? 0 : Math.max(0, xHi - win) + + const ticks = useMemo(() => { + const span = xHi - xLo + // Inside a window, snap the step to a clean value (¼, ½, 1, 2, 5…) so ticks read + // "Min 0.75" rather than "Min 0.85"; the whole cycle keeps its usual spacing. + const NICE = [0.25, 0.5, 1, 2, 5, 10, 15, 30] + const step = + win != null + ? [...NICE].reverse().find((n) => n <= span / 4) ?? 0.25 + : daysTotal <= 8 ? 1 : daysTotal <= 16 ? 2 : daysTotal <= 40 ? 6 : Math.ceil(daysTotal / 6) + const out: number[] = [] + const first = win != null ? Math.ceil(xLo / step) * step : xLo + for (let d = first; d < xHi - 1e-9; d += step) out.push(Math.round(d * 100) / 100) + out.push(Math.round(xHi * 100) / 100) + return out + }, [daysTotal, win, xLo, xHi]) + + // The value axis follows the window too: zoomed to what is on screen, with clean steps. + const { yLo, yHi, yTicksInView } = useMemo(() => { + if (win == null) return { yLo: 0, yHi: yMax, yTicksInView: yTicks } + const values: number[] = [] + for (const m of metas) { + values.push(dayTarget(m.goal, xLo, daysTotal), dayTarget(m.goal, xHi, daysTotal)) + } + for (const row of rows) { + const day = Number(row.day ?? 0) + if (day < xLo - 1e-9 || day > xHi + 1e-9) continue + for (const m of metas) { + for (const key of [m.name, `${m.name}__after`, `${m.name}__pending`]) { + const v = row[key] + if (typeof v === 'number') values.push(v) + } + } + } + const lo = Math.max(0, Math.min(...values, Number.POSITIVE_INFINITY)) + const hi = Math.max(...values, 1) + const span = Math.max(hi - lo, hi * 0.1, 1) + const magnitude = 10 ** Math.floor(Math.log10(span)) + const unit = span / magnitude + const step = (unit <= 2 ? 0.5 : unit <= 5 ? 1 : 2.5) * magnitude + const floor = Math.max(0, Math.floor((lo - span * 0.05) / step) * step) + const ceil = Math.ceil((hi + span * 0.08) / step) * step + const t: number[] = [] + for (let v = floor; v <= ceil + 1e-9; v += step) t.push(v) + return { yLo: floor, yHi: ceil, yTicksInView: t } + }, [win, rows, metas, xLo, xHi, daysTotal, yMax, yTicks]) + + const anyDrop = drops.length > 0 + const anySteer = steers.length > 0 + + const windowLabel = (w: number) => `Last ${w} ${w === 1 ? 'day' : 'days'}` + + return ( +
+ {windowOptions.length > 0 && ( +
+
+ {[...windowOptions, 'all' as const].map((opt) => { + const active = opt === 'all' ? win == null : win === opt + return ( + + ) + })} +
+
+ )} +
+ + + + `${dayLabel} ${Number.isInteger(d) ? d : d.toFixed(2).replace(/0+$/, '')}`} + tick={{ fontSize: 11 }} + stroke="currentColor" + opacity={0.5} + tickLine={false} + /> + formatMoney(v, currency)} + tick={{ fontSize: 11 }} + stroke="currentColor" + opacity={0.5} + width={60} + tickLine={false} + /> + } + wrapperStyle={{ zIndex: 30, outline: 'none' }} + cursor={{ stroke: 'currentColor', opacity: 0.2 }} + /> + {drops + .filter((d) => d.day <= xHi) + .map((d) => ( + + ))} + {drops + .filter((d) => d.day >= xLo && d.day <= xHi) + .map((d) => ( + + ))} + {metas.map((m) => ( + + ))} + {!isPastRun && + metas.map((m) => ( + + ))} + {metas.map((m) => ( + + ))} + {metas + .filter((m) => m.dropDay != null) + .map((m) => ( + + ))} + + } + /> + + +
+ {showLegend && ( +
+ {metas.map((m) => ( + + + {m.name} — running total + + ))} + + + dashed steps = target to clear by each {dayLabel.toLowerCase()}'s end + + {!isPastRun && ( + + + dotted = not yet measured, at recent pace + + )} + {anySteer && ( + + steered chunk + + )} + {anyDrop && ( + + + commitment dropped + + )} +
+ )} +
+ ) +} + +/** One card per PSP: promise, reward terms, reward, and current standing by badge. */ +export function CommitmentContractCards({ + connectors, + currency, + colorFor, + statusFor, + achievedFor, + reasonFor, +}: { + connectors: CommitmentConnectorSeries[] + currency?: string | null + colorFor: (connector: string, index: number) => string + statusFor: (connector: string) => PacingStatus + achievedFor: (connector: string) => number + reasonFor?: (connector: string) => string | undefined +}) { + return ( +
+ {connectors.map((c, i) => { + const status = statusFor(c.connector) + const color = colorFor(c.connector, i) + const achieved = achievedFor(c.connector) + const goalText = formatMoney(c.goal, currency) + const isRebate = c.rewardNote.includes('%') + const rebate = isRebate ? c.rewardNote.split(' ')[0] : null + const chip = + status === 'met' + ? { text: 'Met', cls: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } + : status === 'steering' + ? { text: 'Steering', cls: 'bg-amber-500/10 text-amber-600 dark:text-amber-300' } + : status === 'eliminated' + ? { text: 'Eliminated', cls: 'bg-red-500/10 text-red-600 dark:text-red-300' } + : status === 'missed' + ? { text: 'Missed', cls: 'bg-slate-500/10 text-slate-600 dark:text-slate-300' } + : status === 'on_pace' + ? { text: 'On pace', cls: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } + : { text: 'Pending', cls: 'bg-slate-500/10 text-slate-600 dark:text-slate-300' } + return ( +
+
+ + + {c.connector} + + {chip.text} +
+

+ Promise {goalText} · {c.rewardNote} +

+

+ {formatMoneyExact(c.reward, currency)} +

+

+ {rebate ? `${rebate} × ${goalText}` : `flat on hitting ${goalText}`} +

+

+ {formatAchieved(achieved, c.goal, currency)} delivered · {pctOfGoal(achieved, c.goal)}% +

+
+ ) + })} +
+ ) +} diff --git a/website/src/components/pages/ContractSimulationPanel.tsx b/website/src/components/pages/ContractSimulationPanel.tsx new file mode 100644 index 00000000..c6c21659 --- /dev/null +++ b/website/src/components/pages/ContractSimulationPanel.tsx @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react' +import { Handshake } from 'lucide-react' +import { Card, CardBody } from '../ui/Card' +import { Badge } from '../ui/Badge' +import { Button } from '../ui/Button' +import { apiPost } from '../../lib/api' +import { useVolumeCommitment, useVolumeCommitmentSeries } from '../../hooks/useVolumeCommitment' +import { SECS_PER_DAY, compactAmount as compact, isTestCycle as isTestCycleOf } from './volumeCommitmentChartBits' + +/** Extra payments over the seconds left, so latency cannot end the run before the cycle does. */ +const RUN_HEADROOM = 3 + +/** Loads the simulator from the active contract, sized to the time left in the cycle. */ +export function ContractSimulationPanel({ + merchantId, + isSimulating, + tps, + onContractGone, + onCycleEnded, + onLoad, +}: { + merchantId: string | null + isSimulating: boolean + /** Payments the run fires per second — the divisor that turns a daily total into a ticket size. */ + tps: number + /** Called when no contract is available, so the page can drop a pace set by an earlier Load. */ + onContractGone: () => void + /** Called when the cycle closes mid-run: volume sent past it lands in the next period. */ + onCycleEnded: () => void + onLoad: (preset: { + gateways: string[] + amount: number + totalPayments: number + paceMs: number + }) => void +}) { + const { data, mutate } = useVolumeCommitment(merchantId ?? undefined) + const series = useVolumeCommitmentSeries(merchantId ?? undefined) + const seriesMutate = series.mutate + const connectors = series.data?.connectors ?? [] + const hasContract = Boolean(data?.active) && connectors.length > 0 + + // The page's callbacks change identity every render; the effects below key on facts, not on them. + const callbacks = useRef({ onContractGone, onCycleEnded }) + callbacks.current = { onContractGone, onCycleEnded } + + // Re-render every second so the countdown ticks and a run can be cut off the moment the cycle + // closes, without waiting for the next poll. + const [, setNow] = useState(Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (!hasContract) callbacks.current.onContractGone() + }, [hasContract]) + + const cycleEndMs = connectors[0] ? Date.parse(connectors[0].cycleEnd) : 0 + const secondsLeft = cycleEndMs ? Math.max(0, Math.round((cycleEndMs - Date.now()) / 1000)) : 0 + const cycleOver = Boolean(cycleEndMs) && secondsLeft === 0 + + // A run outliving its cycle delivers into the next period, against goals that have just reset. + useEffect(() => { + if (cycleOver && isSimulating) callbacks.current.onCycleEnded() + }, [cycleOver, isSimulating]) + + // Refetch on cycle close so the countdown does not sit at "Cycle over" until the next poll. + useEffect(() => { + if (!cycleOver) return + void seriesMutate() + void mutate() + }, [cycleOver, seriesMutate, mutate]) + + if (!hasContract || !data) return null + + const daySecs = data.daySecs ?? SECS_PER_DAY + const expectedDaily = data.expectedDailyTraffic ?? 0 + const isTestCycle = isTestCycleOf(daySecs) + const cycleDays = Math.max(...connectors.map((c) => c.daysTotal), 1) + + // Volume rate is a contract term: `expectedDaily` per contract day, however many payments carry + // it. TPS only decides how finely that is chopped — more payments of proportionally less each. + const paymentsPerDay = Math.max(1, Math.round(tps * daySecs)) + const perPayment = expectedDaily > 0 ? Math.max(1, Math.round(expectedDaily / paymentsPerDay)) : 1000 + const paceMs = Math.max(1, Math.round((daySecs * 1000) / paymentsPerDay)) + // Over-provisioned so the cycle, never the count, ends the run; `onCycleEnded` discards the rest. + const totalPayments = Math.max(1, Math.round(tps * secondsLeft * RUN_HEADROOM)) + + async function deactivate() { + if (!data?.ruleId || !merchantId) return + await apiPost('/routing/deactivate', { + created_by: merchantId, + routing_algorithm_id: data.ruleId, + }) + void mutate() + } + + return ( + + +
+
+ + + Active volume contract + + {cycleOver ? ( + Cycle over + ) : data.psps.length === 0 && data.eliminated.length === 0 ? ( + // The contract is live but no plan covers this cycle yet — right after activation, + // or in the seconds between one cycle closing and the next forecast landing. + Forecast pending + ) : isTestCycle ? ( + + Test cycle · {secondsLeft}s left of {cycleDays} min + + ) : ( + {cycleDays}-day cycle + )} +
+
+ + +
+
+ +
+ {connectors.map((c) => { + const pacing = data.psps.find((p) => p.connector === c.connector) + // A dropped PSP leaves `psps` for `eliminated`, but its delivered volume is still real. + const dropped = data.eliminated.find((e) => e.connector === c.connector) + const achieved = pacing?.achieved ?? dropped?.achieved ?? 0 + const eliminated = c.eliminated + return ( + + {c.connector} {compact(achieved)}/{compact(c.goal)} + {eliminated ? ( + · eliminated + ) : pacing?.steering ? ( + · steering + ) : null} + + ) + })} +
+ + {cycleOver ? ( +

+ This cycle has closed — its goals are settled and a new one has begun, with delivery + back at zero. Any run still going was stopped, because volume sent now counts toward the + next period. Reload the contract to drive the fresh cycle, or deactivate it to stop. +

+ ) : ( +

+ Every payment {perPayment.toLocaleString()} at {tps}/sec + — {compact(expectedDaily)} per day{isTestCycle ? ' (a minute on this test cycle)' : ''}, the contract's rate — until + the cycle closes. More TPS means smaller payments, not more volume; pausing is a real + traffic drop. +

+ )} +
+
+ ) +} diff --git a/website/src/components/pages/DecisionSimulatorPage.tsx b/website/src/components/pages/DecisionSimulatorPage.tsx index fca3ec3e..e72c135e 100644 --- a/website/src/components/pages/DecisionSimulatorPage.tsx +++ b/website/src/components/pages/DecisionSimulatorPage.tsx @@ -15,6 +15,8 @@ import { useMerchantStore } from '../../store/merchantStore' import { useMerchantFeatures } from '../../hooks/useMerchantFeatures' import { useAuthStore } from '../../store/authStore' import { apiErrorStatus, apiPost, fetcher } from '../../lib/api' +import { ContractSimulationPanel } from './ContractSimulationPanel' +import { VolumeCommitmentRunChart } from './VolumeCommitmentRunChart' import { CHART_TOOLTIP_ITEM_STYLE, CHART_TOOLTIP_LABEL_STYLE, CHART_TOOLTIP_STYLE } from '../../lib/chartStyles' import { DecideGatewayResponse, GatewayConnector, MultiObjectiveInfo, PaymentAuditEvent, PaymentAuditResponse, RankedPsp, RoutingEvent, RoutingEventType, UpdateScoreResponse } from '../../types/api' import { ROUTING_APPROACH_COLORS } from '../../lib/constants' @@ -320,6 +322,10 @@ interface SimulationResult { cardProgram?: string cardIssuerRegion?: string cardScenario?: string + // Whether the volume-commitment engine moved this payment, and — when it did — the PSP + // approval-rate routing had picked. Drives the per-PSP steering chart above the results. + steerOutcome?: 'STEERED' | 'SR_PREVAILED' | null + steerSrHead?: string | null } // Soft, sentence-case stat label (vs the all-caps SurfaceLabel) for the cost/auth summary. @@ -425,6 +431,42 @@ type VolumePaymentEntry = { const EXPLORER_STORAGE_KEY_PREFIX = 'decision-explorer-state-v2' const EXPLORER_RESULT_TTL_MS = 10 * 60 * 1000 +/** + * Run control and the committed rows, held outside the component tree. + * + * The traffic loop is plain async work against the API — navigating away only destroys the React + * state it writes to, not the work itself. Keeping these here lets a run keep driving traffic + * while you watch it land on Analytics, and lets the page reattach to a run still in flight when + * you come back. `useRef` already handed out `{ current }` objects, so these are drop-in. + */ +const simulationAbortRef = { current: false } +const runGenerationRef = { current: 0 } +const simulationPausedRef = { current: false } +const runProgressRef = { current: 0 } + +/** + * What a run in flight has produced so far, plus whoever is currently displaying it. + * + * The loop's `setState` calls belong to the component instance that started it; navigating away + * destroys that instance, so its writes go nowhere and a remounted page would sit frozen on the + * last rows it happened to see. Publishing here and letting the mounted page subscribe keeps the + * display attached to the run rather than to the instance that launched it. + */ +const liveRun: { + running: boolean + results: SimulationResult[] + subscribers: Set<() => void> +} = { + running: false, + results: [], + subscribers: new Set(), +} + +/** Tell whichever page is mounted that the run moved. */ +function publishLiveRun() { + liveRun.subscribers.forEach(notify => notify()) +} + const DEFAULT_FORM: FormState = { amount: '1000', currency: '', @@ -1190,12 +1232,20 @@ export function DecisionSimulatorPage() { amountRangeRef.current = { min: simulationConfig.minAmount, max: simulationConfig.maxAmount } }, [simulationConfig.minAmount, simulationConfig.maxAmount]) const [errorInfo, setErrorInfo] = useState(initialState.errorInfo) + /** + * Milliseconds between dispatches, and only ever set by loading a volume contract. + * + * A contract states volume *per contract day*, so its run has to be spread across that day + * rather than delivered in one burst — the pacing controller measures a rate, and a burst reads + * as a day's traffic arriving in five seconds. Every other run wants full speed, so this is kept + * out of `SimulationConfig` (which is persisted) and cleared whenever the contract goes away or + * the tab is reset: loading a contract once must never leave later simulations throttled. + */ + const [contractPaceMs, setContractPaceMs] = useState(0) const [gatewaySimConfigs, setGatewaySimConfigs] = useState>(initialState.gatewaySimConfigs) const gatewaySimConfigsRef = useRef(gatewaySimConfigs) useEffect(() => { gatewaySimConfigsRef.current = gatewaySimConfigs }, [gatewaySimConfigs]) const eliminationEnabled = Object.values(gatewaySimConfigs).some(c => c.failureMode === 'timeout') - const simulationAbortRef = useRef(false) - useEffect(() => () => { simulationAbortRef.current = true }, []) /** * Bumped on every run start, and by anything that discards a run outright (Clear results, a * merchant-scope switch). A run captures the value at entry and only touches shared state while it @@ -1205,7 +1255,6 @@ export function DecisionSimulatorPage() { * the engine. The token can only move forward, so a superseded run can never mistake itself for * the current one. */ - const runGenerationRef = useRef(0) const [debitForm, setDebitForm] = useState(initialState.debitForm) @@ -1236,10 +1285,8 @@ export function DecisionSimulatorPage() { // aborted, so position, outcome accumulators, the feed, and backend scores are // all preserved across a pause. const [isPaused, setIsPaused] = useState(false) - const simulationPausedRef = useRef(false) // Batch index (loop `start`) the run should continue from. The pause-idle block // writes the next-unrun index here so it survives leaving and returning to the page. - const runProgressRef = useRef(0) // A paused run that outlived an unmount (e.g. the user paused, opened Multi-Objective // config, then came back). Persisted so returning offers "Resume" — which continues // from runProgressRef's saved index instead of restarting — rather than a fresh run. @@ -1680,6 +1727,7 @@ export function DecisionSimulatorPage() { setVolumeDistribution(defaults.volumeDistribution) setVolumeEvaluationLog(defaults.volumeEvaluationLog) setVolumeProgress(defaults.volumeProgress) + liveRun.results = defaults.simulationResults setSimulationResults(defaults.simulationResults) setSimulationStartedAtMs(null) setResponseOpen(defaults.responseOpen) @@ -1729,6 +1777,9 @@ export function DecisionSimulatorPage() { setVolumeDistribution(nextState.volumeDistribution) setVolumeEvaluationLog(nextState.volumeEvaluationLog) setVolumeProgress(nextState.volumeProgress) + liveRun.results = nextState.simulationResults + liveRun.running = false + setContractPaceMs(0) setSimulationResults(nextState.simulationResults) setSimulationStartedAtMs(null) setResponseOpen(nextState.responseOpen) @@ -1757,6 +1808,25 @@ export function DecisionSimulatorPage() { setIsSimulating(false) } + // Follow the run for as long as this page is mounted — on first mount to reattach to one + // already in flight, and on every tick thereafter, since the loop publishes rather than calling + // this instance's setters directly. + useEffect(() => { + const sync = () => { + setSimulationResults(liveRun.results) + setIsSimulating(liveRun.running) + setIsPaused(liveRun.running && simulationPausedRef.current) + } + liveRun.subscribers.add(sync) + // Adopt straight away rather than waiting for the next flush (which may be a second or more + // out) — both for a run still going and for one that finished while the page was away, whose + // closing rows were never written to any mounted instance. + if (liveRun.running || liveRun.results.length > 0) sync() + return () => { + liveRun.subscribers.delete(sync) + } + }, []) + useEffect(() => { if (stateScopeKey === currentScopeKey) return applyExplorerState(loadExplorerState(currentScopeKey), currentScopeKey) @@ -2248,6 +2318,7 @@ export function DecisionSimulatorPage() { merchantId: effectiveMerchantId, }) // Clear local state so the UI reflects the fresh-scores starting point. + liveRun.results = [] setSimulationResults([]) setTxFilters({}) routingEvents.refresh() @@ -2275,13 +2346,17 @@ export function DecisionSimulatorPage() { const isResume = resumeFrom > 0 setIsSimulating(true) + liveRun.running = true setIsPaused(false) simulationPausedRef.current = false setResumableRun(null) setSimulationStartedAtMs(Date.now()) setError(null) setSetupPrompt(null) - if (!isResume) setSimulationResults([]) + if (!isResume) { + liveRun.results = [] + setSimulationResults([]) + } simulationAbortRef.current = false // Claim ownership of the shared run state. Any earlier run still unwinding is now superseded and // will drop out of its loop without writing. @@ -2324,6 +2399,7 @@ export function DecisionSimulatorPage() { // pool size below). Read once here so a mid-run slider change can't reshape an in-flight run. // 1 reproduces the original strictly-sequential loop. const concurrency = Math.max(1, Math.min(MAX_SIMULATION_TPS, Math.round(simulationConfig.tps) || 1)) + const paceMs = Math.max(0, Math.round(contractPaceMs)) // One full transaction (decide → score → optional smart retry). Returns the row to // append; throws on a backend error so the batch can tally it. `drawSuccess` mutates @@ -2449,6 +2525,8 @@ export function DecisionSimulatorPage() { cardProgram, cardIssuerRegion: cardIssuerCountry, cardScenario: variant?.label, + steerOutcome: decideRes.volume_steer_info?.outcome ?? null, + steerSrHead: decideRes.volume_steer_info?.srHead ?? null, } } @@ -2470,7 +2548,8 @@ export function DecisionSimulatorPage() { if (!isCurrentRun()) return const now = Date.now() if (force || now - lastUIUpdate > 150) { - setSimulationResults([...results]) + liveRun.results = [...results] + publishLiveRun() markExplorerRunDataUpdated() lastUIUpdate = now } @@ -2524,6 +2603,12 @@ export function DecisionSimulatorPage() { // Record the resume point as the committed count and flush on the shared throttle. runProgressRef.current = results.length flushResults(false) + + // Hold the configured rate. Each of the `concurrency` workers waits its own share, so + // the run as a whole dispatches one payment every `paceMs`. + if (paceMs > 0) { + await new Promise(resolve => setTimeout(resolve, paceMs * concurrency)) + } } } @@ -2542,10 +2627,10 @@ export function DecisionSimulatorPage() { // `isPaused` now describe the run that replaced it — flipping them here is what let the old // run hide a live run's Stop button and re-offer Run simulation. if (isCurrentRun()) { - setSimulationResults([...results]) - setIsSimulating(false) - setIsPaused(false) + liveRun.running = false + liveRun.results = [...results] simulationPausedRef.current = false + publishLiveRun() } // Events from the last txns can land just after the loop ends. Safe either way — it only // refetches the feed. @@ -2763,6 +2848,20 @@ export function DecisionSimulatorPage() { ) }, [eligibleGatewaysParsed]) + // The same color the trend charts use for a connector; a contract PSP that is not in the + // eligible list yet still gets a stable palette slot rather than the first color every time. + const colorForGateway = useMemo(() => { + const map = gatewayColorMap as Record + return (gateway: string) => { + if (map[gateway]) return map[gateway] + const override = GW_COLOR_OVERRIDES[gateway.toLowerCase()] + if (override) return override + let hash = 0 + for (const ch of gateway) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0 + return GW_PALETTE[hash % GW_PALETTE.length] + } + }, [gatewayColorMap]) + // Auto-populate errorInfo for any gateway whose config has no error code yet, // once GSM rules have loaded from the API. useEffect(() => { @@ -3344,6 +3443,7 @@ export function DecisionSimulatorPage() { /** Everything below the control bar — the charts, the summary column and the transaction log. */ function clearBatchResults() { const defaults = getDefaultExplorerState() + liveRun.results = defaults.simulationResults setSimulationResults(defaults.simulationResults) setResumableRun(defaults.resumableRun) // Abort any in-flight run and drop the run-start timestamp so the @@ -3377,6 +3477,8 @@ export function DecisionSimulatorPage() { // The batch tab's own bar exposes these two separately; "reset the tab" is just both. resetBatchInputs() clearBatchResults() + // A pace only belongs to a loaded contract; resetting the tab drops it with everything else. + setContractPaceMs(0) } else if (activeTab === 'rule') { setRuleResetSignal(n => n + 1) } else if (activeTab === 'volume') { @@ -3854,6 +3956,39 @@ export function DecisionSimulatorPage() { style={activeTab === 'rule' ? { display: 'none' } : undefined} >
+ {activeTab === 'batch' && ( + setContractPaceMs(0)} + onCycleEnded={() => { + // Volume sent past the cycle end lands in the next period, against goals that have + // just reset — stop rather than quietly mis-attribute it. + simulationAbortRef.current = true + setContractPaceMs(0) + }} + onLoad={({ gateways, amount, totalPayments, paceMs }) => { + setContractPaceMs(paceMs) + // `form.amount` drives the fixed-amount path and the min/max pair drives the + // multi-objective one; setting both keeps the ticket exact either way. + setForm(f => ({ ...f, eligible_gateways: gateways.join(', '), amount: String(amount) })) + setSimulationConfig(c => ({ + ...c, + minAmount: amount, + maxAmount: amount, + totalPayments: String(totalPayments), + })) + }} + /> + )} + {activeTab === 'batch' && ( + + )} {activeTab === 'batch' && ( diff --git a/website/src/components/pages/RoutingEventsPage.tsx b/website/src/components/pages/RoutingEventsPage.tsx index a7f3a477..db7f5da8 100644 --- a/website/src/components/pages/RoutingEventsPage.tsx +++ b/website/src/components/pages/RoutingEventsPage.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { ArrowRightLeft, BellRing, Target, TrendingDown, SlidersHorizontal } from 'lucide-react' +import { ArrowRightLeft, BellRing, SlidersHorizontal } from 'lucide-react' import { describeRoutingEvent, useRoutingEvents } from '../../hooks/useRoutingEvents' import { AnalyticsRangeValue, RoutingEvent, RoutingEventType } from '../../types/api' import { Badge } from '../ui/Badge' @@ -14,13 +14,14 @@ const PRESET_OPTIONS: { value: AnalyticsRangeValue; label: string }[] = [ { value: '1w', label: 'Last 1 week' }, ] -const EVENT_TYPE_META: Record< - RoutingEventType, - { label: string; badge: 'blue' | 'green' | 'orange' | 'purple'; icon: React.ElementType } +/** + * Only the event types the feed can actually carry. Auth-band crossings are switched off + * server-side (`resolve_auth_band` always returns Off), so they are not offered as filters. + */ +const EVENT_TYPE_META: Partial< + Record > = { leader_changed: { label: 'Leader change', badge: 'blue', icon: ArrowRightLeft }, - gateway_entered_auth_band: { label: 'Entered auth band', badge: 'green', icon: Target }, - gateway_exited_auth_band: { label: 'Exited auth band', badge: 'orange', icon: TrendingDown }, calibration_applied: { label: 'Autopilot tuning', badge: 'purple', icon: SlidersHorizontal }, } @@ -65,7 +66,7 @@ export function RoutingEventsPage() {
- +
{unseenCount > 0 && ( @@ -93,6 +94,7 @@ export function RoutingEventsPage() {
{ALL_EVENT_TYPES.map((type) => { const meta = EVENT_TYPE_META[type] + if (!meta) return null const active = activeTypes.has(type) return ( +
@@ -604,6 +611,10 @@ export function SRRoutingPage() { {/* ── Cost Estimation tab ── */} {activeTab === 'cost' && } + + {/* ── Volume Contracts tab: the contract editor, hosted here beside the other routing + objectives. It keeps its own data hooks; only the page chrome is dropped. ── */} + {activeTab === 'volume' && } )}
@@ -818,6 +829,12 @@ const SR_FEATURES: { feature: KnownFeature; title: string; description: string; 'Multi-objective routing: alongside approval rate, weighs each PSP\'s expected cost and picks the highest expected-value option. Works with either the Autopilot or Manual scoring config.', docsUrl: 'https://docs.hyperswitch.io/integration-guide/workflows/intelligent-routing/routing-strategies/multi-objective-routing', }, + { + feature: 'volume-contracts', + title: 'Volume contracts (meet PSP commitments)', + description: + 'Multi-objective routing: keeps approval-rate routing in charge, but when a contracted volume commitment is drifting behind pace, steers a little extra volume to that PSP — only onto payments where it approves about as well, so approvals barely move. Runs alongside Cost savings; when both are on, a behind-pace commitment takes priority for eligible payments.', + }, ] function SrDimensionsConfig({ merchantId }: { merchantId: string | null }) { diff --git a/website/src/components/pages/VolumeCommitmentAnalytics.tsx b/website/src/components/pages/VolumeCommitmentAnalytics.tsx new file mode 100644 index 00000000..0483de44 --- /dev/null +++ b/website/src/components/pages/VolumeCommitmentAnalytics.tsx @@ -0,0 +1,805 @@ +import { useCallback, useMemo, useState } from 'react' +import { + Bar, + BarChart, + CartesianGrid, + Customized, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { Handshake } from 'lucide-react' +import { Card, CardBody, CardHeader, SurfaceLabel } from '../ui/Card' +import { Badge } from '../ui/Badge' +import { ErrorMessage } from '../ui/ErrorMessage' +import { Spinner } from '../ui/Spinner' +import { useMerchantStore } from '../../store/merchantStore' +import { + useVolumeCommitment, + useVolumeCommitmentAudit, + useVolumeCommitmentImpact, + useVolumeCommitmentSeries, +} from '../../hooks/useVolumeCommitment' +import { CHART_TOOLTIP_LABEL_STYLE, CHART_TOOLTIP_STYLE } from '../../lib/chartStyles' +import { CommitmentAuditEvent, CommitmentConnectorImpact, CommitmentConnectorSeries } from '../../types/api' +import { CommitmentContractCards, CommitmentPacingChart, PacingStatus } from './CommitmentPacingChart' +import { + COMMITMENT_SERIES_COLORS, + DashSwatch, + HatchDefs, + HatchSwatch, + SECS_PER_DAY, + SolidSwatch, + bucketsPerDay, + compactAmount, + dayUnit, + firstEliminationByConnector, + formatMoney, + hatchId, pctOfGoal} from './volumeCommitmentChartBits' + +/** Stable empty fallbacks, so memos keyed on "no data yet" do not recompute every render. */ +const NO_SERIES: CommitmentConnectorSeries[] = [] +const NO_IMPACT: CommitmentConnectorImpact[] = [] + +/** Color by contract position — a PSP keeps it whatever its standing. */ +function seriesColor(index: number) { + return COMMITMENT_SERIES_COLORS[index % COMMITMENT_SERIES_COLORS.length] +} + +/** Picker chips — selected reads as a filled pill, the rest as quiet outlines. */ +function chipClass(selected: boolean) { + return `rounded-full border px-2.5 py-1 text-xs font-medium tabular-nums transition-colors ${ + selected + ? 'border-brand-500/50 bg-brand-500/10 text-brand-600 dark:text-brand-400' + : 'border-slate-200 text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-white/5' + }` +} + +const AUDIT_BADGES: Record = { + forecast: { label: 'Forecast', variant: 'blue' }, + steered: { label: 'Steered', variant: 'orange' }, + eliminated: { label: 'Eliminated', variant: 'red' }, +} + +/** Runs offered in the picker — the newest few; older cycles stay reachable through the API. */ +const RUNS_SHOWN = 10 +/** Audit entries rendered, out of the window the backend returns. */ +const AUDIT_EVENTS_SHOWN = 200 + +function formatWhen(ms: number) { + return new Date(ms).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatCount(value: number) { + return value.toLocaleString() +} + +function pct(part: number, whole: number) { + return whole > 0 ? `${((part / whole) * 100).toFixed(0)}%` : '—' +} + +type Metric = 'volume' | 'payments' + +/** The formatter a metric's numbers read in. */ +function fmtFor(metric: Metric) { + return metric === 'volume' ? compactAmount : formatCount +} + +/** One row of the before / with-contract charts. */ +type ImpactRow = { + name: string + color: string + hatch: string + eliminated: boolean + goal: number + before: number + unaided: number + steered: number + beforePayments: number + unaidedPayments: number + steeredPayments: number + cededPayments: number + ceded: number +} + +/** One contract day on the day-wise charts: a label plus `__unaided` / `__steered` pairs, + * and `__pace` — what that promise needed *that* day given what had landed before it. */ +type DayRow = { day: string } & Record + +type BandScale = ((value: string) => number | undefined) & { bandwidth?: () => number } + +/** A dashed segment per PSP across each day's band at that day's required pace; mounted through + * `` so it can read the chart's band and value scales. */ +export function PaceMarkers(props: { + rows: DayRow[] + psps: ImpactRow[] + xAxisMap?: Record + yAxisMap?: Record number | undefined }> +}) { + const { rows, psps, xAxisMap, yAxisMap } = props + const xAxis = xAxisMap ? Object.values(xAxisMap)[0] : undefined + const yAxis = yAxisMap ? Object.values(yAxisMap)[0] : undefined + if (!xAxis || !yAxis) return null + const band = xAxis.scale.bandwidth?.() ?? 0 + if (band <= 0) return null + const inset = band * 0.08 + return ( + + {rows.flatMap((row) => + psps.map((r) => { + const value = row[`${r.name}__pace`] + if (typeof value !== 'number') return null + const x0 = xAxis.scale(row.day) + const y = yAxis.scale(value) + if (x0 == null || y == null) return null + return ( + + ) + }), + )} + + ) +} + +/** Per-day stacked columns: solid unaided, hatched steered; dashed = what each promise needed that day. */ +function DayWiseChart({ + rows, + psps, + metric, + yMax, + hatchScope, +}: { + rows: DayRow[] + psps: ImpactRow[] + metric: Metric + yMax: number + hatchScope: string +}) { + const fmt = fmtFor(metric) + const hatch = (name: string) => hatchId(hatchScope, name) + return ( +
+ + + ({ id: hatch(r.name), color: r.color }))} />} /> + + `Day ${d}`} + tick={{ fontSize: 11 }} + stroke="currentColor" + opacity={0.5} + tickLine={false} + interval={rows.length > 16 ? Math.ceil(rows.length / 8) - 1 : 0} + /> + + { + if (!active || !payload?.length) return null + const row = payload[0]?.payload as DayRow | undefined + if (!row) return null + return ( +
+

+ Day {label} +

+ {psps.map((r) => { + const unaided = Number(row[`${r.name}__unaided`] ?? 0) + const steered = Number(row[`${r.name}__steered`] ?? 0) + const needed = row[`${r.name}__pace`] + return ( +
+ + {r.name} + + {fmt(unaided + steered)} + {steered > 0 && · {fmt(steered)} steered} + {typeof needed === 'number' && · needed {fmt(needed)}} + +
+ ) + })} +
+ ) + }} + /> + {psps.map((r) => ( + + ))} + {psps.map((r) => ( + + ))} + {metric === 'volume' && } />} +
+
+
+ ) +} + +function StatTile({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + + + {label} +

{value}

+ {hint &&

{hint}

} +
+
+ ) +} + +/** Analytics tab: before/after per PSP, pacing chart, and audit trail for one run. */ +export function VolumeCommitmentAnalytics() { + const { merchantId } = useMerchantStore() + // `undefined` = the run in flight; a run id pins the whole tab to that past execution. + const [selectedRun, setSelectedRun] = useState(undefined) + const [metric, setMetric] = useState('volume') + + const pacing = useVolumeCommitment(merchantId) + // Sub-day buckets so running totals curve within a day. + const perDay = bucketsPerDay(pacing.data?.daySecs) + const series = useVolumeCommitmentSeries(merchantId, selectedRun, { perDay }) + const audit = useVolumeCommitmentAudit(merchantId, selectedRun) + const impact = useVolumeCommitmentImpact(merchantId, selectedRun) + + const connectors = series.data?.connectors ?? NO_SERIES + const currency = series.data?.currency + // Color by contract position, shared by every chart and table on the page. + const colorIndex = useMemo( + () => new Map(connectors.map((c, i) => [c.connector, i] as const)), + [connectors], + ) + const byConnector = useMemo( + () => new Map((pacing.data?.psps ?? []).map((p) => [p.connector, p])), + [pacing.data], + ) + const eliminatedReasons = useMemo( + () => new Map((pacing.data?.eliminated ?? []).map((e) => [e.connector, e.reason])), + [pacing.data], + ) + + // A past run's verdict comes from its own series and audit, not the live plan. + const isPastRun = + selectedRun !== undefined && !audit.runs.find((r) => r.runId === selectedRun)?.isCurrent + const eliminatedInRun = useMemo( + () => + new Set( + audit.events + .filter((e) => e.kind === 'eliminated' && e.connector && (!selectedRun || e.runId === selectedRun)) + .map((e) => e.connector as string), + ), + [audit.events, selectedRun], + ) + // First elimination per PSP within the shown run only, so an old cycle's drop does not pin day 0. + const shownRunId = selectedRun ?? audit.runs.find((r) => r.isCurrent)?.runId + const eliminatedAtMs = useMemo( + () => firstEliminationByConnector(audit.events, shownRunId), + [audit.events, shownRunId], + ) + const deliveredInRun = useMemo(() => { + const totals = new Map() + for (const c of connectors) totals.set(c.connector, c.points.reduce((sum, p) => sum + p.total, 0)) + return totals + }, [connectors]) + const achievedFor = useCallback( + (connector: string) => + isPastRun ? (deliveredInRun.get(connector) ?? 0) : (byConnector.get(connector)?.achieved ?? deliveredInRun.get(connector) ?? 0), + [isPastRun, deliveredInRun, byConnector], + ) + const statusFor = useCallback( + (connector: string): PacingStatus => { + const c = connectors.find((x) => x.connector === connector) + const achieved = achievedFor(connector) + const met = (c?.goal ?? 0) > 0 && achieved >= (c?.goal ?? 0) + if (met) return 'met' + const eliminated = isPastRun ? eliminatedInRun.has(connector) : (c?.eliminated ?? false) + if (eliminated) return isPastRun ? 'missed' : 'eliminated' + if (isPastRun) return 'missed' + const live = byConnector.get(connector) + if (!live) return 'pending' + return live.steering ? 'steering' : 'on_pace' + }, + [connectors, achievedFor, isPastRun, eliminatedInRun, byConnector], + ) + const reasonFor = useCallback((connector: string) => eliminatedReasons.get(connector), [eliminatedReasons]) + const pacingColorFor = useCallback( + (connector: string, index: number) => seriesColor(colorIndex.get(connector) ?? index), + [colorIndex], + ) + + const impactConnectors = impact.data?.connectors ?? NO_IMPACT + const impactRows: ImpactRow[] = useMemo( + () => + impactConnectors.map((c, i) => { + const idx = colorIndex.get(c.connector) ?? i + const eliminated = isPastRun ? eliminatedInRun.has(c.connector) : c.eliminated + return { + name: c.connector, + color: seriesColor(idx), + hatch: hatchId('analytics', c.connector), + eliminated, + goal: c.goal, + before: c.before.volume, + unaided: c.unaided.volume, + steered: c.steered.volume, + ceded: c.ceded.volume, + beforePayments: c.before.payments, + unaidedPayments: c.unaided.payments, + steeredPayments: c.steered.payments, + cededPayments: c.ceded.payments, + } + }), + [impactConnectors, colorIndex, isPastRun, eliminatedInRun], + ) + const dayWord = dayUnit(impact.data?.daySecs).word + // Day-by-day rows for both cycles, one row per contract day with a pair of keys per PSP. + const { beforeRows, withRows, beforeYMax, withYMax } = useMemo(() => { + const daysTotal = Math.max(1, impact.data?.daysTotal ?? 1) + const dayMs = Math.max(1, (impact.data?.daySecs ?? SECS_PER_DAY) * 1000) + const baselineDays = impact.data?.baselineDays ?? [] + const cycleDays = impact.data?.cycleDays ?? [] + const baselineSpan = impact.data ? Math.max(1, Math.round((impact.data.baseline.endMs - impact.data.baseline.startMs) / dayMs)) : daysTotal + const pick = (p: { total: number; steered: number; payments: number; steeredPayments: number }) => + metric === 'volume' + ? { unaided: Math.max(0, p.total - p.steered), steered: p.steered } + : { unaided: Math.max(0, p.payments - p.steeredPayments), steered: p.steeredPayments } + // `markThrough`: the last day that gets a pace marker. Days the cycle has not reached yet + // would only show "everything still owed, spread over what is left", which is not a pace + // anyone has failed or met yet. + const build = (points: typeof baselineDays, count: number, markThrough: number, droppedOn?: Map) => { + const byKey = new Map(points.map((p) => [`${p.connector}:${p.dayIndex}`, p])) + const rows: DayRow[] = [] + const deliveredSoFar: Record = {} + for (let i = 0; i < count; i += 1) { + const row: DayRow = { day: String(i) } + for (const r of impactRows) { + const p = byKey.get(`${r.name}:${i}`) + const v = p ? pick(p) : { unaided: 0, steered: 0 } + row[`${r.name}__unaided`] = v.unaided + row[`${r.name}__steered`] = v.steered + // What this day had to bring: the volume still owed when it opened, spread over the + // days that were left — so a short day raises the bar for the next, a strong one lowers it. + // Volume-based regardless of the metric shown; payments have no goal. + // Nothing is needed of a promise once the engine has given it up: its marker would + // only climb toward the impossible and squash every bar under it. + const dropDay = droppedOn?.get(r.name) + const remaining = r.goal - (deliveredSoFar[r.name] ?? 0) + if (metric === 'volume' && r.goal > 0 && remaining > 0 && i <= markThrough && (dropDay == null || i < dropDay)) { + row[`${r.name}__pace`] = remaining / (count - i) + } + deliveredSoFar[r.name] = (deliveredSoFar[r.name] ?? 0) + (p ? p.total : 0) + } + rows.push(row) + } + return rows + } + const cycleStartMs = impact.data?.cycle.startMs + const currentDay = + isPastRun || cycleStartMs == null ? daysTotal - 1 : Math.min(daysTotal - 1, Math.floor((Date.now() - cycleStartMs) / dayMs)) + const beforeSpan = Math.min(daysTotal, baselineSpan) + // The previous cycle is marked only as far as it had traffic; past that, the markers would + // just climb toward "everything, on the last day" over an empty chart. + const lastBaselineDay = Math.max(-1, ...baselineDays.map((p) => p.dayIndex)) + const beforeRows = build(baselineDays, beforeSpan, lastBaselineDay) + // A drop that did not stick — the PSP was written off (often on lagging numbers in a cycle's + // last seconds) but landed its goal anyway — does not erase what it needed on the way there. + const droppedOn = new Map() + if (cycleStartMs != null) { + for (const [name, at] of eliminatedAtMs) { + const r = impactRows.find((x) => x.name === name) + const met = r != null && r.goal > 0 && r.unaided + r.steered >= r.goal + if (!met) droppedOn.set(name, Math.max(0, Math.floor((at - cycleStartMs) / dayMs))) + } + } + const withRows = build(cycleDays, daysTotal, currentDay, droppedOn) + // Each chart scales to its own bars and markers, with headroom, so a quiet cycle beside a + // busy one does not squash the bars of either. + const yMaxOf = (rows: DayRow[]) => { + const max = Math.max( + 0, + ...rows.flatMap((row) => + impactRows.flatMap((r) => [ + Number(row[`${r.name}__unaided`] ?? 0) + Number(row[`${r.name}__steered`] ?? 0), + Number(row[`${r.name}__pace`] ?? 0), + ]), + ), + ) + return max > 0 ? max * 1.12 : 1 + } + return { beforeRows, withRows, beforeYMax: yMaxOf(beforeRows), withYMax: yMaxOf(withRows) } + }, [impact.data, impactRows, metric, isPastRun, eliminatedAtMs]) + + // Headline figures for the cycle. + const totals = useMemo(() => { + const steeredPayments = impactRows.reduce((n, r) => n + r.steeredPayments, 0) + const cyclePayments = impactRows.reduce((n, r) => n + r.unaidedPayments + r.steeredPayments, 0) + const steeredVolume = impactRows.reduce((n, r) => n + r.steered, 0) + const cycleVolume = impactRows.reduce((n, r) => n + r.unaided + r.steered, 0) + const met = impactRows.filter((r) => r.goal > 0 && r.unaided + r.steered >= r.goal) + const rewardSecured = impactConnectors + .filter((c) => c.goal > 0 && c.withContract.volume >= c.goal) + .reduce((n, c) => n + c.reward, 0) + const rewardAtStake = impactConnectors + .filter((c) => !(isPastRun ? eliminatedInRun.has(c.connector) : c.eliminated)) + .reduce((n, c) => n + c.reward, 0) + return { steeredPayments, cyclePayments, steeredVolume, cycleVolume, met: met.length, rewardSecured, rewardAtStake } + }, [impactRows, impactConnectors, isPastRun, eliminatedInRun]) + + if (!merchantId) return + if (pacing.error || series.error) return + if (pacing.isLoading || series.isLoading) { + return ( +
+ +
+ ) + } + + if (connectors.length === 0) { + return ( + + +
+ +
+

+ No volume contracts are active for this merchant +

+

+ Configure commitments on the Multi Objective page's Volume Contracts tab and + activate the document, then enable “Volume contracts (meet PSP commitments)” under + its Feature Flags. Pick a + short test cycle so a full period plays out in minutes, then drive traffic from the + Decision Simulator and watch it land here. +

+
+
+
+
+ ) + } + + const cycle = impact.data?.cycle + const baseline = impact.data?.baseline + const valueOf = (r: ImpactRow, key: 'before' | 'unaided' | 'steered') => + metric === 'volume' ? r[key] : r[`${key}Payments` as const] + + return ( +
+ {/* ── Chapter picker: which run, how far back the "before" reaches, which measure ── */} + + +
+
+

+ What the volume contract did +

+

+ {impact.data ? ( + <> + Contract live since {formatWhen(impact.data.contractSinceMs)} + {cycle && ( + <> + {' '}· this cycle {formatWhen(cycle.startMs)} → {formatWhen(cycle.endMs)} ( + {impact.data.daysTotal} {dayUnit(impact.data.daySecs).short.toLowerCase()} cycle) + + )} + {baseline && ( + <> + {' '}· compared with the previous cycle, {formatWhen(baseline.startMs)} →{' '} + {formatWhen(baseline.endMs)} + + )} + {shownRunId && ( + <> + {' '}· run {shownRunId} + + )} + + ) : ( + 'Waiting for the first measurements of this contract.' + )} +

+
+
+ {(['volume', 'payments'] as const).map((m) => ( + + ))} +
+
+
+ {audit.runs.length > 0 && ( +
+ Run + + {audit.runs.slice(0, RUNS_SHOWN).map((run) => ( + + ))} +
+ )} +
+
+
+ + {/* ── The contract at a glance: promise, reward terms, standing ── */} + + + {/* ── Headline: what the contract is worth and how much routing had to move ── */} +
+ + + + +
+ + {/* ── Chapters 1 & 2: day by day, previous cycle and this one — same axis, same PSP colors ── */} +
+ + + 1 · Previous cycle +

+ What each PSP received per {dayWord} in the previous cycle +

+

+ {baseline ? `${formatWhen(baseline.startMs)} → ${formatWhen(baseline.endMs)}` : 'The cycle before this one'} + {metric === 'volume' ? ' · dashed = what each promise needed that ' + dayWord + ', after what had already landed' : ''} +

+
+ + + +
+ + + + 2 · This cycle +

+ What each PSP received per {dayWord} in this cycle +

+

+ Solid is what approval-rate routing sent on its own; hatched is what the engine steered in. +

+
+ + + +
+
+ + {/* ── Legend + table twin: every value on the bars, and each PSP's standing ── */} + + +
+ {impactRows.map((r) => ( + + + {r.name} + + ))} + + + Steered in + + {metric === 'volume' && ( + + + Pace each promise needs per {dayWord} + + )} +
+
+ + + + + + + + + + + + + + + + {impactRows.map((r) => { + const c = impactConnectors.find((x) => x.connector === r.name) + const total = r.unaided + r.steered + const status = statusFor(r.name) + const live = byConnector.get(r.name) + const fmt = fmtFor(metric) + return ( + + + + + + + + + + + + ) + })} + +
PSPStatusPrevious cycleRouted by approvalSteered inCededThis cycleTargetReward
+ + + {r.name} + + + {status === 'met' ? ( + Met + ) : status === 'eliminated' ? ( + + Eliminated + + ) : status === 'missed' ? ( + Missed + ) : status === 'steering' ? ( + Steering · {((live?.steerRate ?? 0) * 100).toFixed(0)}% + ) : status === 'on_pace' ? ( + On pace + ) : ( + Pending forecast + )} + {fmt(valueOf(r, 'before'))}{fmt(valueOf(r, 'unaided'))}{fmt(valueOf(r, 'steered'))}{fmt(metric === 'volume' ? r.ceded : r.cededPayments)} + {fmt(metric === 'volume' ? total : r.unaidedPayments + r.steeredPayments)} + {metric === 'volume' && r.goal > 0 && ( + · {pctOfGoal(total, r.goal)}% + )} + {compactAmount(r.goal)}{compactAmount(c?.reward ?? 0)}
+
+

+ “Ceded” is what approval-rate routing would have sent a PSP but the engine moved to one + behind on its commitment — the other side of every steered payment. Steering only happens + inside the approval-rate tolerance, so approvals barely move. +

+
+
+ + {/* ── Chapter 3: pacing through the cycle ── */} + + + 3 · Cumulative volume vs. each promise +

+ Solid lines are delivered volume; dashed steps are each PSP's per-day targets +

+ {isPastRun && ( +

+ Showing the finished run {selectedRun} — its own delivery across its own cycle, not + the one currently in flight. +

+ )} +
+ + +

+ Each dashed step is the volume a PSP must clear by that day's end (its promise split + across the cycle). A solid line tracking below its ladder is behind pace — the engine + steers a little extra volume there (▲), spread through the day. When not enough traffic remains + to land a commitment the engine drops it (red marker): from there its line is dotted and + carries natural traffic only, so the rest can still be met. +

+
+
+ + {/* ── Chapter 4: the audit trail ── */} + + + 4 · Audit trail +

+ Every forecast, steer and elimination — what happened, when, and why +

+
+ + {audit.events.length === 0 ? ( +

+ Nothing yet. Entries appear when the scheduler runs a forecast or a payment is + steered. +

+ ) : ( +
+ {audit.events.slice(0, AUDIT_EVENTS_SHOWN).map((event, i) => { + const badge = AUDIT_BADGES[event.kind] + return ( +
+ {badge.label} +
+

{event.message}

+

+ {new Date(event.atEpochMs).toLocaleString()} + {event.connector ? ` · ${event.connector}` : ''} + {event.amount != null ? ` · ${compactAmount(event.amount)}` : ''} + {selectedRun === undefined && event.runId ? ` · ${event.runId}` : ''} +

+
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/website/src/components/pages/VolumeCommitmentRunChart.tsx b/website/src/components/pages/VolumeCommitmentRunChart.tsx new file mode 100644 index 00000000..a61553df --- /dev/null +++ b/website/src/components/pages/VolumeCommitmentRunChart.tsx @@ -0,0 +1,217 @@ +import { useCallback, useMemo } from 'react' +import { Handshake } from 'lucide-react' +import { Card, CardBody } from '../ui/Card' +import { Badge } from '../ui/Badge' +import { + useVolumeCommitment, + useVolumeCommitmentAudit, + useVolumeCommitmentSeries, +} from '../../hooks/useVolumeCommitment' +import { CommitmentPacingChart, PacingStatus } from './CommitmentPacingChart' +import { + SolidSwatch, + bucketsPerDay, + firstEliminationByConnector, + formatAchieved, + formatMoney, + pctOfGoal, +} from './volumeCommitmentChartBits' + +/** The slice of a simulator row this card reads. */ +export type SteerableResult = { + decidedGateway: string + /** Whether the volume-commitment engine moved this payment, from `volume_steer_info.outcome`. */ + steerOutcome?: 'STEERED' | 'SR_PREVAILED' | null + /** The PSP approval-rate routing had picked, when the payment was steered elsewhere. */ + steerSrHead?: string | null +} + +/** Tighter than the default polls while someone is watching a run; a test cycle is only minutes. */ +const RUN_POLL_MS = 5_000 + +type Row = { + name: string + color: string + auth: number + steered: number + ceded: number + status: PacingStatus + steerRate: number + achieved: number + goal: number + reason?: string +} + +function StatusBadge({ row }: { row: Row }) { + switch (row.status) { + case 'met': + return Met + case 'eliminated': + return ( + + Eliminated + + ) + case 'steering': + return Steering · {(row.steerRate * 100).toFixed(0)}% of eligible + case 'on_pace': + return On pace + case 'missed': + return Missed + default: + return Pending forecast + } +} + +/** Live pacing chart for the run plus one line per PSP: standing, and payments routed vs steered. */ +export function VolumeCommitmentRunChart({ + merchantId, + results, + colorFor, +}: { + merchantId: string | null + results: SteerableResult[] + colorFor: (gateway: string) => string +}) { + const pacing = useVolumeCommitment(merchantId ?? undefined, RUN_POLL_MS) + const daySecs = pacing.data?.daySecs + const series = useVolumeCommitmentSeries(merchantId ?? undefined, undefined, { + perDay: bucketsPerDay(daySecs), + refreshInterval: RUN_POLL_MS, + }) + const audit = useVolumeCommitmentAudit(merchantId ?? undefined) + const active = Boolean(pacing.data?.active) + const connectors = useMemo(() => series.data?.connectors ?? [], [series.data]) + const currency = series.data?.currency + + const steeredCount = useMemo( + () => results.filter((r) => r.steerOutcome === 'STEERED').length, + [results], + ) + + // Drop times for *this* cycle only; an old cycle's elimination would pin the marker at minute 0. + const currentRunId = audit.runs.find((r) => r.isCurrent)?.runId + const eliminatedAtMs = useMemo( + () => (currentRunId ? firstEliminationByConnector(audit.events, currentRunId) : new Map()), + [audit.events, currentRunId], + ) + + const rows = useMemo(() => { + const psps = pacing.data?.psps ?? [] + const eliminated = pacing.data?.eliminated ?? [] + const names = [...new Set([...connectors.map((c) => c.connector), ...psps.map((p) => p.connector), ...eliminated.map((e) => e.connector)])] + return names.map((name) => { + let auth = 0 + let steered = 0 + let ceded = 0 + for (const r of results) { + const wasSteered = r.steerOutcome === 'STEERED' + if (r.decidedGateway === name) { + if (wasSteered) steered += 1 + else auth += 1 + } + if (wasSteered && r.steerSrHead === name) ceded += 1 + } + const live = psps.find((p) => p.connector === name) + const dropped = eliminated.find((e) => e.connector === name) + const seriesFor = connectors.find((c) => c.connector === name) + const goal = live?.goal ?? seriesFor?.goal ?? 0 + const achieved = + live?.achieved ?? + dropped?.achieved ?? + seriesFor?.points.reduce((s, p) => s + p.total, 0) ?? + 0 + const status: PacingStatus = goal > 0 && achieved >= goal + ? 'met' + : dropped + ? 'eliminated' + : live + ? live.steering + ? 'steering' + : 'on_pace' + : 'pending' + return { + name, + color: colorFor(name), + auth, + steered, + ceded, + status, + steerRate: live?.steerRate ?? 0, + achieved, + goal, + reason: dropped?.reason, + } + }) + }, [pacing.data, connectors, results, colorFor]) + + const statusFor = useCallback( + (name: string): PacingStatus => rows.find((r) => r.name === name)?.status ?? 'pending', + [rows], + ) + const reasons = useMemo( + () => new Map((pacing.data?.eliminated ?? []).map((e) => [e.connector, e.reason])), + [pacing.data], + ) + + // Nothing to say until a contract is live or this run has actually steered something. + if (!active && steeredCount === 0) return null + if (rows.length === 0) return null + + const total = results.length + + return ( + + +
+
+ + Cumulative volume vs. each promise +
+ + {steeredCount.toLocaleString()} of {total.toLocaleString()} payments steered + {total > 0 ? ` · ${((steeredCount / total) * 100).toFixed(1)}%` : ''} + {currentRunId && {currentRunId}} + +
+ + {connectors.length > 0 ? ( + + ) : ( +

Waiting for the contract's first measurements.

+ )} + + {/* One line per PSP: its standing, and where this run's payments to it came from. */} +
+ {rows.map((r) => ( +
+ + {r.name} + + + {r.goal > 0 && ( + <> + {formatAchieved(r.achieved, r.goal, currency)}/{formatMoney(r.goal, currency)} + {r.status !== 'eliminated' && ` · ${pctOfGoal(r.achieved, r.goal)}%`} + {' · '} + + )} + {r.auth} by approval · {r.steered} steered in + {r.ceded > 0 ? ` · ${r.ceded} ceded` : ''} + +
+ ))} +
+
+
+ ) +} diff --git a/website/src/components/pages/VolumeContractsPage.tsx b/website/src/components/pages/VolumeContractsPage.tsx index 2ffd75fe..b69f5121 100644 --- a/website/src/components/pages/VolumeContractsPage.tsx +++ b/website/src/components/pages/VolumeContractsPage.tsx @@ -1,13 +1,15 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import useSWR, { useSWRConfig } from 'swr' import { - CalendarClock, + ArrowLeft, ChevronDown, - ChevronUp, - Layers, + ChevronRight, Plus, - Target, + PowerOff, + SlidersHorizontal, Trash2, + Zap, } from 'lucide-react' import { apiPost } from '../../lib/api' import { useMerchantStore } from '../../store/merchantStore' @@ -17,11 +19,15 @@ import type { RoutingAlgorithm, VolumeContract, VolumeContractConfig, + VolumeContractReward, VolumeContractTier, } from '../../types/api' -import { Card, CardBody, CardHeader, InsetPanel } from '../ui/Card' +import { Card, CardBody, InsetPanel } from '../ui/Card' import { Button } from '../ui/Button' -import { Badge } from '../ui/Badge' +import { PageHeading } from '../ui/PageHeading' +import { HeaderFilter, HeaderSearch, RowMenu } from '../ui/TableControls' +import { parseBackendTimestamp } from '../../lib/routingRuleTimestamps' +import { formatMoney } from './volumeCommitmentChartBits' import { Spinner } from '../ui/Spinner' import { ErrorMessage } from '../ui/ErrorMessage' import { ConfirmDialog } from '../ui/ConfirmDialog' @@ -52,6 +58,7 @@ const ANCHOR_HELP: Record = { calendar_month: 'Day of month the cycle starts (1–30)', calendar_quarter: 'Month within the quarter the cycle starts (1–3)', calendar_year: 'Month the cycle starts (1–12)', + test_minutes: 'Cycle length in minutes (2–240) — one minute per contract day', } interface TierForm { @@ -68,7 +75,7 @@ interface ContractForm { id: string connector: string status: 'active' | 'inactive' - cycleType: 'calendar_month' | 'calendar_quarter' | 'calendar_year' + cycleType: 'calendar_month' | 'calendar_quarter' | 'calendar_year' | 'test_minutes' anchor: string timezone: string archetype: 'lumpsum' | 'tiered' @@ -111,7 +118,190 @@ function amount(value: string): string { return value.trim() } -export function VolumeContractsPage() { +type StatusFilter = 'all' | 'active' | 'inactive' + +/** + * When the document was written. Deliberately `created_at`, not `modified_at`: activation stamps + * `modified_at` (test cycles anchor to it), so "last modified" would read as "last activated". + */ +function formatCreated(doc: RoutingAlgorithm) { + const ms = doc.created_at ? parseBackendTimestamp(doc.created_at) : 0 + if (!ms) return null + const date = new Date(ms) + return { + date: date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), + time: date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }), + full: date.toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' }), + } +} + +/** What a PSP promised and earns, in words. Stored amounts are minor units. */ +type CommitmentLine = { connector: string; promise: string; reward: string; status: 'active' | 'inactive' } + +function commitmentLines(config: VolumeContractConfig | undefined): CommitmentLine[] { + if (!config?.volume_contracts?.length) return [] + const currency = config.metric === 'volume' ? null : config.currency?.denomination + const money = (value: unknown) => formatMoney(Number(value ?? 0), currency) + const rewardText = (reward: VolumeContractReward) => + reward.kind === 'flat' ? `${money(reward.value.flat_amount)} flat` : `${reward.value.rebate_bps / 100}% rebate` + return config.volume_contracts.map((c) => { + const status = c.status ?? 'active' + if (c.archetype === 'lumpsum') { + return { connector: c.connector, promise: money(c.terms.target), reward: rewardText(c.terms.reward), status } + } + if (c.archetype === 'tiered') { + const targeted = c.terms.tiers.find((t) => t.targeted) ?? c.terms.tiers[0] + const rate = targeted ? ('rebate_bps' in targeted.rate ? targeted.rate.rebate_bps : targeted.rate.rate_bps) : 0 + return { + connector: c.connector, + promise: targeted ? money(targeted.threshold) : '—', + reward: `${rate / 100}% ${targeted?.kind === 'marginal' ? 'marginal' : 'retroactive'} · ${c.terms.tiers.length} tier${c.terms.tiers.length === 1 ? '' : 's'}`, + status, + } + } + return { connector: c.connector, promise: money(c.terms.floor), reward: rewardText(c.terms.reward), status } + }) +} + +/** Document-level settings as label/value pairs for the detail panel. */ +function documentSettings(config: VolumeContractConfig) { + const currency = config.metric === 'volume' ? null : config.currency?.denomination + const toleranceBps = config.tolerance_bps ?? (config.tolerance ? parseFloat(config.tolerance) : undefined) + return [ + { label: 'Routing mode', value: config.routing_mode === 'pace_guarded' ? 'Pace‑guarded (auth‑rate first)' : 'Volume‑commitment first' }, + { label: 'Tolerance', value: toleranceBps != null ? `${(toleranceBps / 100).toFixed(toleranceBps % 100 ? 2 : 0)} pp` : '—' }, + { label: 'Metric', value: config.metric === 'volume' ? 'Transaction count' : 'GMV' }, + { label: 'Currency', value: config.currency?.denomination ?? '—' }, + { label: 'Expected daily traffic', value: formatMoney(Number(config.expected_daily_traffic ?? 0), currency) }, + { label: 'Forecast interval', value: config.forecast_interval_secs ? `${config.forecast_interval_secs}s` : 'default' }, + { label: 'Steering interval', value: config.steering_interval_secs ? `${config.steering_interval_secs}s` : 'default' }, + { label: 'Billing cycle', value: summarizeCycle(config) }, + ] +} + +function cycleWords(cycle: VolumeContract['billing_cycle']) { + switch (cycle.type) { + case 'test_minutes': + return `${cycle.anchor}-minute test cycle` + case 'calendar_month': + return `Monthly from day ${cycle.anchor}` + case 'calendar_quarter': + return `Quarterly from month ${cycle.anchor}` + case 'calendar_year': + return `Yearly from month ${cycle.anchor}` + default: + return String(cycle.type) + } +} + +/** + * What an expanded row shows: the document's settings as a compact spec strip, one row per PSP + * commitment, and the raw JSON only on request. + */ +function ContractDocumentDetail({ config }: { config: VolumeContractConfig | undefined }) { + const [showJson, setShowJson] = useState(false) + if (!config) return

This document has no readable configuration.

+ const currency = config.metric === 'volume' ? null : config.currency?.denomination + const money = (value: unknown) => formatMoney(Number(value ?? 0), currency) + const lines = commitmentLines(config) + return ( +
+
+ {documentSettings(config).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ +
+ + + + + + + + + + + + + {config.volume_contracts.map((c, i) => { + const line = lines[i] + return ( + + + + + + + + + ) + })} + +
PSPPromiseRewardCycleTimezoneStatus
{c.connector}{line?.promise} + {line?.reward} + {c.archetype === 'tiered' && ( + + {c.terms.tiers.map((t) => `${money(t.threshold)} → ${('rebate_bps' in t.rate ? t.rate.rebate_bps : t.rate.rate_bps) / 100}%${t.targeted ? ' (target)' : ''}`).join(' · ')} + + )} + {cycleWords(c.billing_cycle)}{c.billing_cycle.timezone} + + {(c.status ?? 'active') === 'active' ? 'Active' : 'Inactive'} + +
+
+ +
+ + {showJson && ( +
+            {JSON.stringify(config, null, 2)}
+          
+ )} +
+
+ ) +} + +/** The billing cycle in words, from the first contract — documents share one in practice. */ +function summarizeCycle(config: VolumeContractConfig | undefined) { + const cycle = config?.volume_contracts?.[0]?.billing_cycle + if (!cycle) return '—' + switch (cycle.type) { + case 'test_minutes': + return `${cycle.anchor}-minute test cycle` + case 'calendar_month': + return `Monthly from day ${cycle.anchor} (${cycle.timezone})` + case 'calendar_quarter': + return `Quarterly from month ${cycle.anchor} (${cycle.timezone})` + case 'calendar_year': + return `Yearly from month ${cycle.anchor} (${cycle.timezone})` + default: + return String(cycle.type) + } +} + +/** + * The contract editor. `embedded` drops the page heading so it can sit as a tab on the Multi + * Objective Routing page, where that page already supplies the title; everything else — data, + * validation, activation — is identical either way. + */ +export function VolumeContractsPage({ embedded = false }: { embedded?: boolean } = {}) { const merchantId = useMerchantStore((s) => s.merchantId) const canEditRouting = useCanEditRouting() const { mutate: mutateCache } = useSWRConfig() @@ -148,9 +338,7 @@ export function VolumeContractsPage() { // ── Builder state ─────────────────────────────────────────────────────────── const [docName, setDocName] = useState('') const [docDesc, setDocDesc] = useState('') - const [routingMode, setRoutingMode] = useState<'pace_guarded' | 'volume_commitment'>('pace_guarded') const [tolerancePp, setTolerancePp] = useState('5') - const [metric, setMetric] = useState<'gmv' | 'volume'>('gmv') const [currency, setCurrency] = useState('USD') const [amountUnits, setAmountUnits] = useState<'major' | 'minor'>('major') const [expectedDailyTraffic, setExpectedDailyTraffic] = useState('') @@ -158,10 +346,76 @@ export function VolumeContractsPage() { const [steeringInterval, setSteeringInterval] = useState('') const [contracts, setContracts] = useState([emptyContract()]) + // ── Merchant-level settings live outside the document builder ──────────────────────────── + // Edited on their own screen and kept per merchant in this browser; every new document is + // stamped with them (the engine still reads them from the document). Seeded from the active + // document the first time, so an existing setup carries over. + type MerchantSettings = { + tolerancePp: string + currency: string + amountUnits: 'major' | 'minor' + expectedDailyTraffic: string + forecastInterval: string + steeringInterval: string + } + const settingsKey = merchantId ? `vc_merchant_settings_${merchantId}` : null + const activeConfig = useMemo(() => { + const doc = documents.find((d) => d.id === activeDocumentId) + return (doc?.algorithm_data ?? doc?.algorithm)?.data as VolumeContractConfig | undefined + }, [documents, activeDocumentId]) + function applySettings(next: Partial) { + if (next.tolerancePp != null) setTolerancePp(next.tolerancePp) + if (next.currency != null) setCurrency(next.currency) + if (next.amountUnits) setAmountUnits(next.amountUnits) + if (next.expectedDailyTraffic != null) setExpectedDailyTraffic(next.expectedDailyTraffic) + if (next.forecastInterval != null) setForecastInterval(next.forecastInterval) + if (next.steeringInterval != null) setSteeringInterval(next.steeringInterval) + } + useEffect(() => { + if (!settingsKey) return + let stored: Partial | null = null + try { + const raw = window.localStorage.getItem(settingsKey) + stored = raw ? (JSON.parse(raw) as Partial) : null + } catch { + stored = null + } + if (stored) { + applySettings(stored) + return + } + if (activeConfig) { + const toleranceBps = activeConfig.tolerance_bps ?? (activeConfig.tolerance ? parseFloat(activeConfig.tolerance) : undefined) + applySettings({ + tolerancePp: toleranceBps != null ? String(toleranceBps / 100) : '5', + currency: activeConfig.currency?.denomination ?? 'USD', + amountUnits: activeConfig.currency?.amount_units === 'minor' ? 'minor' : 'major', + expectedDailyTraffic: activeConfig.expected_daily_traffic != null ? String(activeConfig.expected_daily_traffic) : '', + forecastInterval: activeConfig.forecast_interval_secs != null ? String(activeConfig.forecast_interval_secs) : '', + steeringInterval: activeConfig.steering_interval_secs != null ? String(activeConfig.steering_interval_secs) : '', + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settingsKey, activeConfig]) + function saveMerchantSettings() { + if (!settingsKey) return + const next: MerchantSettings = { tolerancePp, currency, amountUnits, expectedDailyTraffic, forecastInterval, steeringInterval } + try { + window.localStorage.setItem(settingsKey, JSON.stringify(next)) + } catch { + // Nothing to do: the values stay in memory for this session. + } + setActionSuccess('Merchant settings saved — every new contract document will use them.') + closeBuilder() + } + const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) - const [createdName, setCreatedName] = useState(null) + const [actionSuccess, setActionSuccess] = useState(null) + const [searchParams, setSearchParams] = useSearchParams() + const [nameFilter, setNameFilter] = useState('') + const [statusFilter, setStatusFilter] = useState('all') const [expandedId, setExpandedId] = useState(null) const [activatingId, setActivatingId] = useState(null) const [deactivatingId, setDeactivatingId] = useState(null) @@ -195,9 +449,11 @@ export function VolumeContractsPage() { function buildConfig(): VolumeContractConfig { const config: VolumeContractConfig = { schema_version: 1, - routing_mode: routingMode, + // The engine implements only pace-guarded steering measured in money (GMV); the form shows + // both fields pre-filled and locked to these values. + routing_mode: 'pace_guarded', tolerance: `${Math.round(parseFloat(tolerancePp || '0') * 100)}bps`, - metric, + metric: 'gmv', currency: { denomination: currency.trim().toUpperCase(), amount_units: amountUnits }, expected_daily_traffic: amount(expectedDailyTraffic), volume_contracts: contracts.map((c): VolumeContract => { @@ -248,17 +504,10 @@ export function VolumeContractsPage() { return config } + // Clears the document only; the merchant-level settings are not the document's to reset. function resetBuilder() { setDocName('') setDocDesc('') - setRoutingMode('pace_guarded') - setTolerancePp('5') - setMetric('gmv') - setCurrency('USD') - setAmountUnits('major') - setExpectedDailyTraffic('') - setForecastInterval('') - setSteeringInterval('') setContracts([emptyContract()]) } @@ -267,7 +516,7 @@ export function VolumeContractsPage() { if (!merchantId) return setSubmitting(true) setSubmitError(null) - setCreatedName(null) + setActionSuccess(null) try { const payload: CreateRoutingRequest = { name: docName.trim(), @@ -277,9 +526,10 @@ export function VolumeContractsPage() { algorithm: { type: 'volume_contract', data: buildConfig() }, } await apiPost('/routing/create', payload) - setCreatedName(docName.trim()) + setActionSuccess(`“${docName.trim()}” created — activate it from the list to hand it to the routing engine.`) resetBuilder() revalidate() + closeBuilder() } catch (e) { setSubmitError(e instanceof Error ? e.message : 'Failed to create contract document') } finally { @@ -336,561 +586,748 @@ export function VolumeContractsPage() { ) } - const unitHint = metric === 'volume' ? 'transaction count' : `${amountUnits} ${currency.toUpperCase()} units` + const unitHint = `${amountUnits} ${currency.toUpperCase()} units` - return ( -
-
-
-

Volume Contracts

-

- Express PSP volume-commitment contracts — goals, rebates and billing cycles. The routing engine - reads the active document to pace and steer traffic; nothing here changes routing until activated. -

-
-
- -
- {/* ── Existing documents ── */} - - -
- -

Contract Documents

-
-
- - {!merchantId ? ( -

Set a merchant ID to see its contract documents.

- ) : isLoading ? ( -
- Loading… -
- ) : documents.length === 0 ? ( -

No contract documents yet. Build one on the right.

- ) : ( -
    - {documents.map((doc) => { - const isActive = doc.id === activeDocumentId - const isExpanded = expandedId === doc.id - const data = (doc.algorithm_data ?? doc.algorithm)?.data as VolumeContractConfig | undefined - return ( -
  • -
    - - {isActive ? Active : Inactive} - -
    + // ── Views: the document list, or the builder as its own screen (like the rules pages) ──────── + // The builder is addressed by URL (`?contract=new`) so a reload or shared link reopens it. + const view = searchParams.get('contract') + const showBuilder = view === 'new' + const showSettings = view === 'settings' + function openView(which: 'new' | 'settings') { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + next.set('contract', which) + return next + }) + } + function openBuilder() { + openView('new') + } + function openSettings() { + openView('settings') + } + function closeBuilder() { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + next.delete('contract') + return next + }) + } - {isExpanded ? ( -
    -
    -                            {JSON.stringify(data, null, 2)}
    -                          
    -
    - {isActive ? ( - - ) : ( - <> - - - - )} -
    -
    - ) : null} -
  • - ) - })} -
- )} -
- -
-
-
+ const visibleDocuments = documents.filter((doc) => { + const isActive = doc.id === activeDocumentId + if (statusFilter === 'active' && !isActive) return false + if (statusFilter === 'inactive' && isActive) return false + if (nameFilter.trim()) { + const needle = nameFilter.trim().toLowerCase() + if (!doc.name.toLowerCase().includes(needle) && !doc.id.toLowerCase().includes(needle)) return false + } + return true + }) - {/* ── Builder ── */} - - -
- -

New Contract Document

-
-
- -
- {/* Document */} -
+ const merchantSettingsFields = ( +
+
+ {fieldLabel('Routing mode', 'only supported mode')} + {}} + disabled + options={[{ value: 'pace_guarded', label: 'Pace-guarded (auth-rate first)' }]} + /> +
+
+ {fieldLabel('Tolerance', 'percentage points')} + setTolerancePp(e.target.value)} + /> +
+
+ {fieldLabel('Metric', 'only supported metric')} + {}} + disabled + options={[{ value: 'gmv', label: 'GMV (money processed)' }]} + /> +
+
+ {fieldLabel('Currency')} + +
+
+ {fieldLabel('Amount units')} + setAmountUnits(v as typeof amountUnits)} + options={[ + { value: 'major', label: 'Major (6000000 = $6M)' }, + { value: 'minor', label: 'Minor (600000000 = $6M in cents)' }, + ]} + /> +
+
+ {fieldLabel('Expected daily traffic', unitHint)} + setExpectedDailyTraffic(e.target.value)} + /> +
- {fieldLabel('Name')} + {fieldLabel('Forecast interval', 'seconds, optional')} setDocName(e.target.value)} + type="number" + min={60} + placeholder="engine default" + value={forecastInterval} + onChange={(e) => setForecastInterval(e.target.value)} />
- {fieldLabel('Description')} + {fieldLabel('Steering interval', 'seconds, optional')} setDocDesc(e.target.value)} + type="number" + min={60} + placeholder="engine default" + value={steeringInterval} + onChange={(e) => setSteeringInterval(e.target.value)} />
+ ) - {/* Merchant-level settings */} - -
- - Merchant-level settings + if (showSettings) { + return ( +
+
+
+ + +
+
+ + + +
+ + Merchant-level settings +
+ {merchantSettingsFields} +
+
+ +
+ + +

+ Existing documents keep the settings they were created with. +

+
+
+ ) + } + + if (showBuilder) { + return ( +
+
+
+ + +
+
+ +
+ {/* Merchant-level settings: set once for the merchant; the values live on their own screen. */} + +
+
+ + Merchant-level settings + + {expectedDailyTraffic.trim() + ? '— applied to this document when it is created.' + : '— expected daily traffic is not set yet; the document cannot be created without it.'} + +
+ +
+
+ + {/* Document */} +
+
+ {fieldLabel('Name')} + setDocName(e.target.value)} + /> +
+
+ {fieldLabel('Description')} + setDocDesc(e.target.value)} + /> +
+
+ + {/* Per-PSP contracts */} +
+ {contracts.map((contract, contractIdx) => ( + +
+ Contract {contractIdx + 1} + {contracts.length > 1 ? ( + + ) : null}
+
- {fieldLabel('Routing mode')} - setRoutingMode(v as typeof routingMode)} - options={[ - { value: 'pace_guarded', label: 'Pace-guarded (auth-rate first)' }, - { value: 'volume_commitment', label: 'Volume-commitment first' }, - ]} - /> -
-
- {fieldLabel('Tolerance', 'percentage points')} + {fieldLabel('Contract ID')} setTolerancePp(e.target.value)} + placeholder="adyen_2026_lumpsum" + value={contract.id} + onChange={(e) => patchContract(contract.key, { id: e.target.value })} />
- {fieldLabel('Metric')} - setMetric(v as typeof metric)} - options={[ - { value: 'gmv', label: 'GMV (money processed)' }, - { value: 'volume', label: 'Volume (transaction count)' }, - ]} - /> -
-
- {fieldLabel('Currency')} + {fieldLabel('Connector', 'exact gateway name')} patchContract(contract.key, { connector: v })} + options={CONNECTOR_SUGGESTIONS} + placeholder="adyen" />
- {fieldLabel('Amount units')} + {fieldLabel('Status')} setAmountUnits(v as typeof amountUnits)} + triggerClassName={inputClass} + value={contract.status} + onChange={(v) => patchContract(contract.key, { status: v as 'active' | 'inactive' })} options={[ - { value: 'major', label: 'Major (6000000 = $6M)' }, - { value: 'minor', label: 'Minor (600000000 = $6M in cents)' }, + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, ]} />
- {fieldLabel('Expected daily traffic', unitHint)} - setExpectedDailyTraffic(e.target.value)} + {fieldLabel('Billing cycle')} + patchContract(contract.key, { cycleType: v as ContractForm['cycleType'] })} + options={[ + { value: 'calendar_month', label: 'Calendar month' }, + { value: 'calendar_quarter', label: 'Calendar quarter' }, + { value: 'calendar_year', label: 'Calendar year' }, + { value: 'test_minutes', label: 'Test cycle (minutes)' }, + ]} />
- {fieldLabel('Forecast interval', 'seconds, optional')} + {fieldLabel('Anchor', ANCHOR_HELP[contract.cycleType])} setForecastInterval(e.target.value)} + min={1} + value={contract.anchor} + onChange={(e) => patchContract(contract.key, { anchor: e.target.value })} />
+ {contract.cycleType === 'test_minutes' && ( +
+

+ Testing only. The cycle lasts {contract.anchor || '?'} minutes and each + minute counts as one contract day, so a full period — pacing, + elimination, steering — plays out while you watch. Drive traffic at it + from the Decision Simulator; timezone is ignored. +

+
+ )}
- {fieldLabel('Steering interval', 'seconds, optional')} - setSteeringInterval(e.target.value)} + value={contract.timezone} + onChange={(v) => patchContract(contract.key, { timezone: v })} + options={TIMEZONE_SUGGESTIONS} + placeholder="UTC" />
-
- {/* Per-PSP contracts */} -
- {contracts.map((contract, contractIdx) => ( - -
- Contract {contractIdx + 1} - {contracts.length > 1 ? ( - - ) : null} -
+
+ {fieldLabel('Archetype')} +
+ {( + [ + ['lumpsum', 'Lumpsum — reward on hitting a target'], + ['tiered', 'Tiered — rebate ladder by threshold'], + ] as const + ).map(([value, label]) => ( + + ))} +
+
-
+ {contract.archetype === 'lumpsum' ? ( +
+
+ {fieldLabel('Target', unitHint)} + patchContract(contract.key, { target: e.target.value })} + /> +
+
+ {fieldLabel('Reward kind')} + patchContract(contract.key, { rewardKind: v as 'flat' | 'percentage' })} + options={[ + { value: 'flat', label: 'Flat amount' }, + { value: 'percentage', label: 'Percentage rebate' }, + ]} + /> +
+ {contract.rewardKind === 'flat' ? (
- {fieldLabel('Contract ID')} + {fieldLabel('Flat amount', unitHint)} patchContract(contract.key, { id: e.target.value })} - /> -
-
- {fieldLabel('Connector', 'exact gateway name')} - patchContract(contract.key, { connector: v })} - options={CONNECTOR_SUGGESTIONS} - placeholder="adyen" - /> -
-
- {fieldLabel('Status')} - patchContract(contract.key, { status: v as 'active' | 'inactive' })} - options={[ - { value: 'active', label: 'Active' }, - { value: 'inactive', label: 'Inactive' }, - ]} - /> -
-
- {fieldLabel('Billing cycle')} - patchContract(contract.key, { cycleType: v as ContractForm['cycleType'] })} - options={[ - { value: 'calendar_month', label: 'Calendar month' }, - { value: 'calendar_quarter', label: 'Calendar quarter' }, - { value: 'calendar_year', label: 'Calendar year' }, - ]} + placeholder="15000" + value={contract.flatAmount} + onChange={(e) => patchContract(contract.key, { flatAmount: e.target.value })} />
+ ) : (
- {fieldLabel('Anchor', ANCHOR_HELP[contract.cycleType])} + {fieldLabel('Rebate', 'basis points')} patchContract(contract.key, { anchor: e.target.value })} + max={10000} + placeholder="25" + value={contract.rebateBps} + onChange={(e) => patchContract(contract.key, { rebateBps: e.target.value })} />
-
- {fieldLabel('Timezone', 'IANA')} - patchContract(contract.key, { timezone: v })} - options={TIMEZONE_SUGGESTIONS} - placeholder="UTC" - /> -
-
- -
- {fieldLabel('Archetype')} -
- {( - [ - ['lumpsum', 'Lumpsum — reward on hitting a target'], - ['tiered', 'Tiered — rebate ladder by threshold'], - ] as const - ).map(([value, label]) => ( - - ))} -
-
- - {contract.archetype === 'lumpsum' ? ( -
-
- {fieldLabel('Target', unitHint)} - patchContract(contract.key, { target: e.target.value })} - /> -
-
- {fieldLabel('Reward kind')} - patchContract(contract.key, { rewardKind: v as 'flat' | 'percentage' })} - options={[ - { value: 'flat', label: 'Flat amount' }, - { value: 'percentage', label: 'Percentage rebate' }, - ]} - /> + )} +
+ ) : ( +
+ {contract.tiers.map((tier, tierIdx) => ( +
+
+ + {contract.tiers.length > 1 ? ( + + ) : null}
- {contract.rewardKind === 'flat' ? ( +
+
+ {fieldLabel('Kind')} + + patchTier(contract.key, tierIdx, { + kind: v as 'retroactive' | 'marginal', + ...(v === 'marginal' ? { targeted: false } : {}), + }) + } + options={[ + { value: 'retroactive', label: 'Retroactive (whole period)' }, + { value: 'marginal', label: 'Marginal (above threshold)' }, + ]} + /> +
- {fieldLabel('Flat amount', unitHint)} + {fieldLabel('Threshold', unitHint)} patchContract(contract.key, { flatAmount: e.target.value })} + placeholder="8000000" + value={tier.threshold} + onChange={(e) => patchTier(contract.key, tierIdx, { threshold: e.target.value })} />
- ) : (
- {fieldLabel('Rebate', 'basis points')} + {fieldLabel(tier.kind === 'retroactive' ? 'Rebate' : 'Rate', 'bps')} patchContract(contract.key, { rebateBps: e.target.value })} + placeholder="20" + value={tier.bps} + onChange={(e) => patchTier(contract.key, tierIdx, { bps: e.target.value })} />
- )} -
- ) : ( -
- {contract.tiers.map((tier, tierIdx) => ( -
-
- - {contract.tiers.length > 1 ? ( - - ) : null} -
-
-
- {fieldLabel('Kind')} - - patchTier(contract.key, tierIdx, { - kind: v as 'retroactive' | 'marginal', - ...(v === 'marginal' ? { targeted: false } : {}), - }) - } - options={[ - { value: 'retroactive', label: 'Retroactive (whole period)' }, - { value: 'marginal', label: 'Marginal (above threshold)' }, - ]} - /> -
-
- {fieldLabel('Threshold', unitHint)} - patchTier(contract.key, tierIdx, { threshold: e.target.value })} - /> -
-
- {fieldLabel(tier.kind === 'retroactive' ? 'Rebate' : 'Rate', 'bps')} - patchTier(contract.key, tierIdx, { bps: e.target.value })} - /> -
-
- {fieldLabel('Rebate lag', 'days')} - patchTier(contract.key, tierIdx, { rebateLagDays: e.target.value })} - /> -
-
- {fieldLabel('Settlement')} - - patchTier(contract.key, tierIdx, { rebateSettlement: v as 'cash' | 'credit_note' }) - } - options={[ - { value: 'cash', label: 'Cash' }, - { value: 'credit_note', label: 'Credit note' }, - ]} - /> -
-
+
+ {fieldLabel('Rebate lag', 'days')} + patchTier(contract.key, tierIdx, { rebateLagDays: e.target.value })} + />
- ))} - +
+ {fieldLabel('Settlement')} + + patchTier(contract.key, tierIdx, { rebateSettlement: v as 'cash' | 'credit_note' }) + } + options={[ + { value: 'cash', label: 'Cash' }, + { value: 'credit_note', label: 'Credit note' }, + ]} + /> +
+
- )} - - ))} + ))} + +
+ )} + + ))} - -
+ +
+
- {/* Submit */} -
- - {createdName ? ( -
- “{createdName}” created. Activate it from the list to hand it to the routing engine. -
- ) : null} -
- - -
-
-
- - + + +
+ + + +

+ {expectedDailyTraffic.trim() + ? 'A new document is created inactive — activate it from the contracts list.' + : 'Set the expected daily traffic under Merchant settings before creating a document.'} +

+
+
+ ) + } + + return ( +
+
+
+ {embedded ? ( +

+ PSP volume-commitment contracts — goals, rebates and billing cycles. The routing engine reads + the active document to pace and steer traffic. +

+ ) : ( + + )} +
+
+ + +
+ {actionError && } + {actionSuccess && ( +
+ {actionSuccess} +
+ )} + + + {!merchantId ? ( +

Set merchant ID to load contract documents.

+ ) : isLoading ? ( +

Loading...

+ ) : documents.length === 0 ? ( +

No contract documents yet.

+ ) : ( +
+ + + + + + + + + + + + {visibleDocuments.length === 0 && ( + + + + )} + {visibleDocuments.map((doc) => { + const isActive = doc.id === activeDocumentId + const isExpanded = expandedId === doc.id + const data = (doc.algorithm_data ?? doc.algorithm)?.data as VolumeContractConfig | undefined + const stamp = formatCreated(doc) + // /routing/delete rejects an active document, so the control mirrors that. + const lockedReason = isActive + ? 'Deactivate this document first' + : !canEditRouting + ? 'You do not have permission to change routing' + : undefined + return [ + setExpandedId(isExpanded ? null : doc.id)} + className={`cursor-pointer border-b border-slate-100 align-middle transition-colors hover:bg-slate-50 dark:border-[#1e2330] dark:hover:bg-[#11151d] ${ + isActive ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : '' + }`} + > + + + + + + , + isExpanded ? ( + + + + ) : null, + ] + })} + +
+ + + setStatusFilter(v as StatusFilter)} + ariaLabel="Filter by status" + /> + Billing CycleCreatedActions
+

No documents match these filters.

+ +
+
+ {isExpanded + ? + : } +
+

{doc.name}

+

{doc.id}

+
+
+
+ + {isActive ? 'Active' : 'Inactive'} + + +

+ {summarizeCycle(data)} +

+

+ {(data?.volume_contracts?.length ?? 0)} PSP commitment{(data?.volume_contracts?.length ?? 0) === 1 ? '' : 's'} +

+
+ {stamp ? ( + + {stamp.date} + {stamp.time} + + ) : '—'} + e.stopPropagation()}> + setPendingDeactivateId(doc.id), + disabled: deactivatingId === doc.id || !canEditRouting, + } + : { + label: activatingId === doc.id ? 'Activating…' : 'Activate', + icon: Zap, + tone: 'positive', + onSelect: () => (activeDocumentId ? setPendingActivateId(doc.id) : doActivate(doc.id)), + disabled: activatingId === doc.id || !canEditRouting, + }, + { + label: deletingId === doc.id ? 'Deleting…' : 'Delete', + icon: Trash2, + tone: 'danger', + onSelect: () => setPendingDeleteId(doc.id), + disabled: Boolean(lockedReason) || deletingId === doc.id, + hint: lockedReason, + }, + ]} + /> +
+ +
+
+ )} +
+ () + for (const e of events) { + if (e.kind !== 'eliminated' || !e.connector) continue + if (runId && e.runId !== runId) continue + const prev = out.get(e.connector) + if (prev == null || e.atEpochMs < prev) out.set(e.connector, e.atEpochMs) + } + return out +} + +/** Percent of goal for display. Rounding must never contradict the verdict: a shortfall never + * reads "100%" — within half a point of the goal it keeps one decimal ("99.6%") — and only an + * actually-met goal prints "100%". */ +export function pctOfGoal(achieved: number, goal: number): string { + if (!(goal > 0)) return '0' + if (achieved >= goal) return '100' + const pct = Math.max(0, (achieved / goal) * 100) + if (Math.round(pct) >= 100) return Math.min(99.9, Math.floor(pct * 10) / 10).toFixed(1) + return Math.round(pct).toString() +} + +/** The achieved amount beside its goal. When compact rounding would print a shortfall as the + * goal itself ("$100/$100" while missed), the achieved side keeps its minor units instead. */ +export function formatAchieved(achieved: number, goal: number, currency?: string | null): string { + const compact = formatMoney(achieved, currency) + if (achieved >= goal || compact !== formatMoney(goal, currency)) return compact + if (!currency) return achieved.toLocaleString() + const major = toMajor(achieved, currency) + try { + return new Intl.NumberFormat('en', { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: 2, + }).format(major) + } catch { + return `${currency} ${major.toLocaleString()}` + } +} + +export function compactAmount(value: number) { + if (!Number.isFinite(value)) return '0' + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k` + return value.toFixed(0) +} + +/** A `` id that is safe whatever characters the connector name carries. */ +export function hatchId(scope: string, connector: string) { + return `vc-hatch-${scope}-${connector.replace(/[^a-zA-Z0-9_-]/g, '_')}` +} + +/** Per-PSP hatch patterns; mount via `` — recharts drops a bare `` child. */ +export function HatchDefs({ entries }: { entries: Array<{ id: string; color: string }> }) { + return ( + + {entries.map(({ id, color }) => ( + + + + + ))} + + ) +} + +/** The little swatches the legends and tables use. */ +export function SolidSwatch({ color }: { color: string }) { + return +} + +export function HatchSwatch({ color }: { color: string }) { + return ( + + ) +} + +export function DashSwatch() { + return ( + + ) +} + +/** Currencies whose minor unit is the major unit — no cents to divide away. */ +const ZERO_DECIMAL = new Set(['JPY', 'KRW', 'VND', 'CLP', 'ISK', 'HUF', 'UGX', 'XAF', 'XOF']) + +/** Stored minor units → major units for display. */ +function toMajor(minor: number, currency: string) { + return ZERO_DECIMAL.has(currency) ? minor : minor / 100 +} + +/** The narrow symbol for a currency code, or the code itself when Intl does not know it. */ +function currencySymbol(currency: string) { + try { + const parts = new Intl.NumberFormat('en', { style: 'currency', currency, currencyDisplay: 'narrowSymbol' }) + .formatToParts(0) + return parts.find((p) => p.type === 'currency')?.value ?? currency + } catch { + return currency + } +} + +/** Minor units → compact money ("$8.0M"); the plain compact number when there is no currency. */ +export function formatMoney(minor: number, currency?: string | null) { + if (!Number.isFinite(minor)) minor = 0 + if (!currency) return compactAmount(minor) + const major = toMajor(minor, currency) + const abs = Math.abs(major) + const symbol = currencySymbol(currency) + const sign = major < 0 ? '-' : '' + if (abs >= 1_000_000) return `${sign}${symbol}${(abs / 1_000_000).toFixed(1)}M` + if (abs >= 10_000) return `${sign}${symbol}${(abs / 1_000).toFixed(0)}k` + if (abs >= 1_000) return `${sign}${symbol}${(abs / 1_000).toFixed(1)}k` + return `${sign}${symbol}${abs.toFixed(0)}` +} + +/** Full-precision money for a headline figure: 2000000 USD → "$20,000". */ +export function formatMoneyExact(minor: number, currency?: string | null) { + if (!Number.isFinite(minor)) minor = 0 + if (!currency) return Math.round(minor).toLocaleString() + const major = toMajor(minor, currency) + try { + return new Intl.NumberFormat('en', { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: 0, + }).format(major) + } catch { + return `${currency} ${Math.round(major).toLocaleString()}` + } +} diff --git a/website/src/hooks/useMerchantFeatures.ts b/website/src/hooks/useMerchantFeatures.ts index bdd039fc..aad206dc 100644 --- a/website/src/hooks/useMerchantFeatures.ts +++ b/website/src/hooks/useMerchantFeatures.ts @@ -10,6 +10,7 @@ export type KnownFeature = | 'elimination' | 'auto-calibration' | 'autopilot' + | 'volume-contracts' export function useMerchantFeatures(merchantId?: string) { const path = merchantId ? `/merchant-account/${merchantId}/features` : null diff --git a/website/src/hooks/useVolumeCommitment.ts b/website/src/hooks/useVolumeCommitment.ts new file mode 100644 index 00000000..23e30475 --- /dev/null +++ b/website/src/hooks/useVolumeCommitment.ts @@ -0,0 +1,84 @@ +import useSWR from 'swr' +import { fetcher } from '../lib/api' +import { + CommitmentAuditResponse, + CommitmentImpactResponse, + CommitmentSeriesResponse, + VolumeCommitmentView, +} from '../types/api' + +/** Default poll for the pacing card and series; the forecast recomputes in the background. */ +export const PACING_POLL_MS = 15_000 +/** Default poll for the audit trail and the impact view. */ +export const ACTIVITY_POLL_MS = 10_000 + +/** A volume-commitment endpoint for one merchant, or `null` (no request) without one. */ +function vcPath( + merchantId: string | undefined, + suffix = '', + params: Record = {}, +) { + if (!merchantId) return null + const query = new URLSearchParams() + for (const [key, value] of Object.entries(params)) if (value) query.set(key, value) + const qs = query.toString() + return `/merchant-account/${merchantId}/volume-commitment${suffix}${qs ? `?${qs}` : ''}` +} + +/** Latest pacing decision from the controller. Polls, since it recomputes in the background. */ +export function useVolumeCommitment(merchantId?: string, refreshInterval = PACING_POLL_MS) { + const { data, error, isLoading, mutate } = useSWR(vcPath(merchantId), fetcher, { + revalidateOnFocus: false, + refreshInterval, + }) + + return { + data, + error, + isLoading, + /** True while a contract document is live, even before its first forecast. */ + isActive: Boolean(data?.active), + mutate, + } +} + +/** Series for the pacing chart; `perDay` = buckets per contract day, `refreshInterval` tightens the poll. */ +export function useVolumeCommitmentSeries( + merchantId?: string, + runId?: string, + options: { perDay?: number; refreshInterval?: number } = {}, +) { + const path = vcPath(merchantId, '/series', { + run_id: runId, + per_day: options.perDay && options.perDay > 1 ? String(options.perDay) : undefined, + }) + const { data, error, isLoading, mutate } = useSWR(path, fetcher, { + revalidateOnFocus: false, + refreshInterval: options.refreshInterval ?? PACING_POLL_MS, + keepPreviousData: true, + }) + return { data, error, isLoading, mutate } +} + +/** Audit events (newest first) and the runs they span; `runId` narrows to one run. */ +export function useVolumeCommitmentAudit(merchantId?: string, runId?: string) { + const path = vcPath(merchantId, '/audit', { run_id: runId }) + const { data, error, isLoading } = useSWR(path, fetcher, { + revalidateOnFocus: false, + refreshInterval: ACTIVITY_POLL_MS, + }) + return { events: data?.events ?? [], runs: data?.runs ?? [], error, isLoading } +} + +/** Previous cycle vs this one per PSP, split unaided / steered / ceded; polls. */ +export function useVolumeCommitmentImpact(merchantId?: string, runId?: string) { + const path = vcPath(merchantId, '/impact', { run_id: runId }) + const { data, error, isLoading, mutate } = useSWR(path, fetcher, { + revalidateOnFocus: false, + refreshInterval: ACTIVITY_POLL_MS, + // A 404 just means no contract is live; keep the last good story on screen rather than flashing. + shouldRetryOnError: false, + keepPreviousData: true, + }) + return { data, error, isLoading, mutate } +} diff --git a/website/src/types/api.ts b/website/src/types/api.ts index ecb0e778..a7ff1c10 100644 --- a/website/src/types/api.ts +++ b/website/src/types/api.ts @@ -12,6 +12,7 @@ export interface DecideGatewayResponse { is_scheduled_outage: boolean debit_routing_output?: DebitRoutingOutput | null multi_objective_info?: MultiObjectiveInfo | null + volume_steer_info?: VolumeSteerInfo | null latency: number | null } @@ -138,7 +139,7 @@ export type VolumeContract = { connector: string status?: 'active' | 'inactive' billing_cycle: { - type: 'calendar_month' | 'calendar_quarter' | 'calendar_year' + type: 'calendar_month' | 'calendar_quarter' | 'calendar_year' | 'test_minutes' anchor: number timezone: string proration?: 'full_period' @@ -674,3 +675,171 @@ export interface PaymentAuditResponse { results: PaymentAuditSummary[] timeline: PaymentAuditEvent[] } + +/** One PSP's pacing state against its volume commitment. */ +export interface PspPacing { + connector: string + goal: number + achieved: number + gap: number + pace: number + srVolume: number + floorPerDay: number + /** Share of eligible payments currently being diverted here, 0..=1. */ + steerRate: number + steeredToday: number + reward: number + steering: boolean +} + +/** A commitment the controller stopped chasing, with the reason why. */ +export interface EliminatedPspView { + connector: string + /** Volume sent so far this cycle — still counted after the drop. */ + achieved: number + gap: number + reward: number + reason: string +} + +export interface VolumeCommitmentView { + merchantId: string + active: boolean + computedAtEpochSecs?: number | null + tolerance?: number | null + /** Total volume the merchant expects per day, from the contract document. */ + expectedDailyTraffic?: number | null + /** How long one contract day lasts in seconds — 86400 for calendar cycles, 60 on a test cycle. */ + daySecs?: number | null + /** Routing rule holding the active contract, so the dashboard can act on it. */ + ruleId?: string | null + rewardAtStake: number + psps: PspPacing[] + eliminated: EliminatedPspView[] +} + +/** One PSP-day of delivered volume on the commitment chart. */ +export interface CommitmentDayVolume { + connector: string + dayIndex: number + /** Where in the cycle the bucket starts, in fractional contract days. */ + day: number + total: number + steered: number + /** Payments behind `total` / `steered`. */ + payments: number + steeredPayments: number +} + +/** One PSP's chart series: the promise it races and its per-day delivery. */ +export interface CommitmentConnectorSeries { + connector: string + goal: number + reward: number + /** How the reward is earned — "0.25% rebate", "lump sum". */ + rewardNote: string + cycleStart: string + /** When the cycle closes — the next one's start. */ + cycleEnd: string + daysTotal: number + eliminated: boolean + points: CommitmentDayVolume[] +} + +export interface CommitmentSeriesResponse { + merchantId: string + /** ISO-4217 code the amounts are in, when the contract states one. */ + currency?: string | null + /** Seconds in one contract day — the unit `points[].day` counts in. */ + daySecs?: number | null + connectors: CommitmentConnectorSeries[] +} + +export type CommitmentAuditKind = 'forecast' | 'steered' | 'eliminated' + +/** One entry in the volume-commitment audit trail. */ +export interface CommitmentAuditEvent { + atEpochMs: number + kind: CommitmentAuditKind + /** The contract execution this entry belongs to. */ + runId?: string + connector?: string + message: string + amount?: number +} + +/** One execution of a contract — a single billing cycle, start to close. */ +export interface CommitmentRunSummary { + runId: string + startedAtEpochMs: number + lastActivityEpochMs: number + forecasts: number + steers: number + eliminations: number + isCurrent: boolean +} + +export interface CommitmentAuditResponse { + merchantId: string + runs: CommitmentRunSummary[] + events: CommitmentAuditEvent[] +} + +/** Payments and volume one PSP received in a window. */ +export interface CommitmentImpactSlice { + payments: number + volume: number +} + +/** One PSP's before-and-after under the contract. */ +export interface CommitmentConnectorImpact { + connector: string + goal: number + reward: number + eliminated: boolean + steering: boolean + /** Everything it received in the previous cycle. */ + before: CommitmentImpactSlice + /** Everything it received in the cycle — `unaided + steered`. */ + withContract: CommitmentImpactSlice + /** The part normal routing sent by itself. */ + unaided: CommitmentImpactSlice + /** The part the nudge moved here to meet the commitment. */ + steered: CommitmentImpactSlice + /** What routing would have sent here but the nudge moved to a PSP behind on its commitment. */ + ceded: CommitmentImpactSlice +} + +export interface CommitmentImpactWindow { + startMs: number + endMs: number +} + +/** The story of a contract: each PSP's traffic in the previous cycle next to this one. */ +export interface CommitmentImpactResponse { + merchantId: string + contractSinceMs: number + cycle: CommitmentImpactWindow + daysTotal: number + daySecs: number + baseline: CommitmentImpactWindow + connectors: CommitmentConnectorImpact[] + /** Day-by-day delivery per PSP across the previous cycle, days counted from its start. */ + baselineDays: CommitmentDayVolume[] + /** The same across the cycle, days counted from the cycle's start. */ + cycleDays: CommitmentDayVolume[] +} + +/** Why the volume-commitment nudge did or did not move a payment, on the decide response. */ +export interface VolumeSteerInfo { + outcome: 'STEERED' | 'SR_PREVAILED' + reason: string + srHead?: string | null + chosen?: string | null + srGapConceded?: number | null + /** Share of eligible payments the chosen PSP was set to take when the roll happened. */ + steerRate?: number | null + steeringCount: number + /** The contract execution this steer belongs to. */ + runId?: string | null +}