//! Payment-provider abstraction. //! //! Today there's exactly one provider, BTCPay. v0.3 adds Zaprite. The //! daemon stores the active provider as a trait object so adding new //! providers is a single-impl drop-in. //! //! ## Why a trait //! //! Pre-v0.2 the daemon hard-coded BTCPay assumptions in `webhook.rs`, //! `purchase.rs`, `reconcile.rs`, and `tipping.rs`. Adding Zaprite would //! have meant either parallel code paths (gross) or post-hoc retrofitting //! (worse). The `PaymentProvider` trait is a one-time refactor that lets //! every later provider slot in cleanly. //! //! ## Trait surface //! //! Just the operations the rest of the daemon actually needs: //! //! - `kind()` — provider identity, for logs / audit / admin UI //! - `create_invoice` — make a hosted-checkout session, return a URL //! - `get_invoice_status` — for the reconcile loop (webhook misses) //! - `validate_webhook` — provider-specific signature scheme + parse //! - `pay_lightning_invoice` — for the tip-recipient flow; default impl //! returns a "not supported" error so providers without a Lightning //! payout capability can stay silent. //! //! ## What stays out of the trait //! //! Provider-specific setup (OAuth-style consent flows, webhook //! registration, store enumeration) lives in provider-specific modules //! like `api::btcpay_authorize`. Those modules are responsible for //! constructing a provider impl and handing it to //! `AppState::set_payment_provider`. use anyhow::Result; use axum::http::HeaderMap; use serde::{Deserialize, Serialize}; use std::any::Any; pub mod btcpay; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProviderKind { Btcpay, Zaprite, } impl ProviderKind { pub fn as_str(&self) -> &'static str { match self { ProviderKind::Btcpay => "btcpay", ProviderKind::Zaprite => "zaprite", } } } /// A monetary amount + the unit it's denominated in. /// /// We carry currency through the system because v0.3 adds USD/EUR for /// card payments via Zaprite. v0.2 still emits everything as `SAT` /// since BTCPay invoices are sat-denominated for our flow. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Money { /// The currency code. ISO 4217 for fiat; `SAT` and `BTC` for Bitcoin. pub currency: String, /// The amount in the currency's smallest indivisible unit (sats for /// BTC, cents for USD, etc.). Using i64 because integer math is /// cheaper than decimals and we never need fractional sats. pub amount: i64, } impl Money { pub fn sats(amount: i64) -> Self { Money { currency: "SAT".to_string(), amount, } } } /// Inputs for `create_invoice`. Bundled into a struct so the trait /// signature stays stable as we add fields. pub struct CreateInvoiceParams<'a> { pub amount: Money, /// Where the buyer is sent after a successful payment. The provider /// appends its own status fragments / query params as needed. pub redirect_url: &'a str, /// Arbitrary metadata pinned to the invoice on the provider's side. /// Used by Keysat to round-trip its internal invoice id back through /// webhook events (`metadata.orderId` for BTCPay; `externalOrderId` /// for Zaprite). pub metadata: serde_json::Value, /// Keysat's internal invoice id (UUID). Passed back in webhook /// events to correlate with the local row. pub external_order_id: &'a str, /// Buyer email if known. Some providers use this for receipts. pub buyer_email: Option<&'a str>, } /// Result of `create_invoice`. Whatever the provider returned, narrowed /// to the two things the rest of Keysat actually needs. #[derive(Debug, Clone)] pub struct CreatedInvoiceHandle { /// Provider-side invoice id. BTCPay invoice id today; Zaprite order /// id later. Stored on the invoice row so we can reconcile. pub provider_invoice_id: String, /// Public URL the buyer is redirected to to pay. pub checkout_url: String, } /// Provider-agnostic invoice status used by the reconcile loop. Maps to /// the daemon's existing `InvoiceStatus` model but stays decoupled so /// the trait doesn't pull in domain types. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderInvoiceStatus { Pending, Settled, Expired, Refunded, Invalid, } /// Parsed webhook event. Only the kinds Keysat actually acts on are /// modeled; everything else falls into `Other` and is ignored. #[derive(Debug, Clone)] pub enum ProviderWebhookEvent { InvoiceSettled { provider_invoice_id: String, }, InvoiceExpired { provider_invoice_id: String, }, InvoiceInvalid { provider_invoice_id: String, }, InvoiceRefunded { provider_invoice_id: String, refunded_amount: Option, }, /// Anything else the provider sent. We log + 200 it so the provider /// stops retrying. Other { kind: String, provider_invoice_id: Option, }, } impl ProviderWebhookEvent { pub fn provider_invoice_id(&self) -> Option<&str> { match self { ProviderWebhookEvent::InvoiceSettled { provider_invoice_id } | ProviderWebhookEvent::InvoiceExpired { provider_invoice_id } | ProviderWebhookEvent::InvoiceInvalid { provider_invoice_id } | ProviderWebhookEvent::InvoiceRefunded { provider_invoice_id, .. } => Some(provider_invoice_id), ProviderWebhookEvent::Other { provider_invoice_id, .. } => provider_invoice_id.as_deref(), } } } /// Result of paying a Lightning invoice via the provider's LN node. #[derive(Debug, Clone)] pub struct PaymentReceipt { pub payment_hash: Option, /// Raw provider response, for the audit log. pub raw: serde_json::Value, } /// The trait every payment provider implements. /// /// Object-safe (uses `&dyn`/`Box`) thanks to `#[async_trait]`. The /// `Any` supertrait lets call sites that still need provider-specific /// types (e.g., the BTCPay-specific authorize flow) downcast. #[async_trait::async_trait] pub trait PaymentProvider: Send + Sync + Any { fn kind(&self) -> ProviderKind; async fn create_invoice( &self, params: CreateInvoiceParams<'_>, ) -> Result; async fn get_invoice_status( &self, provider_invoice_id: &str, ) -> Result; /// Verify and parse a webhook delivery. Implementations are /// responsible for reading whatever signature header their provider /// uses, computing the expected HMAC, and constant-time comparing. fn validate_webhook( &self, headers: &HeaderMap, body: &[u8], ) -> Result; /// Pay a BOLT11 Lightning invoice via the provider's LN node. /// Default impl returns a "not supported" error so providers /// without LN payout capability don't have to override. async fn pay_lightning_invoice(&self, _bolt11: &str) -> Result { anyhow::bail!( "pay_lightning_invoice not supported by this payment provider" ) } /// Hatch for compat-era downcasting. Lets `AppState`'s legacy /// `btcpay_client()` accessor reach the inner BTCPay-specific /// client. v0.3 will retire the compat accessors and remove this. fn as_any(&self) -> &dyn Any; }