diff --git a/backend/src/lib/webhookRegistry.ts b/backend/src/lib/webhookRegistry.ts index ce8529a..a0c1a66 100644 --- a/backend/src/lib/webhookRegistry.ts +++ b/backend/src/lib/webhookRegistry.ts @@ -43,6 +43,70 @@ let retryTimerHandle: NodeJS.Timeout | null = null; let isShuttingDown = false; let db: any; +// ── Circuit breaker ────────────────────────────────────────────────────────── +// After CIRCUIT_FAILURE_THRESHOLD consecutive delivery failures to a given +// URL, stop attempting deliveries to it for CIRCUIT_COOLDOWN_MS. This avoids +// hammering an endpoint that's known to be down and lets it recover. +const CIRCUIT_FAILURE_THRESHOLD = 10; +const CIRCUIT_COOLDOWN_MS = 5 * 60 * 1000; + +const consecutiveFailures = new Map(); +const circuitOpenUntil = new Map(); + +/** True if the circuit for `url` is currently open (deliveries paused). */ +function isCircuitOpen(url: string): boolean { + const openUntil = circuitOpenUntil.get(url); + if (openUntil === undefined) return false; + if (Date.now() >= openUntil) { + // Cooldown elapsed — close the circuit and give the endpoint a fresh start. + circuitOpenUntil.delete(url); + consecutiveFailures.set(url, 0); + return false; + } + return true; +} + +function recordDeliverySuccess(url: string): void { + consecutiveFailures.set(url, 0); + circuitOpenUntil.delete(url); +} + +function recordDeliveryFailure(url: string): void { + const count = (consecutiveFailures.get(url) ?? 0) + 1; + consecutiveFailures.set(url, count); + if (count >= CIRCUIT_FAILURE_THRESHOLD && !circuitOpenUntil.has(url)) { + const openUntil = Date.now() + CIRCUIT_COOLDOWN_MS; + circuitOpenUntil.set(url, openUntil); + logger.error("Webhook circuit breaker opened after consecutive failures", { + url, + consecutiveFailures: count, + cooldownMs: CIRCUIT_COOLDOWN_MS, + resumesAt: new Date(openUntil).toISOString(), + }); + } +} + +/** Exposed for observability/tests. */ +export function getCircuitBreakerState(url: string): { + open: boolean; + consecutiveFailures: number; + openUntil: string | null; +} { + return { + open: isCircuitOpen(url), + consecutiveFailures: consecutiveFailures.get(url) ?? 0, + openUntil: circuitOpenUntil.has(url) + ? new Date(circuitOpenUntil.get(url)!).toISOString() + : null, + }; +} + +/** Reset all circuit breaker state. Exposed for tests. */ +export function resetCircuitBreakers(): void { + consecutiveFailures.clear(); + circuitOpenUntil.clear(); +} + function openDatabase() { fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); const database = new Database(DB_PATH); @@ -172,6 +236,17 @@ async function fireWebhookInternal( ): Promise { // Fall back to the current async context's request ID when not explicitly supplied const effectiveCorrelationId = correlationId ?? getReqId(); + + if (isCircuitOpen(url)) { + logger.warn("Webhook circuit breaker open, skipping delivery", { + url, + attempt: attempt + 1, + attemptedAt: new Date().toISOString(), + correlationId: effectiveCorrelationId, + }); + return; + } + const headers: Record = { "Content-Type": "application/json" }; if (effectiveCorrelationId) { headers["X-Request-ID"] = effectiveCorrelationId; @@ -205,6 +280,7 @@ async function fireWebhookInternal( } succeeded = true; + recordDeliverySuccess(url); webhookDeliveries.inc({ status: "success", attempt: String(attempt + 1) }); if (attempt > 0) { @@ -212,11 +288,14 @@ async function fireWebhookInternal( } } catch (err: any) { deliveryError = err.message; + recordDeliveryFailure(url); webhookDeliveries.inc({ status: "failure", attempt: String(attempt + 1) }); logger.warn("Webhook delivery failed", { url, attempt: attempt + 1, maxRetries: MAX_RETRIES, + attemptedAt, + httpStatus, error: err.message, correlationId: effectiveCorrelationId, }); @@ -232,6 +311,7 @@ async function fireWebhookInternal( logger.error("Webhook delivery failed permanently after max retries", { url, attempts: MAX_RETRIES + 1, + httpStatus, correlationId: effectiveCorrelationId, }); } diff --git a/backend/tests/webhookRegistry.integration.test.ts b/backend/tests/webhookRegistry.integration.test.ts new file mode 100644 index 0000000..62114c8 --- /dev/null +++ b/backend/tests/webhookRegistry.integration.test.ts @@ -0,0 +1,116 @@ +/** + * Covers the webhook circuit breaker: after CIRCUIT_FAILURE_THRESHOLD (10) + * consecutive delivery failures to a URL, further deliveries to that URL are + * skipped (no fetch attempt) until the cooldown window elapses. + */ + +import { describe, it, expect, beforeEach } from "vitest"; + +process.env.WEBHOOKS_DB_PATH = ":memory:"; + +import { + fireWebhook, + getCircuitBreakerState, + resetCircuitBreakers, +} from "../src/lib/webhookRegistry.js"; + +function installFetchSpy(status: number) { + const calls: string[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request) => { + calls.push(String(input)); + return Promise.resolve(new Response(status === 200 ? "{}" : "error", { status })); + }) as typeof fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +const URL = "https://circuit.example.com/hook"; + +describe("webhook circuit breaker", () => { + beforeEach(() => { + resetCircuitBreakers(); + }); + + it("opens after 10 consecutive failures and skips further delivery attempts", async () => { + const spy = installFetchSpy(500); + try { + for (let i = 0; i < 10; i++) { + await fireWebhook(URL, JSON.stringify({ i })); + } + + const state = getCircuitBreakerState(URL); + expect(state.open).toBe(true); + expect(state.consecutiveFailures).toBeGreaterThanOrEqual(10); + + const callsBeforeEleventh = spy.calls.length; + await fireWebhook(URL, JSON.stringify({ eleventh: true })); + + // Circuit is open — no additional fetch attempt should have been made. + expect(spy.calls.length).toBe(callsBeforeEleventh); + } finally { + spy.restore(); + } + }); + + it("does not open the circuit for a URL that stays under the failure threshold", async () => { + const spy = installFetchSpy(500); + try { + for (let i = 0; i < 9; i++) { + await fireWebhook(URL, JSON.stringify({ i })); + } + expect(getCircuitBreakerState(URL).open).toBe(false); + } finally { + spy.restore(); + } + }); + + it("closes and resumes deliveries once the cooldown window elapses", async () => { + const failing = installFetchSpy(500); + try { + for (let i = 0; i < 10; i++) { + await fireWebhook(URL, JSON.stringify({ i })); + } + expect(getCircuitBreakerState(URL).open).toBe(true); + } finally { + failing.restore(); + } + + const realDateNow = Date.now; + try { + // Fast-forward past the 5-minute cooldown. + Date.now = () => realDateNow() + 5 * 60 * 1000 + 1; + + const succeeding = installFetchSpy(200); + try { + await fireWebhook(URL, JSON.stringify({ resumed: true })); + expect(succeeding.calls.length).toBe(1); + expect(getCircuitBreakerState(URL).open).toBe(false); + expect(getCircuitBreakerState(URL).consecutiveFailures).toBe(0); + } finally { + succeeding.restore(); + } + } finally { + Date.now = realDateNow; + } + }); + + it("a success resets the consecutive-failure count", async () => { + const failing = installFetchSpy(500); + try { + for (let i = 0; i < 5; i++) { + await fireWebhook(URL, JSON.stringify({ i })); + } + expect(getCircuitBreakerState(URL).consecutiveFailures).toBe(5); + } finally { + failing.restore(); + } + + const succeeding = installFetchSpy(200); + try { + await fireWebhook(URL, JSON.stringify({ ok: true })); + expect(getCircuitBreakerState(URL).consecutiveFailures).toBe(0); + } finally { + succeeding.restore(); + } + }); +}); diff --git a/contracts/solar_grid/src/lib.rs b/contracts/solar_grid/src/lib.rs index 9fb1ff3..73f0c17 100644 --- a/contracts/solar_grid/src/lib.rs +++ b/contracts/solar_grid/src/lib.rs @@ -29,6 +29,8 @@ pub enum ContractError { ContractNotFrozen = 16, ContractFrozen = 17, CollaboratorNotFound = 18, + RefundExceedsPayments = 19, + RefundLimitExceeded = 20, } // ── Storage keys ────────────────────────────────────────────────────────────── @@ -48,6 +50,9 @@ const DEFAULT_GRACE_PERIOD: u64 = 7200; // 2 hours (in seconds) const GRACE_PERIOD: Symbol = symbol_short!("GRACE_P"); const SECONDS_PER_DAY: u64 = 86_400; const SECONDS_PER_WEEK: u64 = 604_800; +/// Max total i128 refunded across all recipients per rolling window; 0 = unlimited. +const REFUND_LIMIT: Symbol = symbol_short!("RFND_LIM"); +const REFUND_WINDOW: Symbol = symbol_short!("RFND_WIN"); // ── Data types ──────────────────────────────────────────────────────────────── @@ -170,6 +175,19 @@ pub enum DataKey { OwnerMeters(Address), ProviderRevenue(Address), MeterBalance(String), + /// Cumulative amount `payer` has paid towards `meter_id` (lifetime, not reduced by refunds). + PayerPaid(String, Address), + /// Cumulative amount already refunded to `payer` for `meter_id`. + PayerRefunded(String, Address), +} + +/// Tracks admin-issued refunds within the current rolling window, used to cap +/// total refunds per period and prevent contract balance drainage. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RefundWindow { + pub window_start: u64, + pub window_spent: i128, } /// Combined view returned by get_meter_full — meter state plus its balance @@ -656,6 +674,15 @@ impl SolarGridContract { .persistent() .set(&bal_key, &prev_bal.saturating_add(amount)); + // Track lifetime payments per (meter, payer) so refunds can be capped + // to what that address has actually paid. + let payer_paid_key = DataKey::PayerPaid(meter_id.clone(), payer.clone()); + let payer_paid: i128 = env.storage().persistent().get(&payer_paid_key).unwrap_or(0); + env.storage() + .persistent() + .set(&payer_paid_key, &payer_paid.saturating_add(amount)); + + let old_plan = meter.plan.clone(); meter.active = true; meter.plan = plan.clone(); meter.last_payment = now; @@ -674,14 +701,175 @@ impl SolarGridContract { // payment_received env.events().publish( (EVT_NS, symbol_short!("payment"), meter_id.clone()), - (payer, token_address, amount, plan), + (payer, token_address, amount, plan.clone()), ); + // plan_changed — emitted whenever a payment switches the meter's active plan, + // so off-chain services can track plan migrations (e.g. Daily -> Weekly). + if old_plan != plan { + env.events().publish( + (EVT_NS, symbol_short!("plan_chg"), meter_id.clone()), + (old_plan, plan, now), + ); + } // meter_activated — payment always activates the meter env.events() .publish((EVT_NS, symbol_short!("mtr_actv"), meter_id), ()); Ok(()) } + /// Refund a previous payment. Admin-only. + /// + /// Transfers `amount` back to `recipient` from the contract's token balance, + /// reduces `meter_id`'s tracked balance (and the admin's tracked provider + /// revenue) accordingly, and records `reason` in the emitted event for the + /// audit trail. + /// + /// # Guards + /// - `amount` must be <= the total this `recipient` has actually paid towards + /// `meter_id`, minus any amount already refunded to them — this prevents + /// refunding more than was ever received from that address. + /// - Total refunds across all recipients are capped per rolling 24h window + /// via [`Self::set_refund_limit`] (0 = unlimited), to prevent a compromised + /// or buggy admin flow from draining the contract balance in one burst. + /// + /// # Errors + /// - [`ContractError::InvalidAmount`] when `amount <= 0` + /// - [`ContractError::Unauthorized`] when caller is not the contract admin + /// - [`ContractError::MeterNotFound`] when `meter_id` doesn't exist + /// - [`ContractError::RefundExceedsPayments`] when `amount` exceeds what + /// `recipient` has paid (net of prior refunds) for this meter + /// - [`ContractError::RefundLimitExceeded`] when `amount` would push total + /// refunds in the current window past the configured limit + /// - [`ContractError::InsufficientBalance`] when the contract's token + /// balance is less than `amount` + /// + /// Emits: `pmt_rfnd { recipient, amount, reason, meter_id, refunded_balance }` + /// (the logical event name is `payment_refunded`; the on-chain topic is + /// abbreviated to fit the Soroban `Symbol` short-code limit). + pub fn refund_payment( + env: Env, + meter_id: String, + amount: i128, + recipient: Address, + reason: String, + ) -> Result<(), ContractError> { + Self::require_admin(&env)?; + if amount <= 0 { + return Err(ContractError::InvalidAmount); + } + + let key = DataKey::Meter(meter_id.clone()); + let mut meter = Self::get_meter_or_error(&env, &key)?; + + // Cap refunds to what this recipient has actually paid (net of prior refunds). + let paid_key = DataKey::PayerPaid(meter_id.clone(), recipient.clone()); + let refunded_key = DataKey::PayerRefunded(meter_id.clone(), recipient.clone()); + let paid: i128 = env.storage().persistent().get(&paid_key).unwrap_or(0); + let already_refunded: i128 = env.storage().persistent().get(&refunded_key).unwrap_or(0); + let refundable = paid.saturating_sub(already_refunded); + if amount > refundable { + return Err(ContractError::RefundExceedsPayments); + } + + // Enforce the rolling-window cap across all recipients, if configured. + let refund_limit: i128 = env.storage().instance().get(&REFUND_LIMIT).unwrap_or(0); + let now = env.ledger().timestamp(); + if refund_limit > 0 { + let mut window: RefundWindow = env + .storage() + .instance() + .get(&REFUND_WINDOW) + .unwrap_or(RefundWindow { + window_start: now, + window_spent: 0, + }); + if now.saturating_sub(window.window_start) > SECONDS_PER_DAY { + window.window_start = now; + window.window_spent = 0; + } + if window.window_spent.saturating_add(amount) > refund_limit { + return Err(ContractError::RefundLimitExceeded); + } + window.window_spent = window.window_spent.saturating_add(amount); + env.storage().instance().set(&REFUND_WINDOW, &window); + } + + let token_address = Self::get_token_address(&env)?; + let token_client = token::Client::new(&env, &token_address); + let contract_balance = token_client.balance(&env.current_contract_address()); + if amount > contract_balance { + return Err(ContractError::InsufficientBalance); + } + + // Update meter balance — the refunded amount is no longer available for usage. + let bal_key = DataKey::MeterBalance(meter_id.clone()); + let balance: i128 = env.storage().persistent().get(&bal_key).unwrap_or(0); + let new_balance = balance.saturating_sub(amount).max(0); + env.storage().persistent().set(&bal_key, &new_balance); + if new_balance == 0 && meter.active { + meter.active = false; + env.storage().persistent().set(&key, &meter); + env.events() + .publish((EVT_NS, symbol_short!("mtr_deact"), meter_id.clone()), ()); + } + + // Reverse the admin's tracked revenue for the refunded amount, so the + // refunded funds can't also be withdrawn via withdraw_revenue. + let admin = Self::get_admin(&env)?; + let provider_key = DataKey::ProviderRevenue(admin); + let provider_revenue: i128 = env.storage().persistent().get(&provider_key).unwrap_or(0); + env.storage() + .persistent() + .set(&provider_key, &provider_revenue.saturating_sub(amount).max(0)); + + env.storage() + .persistent() + .set(&refunded_key, &already_refunded.saturating_add(amount)); + + token_client.transfer(&env.current_contract_address(), &recipient, &amount); + + env.events().publish( + (EVT_NS, symbol_short!("pmt_rfnd"), meter_id), + (recipient, amount, reason, new_balance, now), + ); + Ok(()) + } + + /// Set the maximum total amount refundable (across all recipients) per + /// rolling 24h window. Admin-only. A limit of 0 means unlimited. + /// + /// Guards against a compromised admin key or a scripting bug issuing a + /// burst of refunds that drains the contract's token balance. + pub fn set_refund_limit(env: Env, limit: i128) -> Result<(), ContractError> { + Self::require_admin(&env)?; + if limit < 0 { + return Err(ContractError::InvalidAmount); + } + let old_limit: i128 = env.storage().instance().get(&REFUND_LIMIT).unwrap_or(0); + env.storage().instance().set(&REFUND_LIMIT, &limit); + env.events().publish( + (EVT_NS, symbol_short!("rfnd_lim")), + (old_limit, limit), + ); + Ok(()) + } + + /// Total amount `payer` has paid towards `meter_id` (lifetime, unaffected by refunds). + pub fn get_payer_paid(env: Env, meter_id: String, payer: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::PayerPaid(meter_id, payer)) + .unwrap_or(0) + } + + /// Total amount already refunded to `payer` for `meter_id`. + pub fn get_payer_refunded(env: Env, meter_id: String, payer: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::PayerRefunded(meter_id, payer)) + .unwrap_or(0) + } + /// Withdraw accumulated revenue from the contract vault to the provider address. /// /// # Access control @@ -3477,6 +3665,188 @@ mod tests { assert!(client.check_access(&meter_id)); } + // ── plan_changed event tests ────────────────────────────────────────────── + + #[test] + fn test_plan_change_emits_plan_chg_event() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "PLAN_CHG"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &10_000_i128); + + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::Daily); + // Same plan again — no plan_chg event expected. + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::Daily); + let events_before = env.events().all(); + let has_plan_chg_yet = events_before.iter().any(|(_, topics, _)| { + topics.len() >= 2 && sym_eq(&env, &topics.get(1).unwrap(), symbol_short!("plan_chg")) + }); + assert!(!has_plan_chg_yet, "plan_chg should not fire when plan is unchanged"); + + // Switch to Weekly — should emit plan_chg. + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::Weekly); + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + topics.len() >= 3 + && sym_eq(&env, &topics.get(1).unwrap(), symbol_short!("plan_chg")) + && topics.get(2) == Some(meter_id.clone().into()) + }); + assert!(found, "plan_chg event not emitted on plan switch"); + } + + // ── refund_payment tests ────────────────────────────────────────────────── + + #[test] + fn test_refund_payment_transfers_and_updates_balance() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let token_client = token::Client::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND1"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &10_000_i128); + + client.make_payment(&meter_id, &user, &10_000_i128, &PaymentPlan::UsageBased); + assert_eq!(client.get_meter_balance(&meter_id), 10_000); + + let reason = String::from_str(&env, "duplicate payment"); + client.refund_payment(&meter_id, &3_000_i128, &user, &reason); + + assert_eq!(client.get_meter_balance(&meter_id), 7_000); + assert_eq!(token_client.balance(&user), 3_000); + assert_eq!(client.get_payer_refunded(&meter_id, &user), 3_000); + } + + #[test] + fn test_refund_payment_emits_pmt_rfnd_event() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND2"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &5_000_i128); + client.make_payment(&meter_id, &user, &5_000_i128, &PaymentPlan::UsageBased); + + let reason = String::from_str(&env, "billing error"); + client.refund_payment(&meter_id, &1_000_i128, &user, &reason); + + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + topics.len() >= 2 + && sym_eq(&env, &topics.get(1).unwrap(), symbol_short!("pmt_rfnd")) + }); + assert!(found, "pmt_rfnd event not emitted"); + } + + #[test] + fn test_refund_payment_rejects_amount_above_total_paid() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND3"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &1_000_i128); + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::UsageBased); + + let reason = String::from_str(&env, "abuse attempt"); + let result = client.try_refund_payment(&meter_id, &1_001_i128, &user, &reason); + assert_eq!(result, Err(Ok(ContractError::RefundExceedsPayments))); + } + + #[test] + fn test_refund_payment_rejects_double_refund_over_paid_total() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND4"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &1_000_i128); + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::UsageBased); + + let reason = String::from_str(&env, "partial refund"); + client.refund_payment(&meter_id, &600_i128, &user, &reason); + // Only 400 remains refundable. + let result = client.try_refund_payment(&meter_id, &500_i128, &user, &reason); + assert_eq!(result, Err(Ok(ContractError::RefundExceedsPayments))); + } + + #[test] + fn test_refund_payment_rejects_recipient_with_no_payments() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND5"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &1_000_i128); + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::UsageBased); + + // A recipient who never paid towards this meter has nothing refundable, + // regardless of the contract's overall token balance. + let reason = String::from_str(&env, "n/a"); + let stranger = Address::generate(&env); + let result = client.try_refund_payment(&meter_id, &100_i128, &stranger, &reason); + assert_eq!(result, Err(Ok(ContractError::RefundExceedsPayments))); + } + + #[test] + fn test_refund_payment_zero_amount_returns_typed_error() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND6"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &1_000_i128); + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::UsageBased); + + let reason = String::from_str(&env, "n/a"); + let result = client.try_refund_payment(&meter_id, &0_i128, &user, &reason); + assert_eq!(result, Err(Ok(ContractError::InvalidAmount))); + } + + #[test] + fn test_refund_payment_respects_rolling_window_limit() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND7"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &10_000_i128); + client.make_payment(&meter_id, &user, &10_000_i128, &PaymentPlan::UsageBased); + + // Cap total refunds to 500 stroops per 24h window. + client.set_refund_limit(&500_i128); + + let reason = String::from_str(&env, "window test"); + client.refund_payment(&meter_id, &500_i128, &user, &reason); + + // A further refund within the same window should be rejected even + // though the payer still has refundable balance. + let result = client.try_refund_payment(&meter_id, &1_i128, &user, &reason); + assert_eq!(result, Err(Ok(ContractError::RefundLimitExceeded))); + + // After the window rolls over, refunds resume. + env.ledger() + .with_mut(|li| li.timestamp += SECONDS_PER_DAY + 1); + client.refund_payment(&meter_id, &1_i128, &user, &reason); + assert_eq!(client.get_payer_refunded(&meter_id, &user), 501); + } + + #[test] + fn test_refund_payment_deactivates_meter_when_balance_hits_zero() { + let (env, client, _admin, token_address) = setup_with_token(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + let user = Address::generate(&env); + let meter_id = String::from_str(&env, "RFND8"); + allowlist_and_register(&client, &meter_id, &user); + token_admin_client.mint(&user, &1_000_i128); + client.make_payment(&meter_id, &user, &1_000_i128, &PaymentPlan::UsageBased); + assert!(client.get_meter(&meter_id).active); + + let reason = String::from_str(&env, "full refund"); + client.refund_payment(&meter_id, &1_000_i128, &user, &reason); + assert!(!client.get_meter(&meter_id).active); // ── Issue #703: DST / Timezone independence tests ──────────────────────── #[test] diff --git a/frontend/src/__tests__/UsageChart.test.tsx b/frontend/src/__tests__/UsageChart.test.tsx index bbaf3dd..b4bfcc7 100644 --- a/frontend/src/__tests__/UsageChart.test.tsx +++ b/frontend/src/__tests__/UsageChart.test.tsx @@ -1,5 +1,10 @@ import { render, screen } from "@testing-library/react"; -import UsageChart, { UsageDataPoint } from "@/components/UsageChart"; +import UsageChart, { + UsageDataPoint, + formatTickLocal, + formatTooltipLocal, + hasTimeComponent, +} from "@/components/UsageChart"; // recharts uses ResizeObserver internally — polyfill for jsdom global.ResizeObserver = class ResizeObserver { @@ -73,4 +78,48 @@ describe("UsageChart", () => { render(); expect(screen.getByText("Energy Usage")).toBeInTheDocument(); }); + + // ── Timezone formatting (issue: x-axis showed raw UTC, no tz indicator) ──── + + describe("hasTimeComponent", () => { + it("is true for a full ISO 8601 timestamp", () => { + expect(hasTimeComponent("2026-08-24T06:00:00Z")).toBe(true); + }); + + it("is false for a plain calendar date", () => { + expect(hasTimeComponent("2026-08-24")).toBe(false); + }); + }); + + describe("formatTickLocal", () => { + it("renders a full timestamp as a local clock time, not the raw UTC string", () => { + const formatted = formatTickLocal("2026-08-24T06:00:00Z"); + expect(formatted).not.toBe("2026-08-24T06:00:00Z"); + expect(formatted).toMatch(/\d{1,2}:\d{2}/); + }); + + it("renders a plain date as a short calendar date, not a clock time", () => { + const formatted = formatTickLocal("2026-08-24"); + expect(formatted).not.toMatch(/\d{1,2}:\d{2}/); + }); + + it("falls back to the raw value for an unparseable string", () => { + expect(formatTickLocal("not-a-date")).toBe("not-a-date"); + }); + }); + + describe("formatTooltipLocal", () => { + it("includes an explicit timezone indicator alongside the local time", () => { + const formatted = formatTooltipLocal("2026-08-24T06:00:00Z"); + // Should carry the clock time plus a timezone abbreviation/offset + // (e.g. "Aug 24, 6:00 AM GMT+3") — never just the bare UTC string. + expect(formatted).not.toBe("2026-08-24T06:00:00Z"); + expect(formatted).toMatch(/\d{1,2}:\d{2}/); + expect(formatted.length).toBeGreaterThan(formatTickLocal("2026-08-24T06:00:00Z").length); + }); + + it("falls back to the raw value for an unparseable string", () => { + expect(formatTooltipLocal("not-a-date")).toBe("not-a-date"); + }); + }); }); diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index 1f74cc3..f3c18fe 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -169,8 +169,11 @@ function MeterCard({ meterId, meter }: { meterId: string; meter: MeterData }) { fetch('/api/meters/' + meterId + '/history?limit=7') .then(r => r.json()) .then(d => { + // Pass the raw ISO 8601 timestamp through — UsageChart formats it in + // the viewer's local timezone (with a timezone indicator) itself, so + // pre-formatting here would throw away the time-of-day and tz info. const events: UsageDataPoint[] = (d.events || []).map((e: { recorded_at: string; units: number; cost?: number }) => ({ - date: new Date(e.recorded_at).toLocaleDateString(), + date: e.recorded_at, units: e.units, cost: e.cost, })); diff --git a/frontend/src/components/UsageChart.tsx b/frontend/src/components/UsageChart.tsx index e9f804a..a9d2806 100644 --- a/frontend/src/components/UsageChart.tsx +++ b/frontend/src/components/UsageChart.tsx @@ -12,6 +12,11 @@ import { } from "recharts"; export interface UsageDataPoint { + /** + * An ISO 8601 timestamp (e.g. "2026-08-24T06:00:00Z"), or a plain + * "YYYY-MM-DD" date for day-granularity data. Always rendered in the + * viewer's local timezone — see `formatTickLocal` / `formatTooltipLocal`. + */ date: string; /** Energy consumed in kWh */ units: number; @@ -19,6 +24,53 @@ export interface UsageDataPoint { cost?: number; } +/** True if `value` carries a time-of-day component (not just a calendar date). */ +export function hasTimeComponent(value: string): boolean { + return /T\d{2}:\d{2}/.test(value); +} + +/** + * Format an x-axis tick in the viewer's local timezone. + * Falls back to the raw value when it isn't a parseable date, so + * already-formatted or unexpected strings don't crash the chart. + */ +export function formatTickLocal(value: string): string { + const parsed = hasTimeComponent(value) + ? new Date(value) + : (() => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(value); + })(); + + if (Number.isNaN(parsed.getTime())) return value; + + return hasTimeComponent(value) + ? parsed.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }) + : parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +/** + * Format a tooltip label with an explicit timezone indicator, so it's clear + * the time shown is local and not UTC (e.g. "Aug 24, 2:00 PM GMT+3"). + */ +export function formatTooltipLocal(value: string): string { + const parsed = hasTimeComponent(value) + ? new Date(value) + : (() => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(value); + })(); + + if (Number.isNaN(parsed.getTime())) return value; + + return parsed.toLocaleString(undefined, { + month: "short", + day: "numeric", + ...(hasTimeComponent(value) ? { hour: "numeric", minute: "2-digit" } : {}), + timeZoneName: "short", + }); +} + interface UsageChartProps { /** Usage data points. Null/undefined treated as empty — no crash. */ data?: UsageDataPoint[] | null; @@ -138,6 +190,7 @@ export default function UsageChart({ - } /> + } labelFormatter={formatTooltipLocal} />