crates/monetize-ui-server/tests/money.rs
//! ★ UW-3: **the operator's money fixes, on the console** (the ONE box has no shell).
//!
//! `SignupAdmin.ListOwedBack / Refund / Settle` and `EstateOrder.Settle` existed only as
//! `monetize-server --refund/--settle` and in tests. The console now carries them, thin:
//!
//! ```text
//! GET /api/owed-back → {signups, total} (a read)
//! POST /api/signups/refund {vendor, session, amount_minor, reason, force, confirm}
//! POST /api/signups/settle {vendor, session, note, confirm}
//! POST /api/estate-orders/<company>/settle {action: retry|refund, confirm}
//! ```
//!
//! Every POST is an admin's with a fresh touch (the console's one step-up table), moves
//! money only with `confirm: true` (the screen asks first), and is sent with the OPERATOR
//! bearer — the console's own, never the service bearer every BFF holds. A console with no
//! operator credential refuses by name and calls nothing.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::body::Body;
use http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use monetize_proto as pb;
use monetize_proto::estate_order_server::{EstateOrder, EstateOrderServer};
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::{json, Value};
use tonic::{Request as TReq, Response, Status};
use tower::ServiceExt;
const SERVICE: &str = "the-service-bearer-every-bff-holds";
const OPERATOR: &str = "the-operator-bearer-of-this-console";
const ADMIN: &str = "break-glass-money";
/// Every call the mock took: (verb, bearer it carried, what it asked).
#[derive(Default)]
struct Mock {
calls: Mutex<Vec<(String, String, String)>>,
}
impl Mock {
fn took<T>(&self, verb: &str, req: &TReq<T>, what: String) {
let bearer = req.metadata().get("authorization").and_then(|v| v.to_str().ok()).and_then(|s| s.strip_prefix("Bearer ")).unwrap_or("").to_owned();
self.calls.lock().unwrap().push((verb.to_owned(), bearer, what));
}
fn calls(&self) -> Vec<(String, String, String)> {
self.calls.lock().unwrap().clone()
}
}
#[derive(Clone)]
struct Svc(Arc<Mock>);
fn row(session: &str) -> pb::SignupRow {
pb::SignupRow { vendor: "stripe/gunnar".into(), session: session.into(), ..Default::default() }
}
#[tonic::async_trait]
impl SignupAdmin for Svc {
async fn list(&self, _: TReq<pb::ListSignupsRequest>) -> Result<Response<pb::ListSignupsResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
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, req: TReq<pb::ListOwedBackRequest>) -> Result<Response<pb::ListOwedBackResponse>, Status> {
self.0.took("ListOwedBack", &req, String::new());
Ok(Response::new(pb::ListOwedBackResponse { signups: vec![row("cs_owed_1")], total: 1 }))
}
async fn refund(&self, req: TReq<pb::RefundSignupRequest>) -> Result<Response<pb::RefundSignupResponse>, Status> {
let r = req.get_ref().clone();
self.0.took("Refund", &req, format!("{}:{} {} {:?} force={}", r.vendor, r.session, r.amount_minor, r.reason, r.force));
Ok(Response::new(pb::RefundSignupResponse { refund_id: format!("{}:refund:11000", r.session), amount: Some(pb::Money { minor: 11000, currency: pb::Currency::Sek as i32 }), state: pb::RefundState::Done as i32, reason: r.reason, requested_at_unix_ms: 1, done_at_unix_ms: 2 }))
}
async fn settle(&self, req: TReq<pb::SettleSignupRequest>) -> Result<Response<pb::RefundSignupResponse>, Status> {
let r = req.get_ref().clone();
self.0.took("Settle", &req, format!("{}:{} {:?}", r.vendor, r.session, r.note));
Ok(Response::new(pb::RefundSignupResponse { state: pb::RefundState::SettledByHand as i32, reason: r.note, done_at_unix_ms: 3, ..Default::default() }))
}
}
#[tonic::async_trait]
impl EstateOrder for Svc {
async fn product_order(&self, _: tonic::Request<pb::EstateProductOrderRequest>) -> Result<tonic::Response<pb::EstateProductOrderResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order")) }
async fn product_order_answer(&self, _: tonic::Request<pb::EstateProductOrderAnswerRequest>) -> Result<tonic::Response<pb::EstateProductOrderAnswerResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_answer")) }
async fn product_order_kinds(&self, _: tonic::Request<pb::EstateProductOrderKindsRequest>) -> Result<tonic::Response<pb::EstateProductOrderKindsResponse>, tonic::Status> { Err(tonic::Status::unimplemented("product_order_kinds")) }
async fn rebuild_main(&self, _: TReq<pb::EstateRebuildMainRequest>) -> Result<Response<pb::EstateRebuildMainResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn start(&self, _: TReq<pb::EstateOrderStartRequest>) -> Result<Response<pb::EstateOrderStartResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn status(&self, _: TReq<pb::EstateOrderStatusRequest>) -> Result<Response<pb::EstateOrderStatusResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn renew(&self, _: TReq<pb::EstateOrderRenewRequest>) -> Result<Response<pb::EstateOrderRenewResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn send_sign_in_again(&self, _: TReq<pb::EstateOrderSignInAgainRequest>) -> Result<Response<pb::EstateOrderSignInAgainResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn change_plan(&self, _: TReq<pb::EstateOrderChangePlanRequest>) -> Result<Response<pb::EstateOrderChangePlanResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn ladder(&self, _: TReq<pb::EstateOrderLadderRequest>) -> Result<Response<pb::EstateOrderLadderResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn purchases(&self, _: TReq<pb::EstateOrderPurchasesRequest>) -> Result<Response<pb::EstateOrderPurchasesResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn notice(&self, _: TReq<pb::EstateNoticeRequest>) -> Result<Response<pb::EstateNoticeResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn sells(&self, _: TReq<pb::EstateOrderSellsRequest>) -> Result<Response<pb::EstateOrderSellsResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn cancel(&self, _: TReq<pb::EstateOrderCancelRequest>) -> Result<Response<pb::EstateOrderCancelResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn restore_front(&self, _: TReq<pb::EstateOrderRestoreFrontRequest>) -> Result<Response<pb::EstateOrderRestoreFrontResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn order_site(&self, _: TReq<pb::EstateOrderSiteRequest>) -> Result<Response<pb::EstateOrderSiteResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn list_sites(&self, _: TReq<pb::EstateSitesRequest>) -> Result<Response<pb::EstateSitesResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn update_estate(&self, _: TReq<pb::EstateUpdateRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn update_status(&self, _: TReq<pb::EstateUpdateStatusRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn cancel_update(&self, _: TReq<pb::EstateUpdateStatusRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> { Err(Status::unimplemented("cancel_update")) }
async fn set_live_update(&self, _: TReq<pb::EstateSetLiveUpdateRequest>) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn adopt_estate(&self, _: TReq<pb::EstateAdoptRequest>) -> Result<Response<pb::EstateAdoptResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn list_code_red(&self, _: TReq<pb::CodeRedListRequest>) -> Result<Response<pb::CodeRedList>, Status> {
Ok(Response::new(pb::CodeRedList::default()))
}
async fn raise_code_red(&self, _: TReq<pb::CodeRed>) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn clear_code_red(&self, _: TReq<pb::CodeRedClearRequest>) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn narrate_log(&self, _: TReq<pb::EstateNarrateLogRequest>) -> Result<Response<pb::EstateNarrateLogResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn install_log(&self, _: TReq<pb::EstateInstallLogRequest>) -> Result<Response<pb::EstateInstallLogResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn ack_code_red(&self, _: TReq<pb::CodeRedAckRequest>) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn first_link(&self, req: TReq<pb::EstateOrderFirstLinkRequest>) -> Result<Response<pb::EstateOrderFirstLinkResponse>, Status> {
let r = req.get_ref().clone();
self.0.took("EstateOrder.FirstLink", &req, format!("{} to={:?}", r.estate, r.to));
Ok(Response::new(pb::EstateOrderFirstLinkResponse { words: format!("{} is handed off, so its main mints the link and mails it to f***@mock.invalid", r.estate), ..Default::default() }))
}
async fn refeed(&self, _: TReq<pb::EstateOrderRefeedRequest>) -> Result<Response<pb::EstateOrderRefeedResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn console(&self, _: TReq<pb::EstateOrderConsoleRequest>) -> Result<Response<pb::EstateOrderConsoleResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn terminate(&self, _: TReq<pb::EstateOrderTerminateRequest>) -> Result<Response<pb::EstateOrderTerminateResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn settle(&self, req: TReq<pb::EstateOrderSettleRequest>) -> Result<Response<pb::EstateOrderSettleResponse>, Status> {
let r = req.get_ref().clone();
self.0.took("EstateOrder.Settle", &req, format!("{} {}", r.company, r.action));
Ok(Response::new(pb::EstateOrderSettleResponse { phase: "refunded".into(), words: format!("{} refunded", r.company) }))
}
}
async fn spawn(mock: Arc<Mock>) -> String {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(SignupAdminServer::new(Svc(mock.clone())))
.add_service(EstateOrderServer::new(Svc(mock)))
.serve_with_incoming(tonic::transport::server::TcpIncoming::from(l)),
);
at.to_string()
}
/// One request through the real router as the break-glass admin (a token: stepped up).
async fn call(mock: Arc<Mock>, with_operator: bool, method: Method, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let at = spawn(mock).await;
let monetize = Monetize::connect_lazy(&at, SERVICE).unwrap();
let operator = with_operator.then(|| Monetize::connect_lazy(&at, OPERATOR).unwrap());
let app = Arc::new(App { webauthn: None, admin_token: Some(ADMIN.into()), events: EventHub::spawn(monetize.clone(), Duration::from_secs(3600)), monetize, operator, ui_dir: None });
let mut req = Request::builder().method(method).uri(uri).header("authorization", format!("Bearer {ADMIN}"));
let body = match body {
Some(b) => {
req = req.header("content-type", "application/json");
Body::from(b.to_string())
}
None => Body::empty(),
};
let res = serve::router(app).oneshot(req.body(body).unwrap()).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())))
}
/// ★ **The owed-back queue is a read** on the service bearer: who paid and got nothing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_owed_back_queue_is_read_on_the_console() {
let mock = Arc::new(Mock::default());
let (status, j) = call(mock.clone(), true, Method::GET, "/api/owed-back", None).await;
assert_eq!(status, StatusCode::OK, "{j}");
assert_eq!(j["total"], 1, "{j}");
assert_eq!(j["signups"][0]["session"], "cs_owed_1", "{j}");
assert_eq!(mock.calls(), [("ListOwedBack".to_string(), SERVICE.to_string(), String::new())]);
}
/// ★ **A refund, a hand-settle and an order's settle are the OPERATOR's**: sent with the
/// console's operator bearer, never the service one, and answered as monetize said.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_money_verbs_go_with_the_operator_bearer() {
let mock = Arc::new(Mock::default());
let (status, j) = call(mock.clone(), true, Method::POST, "/api/signups/refund", Some(json!({"vendor": "stripe/gunnar", "session": "cs_owed_1", "amount_minor": 0, "reason": "no account was made", "confirm": true}))).await;
assert_eq!(status, StatusCode::OK, "{j}");
assert_eq!((j["state"].as_str(), j["refund_id"].as_str()), (Some("done"), Some("cs_owed_1:refund:11000")), "{j}");
let (status, j) = call(mock.clone(), true, Method::POST, "/api/signups/settle", Some(json!({"vendor": "stripe/gunnar", "session": "cs_owed_2", "note": "re_123 by hand", "confirm": true}))).await;
assert_eq!(status, StatusCode::OK, "{j}");
assert_eq!(j["state"], "settled-by-hand", "{j}");
let (status, j) = call(mock.clone(), true, Method::POST, "/api/estate-orders/acme/settle", Some(json!({"action": "refund", "confirm": true}))).await;
assert_eq!(status, StatusCode::OK, "{j}");
assert_eq!(j["phase"], "refunded", "{j}");
assert_eq!(
mock.calls(),
[
("Refund".to_string(), OPERATOR.to_string(), "stripe/gunnar:cs_owed_1 0 \"no account was made\" force=false".to_string()),
("Settle".to_string(), OPERATOR.to_string(), "stripe/gunnar:cs_owed_2 \"re_123 by hand\"".to_string()),
("EstateOrder.Settle".to_string(), OPERATOR.to_string(), "acme refund".to_string()),
]
);
}
/// ★ **Nothing moves money unconfirmed**, and a console with no operator credential says
/// so by name — in neither case is monetize called.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_unconfirmed_or_uncredentialed_money_move_calls_nothing() {
let mock = Arc::new(Mock::default());
for (uri, body) in [
("/api/signups/refund", json!({"vendor": "stripe/gunnar", "session": "cs_1", "reason": "x"})),
("/api/signups/settle", json!({"vendor": "stripe/gunnar", "session": "cs_1", "note": "x", "confirm": false})),
("/api/estate-orders/acme/settle", json!({"action": "retry"})),
] {
let (status, j) = call(mock.clone(), true, Method::POST, uri, Some(body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}: {j}");
assert_eq!(j["code"], "confirm", "{uri}: {j}");
}
let (status, j) = call(mock.clone(), false, Method::POST, "/api/signups/refund", Some(json!({"vendor": "stripe/gunnar", "session": "cs_1", "reason": "x", "confirm": true}))).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{j}");
assert!(j["message"].as_str().unwrap_or_default().contains("operator"), "{j}");
let (status, j) = call(mock.clone(), true, Method::POST, "/api/estate-orders/acme/settle", Some(json!({"action": "drop", "confirm": true}))).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "only retry or refund: {j}");
assert!(mock.calls().is_empty(), "nothing reached monetize: {:?}", mock.calls());
}
/// ★ **FIRST-LINK-1: Sites → <site> → "Send the first sign-in link again"** is the site's
/// settle `first-link`: carried to `EstateOrder.FirstLink` with the operator bearer and NO
/// address (monetize mails the first admin's recorded one), and answered as monetize said.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_first_link_button_asks_monetize_with_the_operator_bearer_and_no_address() {
let mock = Arc::new(Mock::default());
let (status, j) = call(mock.clone(), true, Method::POST, "/api/estate-orders/gunnar-rs/settle", Some(json!({"action": "first-link", "confirm": true}))).await;
assert_eq!(status, StatusCode::OK, "{j}");
assert_eq!(j["words"], "gunnar-rs is handed off, so its main mints the link and mails it to f***@mock.invalid", "{j}");
assert_eq!(mock.calls(), [("EstateOrder.FirstLink".to_string(), OPERATOR.to_string(), "gunnar-rs to=\"\"".to_string())]);
let (status, _) = call(mock.clone(), true, Method::POST, "/api/estate-orders/gunnar-rs/settle", Some(json!({"action": "first-link"}))).await;
assert_ne!(status, StatusCode::OK, "unconfirmed: nothing is asked");
assert_eq!(mock.calls().len(), 1);
}