Skip to main content

max / makenotwork

Kill the mutation survivors the pure-logic pass could not reach The three files 3da1107a left behind, and they were left behind for a reason: each needs a seam rather than an assertion. helpers/billing: `stripe_fee_estimate_applies` had no test of any kind, and it is the gate on whether a hardcoded US card rate is shown to a creator settling in another currency. Its own doc calls quoting that number "worse than saying nothing"; the `-> true` mutant is exactly that failure. payments: `get_balance`'s currency filter moves into `sum_in_currency` so it has a caller a lib test can reach. A connected account holds several currencies at once and the sum comes back as a bare i64, so an inverted filter reports another currency's money as this one's and looks like a number rather than an error. fan_ops: neither suite referenced it. `run_fan_sub_fanout` already takes `&Arc<dyn PaymentProvider>`, so the seam was there and only the double was missing; `ScriptedProvider` in payments::test_provider is the minimal lib-side one, answering the four subscription-lifecycle calls and panicking on the rest. The integration harness keeps its own richer MockPaymentProvider and the two are deliberately not merged. Every test here was checked by making the mutation and confirming the intended test failed. What is still not observed, stated rather than implied: the `failed > 0` warn threshold, which only guards a log line. 2224 lib tests to 2238, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 22:33 UTC
Signed with PGP, not checked
Commit: 50ad75fb703f634fd62efd59f76be345872af5ed
Parent: 46450de
3 files changed, +650 insertions, -12 deletions
@@ -84,6 +84,29 @@
84 84 }
85 85 }
86 86
87 + // ── stripe_fee_estimate_applies ──
88 +
89 + #[test]
90 + fn fee_estimate_applies_to_usd() {
91 + assert!(stripe_fee_estimate_applies(
92 + crate::currency::SettlementCurrency::Usd
93 + ));
94 + }
95 +
96 + #[test]
97 + fn fee_estimate_applies_to_nothing_but_usd() {
98 + for currency in crate::currency::SettlementCurrency::ALL {
99 + if currency == crate::currency::SettlementCurrency::Usd {
100 + continue;
101 + }
102 + assert!(
103 + !stripe_fee_estimate_applies(currency),
104 + "{currency} is not the pricing estimate_stripe_fee models, so no \
105 + number may be shown for it"
106 + );
107 + }
108 + }
109 +
87 110 // ── stripe_timestamp ──
88 111
89 112 #[test]
@@ -176,4 +176,206 @@
176 176 let unique: std::collections::HashSet<_> = labels.iter().collect();
177 177 assert_eq!(unique.len(), labels.len(), "labels collide: {labels:?}");
178 178 }
179 +
180 + // ── run_fan_sub_fanout ──
181 + //
182 + // The returned count is the whole contract: `mnw-admin` prints it, and the
183 + // background twin turns the same count into a WAM ticket. A fan-out that
184 + // reports zero failures when ops failed leaves fans charging against a
185 + // creator state that no longer matches, with nothing to reconcile from.
186 +
187 + use crate::payments::test_provider::ScriptedProvider;
188 +
189 + /// Two handles on one provider: the fan-out takes the trait object, and the
190 + /// assertions read the recorded calls off the concrete type.
191 + fn provider(p: ScriptedProvider) -> (Arc<ScriptedProvider>, Arc<dyn PaymentProvider>) {
192 + let scripted = Arc::new(p);
193 + let stripe: Arc<dyn PaymentProvider> = scripted.clone();
194 + (scripted, stripe)
195 + }
196 +
197 + fn account() -> StripeAccountId {
198 + StripeAccountId::new("acct_test1234567890").expect("a well-formed connected account id")
199 + }
200 +
201 + fn subs(ids: &[&str]) -> Vec<String> {
202 + ids.iter().map(|s| (*s).to_owned()).collect()
203 + }
204 +
205 + #[tokio::test]
206 + async fn applies_the_op_to_every_subscription_in_order() {
207 + let (scripted, stripe) = provider(ScriptedProvider::healthy());
208 + let failed = run_fan_sub_fanout(
209 + &stripe,
210 + &account(),
211 + &subs(&["sub_a", "sub_b", "sub_c"]),
212 + FanSubOp::Pause,
213 + )
214 + .await;
215 +
216 + assert_eq!(failed, 0);
217 + assert_eq!(
218 + scripted.calls(),
219 + vec![
220 + ("pause", "sub_a".to_string()),
221 + ("pause", "sub_b".to_string()),
222 + ("pause", "sub_c".to_string()),
223 + ],
224 + "every subscription gets the op, and none is skipped"
225 + );
226 + }
227 +
228 + #[tokio::test]
229 + async fn counts_the_failures_rather_than_reporting_success() {
230 + let (_scripted, stripe) = provider(ScriptedProvider::failing(["sub_b", "sub_d"]));
231 + let failed = run_fan_sub_fanout(
232 + &stripe,
233 + &account(),
234 + &subs(&["sub_a", "sub_b", "sub_c", "sub_d"]),
235 + FanSubOp::Cancel,
236 + )
237 + .await;
238 +
239 + assert_eq!(
240 + failed, 2,
241 + "two ops failed; a count of 0 or 1 is a dropped cancel nobody reconciles"
242 + );
243 + }
244 +
245 + #[tokio::test]
246 + async fn a_failure_does_not_abort_the_rest() {
247 + let (scripted, stripe) = provider(ScriptedProvider::failing(["sub_a"]));
248 + let failed = run_fan_sub_fanout(
249 + &stripe,
250 + &account(),
251 + &subs(&["sub_a", "sub_b", "sub_c"]),
252 + FanSubOp::Resume,
253 + )
254 + .await;
255 +
256 + assert_eq!(failed, 1);
257 + assert_eq!(
258 + scripted.calls().len(),
259 + 3,
260 + "the first subscription failing must not strand the two behind it"
261 + );
262 + }
263 +
264 + #[tokio::test]
265 + async fn every_subscription_failing_is_counted_as_every_subscription() {
266 + let (_scripted, stripe) = provider(ScriptedProvider::failing(["sub_a", "sub_b"]));
267 + let failed = run_fan_sub_fanout(
268 + &stripe,
269 + &account(),
270 + &subs(&["sub_a", "sub_b"]),
271 + FanSubOp::Pause,
272 + )
273 + .await;
274 +
275 + assert_eq!(failed, 2, "a saturating or decrementing counter shows here");
276 + }
277 +
278 + #[tokio::test]
279 + async fn an_empty_list_is_no_calls_and_no_failures() {
280 + let (scripted, stripe) = provider(ScriptedProvider::healthy());
281 + let failed = run_fan_sub_fanout(&stripe, &account(), &[], FanSubOp::Cancel).await;
282 +
283 + assert_eq!(failed, 0);
284 + assert!(scripted.calls().is_empty());
285 + }
286 +
287 + #[tokio::test]
288 + async fn cancel_at_period_end_carries_the_flag_it_was_given() {
289 + for (cancel, expected) in [
290 + (true, "set_cancel_at_period_end"),
291 + (false, "clear_cancel_at_period_end"),
292 + ] {
293 + let (scripted, stripe) = provider(ScriptedProvider::healthy());
294 + run_fan_sub_fanout(
295 + &stripe,
296 + &account(),
297 + &subs(&["sub_a"]),
298 + FanSubOp::CancelAtPeriodEnd(cancel),
299 + )
300 + .await;
301 +
302 + assert_eq!(
303 + scripted.calls(),
304 + vec![(expected, "sub_a".to_string())],
305 + "CancelAtPeriodEnd({cancel}) must reach Stripe as {expected}"
306 + );
307 + }
308 + }
309 +
310 + // ── spawn_fan_sub_fanout ──
311 + //
312 + // The background twin returns before doing anything, so the only thing a
313 + // test can hold it to is that the work arrives. That is also the whole risk:
314 + // a fan-out that silently never runs looks identical to one that succeeded,
315 + // on a path whose failure is fans still being charged.
316 +
317 + /// Wait for `want` calls to land, or give up. The queue is drained by a
318 + /// separate task, so there is nothing to await on directly.
319 + async fn settle(scripted: &Arc<ScriptedProvider>, want: usize) -> Vec<(&'static str, String)> {
320 + for _ in 0..200 {
321 + let calls = scripted.calls();
322 + if calls.len() >= want {
323 + return calls;
324 + }
325 + tokio::time::sleep(std::time::Duration::from_millis(5)).await;
326 + }
327 + scripted.calls()
328 + }
329 +
330 + #[tokio::test]
331 + async fn the_queued_fan_out_actually_reaches_stripe() {
332 + let (scripted, stripe) = provider(ScriptedProvider::healthy());
333 + let bg = crate::background::spawn_pool_detached();
334 +
335 + spawn_fan_sub_fanout(
336 + &bg,
337 + stripe,
338 + account(),
339 + subs(&["sub_a", "sub_b"]),
340 + FanSubOp::Cancel,
341 + None,
342 + );
343 +
344 + assert_eq!(
345 + settle(&scripted, 2).await,
346 + vec![
347 + ("cancel", "sub_a".to_string()),
348 + ("cancel", "sub_b".to_string()),
349 + ],
350 + "the ops must arrive; a fan-out that returns and does nothing is invisible"
351 + );
352 + }
353 +
354 + #[tokio::test]
355 + async fn a_failing_op_does_not_strand_the_queued_remainder() {
356 + let (scripted, stripe) = provider(ScriptedProvider::failing(["sub_a"]));
357 + let bg = crate::background::spawn_pool_detached();
358 +
359 + spawn_fan_sub_fanout(
360 + &bg,
361 + stripe,
362 + account(),
363 + subs(&["sub_a", "sub_b", "sub_c"]),
364 + FanSubOp::Pause,
365 + None,
366 + );
367 +
368 + assert_eq!(settle(&scripted, 3).await.len(), 3);
369 + }
370 +
371 + #[tokio::test]
372 + async fn an_empty_queued_fan_out_asks_stripe_for_nothing() {
373 + let (scripted, stripe) = provider(ScriptedProvider::healthy());
374 + let bg = crate::background::spawn_pool_detached();
375 +
376 + spawn_fan_sub_fanout(&bg, stripe, account(), Vec::new(), FanSubOp::Resume, None);
377 +
378 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
379 + assert!(scripted.calls().is_empty());
380 + }
179 381 }
@@ -103,6 +103,24 @@
103 103 pub pending_cents: i64,
104 104 }
105 105
106 + /// Sum the entries whose currency is `want`, ignoring the rest.
107 + ///
108 + /// Split out of `get_balance` so the filter has a test. A connected account's
109 + /// Stripe balance carries one entry per currency it holds, and the sum comes
110 + /// back as a bare `i64` with no currency attached to contradict it, so summing
111 + /// the wrong entries reports another currency's money as this one's and looks
112 + /// like a plausible number rather than an error.
113 + fn sum_in_currency<'a, C>(entries: impl IntoIterator<Item = (&'a C, i64)>, want: &C) -> i64
114 + where
115 + C: PartialEq + 'a,
116 + {
117 + entries
118 + .into_iter()
119 + .filter(|(currency, _)| *currency == want)
120 + .map(|(_, amount)| amount)
121 + .sum()
122 + }
123 +
106 124 /// Payment provider abstraction for checkout, connect, and webhook operations.
107 125 #[async_trait::async_trait]
108 126 pub trait PaymentProvider: Send + Sync {
@@ -296,6 +314,349 @@
296 314 ) -> crate::error::Result<String>;
297 315 }
298 316
317 + #[cfg(test)]
318 + pub(crate) mod test_provider {
319 + //! A crate-visible [`PaymentProvider`] double for lib tests.
320 + //!
321 + //! The integration suite already has `MockPaymentProvider`
322 + //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
323 + //! sessions and signs webhooks. It lives in a separate test binary, so a
324 + //! `--lib` test cannot reach it, and this is deliberately the smaller
325 + //! thing. It answers the subscription-lifecycle calls and panics on
326 + //! everything else, which is enough to test the code that fans those out
327 + //! without a database, a router or a Stripe key.
328 + //!
329 + //! Implement a method here when a lib test needs it. Growing this toward
330 + //! the harness's copy would give the crate two mocks to keep in agreement,
331 + //! which is the imitation-oracle failure wiki `testing-posture` describes.
332 +
333 + use std::collections::HashSet;
334 + use std::sync::Mutex;
335 +
336 + use super::*;
337 +
338 + /// Records every subscription op it is asked for, and fails the ones whose
339 + /// subscription id was listed as failing.
340 + #[derive(Default)]
341 + pub(crate) struct ScriptedProvider {
342 + failing: HashSet<String>,
343 + calls: Mutex<Vec<(&'static str, String)>>,
344 + }
345 +
346 + impl ScriptedProvider {
347 + /// Every call succeeds.
348 + pub(crate) fn healthy() -> Self {
349 + Self::default()
350 + }
351 +
352 + /// Every call succeeds except those naming one of `sub_ids`.
353 + pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
354 + Self {
355 + failing: sub_ids.into_iter().map(str::to_owned).collect(),
356 + calls: Mutex::new(Vec::new()),
357 + }
358 + }
359 +
360 + /// `(op, subscription id)` in the order they were applied.
361 + pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
362 + self.calls
363 + .lock()
364 + .expect("no test panics while holding this")
365 + .clone()
366 + }
367 +
368 + fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
369 + self.calls
370 + .lock()
371 + .expect("no test panics while holding this")
372 + .push((op, sub_id.to_string()));
373 + if self.failing.contains(sub_id) {
374 + return Err(crate::error::AppError::BadRequest(format!(
375 + "scripted failure for {sub_id}"
376 + )));
377 + }
378 + Ok(())
379 + }
380 + }
381 +
382 + /// The methods no lib test drives yet. A call is a bug in the test, not a
383 + /// condition to handle, so it panics rather than returning an error the
384 + /// code under test would quietly count as a Stripe failure.
385 + macro_rules! unused {
386 + ($($name:ident),+ $(,)?) => {
387 + $(
388 + #[allow(unused_variables)]
389 + fn $name(&self) -> ! {
390 + unimplemented!(
391 + "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
392 + stringify!($name)
393 + )
394 + }
395 + )+
396 + };
397 + }
398 +
399 + impl ScriptedProvider {
400 + unused!(
401 + create_checkout_session,
402 + create_guest_checkout_session,
403 + create_subscription_checkout_session,
404 + create_tip_checkout_session,
405 + create_fan_plus_checkout_session,
406 + create_creator_tier_checkout_session,
407 + create_synckit_app_sub_checkout_session,
408 + create_cart_checkout_session,
409 + create_connect_account,
410 + create_account_link,
411 + fetch_account,
412 + create_subscription_product_and_price,
413 + get_balance,
414 + cancel_platform_subscription,
415 + set_platform_cancel_at_period_end,
416 + create_billing_portal_session,
417 + create_refund_for_transaction,
418 + create_platform_credit_transfer,
419 + create_platform_credit_reversal,
420 + verify_webhook,
421 + verify_webhook_v2,
422 + create_synckit_customer,
423 + create_synckit_subscription,
424 + update_synckit_subscription_price,
425 + update_synckit_app_sub_price,
426 + cancel_synckit_subscription,
427 + create_synckit_billing_portal,
428 + );
429 + }
430 +
431 + #[async_trait::async_trait]
432 + impl PaymentProvider for ScriptedProvider {
433 + // ── what the fan-out drives ──
434 +
435 + async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
436 + self.record("pause", sub)
437 + }
438 +
439 + async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
440 + self.record("resume", sub)
441 + }
442 +
443 + async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
444 + self.record("cancel", sub)
445 + }
446 +
447 + async fn set_cancel_at_period_end(
448 + &self,
449 + sub: &str,
450 + _account: &str,
451 + cancel: bool,
452 + ) -> crate::error::Result<()> {
453 + self.record(
454 + if cancel {
455 + "set_cancel_at_period_end"
456 + } else {
457 + "clear_cancel_at_period_end"
458 + },
459 + sub,
460 + )
461 + }
462 +
463 + // ── everything else ──
464 +
465 + async fn create_checkout_session(
466 + &self,
467 + _params: &CheckoutParams<'_>,
468 + ) -> crate::error::Result<CheckoutResult> {
469 + ScriptedProvider::create_checkout_session(self)
470 + }
471 + async fn create_guest_checkout_session(
472 + &self,
473 + _params: &GuestCheckoutParams<'_>,
474 + ) -> crate::error::Result<CheckoutResult> {
475 + ScriptedProvider::create_guest_checkout_session(self)
476 + }
477 + async fn create_subscription_checkout_session(
478 + &self,
479 + _params: &SubscriptionCheckoutParams<'_>,
480 + ) -> crate::error::Result<CheckoutResult> {
481 + ScriptedProvider::create_subscription_checkout_session(self)
482 + }
483 + async fn create_tip_checkout_session(
484 + &self,
485 + _params: &TipCheckoutParams<'_>,
486 + ) -> crate::error::Result<CheckoutResult> {
487 + ScriptedProvider::create_tip_checkout_session(self)
488 + }
489 + async fn create_fan_plus_checkout_session(
490 + &self,
491 + _price_id: &str,
492 + _user_id: crate::db::UserId,
493 + _success_url: &str,
494 + _cancel_url: &str,
495 + ) -> crate::error::Result<CheckoutResult> {
496 + ScriptedProvider::create_fan_plus_checkout_session(self)
497 + }
498 + async fn create_creator_tier_checkout_session(
499 + &self,
500 + _price_id: &str,
501 + _user_id: crate::db::UserId,
502 + _tier: &str,
503 + _success_url: &str,
504 + _cancel_url: &str,
505 + _trial_days: Option<i32>,
506 + ) -> crate::error::Result<CheckoutResult> {
507 + ScriptedProvider::create_creator_tier_checkout_session(self)
508 + }
509 + async fn create_synckit_app_sub_checkout_session(
510 + &self,
511 + _params: &SynckitAppSubCheckoutParams<'_>,
512 + ) -> crate::error::Result<CheckoutResult> {
513 + ScriptedProvider::create_synckit_app_sub_checkout_session(self)
514 + }
515 + async fn create_cart_checkout_session(
516 + &self,
517 + _params: &CartCheckoutParams<'_>,
518 + ) -> crate::error::Result<CheckoutResult> {
519 + ScriptedProvider::create_cart_checkout_session(self)
520 + }
521 + async fn create_connect_account(
522 + &self,
523 + _email: &str,
524 + ) -> crate::error::Result<crate::db::StripeAccountId> {
525 + ScriptedProvider::create_connect_account(self)
526 + }
527 + async fn create_account_link(
528 + &self,
529 + _account_id: &str,
530 + _return_url: &str,
531 + _refresh_url: &str,
532 + ) -> crate::error::Result<String> {
533 + ScriptedProvider::create_account_link(self)
534 + }
535 + async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
536 + ScriptedProvider::fetch_account(self)
537 + }
538 + async fn create_subscription_product_and_price(
539 + &self,
540 + _connected_account_id: &str,
541 + _tier_name: &str,
542 + _tier_description: Option<&str>,
543 + _price_cents: i64,
544 + _currency: crate::currency::SettlementCurrency,
545 + ) -> crate::error::Result<(String, String)> {
546 + ScriptedProvider::create_subscription_product_and_price(self)
547 + }
548 + async fn get_balance(
549 + &self,
550 + _account_id: &str,
551 + _currency: crate::currency::SettlementCurrency,
552 + ) -> crate::error::Result<BalanceSummary> {
553 + ScriptedProvider::get_balance(self)
554 + }
555 + async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
556 + ScriptedProvider::cancel_platform_subscription(self)
557 + }
558 + async fn set_platform_cancel_at_period_end(
559 + &self,
560 + _sub: &str,
561 + _cancel: bool,
562 + ) -> crate::error::Result<()> {
563 + ScriptedProvider::set_platform_cancel_at_period_end(self)
564 + }
565 + async fn create_billing_portal_session(
566 + &self,
567 + _customer_id: &str,
568 + _return_url: &str,
569 + ) -> crate::error::Result<String> {
570 + ScriptedProvider::create_billing_portal_session(self)
571 + }
572 + async fn create_refund_for_transaction(
573 + &self,
574 + _payment_intent_id: &str,
575 + _connected_account_id: &str,
576 + _amount_cents: i64,
577 + _transaction_id: crate::db::TransactionId,
578 + ) -> crate::error::Result<()> {
579 + ScriptedProvider::create_refund_for_transaction(self)
580 + }
581 + async fn create_platform_credit_transfer(
582 + &self,
583 + _connected_account_id: &str,
584 + _amount_cents: i64,
585 + _transaction_id: crate::db::TransactionId,
586 + _currency: crate::currency::SettlementCurrency,
587 + ) -> crate::error::Result<String> {
588 + ScriptedProvider::create_platform_credit_transfer(self)
589 + }
590 + async fn create_platform_credit_reversal(
591 + &self,
592 + _transfer_id: &str,
593 + _amount_cents: i64,
594 + _transaction_id: crate::db::TransactionId,
595 + ) -> crate::error::Result<()> {
596 + ScriptedProvider::create_platform_credit_reversal(self)
597 + }
598 + fn verify_webhook(
599 + &self,
600 + _payload: &str,
601 + _signature: &str,
602 + ) -> crate::error::Result<UntypedEvent> {
603 + ScriptedProvider::verify_webhook(self)
604 + }
605 + fn verify_webhook_v2(
606 + &self,
607 + _payload: &str,
608 + _signature: &str,
609 + ) -> crate::error::Result<serde_json::Value> {
610 + ScriptedProvider::verify_webhook_v2(self)
611 + }
612 + async fn create_synckit_customer(
613 + &self,
614 + _developer_user_id: crate::db::UserId,
615 + _app_id: crate::db::SyncAppId,
616 + _email: &str,
617 + _app_name: &str,
618 + ) -> crate::error::Result<String> {
619 + ScriptedProvider::create_synckit_customer(self)
620 + }
621 + async fn create_synckit_subscription(
622 + &self,
623 + _customer_id: &str,
624 + _app_id: crate::db::SyncAppId,
625 + _app_name: &str,
626 + _price_cents: i64,
627 + ) -> crate::error::Result<SynckitSubResult> {
628 + ScriptedProvider::create_synckit_subscription(self)
629 + }
630 + async fn update_synckit_subscription_price(
631 + &self,
632 + _subscription_id: &str,
633 + _new_price_cents: i64,
634 + _app_name: &str,
635 + ) -> crate::error::Result<()> {
636 + ScriptedProvider::update_synckit_subscription_price(self)
637 + }
638 + async fn update_synckit_app_sub_price(
639 + &self,
640 + _subscription_id: &str,
641 + _new_price_cents: i64,
642 + _interval: SyncBillingInterval,
643 + _product_name: &str,
644 + ) -> crate::error::Result<()> {
645 + ScriptedProvider::update_synckit_app_sub_price(self)
646 + }
647 + async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
648 + ScriptedProvider::cancel_synckit_subscription(self)
649 + }
650 + async fn create_synckit_billing_portal(
651 + &self,
652 + _customer_id: &str,
653 + _return_url: &str,
654 + ) -> crate::error::Result<String> {
655 + ScriptedProvider::create_synckit_billing_portal(self)
656 + }
657 + }
658 + }
659 +
299 660 #[async_trait::async_trait]
300 661 impl PaymentProvider for StripeClient {
301 662 async fn create_checkout_session(
@@ -456,18 +817,14 @@
456 817 ) -> crate::error::Result<BalanceSummary> {
457 818 let balance = self.get_connected_account_balance(account_id).await?;
458 819 let want = currency.to_stripe();
459 - let available_cents: i64 = balance
460 - .available
461 - .iter()
462 - .filter(|b| b.currency == want)
463 - .map(|b| b.amount)
464 - .sum();
465 - let pending_cents: i64 = balance
466 - .pending
467 - .iter()
468 - .filter(|b| b.currency == want)
469 - .map(|b| b.amount)
470 - .sum();
820 + let available_cents = sum_in_currency(
821 + balance.available.iter().map(|b| (&b.currency, b.amount)),
822 + &want,
823 + );
824 + let pending_cents = sum_in_currency(
825 + balance.pending.iter().map(|b| (&b.currency, b.amount)),
826 + &want,
827 + );
471 828 Ok(BalanceSummary {
472 829 available_cents,
473 830 pending_cents,
@@ -685,4 +1042,60 @@
685 1042 );
686 1043 }
687 1044 }
1045 +
1046 + // ── sum_in_currency, the filter behind `get_balance` ──
1047 +
1048 + use crate::currency::SettlementCurrency;
1049 +
1050 + /// The entries a connected account holding three currencies would carry.
1051 + fn mixed() -> Vec<(stripe_types::Currency, i64)> {
1052 + vec![
1053 + (SettlementCurrency::Usd.to_stripe(), 1_000),
1054 + (SettlementCurrency::Gbp.to_stripe(), 2_500),
1055 + (SettlementCurrency::Usd.to_stripe(), 250),
1056 + (SettlementCurrency::Eur.to_stripe(), 9_999),
1057 + ]
1058 + }
1059 +
1060 + #[test]
1061 + fn sums_every_entry_in_the_wanted_currency() {
1062 + let entries = mixed();
1063 + let total = sum_in_currency(
1064 + entries.iter().map(|(c, a)| (c, *a)),
1065 + &SettlementCurrency::Usd.to_stripe(),
1066 + );
1067 + assert_eq!(total, 1_250, "both USD entries, and only those");
1068 + }
1069 +
1070 + #[test]
1071 + fn ignores_every_entry_in_another_currency() {
1072 + let entries = mixed();
1073 + for currency in SettlementCurrency::ALL {
1074 + let total =
1075 + sum_in_currency(entries.iter().map(|(c, a)| (c, *a)), &currency.to_stripe());
1076 + let expected = match currency {
1077 + SettlementCurrency::Usd => 1_250,
1078 + SettlementCurrency::Gbp => 2_500,
1079 + SettlementCurrency::Eur => 9_999,
1080 + _ => 0,
1081 + };
1082 + assert_eq!(
1083 + total, expected,
1084 + "{currency} must see its own money and nobody else's"
1085 + );
1086 + }
1087 + }
1088 +
1089 + #[test]
1090 + fn a_currency_the_account_does_not_hold_is_zero_rather_than_everything() {
1091 + let entries = mixed();
1092 + let total = sum_in_currency(
1093 + entries.iter().map(|(c, a)| (c, *a)),
1094 + &SettlementCurrency::Nzd.to_stripe(),
1095 + );
1096 + assert_eq!(
1097 + total, 0,
1098 + "an inverted filter would report 13,749 NZD cents the account never held"
1099 + );
1100 + }
688 1101 }