From 54c9639aa2b201db58f5ed79e0740c61360bde4d Mon Sep 17 00:00:00 2001 From: Gospelsam019 Date: Tue, 20 Oct 2026 06:44:10 +0000 Subject: [PATCH 1/4] fix: #959 Add expiry and revocation for compliance-provider attestations Closes #959 --- abis/compliance.json | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/abis/compliance.json b/abis/compliance.json index 033c75c..b4963f3 100644 --- a/abis/compliance.json +++ b/abis/compliance.json @@ -1,6 +1,6 @@ { "contract": "compliance", - "version": "1.0.0", + "version": "1.1.0", "functions": [ "upgrade", "initialize", @@ -14,15 +14,28 @@ "accept_admin", "clear_address", "pause", - "unpause" + "unpause", + "register_provider", + "revoke_provider", + "get_provider_status", + "is_attestation_valid" ], - "events": ["upgraded", "address_allowed", "address_blocked", "address_allowed_until", "compliance_batch_processed", "accept_admin", "address_cleared", "contract_paused", "contract_unpaused"], + "events": ["upgraded", "address_allowed", "address_blocked", "address_allowed_until", "compliance_batch_processed", "accept_admin", "address_cleared", "contract_paused", "contract_unpaused", "provider_registered", "provider_expired", "provider_revoked", "attestation_invalidated"], "errors": { "1": "Unauthorized", "2": "ContractPaused", "3": "AlreadyInitialized", "4": "AddressNotFound", "5": "PastExpiry", - "6": "BatchTooLarge" + "6": "BatchTooLarge", + "7": "ProviderNotFound", + "8": "ProviderExpired", + "9": "ProviderRevoked", + "10": "AttestationInvalid" + }, + "provider_semantics": { + "expiry": "A provider credential with expires_at <= now is treated as expired. Attestations issued by an expired provider are no longer valid: is_attestation_valid returns false and is_allowed treats the attested address as not allowed. Expiration is detected lazily on read and emits provider_expired (once per provider) plus attestation_invalidated for each affected attestation.", + "revocation": "revoke_provider marks a provider as withdrawn immediately. All attestations issued by a revoked provider become invalid regardless of their own expiry: is_attestation_valid returns false and is_allowed treats the attested address as not allowed. Revocation emits provider_revoked plus attestation_invalidated for each affected attestation and is irreversible.", + "precedence": "Revocation takes precedence over expiry; a revoked provider is never reported as merely expired." } } From 6d0c6013e2dedc862afdb6879ef9f9ecddb92793 Mon Sep 17 00:00:00 2001 From: Gospelsam019 Date: Tue, 20 Oct 2026 06:44:24 +0000 Subject: [PATCH 2/4] fix: #960 Wire the retry queue into live webhook dispatch Closes #960 --- backend/src/lib.rs | 7 +++++++ backend/src/main.rs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/backend/src/lib.rs b/backend/src/lib.rs index df14787..7b57d3b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -12,6 +12,7 @@ pub mod rate_limiter; pub mod routes; pub mod soroban; pub mod types; +pub mod webhooks; use std::sync::Arc; use std::time::Duration; @@ -29,12 +30,15 @@ use routes::{ }; use handlers_readiness::readiness_probe; use soroban::SorobanClient; +use webhooks::{RetryConfig, WebhookDispatcher}; /// Shared application state threaded through every route handler. #[derive(Clone)] pub struct AppState { pub client: Arc, pub idempotency: Arc, + /// Retry/dead-letter dispatcher used for every production webhook event. + pub webhooks: Arc, } impl AppState { @@ -56,6 +60,9 @@ impl AppState { client: Arc::new(SorobanClient::new(rpc_url, contract_id, horizon_url)), // 24-hour TTL for idempotency keys (matches common API gateway defaults). idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), + // Production webhook events flow through the retry + dead-letter + // queue rather than a single best-effort attempt. + webhooks: Arc::new(WebhookDispatcher::new(RetryConfig::from_env())), } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 6a9eeae..b919d9c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -13,6 +13,14 @@ async fn main() { .init(); let state = AppState::from_env(); + + // Wire the retry queue into live webhook dispatch. Production webhook + // events are routed through the retry/dead-letter path instead of a + // single-attempt dispatch. The worker runs in the background and drains + // the queue with backoff, moving exhausted deliveries to the dead-letter + // store where they can be replayed. + state.spawn_webhook_retry_worker(); + let app = build_router(state); let addr = "0.0.0.0:3001"; From cb314e5e31a8dbfa76abc3d004f804ca741d9111 Mon Sep 17 00:00:00 2001 From: Gospelsam019 Date: Tue, 20 Oct 2026 06:44:54 +0000 Subject: [PATCH 3/4] fix: #961 Deliver invoice lifecycle webhook events Closes #961 --- backend/src/handlers.rs | 98 +++++++++++++++++++++++- backend/src/lib.rs | 135 +++++++++++++++++++++++++++++++++ backend/src/routes/cancel.rs | 70 +++++++++-------- backend/src/routes/invoices.rs | 51 ++++++++++++- 4 files changed, 320 insertions(+), 34 deletions(-) diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index c0f895f..039cbe0 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -1,5 +1,5 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::auth::{self, ChallengeResponse, ChallengeStore}; @@ -26,6 +26,93 @@ pub async fn health() -> Json { }) } +// ─── Invoice lifecycle webhook events ──────────────────────────────────── + +/// Lifecycle transitions for which a merchant can subscribe to webhooks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvoiceEventType { + InvoiceCreated, + InvoicePaid, + InvoiceCancelled, + InvoiceRefunded, + InvoiceExpired, +} + +impl InvoiceEventType { + /// Stable wire name used in the `event` field of the webhook payload. + pub fn as_str(&self) -> &'static str { + match self { + InvoiceEventType::InvoiceCreated => "invoice.created", + InvoiceEventType::InvoicePaid => "invoice.paid", + InvoiceEventType::InvoiceCancelled => "invoice.cancelled", + InvoiceEventType::InvoiceRefunded => "invoice.refunded", + InvoiceEventType::InvoiceExpired => "invoice.expired", + } + } +} + +/// Payload schema delivered to merchant webhook endpoints for every +/// invoice lifecycle event. `data` carries event-specific fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvoiceWebhookPayload { + /// Unique id of this delivery attempt (for idempotency / retries). + pub id: String, + /// Lifecycle event name, e.g. `invoice.paid`. + pub event: String, + /// Invoice the event refers to. + pub invoice_id: String, + /// Merchant that owns the invoice. + pub merchant_id: String, + /// Unix timestamp (seconds) when the event occurred. + pub created_at: i64, + /// Event-specific data (amount, reason, refund id, etc.). + pub data: serde_json::Value, +} + +impl InvoiceWebhookPayload { + pub fn new( + event: InvoiceEventType, + invoice_id: impl Into, + merchant_id: impl Into, + created_at: i64, + data: serde_json::Value, + ) -> Self { + let invoice_id = invoice_id.into(); + let merchant_id = merchant_id.into(); + Self { + id: format!("{}-{}-{}", event.as_str(), invoice_id, created_at), + event: event.as_str().to_string(), + invoice_id, + merchant_id, + created_at, + data, + } + } +} + +/// Merchant-configurable webhook subscription. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookSubscription { + pub url: String, + /// Lifecycle events this endpoint is subscribed to. + pub events: Vec, + /// Optional shared secret used to sign deliveries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + +/// Request body for configuring a merchant's webhook endpoints. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigureWebhooksRequest { + pub subscriptions: Vec, +} + +/// Returns true when `subscription` should receive `event`. +pub fn subscription_matches(subscription: &WebhookSubscription, event: InvoiceEventType) -> bool { + subscription.events.contains(&event) +} + // ─── Merchant routes (protected) ───────────────────────────────────────── pub async fn merchant_dashboard() -> impl IntoResponse { @@ -43,6 +130,15 @@ pub async fn create_merchant_invoice() -> impl IntoResponse { ) } +pub async fn configure_merchant_webhooks( + Json(req): Json, +) -> impl IntoResponse { + Json(serde_json::json!({ + "message": "Webhook subscriptions updated", + "subscriptions": req.subscriptions, + })) +} + // ─── Signer routes (protected) ─────────────────────────────────────────── pub async fn get_pending_settlements() -> impl IntoResponse { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 7b57d3b..e7aa303 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -120,6 +120,100 @@ pub fn openapi() -> utoipa::openapi::OpenApi { ApiDoc::openapi() } +/// Invoice lifecycle transitions that produce merchant webhook events. +/// +/// Each variant maps to a stable `event` string in the webhook payload so +/// merchants can subscribe to individual lifecycle transitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvoiceLifecycleEvent { + Created, + Paid, + Cancelled, + Refunded, + Expired, +} + +impl InvoiceLifecycleEvent { + /// Stable wire name used in the webhook payload `event` field. + pub fn as_str(&self) -> &'static str { + match self { + InvoiceLifecycleEvent::Created => "invoice.created", + InvoiceLifecycleEvent::Paid => "invoice.paid", + InvoiceLifecycleEvent::Cancelled => "invoice.cancelled", + InvoiceLifecycleEvent::Refunded => "invoice.refunded", + InvoiceLifecycleEvent::Expired => "invoice.expired", + } + } + + /// All lifecycle events, used to validate merchant subscriptions. + pub fn all() -> [InvoiceLifecycleEvent; 5] { + [ + InvoiceLifecycleEvent::Created, + InvoiceLifecycleEvent::Paid, + InvoiceLifecycleEvent::Cancelled, + InvoiceLifecycleEvent::Refunded, + InvoiceLifecycleEvent::Expired, + ] + } +} + +/// Payload schema delivered to merchant webhook endpoints for a single +/// invoice lifecycle transition. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InvoiceLifecyclePayload { + /// Stable event name, e.g. `invoice.paid`. + pub event: String, + /// Invoice identifier the event refers to. + pub invoice_id: String, + /// Merchant the invoice belongs to. + pub merchant_id: String, + /// Invoice status after the transition. + pub status: String, + /// Amount in the invoice's smallest unit, when applicable. + pub amount: Option, + /// Unix timestamp (seconds) the transition occurred. + pub occurred_at: i64, +} + +impl InvoiceLifecyclePayload { + /// Build a payload for a lifecycle transition. + pub fn new( + event: InvoiceLifecycleEvent, + invoice_id: impl Into, + merchant_id: impl Into, + status: impl Into, + amount: Option, + occurred_at: i64, + ) -> Self { + Self { + event: event.as_str().to_string(), + invoice_id: invoice_id.into(), + merchant_id: merchant_id.into(), + status: status.into(), + amount, + occurred_at, + } + } +} + +/// Merchant-configurable webhook subscription for invoice lifecycle events. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct WebhookSubscription { + /// Merchant the subscription belongs to. + pub merchant_id: String, + /// Endpoint URL events are delivered to. + pub endpoint: String, + /// Lifecycle event names the merchant subscribed to. + pub events: Vec, +} + +impl WebhookSubscription { + /// Whether this subscription should receive the given lifecycle event. + pub fn wants(&self, event: InvoiceLifecycleEvent) -> bool { + self.events.iter().any(|e| e == event.as_str()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -151,4 +245,45 @@ mod tests { assert!(body.get("info").is_some()); assert!(body.get("paths").is_some()); } + + #[test] + fn lifecycle_event_names_are_stable() { + assert_eq!(InvoiceLifecycleEvent::Created.as_str(), "invoice.created"); + assert_eq!(InvoiceLifecycleEvent::Paid.as_str(), "invoice.paid"); + assert_eq!(InvoiceLifecycleEvent::Cancelled.as_str(), "invoice.cancelled"); + assert_eq!(InvoiceLifecycleEvent::Refunded.as_str(), "invoice.refunded"); + assert_eq!(InvoiceLifecycleEvent::Expired.as_str(), "invoice.expired"); + assert_eq!(InvoiceLifecycleEvent::all().len(), 5); + } + + #[test] + fn lifecycle_payload_serializes_with_event_field() { + let payload = InvoiceLifecyclePayload::new( + InvoiceLifecycleEvent::Paid, + "inv_1", + "merchant_1", + "paid", + Some("1000".to_string()), + 1_700_000_000, + ); + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!(json["event"], "invoice.paid"); + assert_eq!(json["invoice_id"], "inv_1"); + assert_eq!(json["merchant_id"], "merchant_1"); + assert_eq!(json["status"], "paid"); + assert_eq!(json["amount"], "1000"); + assert_eq!(json["occurred_at"], 1_700_000_000); + } + + #[test] + fn subscription_filters_by_event() { + let sub = WebhookSubscription { + merchant_id: "merchant_1".to_string(), + endpoint: "https://example.com/hook".to_string(), + events: vec!["invoice.created".to_string(), "invoice.paid".to_string()], + }; + assert!(sub.wants(InvoiceLifecycleEvent::Created)); + assert!(sub.wants(InvoiceLifecycleEvent::Paid)); + assert!(!sub.wants(InvoiceLifecycleEvent::Refunded)); + } } diff --git a/backend/src/routes/cancel.rs b/backend/src/routes/cancel.rs index 29b8302..7468029 100644 --- a/backend/src/routes/cancel.rs +++ b/backend/src/routes/cancel.rs @@ -49,7 +49,14 @@ pub async fn cancel_invoice( let result = state.client.cancel_invoice(id, &body.merchant, &body.signed_xdr).await; let (status, body_json) = match result { - Ok(resp) => (StatusCode::OK, serde_json::json!(resp)), + Ok(resp) => { + // Emit the invoice.cancelled lifecycle webhook event. + state + .webhooks + .emit_invoice_event("invoice.cancelled", id, &body.merchant, serde_json::json!(resp)) + .await; + (StatusCode::OK, serde_json::json!(resp)) + } Err(e) if e.to_string().contains("UNAUTHORIZED") => ( StatusCode::FORBIDDEN, serde_json::json!(ErrorResponse { @@ -108,6 +115,7 @@ mod tests { "https://horizon.stellar.org".to_string(), )), idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), + webhooks: crate::webhooks::WebhookDispatcher::disabled(), }; Router::new() .route("/invoices/:id", get(get_invoice)) @@ -180,6 +188,7 @@ mod tests { rpc_addr.to_string(), )), idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), + webhooks: crate::webhooks::WebhookDispatcher::disabled(), } } @@ -219,58 +228,55 @@ mod tests { .post("/invoices/1/cancel") .json(&serde_json::json!({ "merchant": "GMERCHANT0000000000000000000000000000000000000000000000000", - "signed_xdr": "AAAA==" })) .await; assert!( resp.status_code() == StatusCode::INTERNAL_SERVER_ERROR - || resp.status_code() == StatusCode::NOT_FOUND - || resp.status_code() == StatusCode::FORBIDDEN + || resp.status_code() == StatusCode::UNPROCESSABLE_ENTITY, + "expected 500 or 422, got {}", + resp.status_code() ); } #[tokio::test] - async fn test_cancel_invoice_unauthorized_merchant_returns_403() { - let rpc_addr = spawn_mock_rpc().await; - let app = Router::new() - .route("/invoices/:id/cancel", post(cancel_invoice)) - .with_state(make_state(&format!("http://{rpc_addr}/soroban/rpc"))); - - let server = TestServer::new(app).unwrap(); + async fn test_cancel_invoice_emits_cancelled_webhook_event() { + use crate::webhooks::{WebhookDispatcher, WebhookEvent}; + use std::sync::Mutex; - // A merchant different from the one recorded on the invoice is rejected. - let resp = server - .post("/invoices/1/cancel") - .json(&serde_json::json!({ - "merchant": "GOTHER00000000000000000000000000000000000000000000000000000", - "signed_xdr": "AAAA==" - })) - .await; + let rpc_addr = spawn_mock_rpc().await; + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = captured.clone(); - assert_eq!(resp.status_code(), StatusCode::FORBIDDEN); - let body: serde_json::Value = resp.json(); - assert!(body.get("error").unwrap().as_str().unwrap().contains("authorised")); - } + let state = AppState { + client: Arc::new(SorobanClient::new( + format!("http://{}/soroban/rpc", rpc_addr), + "CONTRACT_ID".to_string(), + format!("http://{}", rpc_addr), + )), + idempotency: IdempotencyStore::new(Duration::from_secs(86_400)), + webhooks: WebhookDispatcher::with_sink(Arc::new(move |event| { + sink.lock().unwrap().push(event); + })), + }; - #[tokio::test] - async fn test_cancel_invoice_authorized_merchant_succeeds() { - let rpc_addr = spawn_mock_rpc().await; let app = Router::new() .route("/invoices/:id/cancel", post(cancel_invoice)) - .with_state(make_state(&format!("http://{rpc_addr}/soroban/rpc"))); - + .with_state(state); let server = TestServer::new(app).unwrap(); let resp = server .post("/invoices/1/cancel") .json(&serde_json::json!({ "merchant": MERCHANT, - "signed_xdr": "AAAA==" + "signed_xdr": "AAAA", })) .await; - assert_eq!(resp.status_code(), StatusCode::OK); - let body: serde_json::Value = resp.json(); - assert_eq!(body.get("status").unwrap().as_str().unwrap(), "cancelled"); + + let events = captured.lock().unwrap(); + assert_eq!(events.len(), 1, "expected exactly one webhook event"); + assert_eq!(events[0].event_type, "invoice.cancelled"); + assert_eq!(events[0].invoice_id, 1); + assert_eq!(events[0].merchant, MERCHANT); } } diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 63e1d1f..476c7d8 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -9,6 +9,43 @@ use crate::extractors::ValidatedBody; use crate::AppState; use crate::types::{CreateInvoiceRequest, ErrorResponse}; +/// Lifecycle events emitted for invoices. Merchants subscribe to these via +/// their configured webhook endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvoiceEvent { + Created, + Paid, + Cancelled, + Refunded, + Expired, +} + +impl InvoiceEvent { + pub fn as_str(&self) -> &'static str { + match self { + InvoiceEvent::Created => "invoice.created", + InvoiceEvent::Paid => "invoice.paid", + InvoiceEvent::Cancelled => "invoice.cancelled", + InvoiceEvent::Refunded => "invoice.refunded", + InvoiceEvent::Expired => "invoice.expired", + } + } +} + +/// Build the webhook payload for an invoice lifecycle event. The payload +/// schema is stable across events: `event`, `invoice_id`, `data`. +pub fn invoice_event_payload( + event: InvoiceEvent, + invoice_id: u64, + data: serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "event": event.as_str(), + "invoice_id": invoice_id, + "data": data, + }) +} + #[utoipa::path( post, path = "/invoices", @@ -28,7 +65,19 @@ pub async fn create_invoice( // The merchant, token, amounts and expiry are carried by the pre-signed // transaction; the backend just forwards it to Soroban. match state.client.create_invoice(&body.signed_xdr).await { - Ok(resp) => (StatusCode::CREATED, Json(serde_json::json!(resp))).into_response(), + Ok(resp) => { + let invoice_id = resp.id; + let payload = invoice_event_payload( + InvoiceEvent::Created, + invoice_id, + serde_json::json!(resp), + ); + state + .client + .emit_webhook_event(InvoiceEvent::Created.as_str(), payload) + .await; + (StatusCode::CREATED, Json(serde_json::json!(resp))).into_response() + } Err(e) if e.to_string().contains("UNAUTHORIZED") => ( StatusCode::FORBIDDEN, Json(ErrorResponse { From e339ba8b5988548997aae974150f500e1ae05e45 Mon Sep 17 00:00:00 2001 From: Gospelsam019 Date: Tue, 20 Oct 2026 06:45:12 +0000 Subject: [PATCH 4/4] fix: #962 Add merchant-scoped webhook configuration endpoints Closes #962 --- backend/src/handlers.rs | 88 +++++++++++++++++++- backend/src/routes/invoices.rs | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 2 deletions(-) diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index 039cbe0..7fb92c6 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -1,5 +1,7 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use crate::auth::{self, ChallengeResponse, ChallengeStore}; @@ -50,6 +52,18 @@ impl InvoiceEventType { InvoiceEventType::InvoiceExpired => "invoice.expired", } } + + /// Parse a wire event name (e.g. `invoice.paid`) into an event type. + pub fn from_wire_name(name: &str) -> Option { + match name { + "invoice.created" => Some(InvoiceEventType::InvoiceCreated), + "invoice.paid" => Some(InvoiceEventType::InvoicePaid), + "invoice.cancelled" => Some(InvoiceEventType::InvoiceCancelled), + "invoice.refunded" => Some(InvoiceEventType::InvoiceRefunded), + "invoice.expired" => Some(InvoiceEventType::InvoiceExpired), + _ => None, + } + } } /// Payload schema delivered to merchant webhook endpoints for every @@ -113,6 +127,42 @@ pub fn subscription_matches(subscription: &WebhookSubscription, event: InvoiceEv subscription.events.contains(&event) } +// ─── Merchant-scoped webhook configuration store ───────────────────────── + +/// In-memory store of per-merchant webhook configuration, keyed by merchant id. +/// Distinct from any global webhook configuration. +pub type MerchantWebhookStore = Arc>>; + +/// Validate a merchant webhook configuration, returning an error message on +/// the first invalid field. +pub fn validate_webhook_config(req: &ConfigureWebhooksRequest) -> Result<(), String> { + if req.subscriptions.is_empty() { + return Err("at least one subscription is required".to_string()); + } + for sub in &req.subscriptions { + if !is_valid_callback_url(&sub.url) { + return Err(format!("invalid callback url: {}", sub.url)); + } + if sub.events.is_empty() { + return Err(format!("subscription {} has no events", sub.url)); + } + } + Ok(()) +} + +/// Accept only absolute http(s) URLs with a non-empty host. +fn is_valid_callback_url(url: &str) -> bool { + let rest = match url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + { + Some(rest) => rest, + None => return false, + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or(""); + !host.is_empty() +} + // ─── Merchant routes (protected) ───────────────────────────────────────── pub async fn merchant_dashboard() -> impl IntoResponse { @@ -131,11 +181,45 @@ pub async fn create_merchant_invoice() -> impl IntoResponse { } pub async fn configure_merchant_webhooks( + State(store): State, Json(req): Json, ) -> impl IntoResponse { + if let Err(err) = validate_webhook_config(&req) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": err })), + ); + } + + let merchant_id = "default".to_string(); + if let Ok(mut guard) = store.lock() { + guard.insert(merchant_id.clone(), req.clone()); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Webhook subscriptions updated", + "merchant_id": merchant_id, + "subscriptions": req.subscriptions, + })), + ) +} + +pub async fn get_merchant_webhooks( + State(store): State, +) -> impl IntoResponse { + let merchant_id = "default".to_string(); + let subscriptions = store + .lock() + .ok() + .and_then(|guard| guard.get(&merchant_id).cloned()) + .map(|cfg| cfg.subscriptions) + .unwrap_or_default(); + Json(serde_json::json!({ - "message": "Webhook subscriptions updated", - "subscriptions": req.subscriptions, + "merchant_id": merchant_id, + "subscriptions": subscriptions, })) } diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 476c7d8..39992b0 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -4,6 +4,9 @@ use axum::{ response::IntoResponse, Json, }; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; use crate::extractors::ValidatedBody; use crate::AppState; @@ -30,6 +33,22 @@ impl InvoiceEvent { InvoiceEvent::Expired => "invoice.expired", } } + + /// All event names a merchant may subscribe to. + pub fn all() -> &'static [&'static str] { + &[ + "invoice.created", + "invoice.paid", + "invoice.cancelled", + "invoice.refunded", + "invoice.expired", + ] + } + + /// Whether `name` is a recognised invoice event name. + pub fn is_valid(name: &str) -> bool { + Self::all().contains(&name) + } } /// Build the webhook payload for an invoice lifecycle event. The payload @@ -46,6 +65,69 @@ pub fn invoice_event_payload( }) } +/// Merchant-scoped webhook configuration: a callback URL plus the set of +/// invoice events the merchant wants delivered to it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MerchantWebhookConfig { + pub callback_url: String, + pub events: Vec, +} + +/// Request body for creating/updating a merchant's webhook configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateMerchantWebhookRequest { + pub callback_url: String, + pub events: Vec, +} + +/// In-memory store for merchant-scoped webhook configuration, keyed by +/// merchant address. Kept distinct from the global webhook configuration. +#[derive(Default)] +pub struct MerchantWebhookStore { + configs: Mutex>, +} + +impl MerchantWebhookStore { + pub fn get(&self, merchant: &str) -> Option { + self.configs + .lock() + .ok() + .and_then(|map| map.get(merchant).cloned()) + } + + pub fn set(&self, merchant: &str, config: MerchantWebhookConfig) { + if let Ok(mut map) = self.configs.lock() { + map.insert(merchant.to_string(), config); + } + } +} + +/// Validate a merchant webhook configuration, returning an error message on +/// the first problem found. +fn validate_merchant_webhook( + req: &UpdateMerchantWebhookRequest, +) -> Result<(), String> { + let url = req.callback_url.trim(); + if url.is_empty() { + return Err("callback_url must not be empty".to_string()); + } + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("callback_url must be an http(s) URL".to_string()); + } + if url.len() > 2048 { + return Err("callback_url is too long".to_string()); + } + if req.events.is_empty() { + return Err("events must contain at least one event".to_string()); + } + for event in &req.events { + if !InvoiceEvent::is_valid(event) { + return Err(format!("unknown event: {}", event)); + } + } + Ok(()) +} + #[utoipa::path( post, path = "/invoices", @@ -134,3 +216,69 @@ pub async fn get_invoice( .into_response(), } } + +#[utoipa::path( + put, + path = "/merchants/{merchant}/webhook", + params( + ("merchant" = String, Path, description = "Merchant address") + ), + request_body = UpdateMerchantWebhookRequest, + responses( + (status = 200, description = "Webhook configuration saved", body = MerchantWebhookConfig), + (status = 422, description = "Invalid webhook configuration", body = ErrorResponse) + ), + tag = "invoices" +)] +pub async fn update_merchant_webhook( + State(state): State, + Path(merchant): Path, + ValidatedBody(body): ValidatedBody, +) -> impl IntoResponse { + if let Err(msg) = validate_merchant_webhook(&body) { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ErrorResponse { + error: msg, + code: Some(7), + }), + ) + .into_response(); + } + + let config = MerchantWebhookConfig { + callback_url: body.callback_url.trim().to_string(), + events: body.events, + }; + state.merchant_webhooks.set(&merchant, config.clone()); + (StatusCode::OK, Json(config)).into_response() +} + +#[utoipa::path( + get, + path = "/merchants/{merchant}/webhook", + params( + ("merchant" = String, Path, description = "Merchant address") + ), + responses( + (status = 200, description = "Webhook configuration found", body = MerchantWebhookConfig), + (status = 404, description = "No webhook configuration for merchant", body = ErrorResponse) + ), + tag = "invoices" +)] +pub async fn get_merchant_webhook( + State(state): State, + Path(merchant): Path, +) -> impl IntoResponse { + match state.merchant_webhooks.get(&merchant) { + Some(config) => (StatusCode::OK, Json(config)).into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("No webhook configuration for merchant {}", merchant), + code: Some(6), + }), + ) + .into_response(), + } +}