Files
keysat/licensing-service/src/payment/mod.rs
T
Grant 6ac118ae70 v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
2026-05-07 10:33:39 -05:00

216 lines
7.4 KiB
Rust

//! 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<Money>,
},
/// Anything else the provider sent. We log + 200 it so the provider
/// stops retrying.
Other {
kind: String,
provider_invoice_id: Option<String>,
},
}
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<String>,
/// Raw provider response, for the audit log.
pub raw: serde_json::Value,
}
/// The trait every payment provider implements.
///
/// Object-safe (uses `&dyn`/`Box<dyn>`) 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<CreatedInvoiceHandle>;
async fn get_invoice_status(
&self,
provider_invoice_id: &str,
) -> Result<ProviderInvoiceStatus>;
/// 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<ProviderWebhookEvent>;
/// 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<PaymentReceipt> {
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;
}