crates/monetize-pay/src/instance.rs
//! **One vendor KIND, several INSTANCES** (ONE monetize, 5.4).
//!
//! A monetize that serves several products bills each through its own
//! account at the same vendor — gunnar's Stripe and holger's Stripe. The
//! config says so with `[vendors.<kind>.<instance>]`, and the server knows the
//! vendor by its INSTANCE id `<kind>/<instance>` (`stripe/gunnar`): that is
//! the name in `vendor()`, on every handle the ledger keeps, on a paid
//! signup, in the webhook path `/callback/<kind>/<instance>`.
//!
//! The vendor's own code knows only its KIND: Stripe's logic checks that a
//! handle is `stripe`'s and answers handles named `stripe`. [`Names`] is the
//! one translation between the two, at the edge: a handle the server hands in
//! must name THIS instance (another instance's is
//! [`PayError::UnknownHandle`], never passed on) and reaches the vendor under
//! its kind; everything the vendor hands out is renamed to the instance.
//! [`Instance`] wraps a linked provider in it; a vendor guest uses the same
//! [`Names`] inside its host.
use crate::payment::{Charge, Of, Page, PayManifest, Payment, Return, Standing};
use crate::reconcile::PaidAtVendor;
use crate::{CallbackEvent, ChargeHandle, ChargeStatus, Money, PayError, RawCallback, Settlement};
/// The placeholder a vendor's `config_envs` may carry:
/// `MONETIZE_STRIPE_{INSTANCE}_SECRET_KEY`, expanded per configured instance
/// by [`expand_env`].
pub const INSTANCE_PLACEHOLDER: &str = "{INSTANCE}";
/// `<kind>/<instance>` → `(kind, instance)`, both checked: lower-case ASCII
/// letters, digits and `-`, non-empty, exactly one `/`. The refusal carries
/// `[vendor-instance-name]`.
pub fn split(id: &str) -> Result<(&str, &str), String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
};
match id.split_once('/') {
Some((kind, instance)) if ok(kind) && ok(instance) => Ok((kind, instance)),
_ => Err(format!(
"[vendor-instance-name] `{id}` is not `<kind>/<instance>` (lower-case letters, digits and `-`): a vendor is configured as [vendors.<kind>.<instance>], one instance per account"
)),
}
}
/// The KIND part of a vendor name: `stripe/gunnar` → `stripe`; a name with no
/// instance is its own kind (a code-built test double, `none`).
pub fn kind_of(id: &str) -> &str {
id.split_once('/').map_or(id, |(k, _)| k)
}
/// `[vendors.stripe.gunnar]` for `stripe/gunnar` — how a refusal names the block.
pub fn block_of(id: &str) -> String {
format!("[vendors.{}]", id.replace('/', "."))
}
/// An instance as it appears in an environment name: `holger-test2` → `HOLGER_TEST2`.
pub fn env_key(instance: &str) -> String {
instance
.chars()
.map(|c| {
if c == '-' {
'_'
} else {
c.to_ascii_uppercase()
}
})
.collect()
}
/// `MONETIZE_STRIPE_{INSTANCE}_SECRET_KEY` for `gunnar` →
/// `MONETIZE_STRIPE_GUNNAR_SECRET_KEY`. A name with no placeholder is itself.
pub fn expand_env(template: &str, instance: &str) -> String {
template.replace(INSTANCE_PLACEHOLDER, &env_key(instance))
}
/// A set of markets interned for the life of the process, once per distinct set, so a
/// [`crate::Vendor`] can borrow it however often a manifest is read.
pub fn intern_markets(m: &[crate::Market]) -> &'static [crate::Market] {
static SETS: std::sync::Mutex<Vec<&'static [crate::Market]>> =
std::sync::Mutex::new(Vec::new());
let mut p = SETS.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = p.iter().find(|s| **s == m) {
return found;
}
let leaked: &'static [crate::Market] = Box::leak(m.to_vec().into_boxed_slice());
p.push(leaked);
leaked
}
/// Interned for the life of the process: a vendor's name is `&'static str`
/// on the trait, and the same instance built again (a live replacement) must
/// not leak again.
pub fn intern(s: &str) -> &'static str {
use std::collections::BTreeSet;
use std::sync::Mutex;
static NAMES: Mutex<BTreeSet<&'static str>> = Mutex::new(BTreeSet::new());
let mut names = NAMES.lock().unwrap_or_else(|p| p.into_inner());
if let Some(n) = names.get(s) {
return n;
}
let n: &'static str = Box::leak(s.to_owned().into_boxed_str());
names.insert(n);
n
}
/// **The translation between an instance and its kind.**
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Names {
/// `stripe/gunnar` — what the server sees.
pub id: &'static str,
/// `stripe` — what the vendor's own code sees.
pub kind: &'static str,
}
impl Names {
/// From a checked `<kind>/<instance>`.
pub fn of(id: &str) -> Result<Names, String> {
let (kind, _) = split(id)?;
Ok(Names {
id: intern(id),
kind: intern(kind),
})
}
/// A handle the server hands in: this instance's, or not ours at all.
pub fn inbound(&self, h: &ChargeHandle) -> Result<ChargeHandle, PayError> {
if h.vendor == self.id {
Ok(ChargeHandle {
vendor: self.kind.into(),
id: h.id.clone(),
})
} else {
Err(PayError::UnknownHandle(h.clone()))
}
}
/// A handle the vendor hands out, named for the instance.
pub fn outbound(&self, h: ChargeHandle) -> ChargeHandle {
if h.vendor == self.kind {
ChargeHandle {
vendor: self.id.into(),
id: h.id,
}
} else {
h
}
}
fn name(&self, vendor: String) -> String {
if vendor == self.kind {
self.id.into()
} else {
vendor
}
}
/// Every name an error carries, renamed.
pub fn error(&self, e: PayError) -> PayError {
match e {
PayError::UnknownHandle(h) => PayError::UnknownHandle(self.outbound(h)),
PayError::Signup(mut s) => {
s.vendor = self.name(std::mem::take(&mut s.vendor));
PayError::Signup(s)
}
PayError::Reversal(mut r) => {
r.vendor = self.name(std::mem::take(&mut r.vendor));
r.handle = self.outbound(r.handle);
PayError::Reversal(r)
}
other => other,
}
}
pub fn event(&self, e: CallbackEvent) -> CallbackEvent {
CallbackEvent {
handle: self.outbound(e.handle),
..e
}
}
pub fn paid(&self, mut p: PaidAtVendor) -> PaidAtVendor {
p.vendor = self.name(p.vendor);
p
}
}
/// **A linked vendor as one instance**: its manifest's `vendor` is the instance id, and
/// every handle is translated by [`Names`].
pub struct Instance {
names: Names,
inner: Box<dyn Payment>,
}
impl Instance {
/// `inner` is spoken to under the name it gives itself (normally the
/// kind: the `stripe` factory builds a vendor called `stripe`); the
/// server only ever sees `id`.
pub fn wrap(id: &str, inner: Box<dyn Payment>) -> Result<Instance, String> {
Names::of(id)?;
let names = Names {
id: intern(id),
kind: intern(&inner.manifest().vendor),
};
Ok(Instance { names, inner })
}
pub fn names(&self) -> Names {
self.names
}
}
impl Payment for Instance {
fn manifest(&self) -> PayManifest {
PayManifest {
vendor: self.names.id.to_string(),
..self.inner.manifest()
}
}
fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError> {
let id = self.names.id;
match self
.inner
.page(charge, back)
.map_err(|e| self.names.error(e))?
{
Page::Order { handle, url } => Ok(Page::Order {
handle: self.names.outbound(handle),
url,
}),
// The paid signup names THIS instance, as its webhook's would.
Page::TestSignup(p) => Ok(Page::TestSignup(p.map(|mut p| {
p.vendor = id.to_string();
p
}))),
other => Ok(other),
}
}
fn status(&self, of: &Of<'_>) -> Result<Standing, PayError> {
let inbound;
let of = match of {
Of::Charge(h) => {
inbound = self.names.inbound(h)?;
Of::Charge(&inbound)
}
Of::Page(h) => match self.names.inbound(h) {
Ok(h) => {
inbound = h;
Of::Page(&inbound)
}
Err(_) => return Ok(Standing::Page(None)),
},
other => other.clone(),
};
let id = self.names.id;
match self.inner.status(&of).map_err(|e| self.names.error(e))? {
// A paid signup names THIS instance, as its webhook's would.
Standing::Signup(Some(crate::signup::SignupAtVendor::Paid(mut p))) => {
p.vendor = id.to_string();
Ok(Standing::Signup(Some(crate::signup::SignupAtVendor::Paid(
p,
))))
}
other => Ok(other),
}
}
fn capture(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.inner
.capture(&self.names.inbound(h)?)
.map_err(|e| self.names.error(e))
}
fn release(&self, h: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.inner
.release(&self.names.inbound(h)?)
.map_err(|e| self.names.error(e))
}
fn refund(&self, h: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
self.inner
.refund(&self.names.inbound(h)?, amount)
.map_err(|e| self.names.error(e))
}
fn callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
self.inner
.callback(raw)
.map(|e| self.names.event(e))
.map_err(|e| self.names.error(e))
}
fn settlement(&self, h: &ChargeHandle) -> Option<Settlement> {
self.inner.settlement(&self.names.inbound(h).ok()?)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError> {
self.inner
.paid_since(since_unix_ms)
.map(|v| v.into_iter().map(|p| self.names.paid(p)).collect())
.map_err(|e| self.names.error(e))
}
fn livemode(&self) -> Option<bool> {
self.inner.livemode()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_split_check_and_expand() {
assert_eq!(
split("stripe/holger-test2").unwrap(),
("stripe", "holger-test2")
);
for bad in [
"stripe",
"stripe/",
"/a",
"Stripe/a",
"stripe/a/b",
"stripe/a b",
] {
assert!(
split(bad).unwrap_err().contains("[vendor-instance-name]"),
"{bad}"
);
}
assert_eq!(kind_of("stripe/gunnar"), "stripe");
assert_eq!(kind_of("mockpay"), "mockpay");
assert_eq!(block_of("stripe/gunnar"), "[vendors.stripe.gunnar]");
assert_eq!(
expand_env("MONETIZE_STRIPE_{INSTANCE}_SECRET_KEY", "holger-test2"),
"MONETIZE_STRIPE_HOLGER_TEST2_SECRET_KEY"
);
assert_eq!(
expand_env("MONETIZE_STRIPE_BASE_URL", "gunnar"),
"MONETIZE_STRIPE_BASE_URL"
);
assert!(
std::ptr::eq(intern("stripe/x"), intern("stripe/x")),
"interned once"
);
}
#[test]
fn handles_cross_the_edge_renamed_and_another_instances_are_refused() {
let n = Names::of("stripe/gunnar").unwrap();
let mine = ChargeHandle {
vendor: "stripe/gunnar".into(),
id: "cs_1".into(),
};
assert_eq!(
n.inbound(&mine).unwrap(),
ChargeHandle {
vendor: "stripe".into(),
id: "cs_1".into()
}
);
for other in ["stripe/holger", "stripe"] {
let h = ChargeHandle {
vendor: other.into(),
id: "cs_1".into(),
};
assert!(
matches!(n.inbound(&h), Err(PayError::UnknownHandle(x)) if x == h),
"{other}"
);
}
assert_eq!(
n.outbound(ChargeHandle {
vendor: "stripe".into(),
id: "cs_2".into()
})
.vendor,
"stripe/gunnar"
);
match n.error(PayError::UnknownHandle(ChargeHandle {
vendor: "stripe".into(),
id: "x".into(),
})) {
PayError::UnknownHandle(h) => assert_eq!(h.vendor, "stripe/gunnar"),
other => panic!("{other:?}"),
}
}
}