Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/tests/vendors_per_product.rs

//! **ONE monetize, several Stripe accounts: one vendor INSTANCE per product.**
//!
//! A monetize that serves gunnar and holger bills each through its own Stripe
//! account. What must hold:
//!
//! * G2 — a vendor block is `[vendors.<kind>.<instance>]`, its id
//!   `<kind>/<instance>`; two instances of one kind sit side by side, and a
//!   flat `[vendors.stripe]` (no instance) is refused at load;
//! * G3 — every PRICED product names its instance (`vendor = "stripe/gunnar"`);
//!   a missing or unknown one is a load-time error, never a silent `None` at
//!   checkout. A purchase goes through its product's instance and nobody
//!   else's — there is no "the only vendor" fallback;
//! * G9 — each instance has its own webhook, `/callback/<kind>/<instance>`;
//!   a bare `/callback/stripe` names no instance and is a 404.

use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Currency, Market, Money, PayError, RawCallback, Settlement};
use monetize_server::config::Config;
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::{http_post, TestServer};

const BEARER: &str = "test-bearer-token-of-sufficient-length";

/// A vendor instance that only says who it is.
struct Named(&'static str);

#[allow(dead_code)]
impl Named {
    pub fn vendor(&self) -> &'static str {
        self.0
    }
    pub fn markets(&self) -> &[Market] {
        &[Market::Se, Market::Dk]
    }
    pub fn reserve(&self, _: &ChargeRequest) -> Result<ChargeHandle, PayError> {
        Ok(ChargeHandle { vendor: self.0.into(), id: "cs_test_1".into() })
    }
    pub fn draw(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn unlock(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Released)
    }
    pub fn charge_status(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Reserved)
    }
    pub fn parse_callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
        Err(PayError::Ignored(format!("seen by {}", self.0)))
    }
    pub fn give_back(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn settled(&self, _: &ChargeHandle) -> Option<Settlement> {
        None
    }
    pub fn redirect(&self, _: &ChargeHandle) -> Option<String> {
        None
    }
    pub fn start_signup(&self, _: &monetize_pay::signup::SignupOrder) -> Result<Option<monetize_pay::signup::SignupCheckout>, PayError> {
        Ok(None)
    }
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Named {
    fn manifest(&self) -> monetize_pay::payment::PayManifest {
        monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
    }
    fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
        Ok(match charge {
            monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
            monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
            monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
            monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
        })
    }
    fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
        Ok(match of {
            monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
            monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
            monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
            monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
        })
    }
    fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.draw(h)
    }
    fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.unlock(h)
    }
    fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.give_back(h, amount)
    }
    fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
        self.parse_callback(raw)
    }
    fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
        self.settled(h)
    }
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
        Err(monetize_pay::reconcile::cannot_be_asked())
    }
    fn livemode(&self) -> Option<bool> {
        None
    }
}

const TWO_PRODUCTS: &str = r#"
[prices.gunnar.SE]
disk_gib_month = 2200
[prices.holger.DK]
disk_gib_month = 1500
[products.gunnar]
vendor = "stripe/gunnar"
[products.holger]
vendor = "stripe/holger"
[vendors.stripe.gunnar]
enabled = true
country = "IE"
[vendors.stripe.holger]
enabled = true
country = "IE"
"#;

#[test]
fn g2_two_stripe_instances_sit_side_by_side_by_kind_and_instance() {
    let cfg = Config::from_toml(TWO_PRODUCTS).expect("two instances of one kind load");
    assert_eq!(cfg.vendors.keys().map(String::as_str).collect::<Vec<_>>(), ["stripe/gunnar", "stripe/holger"]);
    assert_eq!(cfg.vendors["stripe/holger"].country, "IE");
    assert_eq!(cfg.products["gunnar"].vendor.as_deref(), Some("stripe/gunnar"));
    assert_eq!(cfg.products["holger"].vendor.as_deref(), Some("stripe/holger"));
    // It writes back in the shape it was read in.
    let again = Config::from_toml(&toml::to_string(&cfg).unwrap()).expect("round trip");
    assert_eq!(again.vendors.keys().collect::<Vec<_>>(), cfg.vendors.keys().collect::<Vec<_>>());
}

#[test]
fn g2_a_vendor_block_without_an_instance_is_refused_at_load() {
    let e = Config::from_toml("[vendors.stripe]\nenabled = true\n").unwrap_err().to_string();
    assert!(e.contains("[vendors.stripe]") && e.contains("instance"), "{e}");
    for bad in ["[vendors.stripe.Gunnar]\nenabled = true\n", "[vendors.stripe.\"gun nar\"]\nenabled = true\n"] {
        let e = Config::from_toml(bad).unwrap_err().to_string();
        assert!(e.contains("instance"), "{bad}: {e}");
    }
}

