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

nordisk/monetize

crates/monetize-ui-server/tests/site_money.rs

//! ★ **A site's Money page shows that site's money only** (owner, live
//! 2026-10-12: "test2-gunnar-rs · Money" listed `stripe/gunnar:cs_live_…`
//! rows — gunnar.rs's Stripe account — under Paid at the vendor, Owed back and
//! Paid signups). The ONE box sells for two sites through two Stripe
//! instances (`[estates.<label>] vendor`); each site's panels list only rows
//! of its own instance. With no site named, every row (the box's own view).

use std::sync::Arc;
use std::time::Duration;

use axum::body::Body;
use http::{Request, StatusCode};
use http_body_util::BodyExt;
use monetize_proto as pb;
use monetize_proto::products_server::{Products, ProductsServer};
use monetize_proto::reconcile_admin_server::{ReconcileAdmin, ReconcileAdminServer};
use monetize_proto::signup_admin_server::{SignupAdmin, SignupAdminServer};
use monetize_ui_server::app::App;
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::serve;
use monetize_ui_server::sse::EventHub;
use serde_json::Value;
use tonic::{Request as TReq, Response, Status};
use tower::ServiceExt;

const BEARER: &str = "site-money-bearer";
const ADMIN: &str = "break-glass-site-money";

#[derive(Clone)]
struct Svc;

fn row(vendor: &str, session: &str) -> pb::SignupRow {
    pb::SignupRow {
        vendor: vendor.into(),
        session: session.into(),
        amount: Some(pb::Money {
            minor: 12_500,
            currency: pb::Currency::Sek as i32,
        }),
        ..Default::default()
    }
}

fn case(vendor: &str, session: &str, state: &str) -> pb::PaidAtVendorCase {
    pb::PaidAtVendorCase {
        vendor: vendor.into(),
        session: session.into(),
        state: state.into(),
        amount_minor: 12_500,
        currency: "sek".into(),
        ..Default::default()
    }
}

#[tonic::async_trait]
impl Products for Svc {
    async fn list(
        &self,
        _: TReq<pb::ListProductsRequest>,
    ) -> Result<Response<pb::ListProductsResponse>, Status> {
        Ok(Response::new(pb::ListProductsResponse {
            vendors: vec!["stripe/gunnar".into(), "stripe/gunnar-test2".into()],
            estate_vendors: [
                ("gunnar-rs".to_string(), "stripe/gunnar".to_string()),
                (
                    "test2-gunnar-rs".to_string(),
                    "stripe/gunnar-test2".to_string(),
                ),
            ]
            .into(),
            ..Default::default()
        }))
    }
}

#[tonic::async_trait]
impl SignupAdmin for Svc {
    async fn list(
        &self,
        _: TReq<pb::ListSignupsRequest>,
    ) -> Result<Response<pb::ListSignupsResponse>, Status> {
        Ok(Response::new(pb::ListSignupsResponse {
            signups: vec![
                row("stripe/gunnar", "cs_live_kicka"),
                row("stripe/gunnar-test2", "cs_test_t2"),
            ],
        }))
    }
    async fn resend_welcome(
        &self,
        _: TReq<pb::ResendWelcomeRequest>,
    ) -> Result<Response<pb::ResendWelcomeResponse>, Status> {
        Err(Status::unimplemented("not in this mock"))
    }
    async fn list_owed_back(
        &self,
        _: TReq<pb::ListOwedBackRequest>,
    ) -> Result<Response<pb::ListOwedBackResponse>, Status> {
        Ok(Response::new(pb::ListOwedBackResponse {
            signups: vec![
                row("stripe/gunnar", "cs_live_kicka"),
                row("stripe/gunnar-test2", "cs_test_owed"),
            ],
            total: 2,
        }))
    }
    async fn refund(
        &self,
        _: TReq<pb::RefundSignupRequest>,
    ) -> Result<Response<pb::RefundSignupResponse>, Status> {
        Err(Status::unimplemented("not in this mock"))
    }
    async fn settle(
        &self,
        _: TReq<pb::SettleSignupRequest>,
    ) -> Result<Response<pb::RefundSignupResponse>, Status> {
        Err(Status::unimplemented("not in this mock"))
    }
}

