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/src/estate_order/pages.rs

//! ★ **Closing a vendor's payment page, never blind** (F-S-02, F-S-03; A17 2026-10-23). Every
//! place an estate's order, renewal or plan change lets go of its page goes through [`close`]
//! (or [`close_or_capture`] where the sale goes on), and reads the vendor's answer:
//!
//! * the page closed, or nobody paid on it → [`Closed::Closed`];
//! * the buyer authorised it at the last second (Stripe refuses to expire a completed session,
//!   so the release answers an error, and the status then says `Reserved`) → [`Closed::Held`]:
//!   captured where the sale goes on (the ORDERED phase's rule, A19), released again where
//!   nothing may be charged — never lost;
//! * the vendor drew it already → [`Closed::Paid`];
//! * the vendor did not answer → [`Closed::Failed`].
//!
//! A page that could not be let go where nothing may be charged is KEPT on the run
//! ([`EstateRun::unreleased`], [`keep_open`]): said in its history and as an
//! `ALERT estate-orders.page` line, shown with the run, and asked to close again on every tick
//! ([`look`]) until the vendor says it is closed.

use monetize_pay::{ChargeHandle, ChargeStatus};

use super::{EstateRun, Transition};
use crate::state::AppState;

/// What became of a page the shop let go of.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Closed {
    /// Closed, or nothing was paid on it: nothing is held.
    Closed,
    /// The buyer authorised it: the money is held and not drawn.
    Held,
    /// The money is drawn (`vendor_ref`).
    Paid(String),
    /// The vendor did not answer; the page may still be payable.
    Failed(String),
}

/// **Let go of a page**, and read what the vendor says it is now.
pub fn close(vendor: monetize_pay::Vendor<'_>, handle: &ChargeHandle) -> Closed {
    match vendor.release(handle) {
        Ok(ChargeStatus::Released)
        | Ok(ChargeStatus::Declined { .. })
        | Ok(ChargeStatus::AwaitingCustomer) => Closed::Closed,
        Ok(ChargeStatus::Reserved) => Closed::Held,
        Ok(ChargeStatus::Captured { vendor_ref, .. }) => Closed::Paid(vendor_ref),
        // A release refused: a page paid in its last second is one the vendor will not expire.
        Err(e) => match vendor.charge(handle) {
            Ok(ChargeStatus::Released) | Ok(ChargeStatus::Declined { .. }) => Closed::Closed,
            Ok(ChargeStatus::Reserved) => Closed::Held,
            Ok(ChargeStatus::Captured { vendor_ref, .. }) => Closed::Paid(vendor_ref),
            Ok(ChargeStatus::AwaitingCustomer) | Err(_) => Closed::Failed(e.to_string()),
        },
    }
}

/// **[`close`], and a hold authorised at the last second is captured**: the sale goes on.
/// `Failed` names a capture that did not go through (asked again on the next tick).
pub fn close_or_capture(vendor: monetize_pay::Vendor<'_>, handle: &ChargeHandle) -> Closed {
    match close(vendor, handle) {
        Closed::Held => match vendor.capture(handle) {
            Ok(ChargeStatus::Captured { vendor_ref, .. }) => Closed::Paid(vendor_ref),
            Ok(other) => Closed::Failed(format!(
                "paid as the page expired; the capture answered {other:?}"
            )),
            Err(e) => Closed::Failed(format!(
                "paid as the page expired; the capture did not go through yet: {e}"
            )),
        },
        other => other,
    }
}

/// A page the shop let go of and the vendor did not close (yet).
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OpenPage {
    /// The sale it was for (the order's, a renewal's or a plan change's reference).
    pub reference: String,
    pub handle: ChargeHandle,
    /// Why it is still open, as the vendor last answered.
    pub why: String,
    pub at_ms: u64,
}