#[test]
fn g3_a_priced_product_with_no_vendor_or_an_unknown_one_is_a_load_error() {
    let no_vendor = TWO_PRODUCTS.replace("vendor = \"stripe/holger\"\n", "");
    let e = Config::from_toml(&no_vendor).unwrap_err().to_string();
    assert!(e.contains("[products.holger]") && e.contains("vendor"), "{e}");

    // Priced, and no [products.<id>] block YET (a product that self-registers
    // after monetize serves): its list is set aside at start, not refused; the
    // block it arrives with is held to the same rule.
    let no_block = TWO_PRODUCTS.replace("[products.holger]\nvendor = \"stripe/holger\"\n", "");
    Config::from_toml(&no_block).expect("a price list waits for its product's block");

    let unknown = TWO_PRODUCTS.replace("vendor = \"stripe/holger\"", "vendor = \"stripe/nobody\"");
    let e = Config::from_toml(&unknown).unwrap_err().to_string();
    assert!(e.contains("stripe/nobody") && e.contains("[products.holger]"), "{e}");

    let disabled = TWO_PRODUCTS.replace("[vendors.stripe.holger]\nenabled = true", "[vendors.stripe.holger]\nenabled = false");
    let e = Config::from_toml(&disabled).unwrap_err().to_string();
    assert!(e.contains("stripe/holger"), "a disabled instance takes no money: {e}");

    // An unpriced product needs no vendor.
    Config::from_toml("[products.free]\nfree = true\n").expect("a free product names no vendor");
}

#[test]
fn g3_each_product_pays_through_its_own_instance_and_never_another() {
    let reg = Registry::new().vendor(Named("stripe/gunnar")).vendor(Named("stripe/holger")).route("gunnar", "stripe/gunnar").route("holger", "stripe/holger");
    let sek = Money { minor: 2200, currency: Currency::Sek };
    let dkk = Money { minor: 1500, currency: Currency::Dkk };
    assert_eq!(reg.vendor_for(&sek, Market::Se, "gunnar", "").map(|v| v.name), Some("stripe/gunnar"));
    assert_eq!(reg.vendor_for(&dkk, Market::Dk, "holger", "").map(|v| v.name), Some("stripe/holger"));
    // A request naming the product's own instance is fine; another's is refused.
    assert_eq!(reg.vendor_for(&sek, Market::Se, "gunnar", "stripe/gunnar").map(|v| v.name), Some("stripe/gunnar"));
    assert!(reg.vendor_for(&sek, Market::Se, "gunnar", "stripe/holger").is_none(), "gunnar's money never goes to holger's account");
    // A product nobody routed has no vendor, even with a single one linked.
    let one = Registry::new().vendor(Named("stripe/gunnar"));
    assert!(one.vendor_for(&sek, Market::Se, "gunnar", "").is_none(), "no fallback to the only vendor");
    // Free is free, routed or not.
    let zero = Money { minor: 0, currency: Currency::Sek };
    assert_eq!(one.vendor_for(&zero, Market::Se, "gunnar", "").map(|v| v.name), Some(monetize::NO_VENDOR));
}

#[test]
fn g3_a_registry_from_config_routes_by_the_products_vendor() {
    let mut linked = monetize_server::registry::Linked::open();
    linked.vendor(monetize_pay::factory::VendorFactory { name: "stripe", config_envs: &[], build: |b| Ok(Box::new(Named(Box::leak(format!("raw-{}", b.name).into_boxed_str())))) });
    let cfg = Config::from_toml(&TWO_PRODUCTS.replace("[products.gunnar]\nvendor = \"stripe/gunnar\"\n[products.holger]\nvendor = \"stripe/holger\"\n", "")
        .replace("[prices.gunnar.SE]\ndisk_gib_month = 2200\n[prices.holger.DK]\ndisk_gib_month = 1500\n", ""))
    .unwrap();
    let reg = Registry::from_config_with(&cfg, &linked).expect("both instances are built by the one stripe factory");
    let names: Vec<&str> = reg.vendors().map(|v| v.name).collect();
    assert_eq!(names, ["stripe/gunnar", "stripe/holger"], "the server sees the instance, whatever the factory names itself");
}

#[test]
fn g9_livemode_reads_the_kind_of_an_instance() {
    assert_eq!(monetize_pay::livemode_of("stripe/gunnar", "cs_live_a1"), Some(true));
    assert_eq!(monetize_pay::livemode_of("stripe/holger-test2", "cs_test_a1"), Some(false));
    assert_eq!(monetize_pay::livemode_of("sandbox/play", "x"), Some(false));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn g9_each_instance_has_its_own_webhook_and_the_bare_kind_is_none() {
    let reg = Registry::new().vendor(Named("stripe/gunnar")).vendor(Named("stripe/holger")).route("gunnar", "stripe/gunnar").route("holger", "stripe/holger");
    let state = std::sync::Arc::new(AppState::in_memory(reg));
    let server = TestServer::spawn(state, BEARER).await;
    let addr = server.addr;
    let post = move |path: &'static str| tokio::task::spawn_blocking(move || http_post(addr, path, &[], b"{}").expect("answers"));
    let (code, body) = post("/callback/stripe/gunnar").await.unwrap();
    assert_eq!(code, 200, "{body}");
    assert!(body.contains("seen by stripe/gunnar"), "{body}");
    let (code, body) = post("/callback/stripe/holger").await.unwrap();
    assert_eq!(code, 200, "{body}");
    assert!(body.contains("seen by stripe/holger"), "{body}");
    let (code, body) = post("/callback/stripe").await.unwrap();
    assert_eq!(code, 404, "the bare kind names no instance: {body}");
    let (code, _) = post("/callback/stripe/nobody").await.unwrap();
    assert_eq!(code, 404);
    server.stop().await;
}