Skip to main content

max / makenotwork

11.3 KB · 284 lines History Blame Raw
1 //! The Stripe payout-summary card, described.
2 //!
3 //! S4: no `hx-` attributes of its own, no id or class that any file in
4 //! `static/` or `frontend/src` reaches for, and no form.
5 //!
6 //! It replaces `dashboard_tab_payout_summary`, which is deleted with its
7 //! `PayoutSummaryPartialTemplate` and `templates/partials/tabs/payout_summary.html`.
8 //! There is no switch and no fallback: since `64b33b26` a described screen owns
9 //! its address outright, so the Askama half goes in the same change.
10 //!
11 //! # It is a card the Payments tab fetches, not a tab of its own
12 //!
13 //! `templates/partials/tabs/user_payments.html:74` leaves an empty
14 //! `#payout-summary-section` and fills it on `revealed`, because the Stripe
15 //! balance is a network round trip and the tab should not wait for it. That
16 //! split is preserved exactly: this screen answers the same
17 //! address on the same trigger and replaces the same region. The lazy fetch is
18 //! the point of the split and not an artefact of Askama.
19 //!
20 //! That also makes it the one conversion in the batch where [`super::ssh_keys`]'s
21 //! "a list it can read now is a list the reader should not wait for twice"
22 //! does NOT apply. Reading the balance inline would put a Stripe round trip in
23 //! front of the Payments tab, which is the cost the split was made to avoid.
24 //!
25 //! # The three things the description says that the template did not
26 //!
27 //! 1. **The failure branch is a readiness, not a second card.** The template's
28 //! `{% else %}` hand-writes a near-copy of the whole card carrying "Unable
29 //! to load balance". That is [`layout::Readiness::Failed`], which every
30 //! renderer already draws, so it is [`Node::failed`] here and the copy goes.
31 //! 2. **The two balances are a strip.** `.payout-stats-grid` holding two
32 //! `.amount-big` divs with `.text-xs` captions is [`Node::stats`] over two
33 //! [`Figure`]s. The grid was the markup spelling of "these are tiles".
34 //! 3. **"Payouts are not yet enabled" is a banner.** The template gives it a
35 //! bespoke `.payouts-disabled-notice` class; it is a persistent message
36 //! dismissed by fixing its cause, which is [`layout::Notice::Banner`] at
37 //! [`layout::Tone::Warning`].
38 //!
39 //! # Two parity differences, both deliberate
40 //!
41 //! The parity test names each rather than blanket-ignoring them.
42 //!
43 //! - **`rel`.** The template writes `rel="noopener"`; quasi-webview emits
44 //! `rel="noopener noreferrer"` for every external destination. The stricter
45 //! one is right and it is not this screen's call to make either way.
46 //! - **The Stripe link is an anchor, not a button inside an anchor.** The
47 //! template nests `<button>` inside `<a>`, which is invalid: a button is
48 //! interactive content and an anchor may not contain it. Browsers recover,
49 //! so it renders; a described act that leaves is one anchor. Fixing it was
50 //! not this conversion's goal and is a consequence of saying the thing once.
51
52 use makeover_layout as layout;
53 use quasi_declare::declare;
54 use quasi_router::screen::Figure;
55 use quasi_router::{Request, Response, RouteError};
56 use quasi_webview::Webview;
57
58 use super::Viewer;
59 use crate::db;
60
61 /// This screen's name, for the tab strips that mark a section described.
62 pub const SCREEN: &str = "payout_summary";
63
64 /// The address this screen answers, and the one the Askama route gives up.
65 pub const PATH: &str = "/dashboard/tabs/payout-summary";
66
67 /// The region the answer replaces: the div the Payments tab leaves empty.
68 ///
69 /// `pub` so `tests/workflows/described_screens.rs` reads it rather than
70 /// transcribing it. That file says why: a constant its table can name is a
71 /// constant its table cannot disagree with.
72 pub const REGION: &str = "payout-summary-section";
73
74 /// Where a creator manages payouts, which is not a page this server serves.
75 const STRIPE_PAYOUTS: &str = "https://dashboard.stripe.com/payouts";
76
77 /// The balance, as the screen needs it: two strings the app has already
78 /// formatted in the creator's settlement currency.
79 pub(crate) struct BalanceView {
80 available: String,
81 pending: String,
82 }
83
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 let user = viewer
87 .block_on(db::users::get_user_by_id(
88 &viewer.app.db,
89 viewer.reader()?.id,
90 ))
91 .map_err(|_| RouteError::internal("your account could not be read"))?
92 .ok_or_else(|| RouteError::not_found("that account is gone"))?;
93
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)?;
100
101 Ok(Response::fragment(
102 REGION,
103 card(balance.as_ref(), payouts_enabled),
104 ))
105 }
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
138 declare! {
139 /// Everything inside the card.
140 ///
141 /// `subsection`, so the heading stays an `h3`: this card sits inside the
142 /// Payments tab's own sections and the template wrote `<h3 class="card-h">`.
143 #[staged]
144 pub(crate) shape card(balance: Option<&BalanceView>, payouts_enabled: bool) -> Node;
145
146 region REGION as Pane {
147 subsection "Payout Summary";
148 include stripe_link();
149 failed "Unable to load balance. Check your Stripe dashboard for details."
150 unless balance.is_some();
151 text "Payouts are processed automatically via Stripe Connect." when balance.is_some();
152 stats [
153 Figure::new(available(balance), "Available Balance"),
154 Figure::new(pending(balance), "Pending"),
155 ] when balance.is_some();
156 banner layout::Tone::Warning
157 "Payouts are not yet enabled. Complete your Stripe account setup above."
158 when balance.is_some() and not payouts_enabled;
159 }
160 }
161
162 /// What the available balance says, or nothing.
163 ///
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 {
175 balance
176 .map(|held| held.available.clone())
177 .unwrap_or_default()
178 }
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
185 declare! {
186 /// The way out to Stripe's own dashboard.
187 ///
188 /// `external` rather than a route: this address is not one the server
189 /// answers, and saying so is what lets a renderer that is not a browser decide
190 /// what "leaving" means for it.
191 #[constant]
192 shape stripe_link() -> Node;
193
194 act "View in Stripe" to external STRIPE_PAYOUTS;
195 }
196
197 /// The renderer this screen is drawn with.
198 pub fn renderer(viewer: &Viewer) -> Webview {
199 Webview::new().with_shell(viewer.shell())
200 }
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
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217 use quasi_axum::Serves;
218 use quasi_router::Node;
219
220 use super::sample as balance;
221
222 fn render(node: &Node) -> String {
223 Webview::new().fragment(node)
224 }
225
226 #[test]
227 fn the_region_is_the_one_the_payments_tab_leaves_empty() {
228 // The tab fills this on `revealed`. If the id or the address ever
229 // disagrees, the card loads into nothing and nothing else notices.
230 let payments = include_str!("../../templates/partials/tabs/user_payments.html");
231 assert!(payments.contains(&format!("id=\"{REGION}\"")), "{REGION}");
232 assert!(payments.contains(&format!("hx-get=\"{PATH}\"")), "{PATH}");
233 }
234
235 #[test]
236 fn the_balances_are_a_strip_of_two_figures() {
237 let html = render(&card(Some(&balance()), true));
238
239 assert!(html.contains("$1,240.00"), "{html}");
240 assert!(html.contains("Available Balance"), "{html}");
241 assert!(html.contains("$310.50"), "{html}");
242 assert!(html.contains("Pending"), "{html}");
243 }
244
245 #[test]
246 fn a_creator_without_payouts_enabled_is_told_so() {
247 let html = render(&card(Some(&balance()), false));
248 assert!(html.contains("Payouts are not yet enabled."), "{html}");
249 }
250
251 #[test]
252 fn a_creator_with_payouts_enabled_is_not() {
253 let html = render(&card(Some(&balance()), true));
254 assert!(!html.contains("Payouts are not yet enabled."), "{html}");
255 }
256
257 #[test]
258 fn a_balance_that_did_not_load_is_a_readiness_and_not_a_second_card() {
259 let html = render(&card(None, true));
260
261 assert!(html.contains("Unable to load balance."), "{html}");
262 // The figures are the thing that is missing. A strip of two blanks
263 // would be the template's failure mode, not this one's.
264 assert!(!html.contains("Available Balance"), "{html}");
265 // And the way out to Stripe survives the failure, which is the whole
266 // reason the template drew a second card rather than nothing.
267 assert!(html.contains(STRIPE_PAYOUTS), "{html}");
268 }
269
270 #[test]
271 fn the_stripe_link_leaves_and_carries_no_verb() {
272 let html = render(&stripe_link());
273
274 assert!(
275 html.contains(&format!("href=\"{STRIPE_PAYOUTS}\"")),
276 "{html}"
277 );
278 assert!(html.contains("target=\"_blank\""), "{html}");
279 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
280 // An external address is not htmx's business.
281 assert!(!html.contains("hx-get"), "{html}");
282 }
283 }
284