/// **The page could not be let go where nothing may be charged** (`what`: why it was closed).
/// `Held` and `Failed` are kept on the run and asked again every tick; `Paid` is money drawn
/// for nothing — said for the operator to refund. `Closed` says nothing more.
pub fn settle(
    run: &mut EstateRun,
    reference: &str,
    handle: &ChargeHandle,
    closed: Closed,
    what: &str,
    now: u64,
) {
    match closed {
        Closed::Closed => {}
        Closed::Held => keep_open(run, reference, handle, &format!("{what}, and the buyer authorised it at the last second: the hold is released again on every tick"), now),
        Closed::Failed(e) => keep_open(run, reference, handle, &format!("{what}, and closing it did not go through: {e}; asked again on every tick"), now),
        Closed::Paid(vendor_ref) => drawn_for_nothing(run, reference, &vendor_ref, what, now),
    }
}

/// Keep `handle` on the run, said once in its history and as an ALERT.
pub fn keep_open(run: &mut EstateRun, reference: &str, handle: &ChargeHandle, why: &str, now: u64) {
    if let Some(p) = run.unreleased.iter_mut().find(|p| &p.handle == handle) {
        p.why = why.to_owned();
        return;
    }
    run.unreleased.push(OpenPage {
        reference: reference.to_owned(),
        handle: handle.clone(),
        why: why.to_owned(),
        at_ms: now,
    });
    let line = format!("ALERT: the payment page of {reference} is NOT closed: {why}");
    eprintln!(
        "monetize-server: ALERT estate-orders.page {}: {line}",
        run.reference
    );
    run.history.push(Transition {
        phase: String::new(),
        at_ms: now,
        note: line,
    });
}

fn drawn_for_nothing(run: &mut EstateRun, reference: &str, vendor_ref: &str, what: &str, now: u64) {
    let line = format!("ALERT: {reference} was CAPTURED ({vendor_ref}) at the vendor while {what}: nothing was delivered for it — refund it at the vendor");
    eprintln!(
        "monetize-server: ALERT estate-orders.page {}: {line}",
        run.reference
    );
    run.history.push(Transition {
        phase: String::new(),
        at_ms: now,
        note: line,
    });
}

/// **Every page kept open, asked to close again** (each tick): one the vendor now says is
/// closed leaves the run with a line in its history; one drawn meanwhile is said for a refund.
pub fn look(state: &AppState) {
    let waiting: Vec<String> = state
        .estates
        .all()
        .into_iter()
        .filter(|r| !r.unreleased.is_empty())
        .map(|r| r.company)
        .collect();
    for company in waiting {
        let Some(_pen) = state
            .estates
            .pens
            .take(&company, Some(std::time::Duration::ZERO))
        else {
            continue;
        };
        let Some(mut run) = state.estates.get(&company) else {
            continue;
        };
        if retry(state, &mut run) {
            if let Err(e) = super::save(state, &run) {
                eprintln!("monetize-server: ALERT estate-orders.page {}: the run could not be written ({e}); asked again next tick", run.reference);
            }
        }
    }
}

/// [`look`] for one run (its pen held). `true`: the run changed.
pub(super) fn retry(state: &AppState, run: &mut EstateRun) -> bool {
    let Some(cfg) = super::run_terms(state, run).or_else(|| state.estates.cfg.clone()) else {
        return false;
    };
    let Some(vendor) = state.registry.find_vendor(&cfg.vendor) else {
        return false;
    };
    let now = state.now();
    let before = run.clone();
    for p in std::mem::take(&mut run.unreleased) {
        match close(vendor, &p.handle) {
            Closed::Closed => run.history.push(Transition {
                phase: String::new(),
                at_ms: now,
                note: format!(
                    "the payment page of {} is closed at last; nothing is held",
                    p.reference
                ),
            }),
            Closed::Paid(vendor_ref) => drawn_for_nothing(
                run,
                &p.reference,
                &vendor_ref,
                "its page was being closed",
                now,
            ),
            Closed::Held => run.unreleased.push(OpenPage {
                why: "the hold is still authorised; released again next tick".into(),
                ..p
            }),
            Closed::Failed(e) => run.unreleased.push(OpenPage {
                why: format!("closing it did not go through: {e}; asked again next tick"),
                ..p
            }),
        }
    }
    *run != before
}