crates/monetize-ui-server/src/money.rs
//! ★ UW-3: **THE OPERATOR'S MONEY FIXES**, on the console — the ONE box has no shell, so
//! what `monetize-server --refund/--settle` did is here, thin (nothing decided here).
//!
//! ```text
//! GET /api/owed-back → {signups: [SignupJson], total}
//! 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 passkey touch (`auth::needs_step_up`), moves money
//! only with `confirm: true` (the screen asks first) and goes to monetize with the
//! OPERATOR bearer ([`crate::app::App::operator`]) — the service bearer every BFF holds is
//! refused on these verbs by monetize itself. No operator credential: refused by name.
use axum::extract::{Path, State};
use axum::Json;
use monetize_proto as pb;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::api::{currency_name, signup_json, ApiError};
use crate::app::AppState;
use crate::grpc::{Fail, Monetize};
/// The settles of a private order: `retry` and `refund` (owed or waiting), `abandon` (an install
/// still running, F2), `recheck` ("Check again": a failed setup's boxes asked again, F5).
pub const ORDER_ACTIONS: [&str; 5] = ["retry", "refund", "abandon", "recheck", "first-link"];
#[derive(Debug, Deserialize)]
pub struct RefundBody {
pub vendor: String,
pub session: String,
#[serde(default)]
pub amount_minor: u64,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Deserialize)]
pub struct SettleBody {
pub vendor: String,
pub session: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub confirm: bool,
}
#[derive(Debug, Deserialize)]
pub struct OrderSettleBody {
pub action: String,
#[serde(default)]
pub confirm: bool,
}
/// Pure: refused unless the screen confirmed it.
pub fn confirmed(confirm: bool, what: &str) -> Result<(), ApiError> {
if confirm {
return Ok(());
}
Err(ApiError(Fail { code: "confirm".into(), message: format!("{what} moves money: confirm it (`confirm: true`) and send it again") }))
}
fn operator(state: &AppState) -> Result<&Monetize, ApiError> {
state.operator.as_ref().ok_or_else(|| {
ApiError(Fail {
code: "unavailable".into(),
message: "this console holds no operator credential (monetize's operator-token), so it cannot refund or settle; nothing was sent".into(),
})
})
}
/// The wire's refund state, in the console's word.
pub fn refund_state_name(v: i32) -> &'static str {
match pb::RefundState::try_from(v) {
Ok(pb::RefundState::Requested) => "requested",
Ok(pb::RefundState::Done) => "done",
Ok(pb::RefundState::SettledByHand) => "settled-by-hand",
Ok(pb::RefundState::Unspecified) | Err(_) => "none",
}
}
fn refund_json(r: &pb::RefundSignupResponse) -> Value {
json!({
"refund_id": r.refund_id,
"amount_minor": r.amount.as_ref().map(|m| m.minor).unwrap_or(0),
"currency": r.amount.as_ref().map(|m| currency_name(m.currency)).unwrap_or(""),
"state": refund_state_name(r.state),
"reason": r.reason,
"requested_at_unix_ms": r.requested_at_unix_ms,
"done_at_unix_ms": r.done_at_unix_ms,
})
}
/// `GET /api/owed-back` — every payment taken for an account never made (a read).
pub async fn owed_back(State(state): State<AppState>, axum::extract::Query(q): axum::extract::Query<crate::api::SiteQuery>) -> Result<Json<Value>, ApiError> {
let site = crate::api::site_vendor(&state, &q.estate).await?;
let mut r = state.monetize.signups().list_owed_back(pb::ListOwedBackRequest {}).await?.into_inner();
if site.is_some() {
r.signups.retain(|s| crate::api::on_site(&s.vendor, &site));
r.total = r.signups.len() as u64;
}
Ok(Json(json!({ "signups": r.signups.iter().map(signup_json).collect::<Vec<_>>(), "total": r.total })))
}
/// `POST /api/signups/refund` — give a signup's payment back (the operator's).
pub async fn refund(State(state): State<AppState>, Json(b): Json<RefundBody>) -> Result<Json<Value>, ApiError> {
confirmed(b.confirm, "a refund")?;
let r = operator(&state)?
.signups()
.refund(pb::RefundSignupRequest { vendor: b.vendor, session: b.session, amount_minor: b.amount_minor, reason: b.reason, force: b.force })
.await?
.into_inner();
Ok(Json(refund_json(&r)))
}
/// `POST /api/signups/settle` — close out a row refunded by hand at the vendor.
pub async fn settle(State(state): State<AppState>, Json(b): Json<SettleBody>) -> Result<Json<Value>, ApiError> {
confirmed(b.confirm, "settling a payment")?;
let r = operator(&state)?.signups().settle(pb::SettleSignupRequest { vendor: b.vendor, session: b.session, note: b.note }).await?.into_inner();
Ok(Json(refund_json(&r)))
}
/// `POST /api/estate-orders/<company>/settle` — an owed or waiting private order: set it
/// up again (`retry`) or give the money back (`refund`).
pub async fn order_settle(State(state): State<AppState>, Path(company): Path<String>, Json(b): Json<OrderSettleBody>) -> Result<Json<Value>, ApiError> {
let action = b.action.trim().to_owned();
if !ORDER_ACTIONS.contains(&action.as_str()) {
return Err(ApiError(Fail::invalid(format!("{action:?}: an order is settled by `retry`, `refund`, `abandon` or `recheck`"))));
}
confirmed(b.confirm, "settling an order")?;
if action == "first-link" {
// FIRST-LINK-1: no address — monetize mails the first admin's recorded one.
let r = operator(&state)?.estate_orders().first_link(pb::EstateOrderFirstLinkRequest { estate: company, to: String::new() }).await?.into_inner();
return Ok(Json(json!({ "words": r.words })));
}
let r = operator(&state)?.estate_orders().settle(pb::EstateOrderSettleRequest { company, action }).await?.into_inner();
Ok(Json(json!({ "phase": r.phase, "words": r.words })))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refund_state_is_said_in_one_word_and_an_unknown_one_is_none() {
assert_eq!(refund_state_name(pb::RefundState::Done as i32), "done");
assert_eq!(refund_state_name(pb::RefundState::SettledByHand as i32), "settled-by-hand");
assert!(ORDER_ACTIONS.contains(&"abandon"), "F2: \"Abandon this install\" is carried to monetize's Settle");
assert!(ORDER_ACTIONS.contains(&"recheck"), "F5: \"Check again\" is carried to monetize's Settle");
assert!(ORDER_ACTIONS.contains(&"first-link"), "FIRST-LINK-1: \"Send the first sign-in link again\" is carried to EstateOrder.FirstLink");
assert_eq!(refund_state_name(99), "none");
assert!(confirmed(false, "x").is_err() && confirmed(true, "x").is_ok());
}
}