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/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());
    }
}