#[tonic::async_trait]
impl ReconcileAdmin for Svc {
    async fn list_paid_at_vendor(
        &self,
        _: TReq<pb::ListPaidAtVendorRequest>,
    ) -> Result<Response<pb::ListPaidAtVendorResponse>, Status> {
        Ok(Response::new(pb::ListPaidAtVendorResponse {
            cases: vec![
                case("stripe/gunnar", "cs_live_kicka", "settled"),
                case("stripe/gunnar", "cs_live_x", "needs-operator"),
                case("stripe/gunnar-test2", "cs_test_t2", "settled"),
            ],
            total: 3,
            needs_operator: 1,
            settled: 2,
            last_reconcile_unix_ms: 1,
        }))
    }
}

async fn console() -> axum::Router {
    let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let at = l.local_addr().unwrap().to_string();
    tokio::spawn(
        tonic::transport::Server::builder()
            .add_service(ProductsServer::new(Svc))
            .add_service(SignupAdminServer::new(Svc))
            .add_service(ReconcileAdminServer::new(Svc))
            .serve_with_incoming(tonic::transport::server::TcpIncoming::from(l)),
    );
    let monetize = Monetize::connect_lazy(&at, BEARER).unwrap();
    let app = Arc::new(App {
        operators: None,
        admin_token: Some(ADMIN.into()),
        events: EventHub::spawn(monetize.clone(), Duration::from_secs(3600)),
        monetize,
        operator: None,
        ui_dir: None,
    });
    serve::router(app)
}

async fn get(router: &axum::Router, path: &str) -> Value {
    let req = Request::builder()
        .uri(path)
        .header("authorization", format!("Bearer {ADMIN}"))
        .body(Body::empty())
        .unwrap();
    let res = router.clone().oneshot(req).await.unwrap();
    assert_eq!(res.status(), StatusCode::OK, "{path}");
    serde_json::from_slice(&res.into_body().collect().await.unwrap().to_bytes()).unwrap()
}

fn sessions(rows: &Value) -> Vec<String> {
    rows.as_array()
        .unwrap()
        .iter()
        .map(|r| r["session"].as_str().unwrap().to_owned())
        .collect()
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn each_sites_money_panels_list_only_its_own_stripe_rows() {
    let router = console().await;

    let t2 = get(&router, "/api/signups?estate=test2-gunnar-rs").await;
    assert_eq!(
        sessions(&t2["signups"]),
        ["cs_test_t2"],
        "test2's paid signups: {t2}"
    );
    let t2 = get(&router, "/api/owed-back?estate=test2-gunnar-rs").await;
    assert_eq!(
        (sessions(&t2["signups"]), t2["total"].as_u64()),
        (vec!["cs_test_owed".to_string()], Some(1)),
        "test2's owed back: {t2}"
    );
    let t2 = get(&router, "/api/paid-at-vendor?estate=test2-gunnar-rs").await;
    assert_eq!(
        (sessions(&t2["cases"]), t2["needs_operator"].as_u64()),
        (vec!["cs_test_t2".to_string()], Some(0)),
        "test2's paid at the vendor: {t2}"
    );

    let own = get(&router, "/api/signups?estate=gunnar-rs").await;
    assert_eq!(
        sessions(&own["signups"]),
        ["cs_live_kicka"],
        "gunnar.rs keeps its own: {own}"
    );
    let own = get(&router, "/api/paid-at-vendor?estate=gunnar-rs").await;
    assert_eq!(
        (
            own["cases"].as_array().unwrap().len(),
            own["needs_operator"].as_u64()
        ),
        (2, Some(1)),
        "{own}"
    );

    // A site this box sells through no vendor for lists nothing, never another's.
    let ghost = get(&router, "/api/signups?estate=ghost").await;
    assert!(ghost["signups"].as_array().unwrap().is_empty(), "{ghost}");

    // No site named: the whole box.
    let all = get(&router, "/api/signups").await;
    assert_eq!(all["signups"].as_array().unwrap().len(), 2);
}