crates/monetize-server/src/callback.rs
//! **`POST /callback/<kind>/<instance>`** — one vendor instance's webhook
//! (`/callback/stripe/gunnar`; each Stripe account has its own endpoint and
//! signing secret, and a bare `/callback/stripe` is a 404). HTTP, not gRPC, because
//! it is the vendor's wire; on the SAME listener as the gRPC services (tonic's
//! router is an axum router, `accept_http1` is on), so a reverse proxy forwards
//! only `/callback/*` from the public edge.
//!
//! The one rule (monetize-pay's header): a callback moves nothing until
//! `Payment::callback` has VERIFIED it. Then, only for a reference
//! the ledger holds open and whose handle is the one the vendor names, the
//! transaction is resumed through the same path `Purchase.Start` uses.
//!
//! | answer | when |
//! |---|---|
//! | 413 | a body larger than [`MAX_CALLBACK_BYTES`]: no vendor sends one, and it is refused before the parser is instantiated |
//! | 503 | every one of the [`CALLBACK_SLOTS`] is taken (with `Retry-After`), or `parse_callback` said `Unavailable` — our outage, so the vendor retries |
//! | 404 | no such vendor linked. **Not** "no such reference": that verified, so it is a 200 — see below |
//! | 401 | `parse_callback` said `Unverified` — logged, counted, never acted on |
//! | 200 | `parse_callback` said `Ignored`, or the reference names no open attempt — it verified and there is nothing to do: `{ignored, reason}` |
//! | 200 | `parse_callback` said `Signup` — a PAID SIGNUP: `{signup, vendor, session, state, reason}`, and the ledger is never touched (see [`crate::signup`]) |
//! | 409 | verified, but names a handle the ledger did not mint for that reference |
//! | 200 | `{reference, state: PENDING, reason, accepted: true}` — KEPT ([`crate::inbox`]) and granted behind the answer: the grant took longer than `inbox::ANSWER_WITHIN`, is in flight for an earlier delivery, or met a vendor/cloud/product that did not answer (it retries on its own; the vendor need not). Until 2026-10-01 the grant ran inside the request and a growth made it a 504 (money-webhook-504-in-growth) |
//! | 200 | `{…, duplicate: true}` — a redelivery of an event whose grant is done, answered from the kept row |
//! | 500 | the delivery could not be KEPT (inbox or signup store), or the ledger failed: the vendor's retry is the only copy. The body is `internal error` and nothing else; the cause goes to the journal and the status line |
//! | 200 | `{reference, state, reason}` — state after the resume (PAID, PENDING, RELEASED) |
//!
//! # What a refusal leaves behind — and what it deliberately does not
//!
//! **No status code above changes**, and none of the reasoning for them does
//! either. What changed 2026-09-14 is that three of these refusals now leave a
//! ROW an operator can find, in [`crate::refusals`]: a bounded in-memory ring,
//! read over `CallbackAdmin.ListRefusals` and published on the bus so a console
//! that is already open lights up at once.
//!
//! Before it, a refused callback wrote a `functional_status` and an `eprintln!`
//! and nothing else. The front's clock ran two hours fast, every genuine Stripe
//! delivery fell outside the signed 300 s window, and the owner watched a
//! customer pay while monetize recorded absolutely nothing. journald had it;
//! nobody was reading journald.
//!
//! **The three that are recorded** are the three that can leave money at the
//! vendor with no trace ANYWHERE else in monetize:
//!
//! | arm | why it is a row |
//! |---|---|
//! | 401 `Unverified` | the incident itself: a delivery we threw away, and the ledger will never hear of it |
//! | 400 not understood | same shape — a delivery this server could not act on, and no other store sees it |
//! | 409 handle mismatch | VERIFIED, and it says a charge exists against a handle the ledger did not mint |
//!
//! **The rest are deliberately NOT rows**, because each already has a truer
//! home and a panel of noise is a panel nobody reads:
//!
//! * **404, no such vendor** — internet background noise against a path a
//! stranger chose. Recording it would let anyone write a string of their
//! choosing into the operator's screen, which is the one thing this record
//! must not offer.
//! * **200 `Ignored`** — it VERIFIED and there is nothing to do: another
//! system's payment on the same merchant account. Listing those would bury
//! the refusals under the vendor's ordinary traffic.
//! * **200 no open attempt** — a late or duplicate delivery. The poller and the
//! webhook race by design; this is the race working.
//! * **`Signup`, refusals included** — already stored, durably, in
//! [`crate::signup`], and already on the console's `Paid signups` panel. One
//! failure on one screen twice is two failures as far as a reader is concerned.
//! * **a grant still running or retrying** — the attempt is still OPEN in the ledger, so
//! it is already on the console under `Needs you`, with its checkout link.
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use monetize_pay::{PayError, RawCallback};
use crate::refusals::{Refusal, Refused};
use crate::state::AppState;
pub async fn handle(
State(state): State<Arc<AppState>>,
Path(vendor): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// **Bounded before anything is instantiated.** Both checks are free and
// neither reads the path segment into a record: it is the caller's.
if body.len() > MAX_CALLBACK_BYTES {
return (StatusCode::PAYLOAD_TOO_LARGE, "callback body too large").into_response();
}
let Some(slot) = state.callback_slots.take() else {
eprintln!("monetize-server: every one of the {CALLBACK_SLOTS} callback slots is taken; a delivery was answered 503");
return (
[(axum::http::header::RETRY_AFTER, "10")],
(StatusCode::SERVICE_UNAVAILABLE, "busy; retry"),
)
.into_response();
};
let headers: Vec<(String, String)> = headers
.iter()
.map(|(k, v)| {
(
k.as_str().to_string(),
v.to_str().unwrap_or_default().to_string(),
)
})
.collect();
let v = vendor.clone();
let result = tokio::task::spawn_blocking(move || {
callback_blocking(&state, &vendor, &headers, &body, slot)
})
.await;
match result {
Ok(r) => r,
Err(e) => internal(&v, "callback handler panicked", e),
}
}
/// **500, and nothing about why.** The error is ours and goes to the journal
/// and the status line; whoever posted is told only that it failed here.
fn internal(vendor: &str, what: &str, e: impl std::fmt::Display) -> Response {
eprintln!("monetize-server: {vendor} callback: {what}: {e}");
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
false,
&format!("{what}: {e}"),
);
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}
/// **A verified event about nothing we hold, answered as a receipt.**
///
/// The vendor proved the event is its own; the ledger simply has no open attempt
/// for the reference it names — most often because the attempt already closed and
/// this is a late or duplicate delivery. Retrying that produces the same answer
/// forever, and every non-2xx is a failed delivery a vendor counts against the
/// endpoint (Stripe's delivery table treats 400, 404 and 409 alike, and their own
/// reference handler answers 200 to an event type it does not handle).
///
/// Same shape as the `Ignored` arm above, so one thing an operator greps for means
/// "nothing happened, and nothing was wrong".
/// **How many deliveries may be in this process at once** — parsing, settling
/// or granting. Every delivery instantiates the vendor's wasm BEFORE its
/// signature is known, so anyone on the internet can ask for one; past this
/// many the answer is 503 at once and the vendor retries.
pub const CALLBACK_SLOTS: usize = 32;
/// **The largest webhook body this server reads.** A vendor's event is a few
/// kilobytes; anything near this is not one, and it is refused before the
/// vendor's parser is instantiated for it.
pub const MAX_CALLBACK_BYTES: usize = 256 * 1024;
/// The bounded pool of [`CALLBACK_SLOTS`] a delivery must hold a [`Slot`] from.
#[derive(Clone)]
pub struct Slots(Arc<tokio::sync::Semaphore>);
/// One delivery's place in [`Slots`]; given back when dropped, which for a
/// grant or settle running behind the answer is when that work ends.
pub struct Slot {
_permit: tokio::sync::OwnedSemaphorePermit,
}
impl Default for Slots {
fn default() -> Self {
Slots::with(CALLBACK_SLOTS)
}
}
impl Slots {
pub fn with(n: usize) -> Self {
Slots(Arc::new(tokio::sync::Semaphore::new(n)))
}
/// A slot if one is free, at once; never waits.
pub fn take(&self) -> Option<Slot> {
Arc::clone(&self.0)
.try_acquire_owned()
.ok()
.map(|_permit| Slot { _permit })
}
}
fn nothing_to_do(vendor: &str, reason: String) -> Response {
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
true,
&format!("verified event ignored: {reason}"),
);
Json(serde_json::json!({ "ignored": true, "reason": reason })).into_response()
}
/// **The one writer of a refusal record.** Every arm that keeps a row goes
/// through here, so the ring, the testmatrix line, the journal line and the
/// live feed cannot disagree about what happened or say it in four voices.
///
/// It records first and publishes second: the durable-for-this-run fact is the
/// ring, and a console that is not watching must not be able to change what is
/// kept. Publishing is infallible and never blocks (see [`crate::bus`]), so the
/// vendor's request is not waiting on a browser.
///
/// `detail` is bounded and stripped inside [`crate::refusals::RefusalLog::record`],
/// at the one place a row is made — nothing downstream has to remember to.
fn record_refusal(
state: &AppState,
vendor: &str,
refusal: Refusal,
detail: String,
body_bytes: usize,
) -> Refused {
let row = state
.refusals
.record(state.now(), vendor, refusal, &detail, body_bytes as u64);
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
false,
&format!("callback refused ({}): {}", refusal.name(), row.detail),
);
eprintln!(
"monetize-server: {vendor} callback REFUSED ({}) #{} — {} [recorded; the console reads CallbackAdmin.ListRefusals]",
refusal.name(),
row.seq,
row.detail
);
state
.bus
.publish(monetize_proto::event::Payload::CallbackRefused(row.to_pb()));
row
}
fn callback_blocking(
state: &Arc<AppState>,
vendor: &str,
headers: &[(String, String)],
body: &[u8],
slot: Slot,
) -> Response {
let Some(pay) = state.registry.find_vendor(vendor) else {
return (
StatusCode::NOT_FOUND,
format!("no vendor {vendor:?} is linked into this server"),
)
.into_response();
};
let event = match pay.callback(RawCallback { headers, body }) {
Ok(e) => e,
Err(PayError::Unverified(why)) => {
// **401, and now a ROW.** The status and the reason for it are
// untouched: the caller proved nothing, so it is told nothing about
// what this deployment holds, and the body stays the same four
// words it always was. What is new is that the refusal survives the
// request — see the module doc, and [`crate::refusals`] for why an
// unauthenticated caller can fill a bounded ring and nothing else.
record_refusal(state, vendor, Refusal::Unverified, why, body.len());
return (StatusCode::UNAUTHORIZED, "callback did not verify").into_response();
}
Err(PayError::Ignored(why)) => {
// **200, deliberately.** The event VERIFIED — it is simply not about
// anything this deployment holds: another system's payment on the same
// merchant account, an event type the ledger has no answer for. A vendor
// whose endpoint answers 401 to its own genuine traffic retries it for
// days and then disables the endpoint, taking the events that DO matter
// with it. Counted as functional: nothing failed here.
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
true,
&format!("verified event ignored: {why}"),
);
return Json(serde_json::json!({ "ignored": true, "reason": why })).into_response();
}
Err(PayError::Unavailable(why)) => {
// **503: our outage, not their payload.** The delivery verified as
// far as the parser got and the vendor could not be asked for the
// rest; a 4xx would tell the vendor to stop retrying the one copy.
eprintln!("monetize-server: {vendor} callback could not be read for now: {why}");
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
false,
&format!("callback unavailable: {why}"),
);
return (
StatusCode::SERVICE_UNAVAILABLE,
"temporarily unavailable; retry",
)
.into_response();
}
Err(PayError::Signup(signup)) => {
// **A customer who did not exist when the money moved.** There is no
// reference to look up and no attempt to resume — the ledger holds
// nothing for a stranger — so this arm returns before the ledger is
// touched at all, and the engine in `crate::signup` takes it.
//
// **200 in every verified case, refusals included.** A refused
// signup is not a failed delivery: the delivery was perfect and the
// answer is no. Telling Stripe otherwise buys days of retries and
// then a disabled endpoint (`4cafd1d`), and it would not reach the
// one party who needs the answer — the customer's return page,
// which reads it over `Signup.Status`.
//
// **Kept first, answered at once** (money-webhook-504-in-growth):
// the sink creates the principal and pushes the entitlement on the
// same box a growth can hold for minutes, so the settle runs behind
// the answer. The row is the durable record and the idempotency
// key (`(vendor, session)`); a delivery the store could not keep is
// NOT answered 200, so the vendor's retry stays the only copy.
// ★ ST-7: a session this box never minted (and holds no row for) is another
// system's checkout on the same merchant account: verified, answered 200 so the
// vendor stops, and never settled here.
let estate = match state.signups.get(&signup.vendor, &signup.session) {
Ok(Some(rec)) => rec.estate,
Ok(None) => match state.signups.minted(&signup.vendor, &signup.session) {
Ok(Some(estate)) => estate,
Ok(None) => {
let why = format!("signup {}:{} was not minted by this monetize — another system's checkout on the same account; not settled here", signup.vendor, signup.session);
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
true,
&format!("verified event ignored: {why}"),
);
eprintln!("monetize-server: {vendor} callback IGNORED: {why}");
return Json(serde_json::json!({ "ignored": true, "reason": why }))
.into_response();
}
Err(e) => return internal(vendor, "signup store", e),
},
Err(e) => return internal(vendor, "signup store", e),
};
match state
.signups
.record_pending_on(&signup, state.now() as i64, estate.as_deref())
{
Ok(crate::signup::Recorded::Already(rec)) if !rec.state.is_pending() => {
return signup_answer(&signup, &rec.state)
}
Ok(_) => {}
Err(e) => return internal(vendor, "signup store", e),
}
let (tx, rx) = std::sync::mpsc::channel();
let (st, sg) = (Arc::clone(state), signup.clone());
// A jera job on the signup's own lane (A32-14): a settle that panics is an ALERT line
// and a FAILED row; the signup is kept, and the poller's re-settle finishes it.
let key = crate::jobs::of(state, &format!("{}:{}", signup.vendor, signup.session));
let spawned = crate::jobs::once(SIGNUP_SETTLE_JOB, &key, move || {
let _slot = slot;
let decided = crate::signup::settle(&st, &sg);
eprintln!(
"monetize-server: callback signup {}:{} settled behind the answer: {}",
sg.vendor,
sg.session,
signup_word(&decided).0
);
let _ = tx.send(decided);
Ok(())
});
match spawned {
Ok(true) => {}
// Its settle already runs: this answer waits on nothing and says PENDING.
Ok(false) => eprintln!("monetize-server: callback signup {}:{}: its settle already runs", signup.vendor, signup.session),
// Kept, and the poller's re-settle finishes it.
Err(e) => eprintln!("monetize-server: callback signup {}:{}: not settled now ({e}); the poller's re-settle will", signup.vendor, signup.session),
}
let state_of = match rx.recv_timeout(crate::inbox::ANSWER_WITHIN) {
Ok(decided) => decided,
Err(_) => {
eprintln!(
"monetize-server: callback signup {}:{}: kept and answered PENDING within {} ms; the account is made behind the answer",
signup.vendor,
signup.session,
crate::inbox::ANSWER_WITHIN.as_millis()
);
crate::signup::SignupState::Pending
}
};
return signup_answer(&signup, &state_of);
}
Err(PayError::Reversal(r)) => {
// **Money went back, or is contested** (g-disputes): written into
// the ledger once, 200 whatever became of it — a delivery we
// cannot place is somebody else's charge on the same account.
return match crate::reversal::record(state, &r) {
Ok(done) => {
Json(serde_json::json!({ "reversal": r.kind.name(), "reference": done.reference, "seq": done.seq, "recorded": true })).into_response()
}
Err(why) if why.starts_with("ledger:") || why.starts_with("signup store:") || why.starts_with("estate store:") => internal(vendor, "reversal", why),
Err(why) => nothing_to_do(vendor, why),
};
}
Err(e) => {
// **400, unchanged**, and a row for the same reason the 401 keeps
// one: a delivery this server threw away leaves no mark on the
// ledger, and a vendor whose payload we stopped understanding after
// an API version bump looks exactly like silence from here.
//
// Claimed UNVERIFIED even though a parser may well have checked the
// signature before failing: this arm cannot tell which happened,
// and a record that over-claimed whose word a row was would be
// worse than one that under-claims it.
record_refusal(
state,
vendor,
Refusal::NotUnderstood,
e.to_string(),
body.len(),
);
return (
StatusCode::BAD_REQUEST,
format!("callback not understood: {e}"),
)
.into_response();
}
};
// ★ A32-26: an ESTATE order's payment (`estate/<co>`, its renewals and plan changes) has
// ONE completion path, the estate driver's (the vendor asked, the money captured, `paid`);
// its webhook is a thin trigger of it — kept in this same inbox, the run driven now.
if let Some(company) = crate::estate_order::company_of_reference(&event.reference) {
if state
.ledger
.attempt(&event.reference)
.ok()
.flatten()
.is_none()
{
return estate_trigger(state, vendor, body, &event.reference, &company, slot);
}
}
let attempt = match state.ledger.attempt(&event.reference) {
Ok(Some(a)) if a.is_open() => a,
Ok(_) => return nothing_to_do(vendor, format!("no open attempt for {}", event.reference)),
Err(e) => return internal(vendor, "ledger", e),
};
if attempt.handle != event.handle {
// **The loudest of the three, and the only one that VERIFIED.** The
// vendor's signature stood behind this delivery, and it says a charge
// exists against a handle this ledger never minted for that reference.
// 409 unchanged; the row is what puts it in front of somebody, and it
// is the one refusal whose words are the vendor's rather than a
// stranger's — [`Refusal::verified`] carries that as a value so the
// console does not have to read it off the prose.
let why = format!(
"{} was reserved as {}:{}, callback names {}:{}",
event.reference,
attempt.handle.vendor,
attempt.handle.id,
event.handle.vendor,
event.handle.id
);
record_refusal(
state,
vendor,
Refusal::HandleMismatch,
why.clone(),
body.len(),
);
return (StatusCode::CONFLICT, why).into_response();
}
// **Kept, then granted behind the answer** (money-webhook-504-in-growth).
// The ledger's open attempt is the money's record; this row is the
// delivery's, keyed by the event's id, so a redelivery is counted and never
// a second grant. Not kept → not answered 200: the vendor's retry is then
// the only copy of it.
let id = crate::inbox::event_id(body);
let row = match state
.callbacks
.receive(vendor, &id, &event.reference, state.now() as i64)
{
Ok(r) => r.row().clone(),
Err(e) => return internal(vendor, "callback inbox", e),
};
if let crate::inbox::InboxState::Done {
state: word,
reason,
} = &row.state
{
eprintln!(
"monetize-server: {vendor} callback {id} for {} delivered again (delivery {}): already {word} — answered from the kept row, nothing moves",
row.reference, row.deliveries
);
return Json(serde_json::json!({ "reference": row.reference, "state": word, "reason": reason, "duplicate": true })).into_response();
}
if state.resuming.holds(&event.reference) {
eprintln!(
"monetize-server: {vendor} callback {id} for {} (delivery {}): its grant is in flight — answered at once, nothing started",
row.reference, row.deliveries
);
return accepted(&event.reference, crate::services::purchase::IN_FLIGHT);
}
eprintln!("monetize-server: {vendor} callback {id} for {} (delivery {}): kept; granting behind the answer", row.reference, row.deliveries);
let (tx, rx) = std::sync::mpsc::channel();
let (st, v, i, r) = (
Arc::clone(state),
vendor.to_owned(),
id.clone(),
event.reference.clone(),
);
// A jera job on the purchase's own lane (A32-14): a grant that panics is an ALERT line and
// a FAILED row; the attempt is kept, and the poller's tick resumes it.
match crate::jobs::once(
GRANT_JOB,
&crate::jobs::of(state, &event.reference),
move || {
let _slot = slot;
crate::inbox::grant(&st, &v, &i, &r, Some(tx));
Ok(())
},
) {
Ok(true) => {}
Ok(false) => return accepted(&event.reference, crate::services::purchase::IN_FLIGHT),
Err(e) => {
// Kept, and the open attempt is resumed by the poller's tick.
eprintln!("monetize-server: {vendor} callback {id}: not granted now ({e}); the poller's tick will");
return accepted(&event.reference, "kept; the poller's tick finishes it");
}
}
match rx.recv_timeout(crate::inbox::ANSWER_WITHIN) {
Ok(crate::inbox::Granted::Verdict(v))
if v.state == monetize_proto::PurchaseState::Pending =>
{
accepted(&v.reference, &v.reason)
}
Ok(crate::inbox::Granted::Verdict(v)) => {
skidbladnir::functional_status(
"monetize-server/callback",
vendor,
true,
&format!("{} -> {:?} {}", v.reference, v.state, v.reason),
);
Json(serde_json::json!({ "reference": v.reference, "state": format!("{:?}", v.state).to_uppercase(), "reason": v.reason }))
.into_response()
}
// Closed between the two reads — the poller and a webhook race by design.
Ok(crate::inbox::Granted::Closed) => {
nothing_to_do(vendor, format!("no open attempt for {}", event.reference))
}
// The vendor, the cloud or the product did not answer: kept, and the
// grant retries on its own — the vendor need not.
Ok(crate::inbox::Granted::Failed(tonic::Code::Unavailable, why)) => {
accepted(&event.reference, &format!("retrying: {why}"))
}
Ok(crate::inbox::Granted::Failed(_, why)) => internal(vendor, "grant", why),
Err(_) => {
eprintln!(
"monetize-server: {vendor} callback {id} for {}: answered within {} ms; the grant continues behind the answer",
event.reference,
crate::inbox::ANSWER_WITHIN.as_millis()
);
accepted(
&event.reference,
"kept; the grant continues behind this answer",
)
}
}
}
/// ★ **A32-26: an estate's payment webhook, a thin trigger of its one completion path.** The
/// delivery is kept in the callback inbox (its event id: a redelivery is answered from the kept
/// row), then the estate's drive is queued at once — the same jera job the tick queues, whose
/// driver asks the vendor, captures and records `paid`. An estate this box does not run is
/// another system's: answered 200, ignored.
fn estate_trigger(
state: &Arc<AppState>,
vendor: &str,
body: &[u8],
reference: &str,
company: &str,
slot: Slot,
) -> Response {
if state.estates.get(company).is_none() {
return nothing_to_do(vendor, format!("no estate order for {reference} here"));
}
let id = crate::inbox::event_id(body);
let row = match state
.callbacks
.receive(vendor, &id, reference, state.now() as i64)
{
Ok(r) => r.row().clone(),
Err(e) => return internal(vendor, "callback inbox", e),
};
if let crate::inbox::InboxState::Done {
state: word,
reason,
} = &row.state
{
eprintln!("monetize-server: {vendor} callback {id} for {reference} delivered again (delivery {}): already {word} — answered from the kept row, nothing moves", row.deliveries);
return Json(serde_json::json!({ "reference": reference, "state": word, "reason": reason, "duplicate": true })).into_response();
}
// The run's drive, queued now on its own jera lane (the tick's very job): the driver asks
// the vendor, captures and records `paid`. The tick queues it as well when no webhook comes.
let (word, reason) = match crate::estate_order::drive_now(state, company) {
Ok(()) => (
"QUEUED",
"the estate's order is driven on its one completion path".to_string(),
),
Err(e) => ("KEPT", format!("the estate tick drives it ({e})")),
};
let now = state.now() as i64;
if let Err(e) = state.callbacks.update(vendor, &id, |row| {
row.attempts += 1;
row.state = crate::inbox::InboxState::Done {
state: word.into(),
reason: reason.clone(),
};
row.updated_at_unix_ms = now;
}) {
eprintln!("monetize-server: {vendor} callback {id}: the kept row could not be closed ({e}); a redelivery drives the estate again, which is harmless");
}
drop(slot);
eprintln!("monetize-server: {vendor} callback {id} for {reference}: kept; {reason}");
accepted(reference, &reason)
}
/// **200, kept, not finished yet**: `{reference, state: PENDING, reason, accepted}`.
fn accepted(reference: &str, reason: &str) -> Response {
Json(serde_json::json!({ "reference": reference, "state": "PENDING", "reason": reason, "accepted": true })).into_response()
}
fn signup_word(state_of: &crate::signup::SignupState) -> (&'static str, String) {
match state_of {
crate::signup::SignupState::Pending => ("PENDING", String::new()),
crate::signup::SignupState::Created { .. } => ("CREATED", String::new()),
crate::signup::SignupState::Refused { reason, .. } => ("REFUSED", reason.clone()),
}
}
/// The signup arm's answer: `{signup, vendor, session, state, reason}`.
fn signup_answer(
signup: &monetize_pay::PaidSignup,
state_of: &crate::signup::SignupState,
) -> Response {
let (word, reason) = signup_word(state_of);
Json(serde_json::json!({
"signup": true,
"vendor": signup.vendor,
"session": signup.session,
"state": word,
"reason": reason,
}))
.into_response()
}
/// The jera job kind of a callback's signup settle, behind the answer (A32-14).
pub const SIGNUP_SETTLE_JOB: &str = "monetize_callback_signup";
/// The jera job kind of a callback's grant, behind the answer (A32-14).
pub const GRANT_JOB: &str = "monetize_callback_grant";
#[cfg(test)]
mod tests;