mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(premium): invoice through more than one processor
Premium could invoice through exactly one processor, so an operator running BTCPay for BTC, LTC and Grin could not also take Monero or Wownero: BTCPay has no Wownero support, and this operator runs Monero through its own checkout rather than the BTCPay plugin. xmrcheckout and wowcheckout implement the same two Greenfield invoice routes, accept the same `token` auth and report the same statuses, so the existing adapter serves them unchanged. What was missing was more than one of them. Each is now an optional rail with its own URL, store and key (PAYMENT_XMRCHECKOUT_* and PAYMENT_WOWCHECKOUT_*), all three values or none, and a half-configured rail refuses to start and names itself rather than advertising a coin that cannot be paid. The invoice row already recorded which provider minted it; activation now polls that one, never whichever is primary today. An invoice whose rail was switched off is refused and left unconsumed, so turning the rail back on lets the user redeem what they paid. A client that names a rail the instance does not offer is refused rather than handed an invoice in a different coin; a client that names none gets the primary, which is how every client before this behaved. Registration is untouched and keeps the primary. /capabilities lists the rails and their assets, primary first, from config alone, so it never depends on a processor being reachable. PAYMENT_ASSETS labels the primary. Checkout rails also get an exact confirmationsRequired, which BTCPay Server is not sent since it only has speedPolicy buckets.
This commit is contained in:
@@ -123,6 +123,19 @@ pub struct PremiumCapability {
|
||||
pub plans: Vec<PremiumPlanInfo>,
|
||||
/// The relay premium posting targets (mirrors `messaging.relay_url`).
|
||||
pub relay_url: String,
|
||||
/// Ways to pay, primary first. Pass `id` as `rail` to `/premium/invoice`.
|
||||
pub rails: Vec<PremiumRailInfo>,
|
||||
}
|
||||
|
||||
/// One way to pay for premium.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct PremiumRailInfo {
|
||||
/// Rail id, e.g. `btcpay`, `xmrcheckout`, `wowcheckout`.
|
||||
pub id: String,
|
||||
/// Assets this rail takes, e.g. `["BTC","LTC"]`. Empty means the operator
|
||||
/// did not declare them; the wallet should then show the rail unlabelled
|
||||
/// rather than guess.
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Public curated feed details (present only when `features.feed`). A read-only
|
||||
@@ -375,6 +388,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
})
|
||||
.collect(),
|
||||
relay_url: config.messaging.relay.advertised_url.clone(),
|
||||
rails: premium_rails(config),
|
||||
}),
|
||||
feed: feed_advertised(config).then(|| {
|
||||
let f = &config.feed;
|
||||
@@ -399,6 +413,21 @@ fn relay_advertised(config: &Config) -> bool {
|
||||
|
||||
/// Whether the premium tier is enabled AND the relay it gates is advertised
|
||||
/// (config validation already couples premium to relay + the premium-post policy).
|
||||
/// The premium rails, derived from config alone so capabilities never depends
|
||||
/// on a processor being reachable. Mirrors `payment::rails_from_config` order.
|
||||
fn premium_rails(config: &Config) -> Vec<PremiumRailInfo> {
|
||||
let primary = PremiumRailInfo {
|
||||
id: config.registration.payment.provider.clone(),
|
||||
assets: config.premium.primary_assets.clone(),
|
||||
};
|
||||
std::iter::once(primary)
|
||||
.chain(config.premium.rails.iter().map(|r| PremiumRailInfo {
|
||||
id: r.kind.to_string(),
|
||||
assets: vec![r.asset.to_string()],
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn premium_advertised(config: &Config) -> bool {
|
||||
config.premium.enabled && relay_advertised(config)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ use utoipa::OpenApi;
|
||||
crate::api::capabilities::MessagingCapability,
|
||||
crate::api::capabilities::PremiumCapability,
|
||||
crate::api::capabilities::PremiumPlanInfo,
|
||||
crate::api::capabilities::PremiumRailInfo,
|
||||
crate::api::capabilities::FeedCapability,
|
||||
crate::api::prices::PricesResponse,
|
||||
crate::api::auth::AuthResponse,
|
||||
|
||||
+30
-11
@@ -26,6 +26,10 @@ use crate::AppState;
|
||||
pub struct InvoiceReq {
|
||||
/// Plan id (see `/capabilities` → `premium.plans`), e.g. `quarter`.
|
||||
pub plan: String,
|
||||
/// Payment rail (see `/capabilities` → `premium.rails[].id`). Omitted means
|
||||
/// the primary rail, which is how clients before rails behaved.
|
||||
#[serde(default)]
|
||||
pub rail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
@@ -36,6 +40,8 @@ pub struct InvoiceResp {
|
||||
pub plan: String,
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
/// The rail the invoice was minted on.
|
||||
pub rail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
@@ -146,10 +152,16 @@ pub async fn invoice(
|
||||
));
|
||||
}
|
||||
|
||||
let provider = state
|
||||
.payment
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
||||
let rail = state
|
||||
.payment_rails
|
||||
.select(req.rail.as_deref().map(str::trim))
|
||||
.ok_or_else(|| match req.rail {
|
||||
// Named a rail this instance does not offer: refuse rather than mint
|
||||
// an invoice in a coin the user did not choose.
|
||||
Some(_) => AppError::ValidationError("That payment method is not available.".into()),
|
||||
None => AppError::Internal("payment provider not configured".into()),
|
||||
})?;
|
||||
let provider = &rail.provider;
|
||||
|
||||
let pay = &state.cfg().registration.payment;
|
||||
let currency = state.cfg().premium.currency.clone();
|
||||
@@ -182,6 +194,7 @@ pub async fn invoice(
|
||||
plan: plan.id.clone(),
|
||||
amount: plan.amount.clone(),
|
||||
currency,
|
||||
rail: provider.kind().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -207,11 +220,6 @@ pub async fn activate(
|
||||
let user_id = extract_user_id_from_token(&state, &headers).await?;
|
||||
ensure_enabled(&state)?;
|
||||
|
||||
let provider = state
|
||||
.payment
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("payment provider not configured".into()))?;
|
||||
|
||||
let id = req.invoice_id.trim();
|
||||
let uid = user_id.to_string();
|
||||
|
||||
@@ -231,8 +239,19 @@ pub async fn activate(
|
||||
));
|
||||
}
|
||||
|
||||
// Source of truth: the processor. Only Settled grants (paid in full per policy).
|
||||
let inv = provider.get_invoice(id).await?;
|
||||
// Source of truth: the processor THAT MINTED IT. Only Settled grants.
|
||||
let rail = state
|
||||
.payment_rails
|
||||
.for_invoice(&row.provider)
|
||||
.ok_or_else(|| {
|
||||
// The rail was switched off after this invoice was minted. Refuse
|
||||
// and keep the invoice unconsumed, so re-enabling the rail lets the
|
||||
// user redeem it rather than losing what they paid.
|
||||
AppError::ValidationError(
|
||||
"This invoice's payment method is currently unavailable. Try again later.".into(),
|
||||
)
|
||||
})?;
|
||||
let inv = rail.provider.get_invoice(id).await?;
|
||||
if inv.status != InvoiceStatus::Settled {
|
||||
return Err(AppError::ValidationError(
|
||||
"Payment not yet confirmed. Complete the payment and retry.".into(),
|
||||
|
||||
+173
@@ -692,6 +692,96 @@ pub struct PremiumConfig {
|
||||
pub enabled: bool,
|
||||
pub currency: String,
|
||||
pub plans: Vec<PremiumPlan>,
|
||||
/// Assets the PRIMARY processor (`registration.payment`) accepts, as the
|
||||
/// wallet should label it (`PAYMENT_ASSETS`, e.g. `BTC,LTC,GRIN`). Display
|
||||
/// only: the processor decides what it will actually take.
|
||||
pub primary_assets: Vec<String>,
|
||||
/// Additional Greenfield-compatible processors premium may invoice through,
|
||||
/// one per rail. Registration never uses these; it keeps the primary.
|
||||
pub rails: Vec<PaymentRailConfig>,
|
||||
}
|
||||
|
||||
/// One additional BTCPay-Greenfield-compatible processor, for premium only.
|
||||
///
|
||||
/// xmrcheckout and wowcheckout implement the same two invoice routes as BTCPay
|
||||
/// Server, so the existing adapter serves them unchanged. They exist because
|
||||
/// BTCPay itself cannot take Wownero, and this operator runs Monero through its
|
||||
/// own checkout rather than the BTCPay plugin. Each rail is its own store with
|
||||
/// its own key: sharing one across processors would couple their rotation.
|
||||
#[derive(Clone)]
|
||||
pub struct PaymentRailConfig {
|
||||
/// Stable rail id, persisted on the invoice row so activation polls the
|
||||
/// processor that minted it.
|
||||
pub kind: &'static str,
|
||||
/// The one asset this rail accepts, as the wallet should label it.
|
||||
pub asset: &'static str,
|
||||
pub provider_url: String,
|
||||
pub store_id: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// The checkout rails an operator can enable, as (kind, asset, env prefix).
|
||||
/// The kind strings are persisted on invoice rows: never rename one.
|
||||
pub const CHECKOUT_RAILS: &[(&str, &str, &str)] = &[
|
||||
("xmrcheckout", "XMR", "PAYMENT_XMRCHECKOUT"),
|
||||
("wowcheckout", "WOW", "PAYMENT_WOWCHECKOUT"),
|
||||
];
|
||||
|
||||
/// Refuse a checkout rail that cannot work. All three values or none, https in
|
||||
/// production, and a key that is neither short nor a placeholder. Each refusal
|
||||
/// names the rail's variables, so the operator knows which rail is broken.
|
||||
fn validate_rail(rail: &PaymentRailConfig, prod: bool) -> Result<(), AppError> {
|
||||
let prefix = CHECKOUT_RAILS
|
||||
.iter()
|
||||
.find(|(k, _, _)| *k == rail.kind)
|
||||
.map(|(_, _, p)| *p)
|
||||
.unwrap_or("PAYMENT_RAIL");
|
||||
if rail.provider_url.trim().is_empty() || rail.store_id.trim().is_empty() {
|
||||
return Err(cfg_err(format!(
|
||||
"{prefix}_URL and {prefix}_STORE_ID must both be set to enable the {} rail, \
|
||||
or {prefix}_* left entirely unset",
|
||||
rail.kind
|
||||
)));
|
||||
}
|
||||
if prod && !rail.provider_url.starts_with("https://") && !is_loopback_url(&rail.provider_url) {
|
||||
return Err(cfg_err(format!(
|
||||
"{prefix}_URL must be https:// in production (loopback exempt)"
|
||||
)));
|
||||
}
|
||||
if rail.api_key.len() < 8 {
|
||||
return Err(cfg_err(format!(
|
||||
"{prefix}_API_KEY must be set and at least 8 bytes"
|
||||
)));
|
||||
}
|
||||
if prod && looks_placeholder(&rail.api_key) {
|
||||
return Err(cfg_err(format!(
|
||||
"{prefix}_API_KEY looks like a placeholder; set a real value"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the checkout rails from `<PREFIX>_URL`, `<PREFIX>_STORE_ID` and
|
||||
/// `<PREFIX>_API_KEY`. A rail with none of the three is simply off. A rail with
|
||||
/// SOME of them is kept here so `validate()` can refuse it by name: a partial
|
||||
/// rail is an operator mistake, and dropping it silently would advertise a coin
|
||||
/// that then cannot be paid.
|
||||
fn parse_checkout_rails() -> Vec<PaymentRailConfig> {
|
||||
CHECKOUT_RAILS
|
||||
.iter()
|
||||
.filter_map(|&(kind, asset, prefix)| {
|
||||
let url = env_or(&format!("{prefix}_URL"), "");
|
||||
let store = env_or(&format!("{prefix}_STORE_ID"), "");
|
||||
let key = env_or(&format!("{prefix}_API_KEY"), "");
|
||||
(!url.is_empty() || !store.is_empty() || !key.is_empty()).then_some(PaymentRailConfig {
|
||||
kind,
|
||||
asset,
|
||||
provider_url: url,
|
||||
store_id: store,
|
||||
api_key: key,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `PREMIUM_PLANS` = comma-separated `id:days:amount` entries (e.g.
|
||||
@@ -1018,6 +1108,11 @@ impl Config {
|
||||
enabled: env_bool("PREMIUM_ENABLED", false),
|
||||
currency: env_or("PREMIUM_CURRENCY", "").to_uppercase(),
|
||||
plans: parse_premium_plans(&env_or("PREMIUM_PLANS", "")),
|
||||
primary_assets: env_list("PAYMENT_ASSETS")
|
||||
.into_iter()
|
||||
.map(|a| a.to_uppercase())
|
||||
.collect(),
|
||||
rails: parse_checkout_rails(),
|
||||
},
|
||||
feed: FeedConfig {
|
||||
enabled: env_bool("FEED_ENABLED", false),
|
||||
@@ -1493,6 +1588,9 @@ impl Config {
|
||||
)));
|
||||
}
|
||||
}
|
||||
for rail in &self.premium.rails {
|
||||
validate_rail(rail, prod)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1663,6 +1761,8 @@ impl Config {
|
||||
enabled: false,
|
||||
currency: String::new(),
|
||||
plans: Vec::new(),
|
||||
primary_assets: Vec::new(),
|
||||
rails: Vec::new(),
|
||||
},
|
||||
feed: FeedConfig {
|
||||
enabled: false,
|
||||
@@ -2219,6 +2319,79 @@ mod tests {
|
||||
assert!(parse_premium_plans("").is_empty());
|
||||
}
|
||||
|
||||
fn checkout_rail(kind: &'static str) -> PaymentRailConfig {
|
||||
PaymentRailConfig {
|
||||
kind,
|
||||
asset: "XMR",
|
||||
provider_url: "https://checkout.example.org".into(),
|
||||
store_id: "merchant-1".into(),
|
||||
api_key: "xmrcheckout_realkeyvalue0123456789".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_checkout_rail_passes() {
|
||||
let mut c = valid();
|
||||
wire_premium(&mut c);
|
||||
c.premium.rails = vec![checkout_rail("xmrcheckout"), checkout_rail("wowcheckout")];
|
||||
assert!(c.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_configured_rail_is_refused_by_name() {
|
||||
// A rail with a key but no URL would be advertised and then fail every
|
||||
// invoice. The refusal names the rail so the operator knows which one.
|
||||
for strip in ["url", "store", "key"] {
|
||||
let mut c = valid();
|
||||
wire_premium(&mut c);
|
||||
let mut r = checkout_rail("wowcheckout");
|
||||
match strip {
|
||||
"url" => r.provider_url.clear(),
|
||||
"store" => r.store_id.clear(),
|
||||
_ => r.api_key.clear(),
|
||||
}
|
||||
c.premium.rails = vec![r];
|
||||
let err = c
|
||||
.validate()
|
||||
.err()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_default();
|
||||
assert!(err.contains("WOWCHECKOUT"), "missing {strip}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rail_is_https_in_production_only() {
|
||||
let mut r = checkout_rail("xmrcheckout");
|
||||
r.provider_url = "http://checkout.example.org".into();
|
||||
let err = validate_rail(&r, true)
|
||||
.err()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_default();
|
||||
assert!(err.contains("XMRCHECKOUT_URL"), "{err}");
|
||||
assert!(
|
||||
validate_rail(&r, false).is_ok(),
|
||||
"plain http is fine off production"
|
||||
);
|
||||
r.provider_url = "http://127.0.0.1:8080".into();
|
||||
assert!(validate_rail(&r, true).is_ok(), "loopback is exempt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rail_kinds_are_unique_and_stable() {
|
||||
// Kinds are persisted on invoice rows; a duplicate would route one
|
||||
// processor's invoices to another.
|
||||
let mut kinds: Vec<_> = CHECKOUT_RAILS.iter().map(|(k, _, _)| *k).collect();
|
||||
let n = kinds.len();
|
||||
kinds.sort();
|
||||
kinds.dedup();
|
||||
assert_eq!(kinds.len(), n);
|
||||
assert!(
|
||||
!kinds.contains(&"btcpay"),
|
||||
"a checkout rail must not collide with the primary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_enabled_full_config_passes() {
|
||||
let mut c = valid();
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct PremiumInvoiceRow {
|
||||
pub period_days: i32,
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
/// The rail that minted it (`btcpay`, `xmrcheckout`, ...): activation polls
|
||||
/// this processor, never whichever one happens to be primary today.
|
||||
pub provider: String,
|
||||
/// `None` while unspent; set once when the invoice extends premium.
|
||||
pub consumed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -73,17 +76,27 @@ impl Database {
|
||||
i32,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<DateTime<Utc>>,
|
||||
),
|
||||
>(
|
||||
"SELECT invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at \
|
||||
"SELECT invoice_id, user_id, plan_id, period_days, amount, currency, provider, consumed_at \
|
||||
FROM premium_invoices WHERE invoice_id = $1",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(invoice_id, user_id, plan_id, period_days, amount, currency, consumed_at)| {
|
||||
|(
|
||||
invoice_id,
|
||||
user_id,
|
||||
plan_id,
|
||||
period_days,
|
||||
amount,
|
||||
currency,
|
||||
provider,
|
||||
consumed_at,
|
||||
)| {
|
||||
PremiumInvoiceRow {
|
||||
invoice_id,
|
||||
user_id,
|
||||
@@ -91,6 +104,7 @@ impl Database {
|
||||
period_days,
|
||||
amount,
|
||||
currency,
|
||||
provider,
|
||||
consumed_at,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::instrument;
|
||||
|
||||
use super::{Invoice, InvoiceRequest, InvoiceStatus, PaymentProvider};
|
||||
use crate::config::PaymentConfig;
|
||||
use crate::config::{PaymentConfig, PaymentRailConfig};
|
||||
use crate::core::secret::Secret;
|
||||
use crate::error::AppError;
|
||||
|
||||
@@ -32,6 +32,12 @@ const MAX_BODY_BYTES: usize = 1024 * 1024;
|
||||
/// A BTCPay-compatible invoice processor (one store).
|
||||
#[derive(Clone)]
|
||||
pub struct BtcPayProvider {
|
||||
/// Rail id persisted on invoice rows (`btcpay`, `xmrcheckout`, ...).
|
||||
kind: &'static str,
|
||||
/// Also send the exact `checkout.confirmationsRequired`. The checkout apps
|
||||
/// honour an exact count; BTCPay Server has only `speedPolicy` buckets, so
|
||||
/// it is not sent there rather than trusting BTCPay to ignore it.
|
||||
exact_confirmations: bool,
|
||||
/// Base URL, no trailing slash (e.g. `https://pay.example.org`).
|
||||
base_url: String,
|
||||
store_id: String,
|
||||
@@ -43,15 +49,45 @@ impl BtcPayProvider {
|
||||
/// Build from the pay-to-register config. Assumes the config was validated
|
||||
/// (non-empty URL/store/key); a malformed HTTP client build fails closed.
|
||||
pub fn new(cfg: &PaymentConfig) -> Result<Self, AppError> {
|
||||
Self::build(
|
||||
"btcpay",
|
||||
false,
|
||||
&cfg.provider_url,
|
||||
&cfg.store_id,
|
||||
&cfg.api_key,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a checkout rail (xmrcheckout / wowcheckout): same Greenfield
|
||||
/// routes, its own store and key, and an exact confirmation count.
|
||||
pub fn checkout_rail(rail: &PaymentRailConfig) -> Result<Self, AppError> {
|
||||
Self::build(
|
||||
rail.kind,
|
||||
true,
|
||||
&rail.provider_url,
|
||||
&rail.store_id,
|
||||
&rail.api_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn build(
|
||||
kind: &'static str,
|
||||
exact_confirmations: bool,
|
||||
url: &str,
|
||||
store_id: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Self, AppError> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|_| AppError::ConfigError("failed to build payment HTTP client".into()))?;
|
||||
Ok(Self {
|
||||
base_url: cfg.provider_url.trim_end_matches('/').to_string(),
|
||||
store_id: cfg.store_id.clone(),
|
||||
api_key: Secret::new(cfg.api_key.clone()),
|
||||
kind,
|
||||
exact_confirmations,
|
||||
base_url: url.trim_end_matches('/').to_string(),
|
||||
store_id: store_id.to_string(),
|
||||
api_key: Secret::new(api_key.to_string()),
|
||||
http,
|
||||
})
|
||||
}
|
||||
@@ -161,7 +197,7 @@ impl BtcPayProvider {
|
||||
#[async_trait::async_trait]
|
||||
impl PaymentProvider for BtcPayProvider {
|
||||
fn kind(&self) -> &'static str {
|
||||
"btcpay"
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[instrument(skip(self, req), fields(store = %self.store_id))]
|
||||
@@ -174,6 +210,7 @@ impl PaymentProvider for BtcPayProvider {
|
||||
checkout: BtcPayCheckout {
|
||||
speed_policy: Self::speed_policy(req.confirmations),
|
||||
expiration_minutes: req.expires_minutes,
|
||||
confirmations_required: self.exact_confirmations.then_some(req.confirmations),
|
||||
},
|
||||
};
|
||||
let resp: BtcPayInvoiceResp = self.post_json(url, "create_invoice", &body).await?;
|
||||
@@ -209,6 +246,8 @@ struct BtcPayCreateReq<'a> {
|
||||
struct BtcPayCheckout {
|
||||
speed_policy: &'static str,
|
||||
expiration_minutes: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
confirmations_required: Option<u32>,
|
||||
}
|
||||
|
||||
/// The subset of a BTCPay invoice we consume. Extra fields are ignored.
|
||||
@@ -359,6 +398,7 @@ mod tests {
|
||||
checkout: BtcPayCheckout {
|
||||
speed_policy: "MediumSpeed",
|
||||
expiration_minutes: 60,
|
||||
confirmations_required: None,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&body).unwrap();
|
||||
@@ -368,4 +408,47 @@ mod tests {
|
||||
assert_eq!(v["checkout"]["speedPolicy"], "MediumSpeed");
|
||||
assert_eq!(v["checkout"]["expirationMinutes"], 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn btcpay_server_is_not_sent_an_exact_confirmation_count() {
|
||||
// BTCPay Server expresses finality only as speedPolicy buckets, so the
|
||||
// field is omitted rather than trusting BTCPay to ignore it.
|
||||
let c = BtcPayCheckout {
|
||||
speed_policy: "MediumSpeed",
|
||||
expiration_minutes: 60,
|
||||
confirmations_required: None,
|
||||
};
|
||||
let v = serde_json::to_value(&c).unwrap();
|
||||
assert!(v.get("confirmationsRequired").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkout_rail_is_sent_the_exact_count() {
|
||||
let c = BtcPayCheckout {
|
||||
speed_policy: "LowMediumSpeed",
|
||||
expiration_minutes: 60,
|
||||
confirmations_required: Some(3),
|
||||
};
|
||||
let v = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(v["confirmationsRequired"], 3);
|
||||
// speedPolicy still rides along; the checkouts accept and ignore it.
|
||||
assert!(v.get("speedPolicy").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_rail_reports_its_own_kind() {
|
||||
// The kind is persisted on the invoice row and routes activation, so two
|
||||
// rails must never report the same one.
|
||||
let rail = |kind| crate::config::PaymentRailConfig {
|
||||
kind,
|
||||
asset: "XMR",
|
||||
provider_url: "https://pay.example.org".into(),
|
||||
store_id: "s".into(),
|
||||
api_key: "xmrcheckout_secretkey".into(),
|
||||
};
|
||||
let x = BtcPayProvider::checkout_rail(&rail("xmrcheckout")).unwrap();
|
||||
let w = BtcPayProvider::checkout_rail(&rail("wowcheckout")).unwrap();
|
||||
assert_ne!(x.kind(), w.kind());
|
||||
assert!(x.exact_confirmations && w.exact_confirmations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +127,130 @@ pub fn from_config(cfg: &Config) -> Result<Option<Arc<dyn PaymentProvider>>, App
|
||||
};
|
||||
Ok(Some(provider))
|
||||
}
|
||||
|
||||
/// One way to pay for premium: a processor plus the assets it takes.
|
||||
#[derive(Clone)]
|
||||
pub struct PaymentRail {
|
||||
pub provider: Arc<dyn PaymentProvider>,
|
||||
/// Asset codes as the wallet should label them.
|
||||
pub assets: Vec<String>,
|
||||
}
|
||||
|
||||
impl PaymentRail {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
self.provider.kind()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every processor premium may invoice through, primary first.
|
||||
///
|
||||
/// The primary is the same provider the registration gate uses, so an instance
|
||||
/// with no checkout rails behaves exactly as before. Rails are looked up by the
|
||||
/// kind persisted on the invoice row, so an invoice is always polled at the
|
||||
/// processor that minted it, never at whichever one is first today.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PaymentRails {
|
||||
rails: Vec<PaymentRail>,
|
||||
}
|
||||
|
||||
impl PaymentRails {
|
||||
/// The rails in advertisement order, primary first.
|
||||
pub fn all(&self) -> &[PaymentRail] {
|
||||
&self.rails
|
||||
}
|
||||
|
||||
/// The rail a new invoice uses: the one named, or the primary when none is.
|
||||
/// An unknown name is refused rather than defaulted, so a client that asked
|
||||
/// for Monero is never silently handed a Bitcoin invoice.
|
||||
pub fn select(&self, kind: Option<&str>) -> Option<&PaymentRail> {
|
||||
match kind {
|
||||
None => self.rails.first(),
|
||||
Some(k) => self.rails.iter().find(|r| r.kind() == k),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rail that minted a stored invoice.
|
||||
pub fn for_invoice(&self, provider_kind: &str) -> Option<&PaymentRail> {
|
||||
self.rails.iter().find(|r| r.kind() == provider_kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build premium's rails: the primary provider (if any) followed by each
|
||||
/// configured checkout rail. Empty when premium is off.
|
||||
pub fn rails_from_config(
|
||||
cfg: &Config,
|
||||
primary: Option<&Arc<dyn PaymentProvider>>,
|
||||
) -> Result<PaymentRails, AppError> {
|
||||
if !cfg.premium.enabled {
|
||||
return Ok(PaymentRails::default());
|
||||
}
|
||||
let mut rails = Vec::new();
|
||||
if let Some(p) = primary {
|
||||
rails.push(PaymentRail {
|
||||
provider: Arc::clone(p),
|
||||
assets: cfg.premium.primary_assets.clone(),
|
||||
});
|
||||
}
|
||||
for rail in &cfg.premium.rails {
|
||||
rails.push(PaymentRail {
|
||||
provider: Arc::new(BtcPayProvider::checkout_rail(rail)?),
|
||||
assets: vec![rail.asset.to_string()],
|
||||
});
|
||||
}
|
||||
Ok(PaymentRails { rails })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rail_tests {
|
||||
use super::*;
|
||||
|
||||
struct Fake(&'static str);
|
||||
|
||||
#[async_trait]
|
||||
impl PaymentProvider for Fake {
|
||||
fn kind(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
async fn create_invoice(&self, _: &InvoiceRequest) -> Result<Invoice, AppError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_invoice(&self, _: &str) -> Result<Invoice, AppError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
fn rails(kinds: &[&'static str]) -> PaymentRails {
|
||||
PaymentRails {
|
||||
rails: kinds
|
||||
.iter()
|
||||
.map(|k| PaymentRail {
|
||||
provider: Arc::new(Fake(k)),
|
||||
assets: vec![],
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_choice_means_the_primary() {
|
||||
let r = rails(&["btcpay", "xmrcheckout"]);
|
||||
assert_eq!(r.select(None).map(|x| x.kind()), Some("btcpay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_rail_is_refused_not_defaulted() {
|
||||
// Asking for a coin this instance cannot take must not quietly mint an
|
||||
// invoice in a different coin.
|
||||
let r = rails(&["btcpay", "xmrcheckout"]);
|
||||
assert!(r.select(Some("wowcheckout")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invoice_is_polled_where_it_was_minted() {
|
||||
let r = rails(&["btcpay", "xmrcheckout", "wowcheckout"]);
|
||||
for k in ["btcpay", "xmrcheckout", "wowcheckout"] {
|
||||
assert_eq!(r.for_invoice(k).map(|x| x.kind()), Some(k));
|
||||
}
|
||||
assert!(r.for_invoice("retired-processor").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ pub struct AppState {
|
||||
/// Payment processor backing the optional pay-to-register gate; `Some` only
|
||||
/// when `REGISTRATION_REQUIRE_PAYMENT` is on.
|
||||
pub payment: Option<Arc<dyn crate::infra::payment::PaymentProvider>>,
|
||||
/// Every processor premium may invoice through, primary first. Empty unless
|
||||
/// premium is on.
|
||||
pub payment_rails: crate::infra::payment::PaymentRails,
|
||||
/// Optional first-party Nostr relay (messaging plane); `Some` only when
|
||||
/// `RELAY_ENABLED` is on. Advertised via `/capabilities` + NIP-05 hints.
|
||||
pub relay: Option<Arc<dyn crate::infra::relay::RelayProvider>>,
|
||||
|
||||
@@ -66,6 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chains = ChainClients::from_config(&config)?;
|
||||
// Payment processor for the pay-to-register gate (None unless it's enabled).
|
||||
let payment = smirk_backend_core::infra::payment::from_config(&config)?;
|
||||
let payment_rails =
|
||||
smirk_backend_core::infra::payment::rails_from_config(&config, payment.as_ref())?;
|
||||
// Optional Nostr relay (messaging plane); None unless RELAY_ENABLED.
|
||||
let relay = smirk_backend_core::infra::relay::from_config(&config)?;
|
||||
|
||||
@@ -129,6 +131,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
sessions,
|
||||
chains,
|
||||
payment,
|
||||
payment_rails,
|
||||
relay,
|
||||
web_challenges: Arc::default(),
|
||||
prices: prices_cache,
|
||||
|
||||
@@ -79,6 +79,9 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
|
||||
let chains = ChainClients::from_config(&config).expect("build chain clients");
|
||||
let payment =
|
||||
smirk_backend_core::infra::payment::from_config(&config).expect("build payment provider");
|
||||
let payment_rails =
|
||||
smirk_backend_core::infra::payment::rails_from_config(&config, payment.as_ref())
|
||||
.expect("build payment rails");
|
||||
let relay =
|
||||
smirk_backend_core::infra::relay::from_config(&config).expect("build relay provider");
|
||||
let prices = Arc::new(tokio::sync::RwLock::new(
|
||||
@@ -98,6 +101,7 @@ pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option<TestApp> {
|
||||
sessions,
|
||||
chains,
|
||||
payment,
|
||||
payment_rails,
|
||||
relay,
|
||||
web_challenges: Arc::default(),
|
||||
prices,
|
||||
|
||||
Reference in New Issue
Block a user