Skip to main content

max / makenotwork

Put the payout summary card on the residual seam Twelve of seventeen. The first residual holding a figure strip, which is the other half of what staging a structured slot buys: a `Figure` has no sentinel, so the strip's markup is compiled and each number is a hole. That only works if the description names the figures, so it does. `stats figures(balance)` was a supplier answering `Vec<Figure>` -- one value the staged shape cannot stand in for -- and is now the two figures written out, each built from a supplier answering a string. R9 still applies: both are asked even when the strip is not drawn, because a guard decides whether a member is placed and not whether its holes are evaluated. The Stripe read moves into `balance`, beside a `reading` the mount calls, which is the split every screen on the seam makes: one read stating the card and filling its holes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 02:29 UTC
Commit: 293be7a135f31e95ee39c2a84f61649b16662185
Parent: 1fa59d0
4 files changed, +153 insertions, -54 deletions
@@ -575,7 +575,20 @@
575 575 ),
576 576 (
577 577 payout_summary::PATH,
578 - mount(app, payout_summary::screen, &[], payout_summary::renderer),
578 + served_panel_mount(
579 + app,
580 + payout_summary::REGION,
581 + &[],
582 + payout_summary::renderer,
583 + |viewer, _| {
584 + let (balance, payouts_enabled) = payout_summary::reading(viewer)?;
585 + Ok(payout_summary::card_serve(
586 + &residuals::PAYOUT_SUMMARY,
587 + balance.as_ref(),
588 + payouts_enabled,
589 + ))
590 + },
591 + ),
579 592 ),
580 593 // A nest that answers no address of its own: the Members panel is read
581 594 // through its Askama route, which keeps a conditional GET, and only its
@@ -76,13 +76,13 @@
76 76
77 77 /// The balance, as the screen needs it: two strings the app has already
78 78 /// formatted in the creator's settlement currency.
79 - pub struct BalanceView {
79 + pub(crate) struct BalanceView {
80 80 available: String,
81 81 pending: String,
82 82 }
83 83
84 - /// The card.
85 - pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
84 + /// The one read this card makes, for the mount that serves it from a residual.
85 + pub(crate) fn reading(viewer: &Viewer) -> Result<(Option<BalanceView>, bool), RouteError> {
86 86 let user = viewer
87 87 .block_on(db::users::get_user_by_id(
88 88 &viewer.app.db,
@@ -91,46 +91,57 @@
91 91 .map_err(|_| RouteError::internal("your account could not be read"))?
92 92 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
93 93
94 - // Both `None` cases are the same screen: no Stripe account configured and a
95 - // balance call that failed are indistinguishable to a reader, and the
96 - // template already drew one card for both.
97 - let balance = match (&viewer.app.payments, &user.stripe_account_id) {
98 - (Some(stripe), Some(account_id)) => {
99 - match viewer.block_on(stripe.get_balance(account_id, user.settlement_currency)) {
100 - Ok(balance) => Some(BalanceView {
101 - available: crate::formatting::format_revenue(
102 - balance.available_cents,
103 - user.settlement_currency,
104 - ),
105 - pending: crate::formatting::format_revenue(
106 - balance.pending_cents,
107 - user.settlement_currency,
108 - ),
109 - }),
110 - Err(e) => {
111 - // Warned rather than surfaced, as the Askama handler did:
112 - // a creator cannot act on a Stripe API error and the card
113 - // says so in words they can.
114 - tracing::warn!(error = ?e, "failed to fetch Stripe balance for payout summary");
115 - None
116 - }
117 - }
118 - }
119 - _ => None,
120 - };
94 + Ok((balance(viewer, &user), user.stripe_payouts_enabled))
95 + }
96 +
97 + /// The card.
98 + pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
99 + let (balance, payouts_enabled) = reading(viewer)?;
121 100
122 101 Ok(Response::fragment(
123 102 REGION,
124 - card(balance.as_ref(), user.stripe_payouts_enabled),
103 + card(balance.as_ref(), payouts_enabled),
125 104 ))
126 105 }
127 106
107 + /// The reader's Stripe balance, or nothing.
108 + ///
109 + /// Both `None` cases are the same screen: no Stripe account configured and a
110 + /// balance call that failed are indistinguishable to a reader, and the template
111 + /// already drew one card for both.
112 + fn balance(viewer: &Viewer, user: &crate::db::DbUser) -> Option<BalanceView> {
113 + let (Some(stripe), Some(account_id)) = (&viewer.app.payments, &user.stripe_account_id) else {
114 + return None;
115 + };
116 +
117 + match viewer.block_on(stripe.get_balance(account_id, user.settlement_currency)) {
118 + Ok(balance) => Some(BalanceView {
119 + available: crate::formatting::format_revenue(
120 + balance.available_cents,
121 + user.settlement_currency,
122 + ),
123 + pending: crate::formatting::format_revenue(
124 + balance.pending_cents,
125 + user.settlement_currency,
126 + ),
127 + }),
128 + Err(error) => {
129 + // Warned rather than surfaced, as the Askama handler did: a creator
130 + // cannot act on a Stripe API error and the card says so in words
131 + // they can.
132 + tracing::warn!(?error, "failed to fetch Stripe balance for payout summary");
133 + None
134 + }
135 + }
136 + }
137 +
128 138 declare! {
129 139 /// Everything inside the card.
130 140 ///
131 141 /// `subsection`, so the heading stays an `h3`: this card sits inside the
132 142 /// Payments tab's own sections and the template wrote `<h3 class="card-h">`.
133 - shape card(balance: Option<&BalanceView>, payouts_enabled: bool) -> Node;
143 + #[staged]
144 + pub(crate) shape card(balance: Option<&BalanceView>, payouts_enabled: bool) -> Node;
134 145
135 146 region REGION as Pane {
136 147 subsection "Payout Summary";
@@ -138,36 +149,46 @@
138 149 failed "Unable to load balance. Check your Stripe dashboard for details."
139 150 unless balance.is_some();
140 151 text "Payouts are processed automatically via Stripe Connect." when balance.is_some();
141 - stats figures(balance) when balance.is_some();
152 + stats [
153 + Figure::new(available(balance), "Available Balance"),
154 + Figure::new(pending(balance), "Pending"),
155 + ] when balance.is_some();
142 156 banner layout::Tone::Warning
143 157 "Payouts are not yet enabled. Complete your Stripe account setup above."
144 158 when balance.is_some() and not payouts_enabled;
145 159 }
146 160 }
147 161
148 - /// The two figures, or none when the balance did not load.
162 + /// What the available balance says, or nothing.
149 163 ///
150 - /// A `-> Vec<Figure>` payload supplier, which is the remedy the deferred table
151 - /// names for a mapped payload. R9 is why it answers for the absent case rather
152 - /// than being skipped: a guard decides whether a member is placed, not whether
153 - /// its holes are evaluated, so this is asked even when the strip is not drawn.
154 - fn figures(balance: Option<&BalanceView>) -> Vec<Figure> {
164 + /// Two suppliers answering strings rather than one answering `Vec<Figure>`, and
165 + /// the seam is why. A `Figure` has no sentinel, so a staged shape cannot hand
166 + /// one to `stats` as a value; what it can do is keep the constructor and stage
167 + /// what the constructor is given, which puts the strip's markup in the residual
168 + /// as a literal and each figure's number in it as a hole. That only works if
169 + /// the description names the figures, so it does.
170 + ///
171 + /// R9 is why these answer for the absent case rather than being skipped: a
172 + /// guard decides whether a member is placed, not whether its holes are
173 + /// evaluated, so both are asked even when the strip is not drawn.
174 + fn available(balance: Option<&BalanceView>) -> String {
155 175 balance
156 - .map(|balance| {
157 - vec![
158 - Figure::new(balance.available.clone(), "Available Balance"),
159 - Figure::new(balance.pending.clone(), "Pending"),
160 - ]
161 - })
176 + .map(|held| held.available.clone())
162 177 .unwrap_or_default()
163 178 }
164 179
180 + /// What the pending balance says, or nothing. See [`available`].
181 + fn pending(balance: Option<&BalanceView>) -> String {
182 + balance.map(|held| held.pending.clone()).unwrap_or_default()
183 + }
184 +
165 185 declare! {
166 186 /// The way out to Stripe's own dashboard.
167 187 ///
168 188 /// `external` rather than a route: this address is not one the server
169 189 /// answers, and saying so is what lets a renderer that is not a browser decide
170 190 /// what "leaving" means for it.
191 + #[constant]
171 192 shape stripe_link() -> Node;
172 193
173 194 act "View in Stripe" to external STRIPE_PAYOUTS;
@@ -178,18 +199,25 @@
178 199 Webview::new().with_shell(viewer.shell())
179 200 }
180 201
202 + /// A balance as the tests draw it.
203 + ///
204 + /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
205 + /// one too, and `BalanceView` is this module's own type. Test-only.
206 + #[cfg(test)]
207 + pub(crate) fn sample() -> BalanceView {
208 + BalanceView {
209 + available: "$1,240.00".into(),
210 + pending: "$310.50".into(),
211 + }
212 + }
213 +
181 214 #[cfg(test)]
182 215 mod tests {
183 216 use super::*;
184 217 use quasi_axum::Serves;
185 218 use quasi_router::Node;
186 219
187 - fn balance() -> BalanceView {
188 - BalanceView {
189 - available: "$1,240.00".into(),
190 - pending: "$310.50".into(),
191 - }
192 - }
220 + use super::sample as balance;
193 221
194 222 fn render(node: &Node) -> String {
195 223 Webview::new().fragment(node)
@@ -95,6 +95,9 @@
95 95 ("LIBRARY_CONTACTS", |plan| {
96 96 super::library_contacts::pane_staged(plan)
97 97 }),
98 + ("PAYOUT_SUMMARY", |plan| {
99 + super::payout_summary::card_staged(plan)
100 + }),
98 101 ]
99 102 }
100 103
@@ -671,6 +674,31 @@
671 674 }
672 675 }
673 676
677 + /// The payout card fills to what the renderer builds.
678 + ///
679 + /// The first residual holding a **figure strip**, which is the other half
680 + /// of what staging a structured slot buys: a `Figure` has no sentinel, so
681 + /// the strip's markup is compiled and each number is a hole. Filled with a
682 + /// balance and without one, and with payouts enabled and not, because the
683 + /// card's three guards read those two facts between them.
684 + #[test]
685 + fn the_payout_residual_fills_to_what_the_renderer_builds() {
686 + use crate::quasi::payout_summary::{card, card_serve, sample};
687 + use quasi_axum::Serves as _;
688 +
689 + let balance = sample();
690 + for held in [Some(&balance), None] {
691 + for payouts_enabled in [true, false] {
692 + assert_eq!(
693 + card_serve(&PAYOUT_SUMMARY, held, payouts_enabled),
694 + Webview::new().fragment(&card(held, payouts_enabled)),
695 + "balance={} payouts_enabled={payouts_enabled}",
696 + held.is_some(),
697 + );
698 + }
699 + }
700 + }
701 +
674 702 /// Every screen the roster names is checked above.
675 703 ///
676 704 /// The gap this closes is the one a table opens: a screen added to
@@ -686,8 +714,8 @@
686 714 fn every_screen_on_the_seam_is_checked() {
687 715 /// Screens with their own filling test: `/use-cases`, `/fan-plus`,
688 716 /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the
689 - /// two forum panes, and the two contact panes.
690 - const HOLED: usize = 9;
717 + /// two forum panes, the two contact panes, and the payout card.
718 + const HOLED: usize = 10;
691 719
692 720 assert_eq!(
693 721 roster().len(),
@@ -389,4 +389,34 @@
389 389 ])),
390 390 ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"library-contacts-anchored\" data-menu=\"anchored\" hidden></div></div>")),
391 391 ]);
392 +
393 + pub static PAYOUT_SUMMARY: ::quasi_router::stage::Residual =
394 + ::quasi_router::stage::Residual::compiled(&[
395 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"payout-summary-section\" class=\"region pane\"><h3 class=\"heading\">Payout Summary</h3><a class=\"button\" data-act href=\"https://dashboard.stripe.com/payouts\" target=\"_blank\" rel=\"noopener noreferrer\">View in Stripe</a>")),
396 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
397 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"failed\" data-tone=\"danger\" role=\"alert\"><p class=\"placeholder-text\">Unable to load balance. Check your Stripe dashboard for details.</p></div>")),
398 +
399 + ])),
400 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
401 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Payouts are processed automatically via Stripe Connect.</p>")),
402 +
403 + ])),
404 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
405 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"figures\"><div class=\"figure\" aria-label=\"Available Balance: ")),
406 + ::quasi_router::stage::Op::Hole { scope: 0, id: 0 },
407 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><span class=\"figure-value\" aria-hidden=\"true\">")),
408 + ::quasi_router::stage::Op::Hole { scope: 0, id: 0 },
409 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span><span class=\"figure-caption\" aria-hidden=\"true\">Available Balance</span></div><div class=\"figure\" aria-label=\"Pending: ")),
410 + ::quasi_router::stage::Op::Hole { scope: 0, id: 1 },
411 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><span class=\"figure-value\" aria-hidden=\"true\">")),
412 + ::quasi_router::stage::Op::Hole { scope: 0, id: 1 },
413 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span><span class=\"figure-caption\" aria-hidden=\"true\">Pending</span></div></div>")),
414 +
415 + ])),
416 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
417 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"banner\" data-tone=\"warning\" role=\"alert\">Payouts are not yet enabled. Complete your Stripe account setup above.</div>")),
418 +
419 + ])),
420 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"payout-summary-section-anchored\" data-menu=\"anchored\" hidden></div></div>")),
421 + ]);
392 422