| 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 |
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 |
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 |
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)
|