Skip to main content

max / makenotwork

47.7 KB · 1384 lines History Blame Raw
1 //! Stripe payment processing via Connect Direct Charges.
2 //!
3 //! Wraps the Stripe API for one-time purchases and recurring subscriptions
4 //! using the Direct Charges pattern: payments are created directly on the
5 //! creator's connected Stripe account with no `application_fee_amount`,
6 //! enforcing Makenotwork's 0% platform fee promise. The only deduction
7 //! creators see is Stripe's own processing fee (~3%).
8 //!
9 //! Key responsibilities:
10 //! - Standard connected account creation and onboarding links
11 //! - One-time purchase and subscription Checkout Session creation
12 //! - Webhook signature verification (v1 and v2 thin events)
13 //! - Event extraction helpers for checkout, subscription, invoice, account,
14 //! and refund webhook events
15 //! - Subscription product and price creation on connected accounts
16
17 mod checkout;
18 mod checkout_metadata;
19 mod connect;
20 pub mod fan_ops;
21 /// The MNW event vocabulary the webhook dispatcher reasons in.
22 pub mod mnw_event;
23 pub mod refund;
24 pub mod synckit_app_pricing;
25 pub mod synckit_billing;
26 mod webhooks;
27
28 pub use checkout::*;
29 pub use checkout_metadata::*;
30 pub use mnw_event::*;
31 pub use synckit_app_pricing::{
32 ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS, SyncBillingInterval,
33 quote_price_cents,
34 };
35 pub use synckit_billing::SynckitSubResult;
36 pub use webhooks::*;
37
38 use std::time::Duration;
39
40 use crate::config::StripeConfig;
41 use stripe::{Client, ClientBuilder};
42
43 /// Per-attempt HTTP timeout for outbound Stripe calls. The async client has no
44 /// timeout by default, so a hung connection would otherwise stall the caller
45 /// indefinitely, on a webhook handler that holds the response open and invites
46 /// Stripe's retry storm. 30s matches async-stripe's own blocking-client default;
47 /// the request strategy still retries a timed-out attempt where permitted.
48 const STRIPE_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
49
50 /// A connected-account id as a payment provider minted it.
51 ///
52 /// The `PaymentProvider` trait deals in this rather than in
53 /// [`crate::db::StripeAccountId`], which carries Stripe's `acct_` shape in its
54 /// validator: a non-Stripe provider has no legal value to return there. The
55 /// wrapper is deliberately opaque, holding whatever the provider handed back
56 /// with no format claim of its own. Vendor validation happens where the value
57 /// meets a vendor-shaped column, at the call site.
58 #[derive(Clone, Debug, PartialEq, Eq)]
59 pub struct ProviderAccountId(String);
60
61 impl ProviderAccountId {
62 /// Wrap what a provider returned. No validation: the provider minted it.
63 pub fn from_provider(id: String) -> Self {
64 Self(id)
65 }
66
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70
71 /// Consume the wrapper, returning the inner `String`.
72 pub fn into_inner(self) -> String {
73 self.0
74 }
75 }
76
77 impl std::fmt::Display for ProviderAccountId {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 self.0.fmt(f)
80 }
81 }
82
83 /// Stripe client wrapper for payment operations
84 #[derive(Clone)]
85 pub struct StripeClient {
86 pub(crate) client: Client,
87 pub(crate) config: StripeConfig,
88 }
89
90 impl StripeClient {
91 /// Create a new Stripe client from configuration.
92 ///
93 /// Fallible because the builder validates the client config; a build error is
94 /// an internal invariant violation (the secret key comes from validated
95 /// config), so it is classified `Internal` and surfaces at boot.
96 pub fn new(config: &StripeConfig) -> Result<Self> {
97 // `build()` constructs the rustls connector, which reads the
98 // process-wide provider and panics if none is installed. Doing it here
99 // rather than relying on a caller means no construction order can get
100 // this wrong; the call is idempotent.
101 crate::crypto::install_default_crypto_provider();
102
103 let client = ClientBuilder::new(&config.secret_key)
104 .timeout(STRIPE_HTTP_TIMEOUT)
105 .build()
106 .map_err(|e| {
107 AppError::Internal(anyhow::anyhow!("failed to build Stripe client: {e}"))
108 })?;
109 Ok(StripeClient {
110 client,
111 config: config.clone(),
112 })
113 }
114
115 /// Parse a connected account ID string into an `AccountId`.
116 ///
117 /// Account IDs are read from our own DB (`users.stripe_account_id`), so a
118 /// parse failure is an internal invariant violation rather than bad user
119 /// input, classify it `Internal` and keep the underlying error for ops.
120 pub(crate) fn parse_account_id(account_id: &str) -> Result<stripe_shared::AccountId> {
121 account_id.parse().map_err(|e| {
122 AppError::Internal(anyhow::anyhow!(
123 "Invalid Stripe account ID '{account_id}': {e}"
124 ))
125 })
126 }
127 }
128
129 use crate::error::{AppError, Result};
130
131 /// Simplified checkout result: what handlers need from Stripe sessions.
132 pub struct CheckoutResult {
133 pub id: String,
134 pub url: Option<String>,
135 }
136
137 /// Simplified balance: what handlers need from Stripe balance.
138 pub struct BalanceSummary {
139 pub available_cents: i64,
140 pub pending_cents: i64,
141 }
142
143 /// Sum the entries whose currency is `want`, ignoring the rest.
144 ///
145 /// Split out of `get_balance` so the filter has a test. A connected account's
146 /// Stripe balance carries one entry per currency it holds, and the sum comes
147 /// back as a bare `i64` with no currency attached to contradict it, so summing
148 /// the wrong entries reports another currency's money as this one's and looks
149 /// like a plausible number rather than an error.
150 fn sum_in_currency<'a, C>(entries: impl IntoIterator<Item = (&'a C, i64)>, want: &C) -> i64
151 where
152 C: PartialEq + 'a,
153 {
154 entries
155 .into_iter()
156 .filter(|(currency, _)| *currency == want)
157 .map(|(_, amount)| amount)
158 .sum()
159 }
160
161 /// Payment provider abstraction for checkout, connect, and webhook operations.
162 #[async_trait::async_trait]
163 pub trait PaymentProvider: Send + Sync {
164 // Checkout
165 async fn create_checkout_session(
166 &self,
167 params: &CheckoutParams<'_>,
168 ) -> crate::error::Result<CheckoutResult>;
169 async fn create_guest_checkout_session(
170 &self,
171 params: &GuestCheckoutParams<'_>,
172 ) -> crate::error::Result<CheckoutResult>;
173 async fn create_subscription_checkout_session(
174 &self,
175 params: &SubscriptionCheckoutParams<'_>,
176 ) -> crate::error::Result<CheckoutResult>;
177 async fn create_tip_checkout_session(
178 &self,
179 params: &TipCheckoutParams<'_>,
180 ) -> crate::error::Result<CheckoutResult>;
181 async fn create_fan_plus_checkout_session(
182 &self,
183 price_id: &str,
184 user_id: crate::db::UserId,
185 success_url: &str,
186 cancel_url: &str,
187 ) -> crate::error::Result<CheckoutResult>;
188 async fn create_creator_tier_checkout_session(
189 &self,
190 price_id: &str,
191 user_id: crate::db::UserId,
192 tier: &str,
193 success_url: &str,
194 cancel_url: &str,
195 trial_days: Option<i32>,
196 ) -> crate::error::Result<CheckoutResult>;
197 async fn create_synckit_app_sub_checkout_session(
198 &self,
199 params: &SynckitAppSubCheckoutParams<'_>,
200 ) -> crate::error::Result<CheckoutResult>;
201 async fn create_cart_checkout_session(
202 &self,
203 params: &CartCheckoutParams<'_>,
204 ) -> crate::error::Result<CheckoutResult>;
205
206 // Connect
207 async fn create_connect_account(&self, email: &str) -> crate::error::Result<ProviderAccountId>;
208 /// Balance in the account's own settlement currency. A connected account can
209 /// hold several currencies at once; summing across them would be adding
210 /// pounds to euros.
211 async fn get_balance(
212 &self,
213 account_id: &str,
214 currency: crate::currency::SettlementCurrency,
215 ) -> crate::error::Result<BalanceSummary>;
216
217 // Subscription lifecycle
218 async fn pause_subscription(
219 &self,
220 stripe_sub_id: &str,
221 connected_account_id: &str,
222 ) -> crate::error::Result<()>;
223 async fn resume_subscription(
224 &self,
225 stripe_sub_id: &str,
226 connected_account_id: &str,
227 ) -> crate::error::Result<()>;
228 async fn cancel_subscription(
229 &self,
230 stripe_sub_id: &str,
231 connected_account_id: &str,
232 ) -> crate::error::Result<()>;
233 /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume).
234 async fn set_cancel_at_period_end(
235 &self,
236 stripe_sub_id: &str,
237 connected_account_id: &str,
238 cancel: bool,
239 ) -> crate::error::Result<()>;
240 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
241 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
242 /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier).
243 async fn set_platform_cancel_at_period_end(
244 &self,
245 stripe_sub_id: &str,
246 cancel: bool,
247 ) -> crate::error::Result<()>;
248
249 // Webhooks
250 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
251 fn verify_webhook_v2(
252 &self,
253 payload: &str,
254 signature: &str,
255 ) -> crate::error::Result<serde_json::Value>;
256 /// Turn a verified envelope into the MNW vocabulary.
257 ///
258 /// Base trait rather than an extension: a provider that sends webhooks must
259 /// be able to say what its events mean, so this is not a capability any
260 /// provider can lack.
261 ///
262 /// Takes the whole envelope rather than `(type, object)` because the retry
263 /// worker calls it alone — a stored payload was verified once already and
264 /// has no signature to re-check, so it has no `verify_webhook` result to
265 /// destructure. The live path composes the two.
266 fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result<MnwEvent>;
267
268 // SyncKit subscription re-pricing and cancellation. Creating the customer
269 // and the subscription needs [`CustodialCustomers`]; changing the price of
270 // one that already exists is an ordinary subscription edit, so it stays
271 // here.
272 async fn update_synckit_subscription_price(
273 &self,
274 subscription_id: &str,
275 new_price_cents: i64,
276 app_name: &str,
277 ) -> crate::error::Result<()>;
278 /// Re-price an end-user SyncKit app subscription. Used by the cap-change
279 /// path; takes effect at next billing cycle (no proration).
280 async fn update_synckit_app_sub_price(
281 &self,
282 subscription_id: &str,
283 new_price_cents: i64,
284 interval: SyncBillingInterval,
285 product_name: &str,
286 ) -> crate::error::Result<()>;
287 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>;
288 }
289
290 // ── Capability extensions ─────────────────────────────────────────────────
291 //
292 // Split out of `PaymentProvider` on 2026-08-28 (`9a452f48`, option (a)). Each
293 // one is a capability a rail can genuinely lack, and the grouping is by the
294 // *reason* it lacks it rather than by the shape of the method. A provider that
295 // cannot host a portal does not implement [`HostedPortal`], so the compiler
296 // says so at the wiring rather than the route saying so at runtime with an
297 // `Err(Unsupported)` nobody can plan around.
298
299 /// Provider-hosted billing pages the customer is redirected to.
300 ///
301 /// One trait for both entry points because they are one capability: the
302 /// SyncKit portal call delegates straight to the general one
303 /// (`StripeClient::create_synckit_billing_portal`), so a rail that can serve
304 /// either can serve both.
305 #[async_trait::async_trait]
306 pub trait HostedPortal: Send + Sync {
307 /// Create a provider-hosted billing portal session. Returns the URL to
308 /// redirect to.
309 async fn create_billing_portal_session(
310 &self,
311 customer_id: &str,
312 return_url: &str,
313 ) -> crate::error::Result<String>;
314
315 /// The same portal for a SyncKit developer's per-app customer.
316 async fn create_synckit_billing_portal(
317 &self,
318 customer_id: &str,
319 return_url: &str,
320 ) -> crate::error::Result<String>;
321 }
322
323 /// Provider-hosted onboarding for connected accounts, plus reading one back.
324 ///
325 /// A rail that onboards sellers out of band (a form we host, a contract, a bank
326 /// enrolment) mints an account id without ever having a link to send anyone to,
327 /// and has no account object of the provider's shape to fetch.
328 #[async_trait::async_trait]
329 pub trait ConnectOnboarding: Send + Sync {
330 async fn create_account_link(
331 &self,
332 account_id: &str,
333 return_url: &str,
334 refresh_url: &str,
335 ) -> crate::error::Result<String>;
336
337 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
338 }
339
340 /// A product/price catalogue held on the provider's side.
341 ///
342 /// A rail that prices at charge time has nothing to create here: the amount is
343 /// an argument, not a stored object with an id.
344 #[async_trait::async_trait]
345 pub trait Catalogue: Send + Sync {
346 async fn create_subscription_product_and_price(
347 &self,
348 connected_account_id: &str,
349 tier_name: &str,
350 tier_description: Option<&str>,
351 price_cents: i64,
352 currency: crate::currency::SettlementCurrency,
353 ) -> crate::error::Result<(String, String)>;
354 }
355
356 /// Refunds initiated through the provider.
357 ///
358 /// Line-scoped: refunds `amount_cents` of the shared PaymentIntent and tags the
359 /// refund with the transaction id so the `refund.created` webhook marks and
360 /// revokes exactly that line (cart orders share one PaymentIntent).
361 #[async_trait::async_trait]
362 pub trait Refundable: Send + Sync {
363 async fn create_refund_for_transaction(
364 &self,
365 payment_intent_id: &str,
366 connected_account_id: &str,
367 amount_cents: i64,
368 transaction_id: crate::db::TransactionId,
369 ) -> crate::error::Result<()>;
370 }
371
372 /// Platform-funded transfers into a connected account, and their reversal.
373 ///
374 /// This is money moving from MNW's own balance to a creator's, which only a
375 /// rail that holds a platform balance can do. It makes the creator whole for a
376 /// Fan+ credit applied to their sale. Deterministic idempotency keys keep
377 /// replays and retries from double-paying or double-clawing.
378 #[async_trait::async_trait]
379 pub trait PlatformTransfers: Send + Sync {
380 /// Reimburse a creator for a platform-funded credit. Returns the transfer
381 /// id so it can be reversed if the sale is refunded.
382 async fn create_platform_credit_transfer(
383 &self,
384 connected_account_id: &str,
385 amount_cents: i64,
386 transaction_id: crate::db::TransactionId,
387 currency: crate::currency::SettlementCurrency,
388 ) -> crate::error::Result<String>;
389
390 /// Reverse a settled platform-credit transfer when its sale is refunded,
391 /// clawing the reimbursement back from the connected account to MNW.
392 async fn create_platform_credit_reversal(
393 &self,
394 transfer_id: &str,
395 amount_cents: i64,
396 transaction_id: crate::db::TransactionId,
397 ) -> crate::error::Result<()>;
398 }
399
400 /// Customer records the provider holds on our behalf, and subscriptions billed
401 /// against them.
402 ///
403 /// SyncKit v2 developer billing keeps one customer and one subscription per
404 /// app, separate from creator-tier and Fan+ subscriptions; see
405 /// `synckit_billing.rs` for the rationale on per-app customers. A rail that
406 /// does not custody customers (charging a token per transaction, say) has
407 /// nowhere to put one.
408 #[async_trait::async_trait]
409 pub trait CustodialCustomers: Send + Sync {
410 async fn create_synckit_customer(
411 &self,
412 developer_user_id: crate::db::UserId,
413 app_id: crate::db::SyncAppId,
414 email: &str,
415 app_name: &str,
416 ) -> crate::error::Result<String>;
417
418 async fn create_synckit_subscription(
419 &self,
420 customer_id: &str,
421 app_id: crate::db::SyncAppId,
422 app_name: &str,
423 price_cents: i64,
424 ) -> crate::error::Result<SynckitSubResult>;
425 }
426
427 /// A provider that implements the base trait and every capability, which is
428 /// what a full-service rail like Stripe is.
429 ///
430 /// Wiring convenience only: [`PaymentCapabilities::all`] takes one `Arc` and
431 /// hands back a handle per capability, so a deployment names its provider once.
432 pub trait FullPaymentProvider:
433 PaymentProvider
434 + HostedPortal
435 + ConnectOnboarding
436 + Catalogue
437 + Refundable
438 + PlatformTransfers
439 + CustodialCustomers
440 + 'static
441 {
442 }
443
444 impl<T> FullPaymentProvider for T where
445 T: PaymentProvider
446 + HostedPortal
447 + ConnectOnboarding
448 + Catalogue
449 + Refundable
450 + PlatformTransfers
451 + CustodialCustomers
452 + 'static
453 {
454 }
455
456 /// Typed handles to whichever capabilities the wired provider implements.
457 ///
458 /// One field per extension trait, grouped the way `AppStorage` groups the
459 /// buckets: the alternative was six more fields on each of `AppState`,
460 /// `Billing` and `AppStateParts` plus their two propagation sites, which is
461 /// eighteen declarations for six capabilities. Grouping keeps the reference
462 /// typed (`state.payment_caps.require_hosted_portal()?` is one unwrap and no
463 /// `Any`) without that.
464 ///
465 /// `None` means the deployment's provider cannot do it, which is the same
466 /// answer as "no provider is configured at all" from a route's point of view.
467 #[derive(Clone, Default)]
468 pub struct PaymentCapabilities {
469 pub hosted_portal: Option<std::sync::Arc<dyn HostedPortal>>,
470 pub connect_onboarding: Option<std::sync::Arc<dyn ConnectOnboarding>>,
471 pub catalogue: Option<std::sync::Arc<dyn Catalogue>>,
472 pub refundable: Option<std::sync::Arc<dyn Refundable>>,
473 pub platform_transfers: Option<std::sync::Arc<dyn PlatformTransfers>>,
474 pub custodial_customers: Option<std::sync::Arc<dyn CustodialCustomers>>,
475 }
476
477 impl PaymentCapabilities {
478 /// Every capability, all backed by the one provider value.
479 pub fn all<P: FullPaymentProvider>(provider: std::sync::Arc<P>) -> Self {
480 Self {
481 hosted_portal: Some(provider.clone()),
482 connect_onboarding: Some(provider.clone()),
483 catalogue: Some(provider.clone()),
484 refundable: Some(provider.clone()),
485 platform_transfers: Some(provider.clone()),
486 custodial_customers: Some(provider),
487 }
488 }
489 }
490
491 impl PaymentCapabilities {
492 fn missing(what: &str) -> crate::error::AppError {
493 crate::error::AppError::ServiceUnavailable(format!(
494 "The configured payment provider does not support {what}"
495 ))
496 }
497
498 /// The hosted-portal capability, or a 503 if the provider has none.
499 pub fn require_hosted_portal(&self) -> crate::error::Result<&std::sync::Arc<dyn HostedPortal>> {
500 self.hosted_portal
501 .as_ref()
502 .ok_or_else(|| Self::missing("hosted billing portals"))
503 }
504
505 /// The Connect-onboarding capability, or a 503 if the provider has none.
506 pub fn require_connect_onboarding(
507 &self,
508 ) -> crate::error::Result<&std::sync::Arc<dyn ConnectOnboarding>> {
509 self.connect_onboarding
510 .as_ref()
511 .ok_or_else(|| Self::missing("hosted account onboarding"))
512 }
513
514 /// The catalogue capability, or a 503 if the provider has none.
515 pub fn require_catalogue(&self) -> crate::error::Result<&std::sync::Arc<dyn Catalogue>> {
516 self.catalogue
517 .as_ref()
518 .ok_or_else(|| Self::missing("a hosted product catalogue"))
519 }
520
521 /// The refund capability, or a 503 if the provider has none.
522 pub fn require_refundable(&self) -> crate::error::Result<&std::sync::Arc<dyn Refundable>> {
523 self.refundable
524 .as_ref()
525 .ok_or_else(|| Self::missing("refunds"))
526 }
527
528 /// The platform-transfer capability, or a 503 if the provider has none.
529 pub fn require_platform_transfers(
530 &self,
531 ) -> crate::error::Result<&std::sync::Arc<dyn PlatformTransfers>> {
532 self.platform_transfers
533 .as_ref()
534 .ok_or_else(|| Self::missing("platform transfers"))
535 }
536
537 /// The custodial-customer capability, or a 503 if the provider has none.
538 pub fn require_custodial_customers(
539 &self,
540 ) -> crate::error::Result<&std::sync::Arc<dyn CustodialCustomers>> {
541 self.custodial_customers
542 .as_ref()
543 .ok_or_else(|| Self::missing("provider-held customer records"))
544 }
545 }
546
547 #[cfg(test)]
548 pub(crate) mod test_provider {
549 //! A crate-visible [`PaymentProvider`] double for lib tests.
550 //!
551 //! The integration suite already has `MockPaymentProvider`
552 //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
553 //! sessions and signs webhooks. It lives in a separate test binary, so a
554 //! `--lib` test cannot reach it, and this is deliberately the smaller
555 //! thing. It answers the subscription-lifecycle calls and panics on
556 //! everything else, which is enough to test the code that fans those out
557 //! without a database, a router or a Stripe key.
558 //!
559 //! Implement a method here when a lib test needs it. Growing this toward
560 //! the harness's copy would give the crate two mocks to keep in agreement,
561 //! which is the imitation-oracle failure wiki `testing-posture` describes.
562
563 use std::collections::HashSet;
564 use std::sync::Mutex;
565
566 use super::*;
567
568 /// Records every subscription op it is asked for, and fails the ones whose
569 /// subscription id was listed as failing.
570 #[derive(Default)]
571 pub(crate) struct ScriptedProvider {
572 failing: HashSet<String>,
573 calls: Mutex<Vec<(&'static str, String)>>,
574 }
575
576 impl ScriptedProvider {
577 /// Every call succeeds.
578 pub(crate) fn healthy() -> Self {
579 Self::default()
580 }
581
582 /// Every call succeeds except those naming one of `sub_ids`.
583 pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
584 Self {
585 failing: sub_ids.into_iter().map(str::to_owned).collect(),
586 calls: Mutex::new(Vec::new()),
587 }
588 }
589
590 /// `(op, subscription id)` in the order they were applied.
591 pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
592 self.calls
593 .lock()
594 .expect("no test panics while holding this")
595 .clone()
596 }
597
598 fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
599 self.calls
600 .lock()
601 .expect("no test panics while holding this")
602 .push((op, sub_id.to_string()));
603 if self.failing.contains(sub_id) {
604 return Err(crate::error::AppError::BadRequest(format!(
605 "scripted failure for {sub_id}"
606 )));
607 }
608 Ok(())
609 }
610 }
611
612 /// The methods no lib test drives yet. A call is a bug in the test, not a
613 /// condition to handle, so it panics rather than returning an error the
614 /// code under test would quietly count as a Stripe failure.
615 macro_rules! unused {
616 ($($name:ident),+ $(,)?) => {
617 $(
618 #[allow(unused_variables)]
619 fn $name(&self) -> ! {
620 unimplemented!(
621 "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
622 stringify!($name)
623 )
624 }
625 )+
626 };
627 }
628
629 impl ScriptedProvider {
630 unused!(
631 create_checkout_session,
632 create_guest_checkout_session,
633 create_subscription_checkout_session,
634 create_tip_checkout_session,
635 create_fan_plus_checkout_session,
636 create_creator_tier_checkout_session,
637 create_synckit_app_sub_checkout_session,
638 create_cart_checkout_session,
639 create_connect_account,
640 create_account_link,
641 fetch_account,
642 create_subscription_product_and_price,
643 get_balance,
644 cancel_platform_subscription,
645 set_platform_cancel_at_period_end,
646 create_billing_portal_session,
647 create_refund_for_transaction,
648 create_platform_credit_transfer,
649 create_platform_credit_reversal,
650 verify_webhook,
651 verify_webhook_v2,
652 normalize_webhook,
653 create_synckit_customer,
654 create_synckit_subscription,
655 update_synckit_subscription_price,
656 update_synckit_app_sub_price,
657 cancel_synckit_subscription,
658 create_synckit_billing_portal,
659 );
660 }
661
662 #[async_trait::async_trait]
663 impl PaymentProvider for ScriptedProvider {
664 // ── what the fan-out drives ──
665
666 async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
667 self.record("pause", sub)
668 }
669
670 async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
671 self.record("resume", sub)
672 }
673
674 async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
675 self.record("cancel", sub)
676 }
677
678 async fn set_cancel_at_period_end(
679 &self,
680 sub: &str,
681 _account: &str,
682 cancel: bool,
683 ) -> crate::error::Result<()> {
684 self.record(
685 if cancel {
686 "set_cancel_at_period_end"
687 } else {
688 "clear_cancel_at_period_end"
689 },
690 sub,
691 )
692 }
693
694 // ── everything else ──
695
696 async fn create_checkout_session(
697 &self,
698 _params: &CheckoutParams<'_>,
699 ) -> crate::error::Result<CheckoutResult> {
700 ScriptedProvider::create_checkout_session(self)
701 }
702 async fn create_guest_checkout_session(
703 &self,
704 _params: &GuestCheckoutParams<'_>,
705 ) -> crate::error::Result<CheckoutResult> {
706 ScriptedProvider::create_guest_checkout_session(self)
707 }
708 async fn create_subscription_checkout_session(
709 &self,
710 _params: &SubscriptionCheckoutParams<'_>,
711 ) -> crate::error::Result<CheckoutResult> {
712 ScriptedProvider::create_subscription_checkout_session(self)
713 }
714 async fn create_tip_checkout_session(
715 &self,
716 _params: &TipCheckoutParams<'_>,
717 ) -> crate::error::Result<CheckoutResult> {
718 ScriptedProvider::create_tip_checkout_session(self)
719 }
720 async fn create_fan_plus_checkout_session(
721 &self,
722 _price_id: &str,
723 _user_id: crate::db::UserId,
724 _success_url: &str,
725 _cancel_url: &str,
726 ) -> crate::error::Result<CheckoutResult> {
727 ScriptedProvider::create_fan_plus_checkout_session(self)
728 }
729 async fn create_creator_tier_checkout_session(
730 &self,
731 _price_id: &str,
732 _user_id: crate::db::UserId,
733 _tier: &str,
734 _success_url: &str,
735 _cancel_url: &str,
736 _trial_days: Option<i32>,
737 ) -> crate::error::Result<CheckoutResult> {
738 ScriptedProvider::create_creator_tier_checkout_session(self)
739 }
740 async fn create_synckit_app_sub_checkout_session(
741 &self,
742 _params: &SynckitAppSubCheckoutParams<'_>,
743 ) -> crate::error::Result<CheckoutResult> {
744 ScriptedProvider::create_synckit_app_sub_checkout_session(self)
745 }
746 async fn create_cart_checkout_session(
747 &self,
748 _params: &CartCheckoutParams<'_>,
749 ) -> crate::error::Result<CheckoutResult> {
750 ScriptedProvider::create_cart_checkout_session(self)
751 }
752 async fn create_connect_account(
753 &self,
754 _email: &str,
755 ) -> crate::error::Result<ProviderAccountId> {
756 ScriptedProvider::create_connect_account(self)
757 }
758 async fn get_balance(
759 &self,
760 _account_id: &str,
761 _currency: crate::currency::SettlementCurrency,
762 ) -> crate::error::Result<BalanceSummary> {
763 ScriptedProvider::get_balance(self)
764 }
765 async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
766 ScriptedProvider::cancel_platform_subscription(self)
767 }
768 async fn set_platform_cancel_at_period_end(
769 &self,
770 _sub: &str,
771 _cancel: bool,
772 ) -> crate::error::Result<()> {
773 ScriptedProvider::set_platform_cancel_at_period_end(self)
774 }
775 fn verify_webhook(
776 &self,
777 _payload: &str,
778 _signature: &str,
779 ) -> crate::error::Result<UntypedEvent> {
780 ScriptedProvider::verify_webhook(self)
781 }
782 fn verify_webhook_v2(
783 &self,
784 _payload: &str,
785 _signature: &str,
786 ) -> crate::error::Result<serde_json::Value> {
787 ScriptedProvider::verify_webhook_v2(self)
788 }
789 fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result<MnwEvent> {
790 ScriptedProvider::normalize_webhook(self)
791 }
792 async fn update_synckit_subscription_price(
793 &self,
794 _subscription_id: &str,
795 _new_price_cents: i64,
796 _app_name: &str,
797 ) -> crate::error::Result<()> {
798 ScriptedProvider::update_synckit_subscription_price(self)
799 }
800 async fn update_synckit_app_sub_price(
801 &self,
802 _subscription_id: &str,
803 _new_price_cents: i64,
804 _interval: SyncBillingInterval,
805 _product_name: &str,
806 ) -> crate::error::Result<()> {
807 ScriptedProvider::update_synckit_app_sub_price(self)
808 }
809 async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
810 ScriptedProvider::cancel_synckit_subscription(self)
811 }
812 }
813
814 // ── The capability extensions, every one of them a panic: no lib test
815 // drives an extension yet, and `ScriptedProvider` implements them so the
816 // double stays wirable wherever a full provider is expected.
817
818 #[async_trait::async_trait]
819 impl HostedPortal for ScriptedProvider {
820 async fn create_billing_portal_session(
821 &self,
822 _customer_id: &str,
823 _return_url: &str,
824 ) -> crate::error::Result<String> {
825 ScriptedProvider::create_billing_portal_session(self)
826 }
827 async fn create_synckit_billing_portal(
828 &self,
829 _customer_id: &str,
830 _return_url: &str,
831 ) -> crate::error::Result<String> {
832 ScriptedProvider::create_synckit_billing_portal(self)
833 }
834 }
835
836 #[async_trait::async_trait]
837 impl ConnectOnboarding for ScriptedProvider {
838 async fn create_account_link(
839 &self,
840 _account_id: &str,
841 _return_url: &str,
842 _refresh_url: &str,
843 ) -> crate::error::Result<String> {
844 ScriptedProvider::create_account_link(self)
845 }
846 async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
847 ScriptedProvider::fetch_account(self)
848 }
849 }
850
851 #[async_trait::async_trait]
852 impl Catalogue for ScriptedProvider {
853 async fn create_subscription_product_and_price(
854 &self,
855 _connected_account_id: &str,
856 _tier_name: &str,
857 _tier_description: Option<&str>,
858 _price_cents: i64,
859 _currency: crate::currency::SettlementCurrency,
860 ) -> crate::error::Result<(String, String)> {
861 ScriptedProvider::create_subscription_product_and_price(self)
862 }
863 }
864
865 #[async_trait::async_trait]
866 impl Refundable for ScriptedProvider {
867 async fn create_refund_for_transaction(
868 &self,
869 _payment_intent_id: &str,
870 _connected_account_id: &str,
871 _amount_cents: i64,
872 _transaction_id: crate::db::TransactionId,
873 ) -> crate::error::Result<()> {
874 ScriptedProvider::create_refund_for_transaction(self)
875 }
876 }
877
878 #[async_trait::async_trait]
879 impl PlatformTransfers for ScriptedProvider {
880 async fn create_platform_credit_transfer(
881 &self,
882 _connected_account_id: &str,
883 _amount_cents: i64,
884 _transaction_id: crate::db::TransactionId,
885 _currency: crate::currency::SettlementCurrency,
886 ) -> crate::error::Result<String> {
887 ScriptedProvider::create_platform_credit_transfer(self)
888 }
889 async fn create_platform_credit_reversal(
890 &self,
891 _transfer_id: &str,
892 _amount_cents: i64,
893 _transaction_id: crate::db::TransactionId,
894 ) -> crate::error::Result<()> {
895 ScriptedProvider::create_platform_credit_reversal(self)
896 }
897 }
898
899 #[async_trait::async_trait]
900 impl CustodialCustomers for ScriptedProvider {
901 async fn create_synckit_customer(
902 &self,
903 _developer_user_id: crate::db::UserId,
904 _app_id: crate::db::SyncAppId,
905 _email: &str,
906 _app_name: &str,
907 ) -> crate::error::Result<String> {
908 ScriptedProvider::create_synckit_customer(self)
909 }
910 async fn create_synckit_subscription(
911 &self,
912 _customer_id: &str,
913 _app_id: crate::db::SyncAppId,
914 _app_name: &str,
915 _price_cents: i64,
916 ) -> crate::error::Result<SynckitSubResult> {
917 ScriptedProvider::create_synckit_subscription(self)
918 }
919 }
920 }
921
922 #[async_trait::async_trait]
923 impl PaymentProvider for StripeClient {
924 async fn create_checkout_session(
925 &self,
926 params: &CheckoutParams<'_>,
927 ) -> crate::error::Result<CheckoutResult> {
928 let session = StripeClient::create_checkout_session(self, params).await?;
929 Ok(CheckoutResult {
930 id: session.id.to_string(),
931 url: session.url,
932 })
933 }
934
935 async fn create_guest_checkout_session(
936 &self,
937 params: &GuestCheckoutParams<'_>,
938 ) -> crate::error::Result<CheckoutResult> {
939 let session = StripeClient::create_guest_checkout_session(self, params).await?;
940 Ok(CheckoutResult {
941 id: session.id.to_string(),
942 url: session.url,
943 })
944 }
945
946 async fn create_subscription_checkout_session(
947 &self,
948 params: &SubscriptionCheckoutParams<'_>,
949 ) -> crate::error::Result<CheckoutResult> {
950 let session = StripeClient::create_subscription_checkout_session(self, params).await?;
951 Ok(CheckoutResult {
952 id: session.id.to_string(),
953 url: session.url,
954 })
955 }
956
957 async fn create_tip_checkout_session(
958 &self,
959 params: &TipCheckoutParams<'_>,
960 ) -> crate::error::Result<CheckoutResult> {
961 let session = StripeClient::create_tip_checkout_session(self, params).await?;
962 Ok(CheckoutResult {
963 id: session.id.to_string(),
964 url: session.url,
965 })
966 }
967
968 async fn create_fan_plus_checkout_session(
969 &self,
970 price_id: &str,
971 user_id: crate::db::UserId,
972 success_url: &str,
973 cancel_url: &str,
974 ) -> crate::error::Result<CheckoutResult> {
975 let session = StripeClient::create_fan_plus_checkout_session(
976 self,
977 price_id,
978 user_id,
979 success_url,
980 cancel_url,
981 )
982 .await?;
983 Ok(CheckoutResult {
984 id: session.id.to_string(),
985 url: session.url,
986 })
987 }
988
989 async fn create_creator_tier_checkout_session(
990 &self,
991 price_id: &str,
992 user_id: crate::db::UserId,
993 tier: &str,
994 success_url: &str,
995 cancel_url: &str,
996 trial_days: Option<i32>,
997 ) -> crate::error::Result<CheckoutResult> {
998 let session = StripeClient::create_creator_tier_checkout_session(
999 self,
1000 price_id,
1001 user_id,
1002 tier,
1003 success_url,
1004 cancel_url,
1005 trial_days,
1006 )
1007 .await?;
1008 Ok(CheckoutResult {
1009 id: session.id.to_string(),
1010 url: session.url,
1011 })
1012 }
1013
1014 async fn create_synckit_app_sub_checkout_session(
1015 &self,
1016 params: &SynckitAppSubCheckoutParams<'_>,
1017 ) -> crate::error::Result<CheckoutResult> {
1018 let session = StripeClient::create_synckit_app_sub_checkout_session(self, params).await?;
1019 Ok(CheckoutResult {
1020 id: session.id.to_string(),
1021 url: session.url,
1022 })
1023 }
1024
1025 async fn create_cart_checkout_session(
1026 &self,
1027 params: &CartCheckoutParams<'_>,
1028 ) -> crate::error::Result<CheckoutResult> {
1029 let session = StripeClient::create_cart_checkout_session(self, params).await?;
1030 Ok(CheckoutResult {
1031 id: session.id.to_string(),
1032 url: session.url,
1033 })
1034 }
1035
1036 async fn create_connect_account(&self, email: &str) -> crate::error::Result<ProviderAccountId> {
1037 let account = StripeClient::create_connect_account(self, email).await?;
1038 Ok(ProviderAccountId::from_provider(account.into_inner()))
1039 }
1040
1041 async fn get_balance(
1042 &self,
1043 account_id: &str,
1044 currency: crate::currency::SettlementCurrency,
1045 ) -> crate::error::Result<BalanceSummary> {
1046 let balance = self.get_connected_account_balance(account_id).await?;
1047 let want = currency.to_stripe();
1048 let available_cents = sum_in_currency(
1049 balance.available.iter().map(|b| (&b.currency, b.amount)),
1050 &want,
1051 );
1052 let pending_cents = sum_in_currency(
1053 balance.pending.iter().map(|b| (&b.currency, b.amount)),
1054 &want,
1055 );
1056 Ok(BalanceSummary {
1057 available_cents,
1058 pending_cents,
1059 })
1060 }
1061
1062 async fn pause_subscription(
1063 &self,
1064 stripe_sub_id: &str,
1065 connected_account_id: &str,
1066 ) -> crate::error::Result<()> {
1067 StripeClient::pause_subscription(self, stripe_sub_id, connected_account_id).await
1068 }
1069
1070 async fn resume_subscription(
1071 &self,
1072 stripe_sub_id: &str,
1073 connected_account_id: &str,
1074 ) -> crate::error::Result<()> {
1075 StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await
1076 }
1077
1078 async fn cancel_subscription(
1079 &self,
1080 stripe_sub_id: &str,
1081 connected_account_id: &str,
1082 ) -> crate::error::Result<()> {
1083 StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await
1084 }
1085
1086 async fn set_cancel_at_period_end(
1087 &self,
1088 stripe_sub_id: &str,
1089 connected_account_id: &str,
1090 cancel: bool,
1091 ) -> crate::error::Result<()> {
1092 StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel)
1093 .await
1094 }
1095
1096 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> {
1097 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
1098 }
1099
1100 async fn set_platform_cancel_at_period_end(
1101 &self,
1102 stripe_sub_id: &str,
1103 cancel: bool,
1104 ) -> crate::error::Result<()> {
1105 StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await
1106 }
1107
1108 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
1109 StripeClient::verify_webhook(self, payload, signature)
1110 }
1111
1112 fn verify_webhook_v2(
1113 &self,
1114 payload: &str,
1115 signature: &str,
1116 ) -> crate::error::Result<serde_json::Value> {
1117 StripeClient::verify_webhook_v2(self, payload, signature)
1118 }
1119
1120 fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result<MnwEvent> {
1121 StripeClient::normalize_webhook(self, event)
1122 }
1123
1124 async fn update_synckit_subscription_price(
1125 &self,
1126 subscription_id: &str,
1127 new_price_cents: i64,
1128 app_name: &str,
1129 ) -> crate::error::Result<()> {
1130 StripeClient::update_synckit_subscription_price(
1131 self,
1132 subscription_id,
1133 new_price_cents,
1134 app_name,
1135 )
1136 .await
1137 }
1138
1139 async fn update_synckit_app_sub_price(
1140 &self,
1141 subscription_id: &str,
1142 new_price_cents: i64,
1143 interval: SyncBillingInterval,
1144 product_name: &str,
1145 ) -> crate::error::Result<()> {
1146 StripeClient::update_synckit_app_sub_price(
1147 self,
1148 subscription_id,
1149 new_price_cents,
1150 interval,
1151 product_name,
1152 )
1153 .await
1154 }
1155
1156 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> {
1157 StripeClient::cancel_synckit_subscription(self, subscription_id).await
1158 }
1159 }
1160
1161 #[async_trait::async_trait]
1162 impl HostedPortal for StripeClient {
1163 async fn create_billing_portal_session(
1164 &self,
1165 stripe_customer_id: &str,
1166 return_url: &str,
1167 ) -> crate::error::Result<String> {
1168 StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
1169 }
1170
1171 async fn create_synckit_billing_portal(
1172 &self,
1173 customer_id: &str,
1174 return_url: &str,
1175 ) -> crate::error::Result<String> {
1176 StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await
1177 }
1178 }
1179
1180 #[async_trait::async_trait]
1181 impl ConnectOnboarding for StripeClient {
1182 async fn create_account_link(
1183 &self,
1184 account_id: &str,
1185 return_url: &str,
1186 refresh_url: &str,
1187 ) -> crate::error::Result<String> {
1188 StripeClient::create_account_link(self, account_id, return_url, refresh_url).await
1189 }
1190
1191 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate> {
1192 StripeClient::fetch_account(self, account_id).await
1193 }
1194 }
1195
1196 #[async_trait::async_trait]
1197 impl Catalogue for StripeClient {
1198 async fn create_subscription_product_and_price(
1199 &self,
1200 connected_account_id: &str,
1201 tier_name: &str,
1202 tier_description: Option<&str>,
1203 price_cents: i64,
1204 currency: crate::currency::SettlementCurrency,
1205 ) -> crate::error::Result<(String, String)> {
1206 StripeClient::create_subscription_product_and_price(
1207 self,
1208 connected_account_id,
1209 tier_name,
1210 tier_description,
1211 price_cents,
1212 currency,
1213 )
1214 .await
1215 }
1216 }
1217
1218 #[async_trait::async_trait]
1219 impl Refundable for StripeClient {
1220 async fn create_refund_for_transaction(
1221 &self,
1222 payment_intent_id: &str,
1223 connected_account_id: &str,
1224 amount_cents: i64,
1225 transaction_id: crate::db::TransactionId,
1226 ) -> crate::error::Result<()> {
1227 StripeClient::create_refund_for_transaction(
1228 self,
1229 payment_intent_id,
1230 connected_account_id,
1231 amount_cents,
1232 transaction_id,
1233 )
1234 .await
1235 }
1236 }
1237
1238 #[async_trait::async_trait]
1239 impl PlatformTransfers for StripeClient {
1240 async fn create_platform_credit_transfer(
1241 &self,
1242 connected_account_id: &str,
1243 amount_cents: i64,
1244 transaction_id: crate::db::TransactionId,
1245 currency: crate::currency::SettlementCurrency,
1246 ) -> crate::error::Result<String> {
1247 StripeClient::create_platform_credit_transfer(
1248 self,
1249 connected_account_id,
1250 amount_cents,
1251 transaction_id,
1252 currency,
1253 )
1254 .await
1255 }
1256
1257 async fn create_platform_credit_reversal(
1258 &self,
1259 transfer_id: &str,
1260 amount_cents: i64,
1261 transaction_id: crate::db::TransactionId,
1262 ) -> crate::error::Result<()> {
1263 StripeClient::create_platform_credit_reversal(
1264 self,
1265 transfer_id,
1266 amount_cents,
1267 transaction_id,
1268 )
1269 .await
1270 }
1271 }
1272
1273 #[async_trait::async_trait]
1274 impl CustodialCustomers for StripeClient {
1275 async fn create_synckit_customer(
1276 &self,
1277 developer_user_id: crate::db::UserId,
1278 app_id: crate::db::SyncAppId,
1279 email: &str,
1280 app_name: &str,
1281 ) -> crate::error::Result<String> {
1282 StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name)
1283 .await
1284 }
1285
1286 async fn create_synckit_subscription(
1287 &self,
1288 customer_id: &str,
1289 app_id: crate::db::SyncAppId,
1290 app_name: &str,
1291 price_cents: i64,
1292 ) -> crate::error::Result<SynckitSubResult> {
1293 StripeClient::create_synckit_subscription(self, customer_id, app_id, app_name, price_cents)
1294 .await
1295 }
1296 }
1297
1298 #[cfg(test)]
1299 mod tests {
1300 //! Stripe id parsing at the boundary between our database and Stripe's API.
1301 //! These ids come out of our own rows, so a parse failure means our data is
1302 //! wrong, and the classification matters: `Internal` pages us, `BadRequest`
1303 //! would blame the creator for our own corrupted column.
1304
1305 use super::*;
1306
1307 #[test]
1308 fn account_id_parsing_rejects_nothing_at_all() {
1309 // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId`
1310 // derives `FromStr` with `type Err = Infallible`, so every value parses
1311 // and the `Invalid Stripe account ID` branch cannot be reached. The doc
1312 // comment above reasons carefully about classifying the failure as
1313 // `Internal` rather than `BadRequest`; there is no failure to classify.
1314 //
1315 // The consequence is not academic: an empty `users.stripe_account_id`
1316 // becomes an empty connected-account header on a live charge instead of
1317 // an error we can see.
1318 assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok());
1319 for anything in ["", "cus_123", "not an id", "acct_"] {
1320 assert!(
1321 StripeClient::parse_account_id(anything).is_ok(),
1322 "{anything:?} parses today; if this now fails, the guard became real \
1323 and the test should assert the new contract"
1324 );
1325 }
1326 }
1327
1328 // ── sum_in_currency, the filter behind `get_balance` ──
1329
1330 use crate::currency::SettlementCurrency;
1331
1332 /// The entries a connected account holding three currencies would carry.
1333 fn mixed() -> Vec<(stripe_types::Currency, i64)> {
1334 vec![
1335 (SettlementCurrency::Usd.to_stripe(), 1_000),
1336 (SettlementCurrency::Gbp.to_stripe(), 2_500),
1337 (SettlementCurrency::Usd.to_stripe(), 250),
1338 (SettlementCurrency::Eur.to_stripe(), 9_999),
1339 ]
1340 }
1341
1342 #[test]
1343 fn sums_every_entry_in_the_wanted_currency() {
1344 let entries = mixed();
1345 let total = sum_in_currency(
1346 entries.iter().map(|(c, a)| (c, *a)),
1347 &SettlementCurrency::Usd.to_stripe(),
1348 );
1349 assert_eq!(total, 1_250, "both USD entries, and only those");
1350 }
1351
1352 #[test]
1353 fn ignores_every_entry_in_another_currency() {
1354 let entries = mixed();
1355 for currency in SettlementCurrency::ALL {
1356 let total =
1357 sum_in_currency(entries.iter().map(|(c, a)| (c, *a)), &currency.to_stripe());
1358 let expected = match currency {
1359 SettlementCurrency::Usd => 1_250,
1360 SettlementCurrency::Gbp => 2_500,
1361 SettlementCurrency::Eur => 9_999,
1362 _ => 0,
1363 };
1364 assert_eq!(
1365 total, expected,
1366 "{currency} must see its own money and nobody else's"
1367 );
1368 }
1369 }
1370
1371 #[test]
1372 fn a_currency_the_account_does_not_hold_is_zero_rather_than_everything() {
1373 let entries = mixed();
1374 let total = sum_in_currency(
1375 entries.iter().map(|(c, a)| (c, *a)),
1376 &SettlementCurrency::Nzd.to_stripe(),
1377 );
1378 assert_eq!(
1379 total, 0,
1380 "an inverted filter would report 13,749 NZD cents the account never held"
1381 );
1382 }
1383 }
1384