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.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
//! 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;
|
||||
}
|
||||
Reference in New Issue
Block a user