Skip to main content

max / makenotwork

Split PaymentProvider into a base trait plus six capability extensions PaymentProvider carried 31 methods, ten of which a rail that is not custodial and hosts no pages cannot implement. It now carries the 21 a non-custodial, non-hosting provider could: the eight checkout-session constructors, create_connect_account, get_balance, the six subscription lifecycle methods, the two webhook verifiers, and the three SyncKit subscription re-pricing/cancel methods. The base list was derived arithmetically in the brief (31 minus 10); checked method by method here, it partitions exactly with no orphan landing in the base by default. Six extensions, not the four the task title named. The grouping is by the reason a rail lacks the capability, which is what the enumeration and the wiki audit establish: HostedPortal, ConnectOnboarding, Catalogue, Refundable, PlatformTransfers, CustodialCustomers. No pair was collapsed beyond the one the enumeration already pairs: the two portal calls are one trait because create_synckit_billing_portal delegates straight to create_billing_portal_session, so a rail that can serve either serves both. Every other pair is separable on its own terms, and merging them would put back the runtime Unsupported the split exists to remove. Call sites reach an extension through a typed reference, no Any. The handles live in one PaymentCapabilities value rather than six loose fields: AppState, the Billing FromRef slice and AppStateParts all carry the provider, so six fields each plus the two propagation sites is eighteen declarations for six capabilities, which is the "field count gets silly" case the brief flags. Grouping follows AppStorage, which groups four optional buckets the same way, and keeps the reference typed: payments.payment_caps.require_hosted_portal()? is one unwrap. A missing capability answers 503 naming what the provider cannot do. process_v2_thin_event now takes &dyn ConnectOnboarding and refund::refund takes Option<&Arc<dyn Refundable>>, since that is all either needs. The platform-credit sweeps gate on payment_caps.platform_transfers instead of on any provider being configured. Three implementors, not the two the brief listed: StripeClient, ScriptedProvider (the lib double), and MockPaymentProvider in tests/harness/stripe.rs, which the brief did not mention. All three implement every extension, so no test loses coverage.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01KD2dJJETtYs7R5kku6YyLk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 20:18 UTC
Signed with PGP, not checked
Commit: 3a9b6dc7c0d41532b4aea7def1ed2b6dfbb8d696
Parent: 2c8cf51
20 files changed, +524 insertions, -326 deletions
@@ -103,7 +103,7 @@
103 103 use config::Config;
104 104 use docengine::DocLoader;
105 105 use email::EmailClient;
106 - use payments::PaymentProvider;
106 + use payments::{PaymentCapabilities, PaymentProvider};
107 107 use routes::{
108 108 admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes,
109 109 git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, rpm_routes,
@@ -129,6 +129,11 @@
129 129 /// bucket, and the public CDN-served bucket.
130 130 pub storage: AppStorage,
131 131 pub payments: Option<Arc<dyn PaymentProvider>>,
132 + /// Typed handles to the optional capabilities the wired provider also
133 + /// implements (hosted portals, Connect onboarding, refunds, and the rest).
134 + /// Populated from the same value as `payments`; see
135 + /// [`payments::PaymentCapabilities`].
136 + pub payment_caps: PaymentCapabilities,
132 137 pub email: EmailClient,
133 138 pub docs: Arc<DocLoader>,
134 139 pub tier_prices: tier_prices::TierPrices,
@@ -251,6 +256,7 @@
251 256 #[derive(Clone)]
252 257 pub struct Billing {
253 258 pub payments: Option<Arc<dyn PaymentProvider>>,
259 + pub payment_caps: PaymentCapabilities,
254 260 pub tier_prices: tier_prices::TierPrices,
255 261 pub runway_config: tier_prices::RunwayConfig,
256 262 pub fee_calculator: fee_calculator::FeeCalculator,
@@ -260,6 +266,7 @@
260 266 fn from_ref(s: &AppState) -> Self {
261 267 Self {
262 268 payments: s.payments.clone(),
269 + payment_caps: s.payment_caps.clone(),
263 270 tier_prices: s.tier_prices.clone(),
264 271 runway_config: s.runway_config.clone(),
265 272 fee_calculator: s.fee_calculator.clone(),
@@ -411,6 +418,7 @@
411 418 pub config: Config,
412 419 pub storage: AppStorage,
413 420 pub payments: Option<Arc<dyn PaymentProvider>>,
421 + pub payment_caps: PaymentCapabilities,
414 422 pub email: EmailClient,
415 423 pub docs: Arc<DocLoader>,
416 424 pub tier_prices: tier_prices::TierPrices,
@@ -476,6 +484,7 @@
476 484 config: parts.config,
477 485 storage: parts.storage,
478 486 payments: parts.payments,
487 + payment_caps: parts.payment_caps,
479 488 email: parts.email,
480 489 docs: parts.docs,
481 490 tier_prices: parts.tier_prices,
@@ -414,17 +414,22 @@
414 414 None
415 415 };
416 416
417 - // Initialize Stripe client if configured
418 - let stripe: Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>> = if let Some(
419 - ref stripe_config,
420 - ) =
421 - config.stripe
422 - {
417 + // Initialize Stripe client if configured. Stripe implements every
418 + // capability extension, so one `Arc` becomes both the base provider and
419 + // the whole capability set.
420 + let (stripe, payment_caps): (
421 + Option<std::sync::Arc<dyn makenotwork::payments::PaymentProvider>>,
422 + makenotwork::payments::PaymentCapabilities,
423 + ) = if let Some(ref stripe_config) = config.stripe {
423 424 match StripeClient::new(stripe_config) {
424 425 Ok(client) => {
425 426 tracing::info!("Stripe payments initialized");
426 - Some(std::sync::Arc::new(client)
427 - as std::sync::Arc<dyn makenotwork::payments::PaymentProvider>)
427 + let client = std::sync::Arc::new(client);
428 + (
429 + Some(client.clone()
430 + as std::sync::Arc<dyn makenotwork::payments::PaymentProvider>),
431 + makenotwork::payments::PaymentCapabilities::all(client),
432 + )
428 433 }
429 434 Err(e) => {
430 435 // Invalid client config is a boot invariant violation, exit
@@ -435,7 +440,7 @@
435 440 }
436 441 } else {
437 442 tracing::info!("Stripe not configured. Payments will be unavailable.");
438 - None
443 + (None, makenotwork::payments::PaymentCapabilities::default())
439 444 };
440 445
441 446 // Initialize email client (logs in dev mode when POSTMARK_TOKEN is not set)
@@ -572,6 +577,7 @@
572 577 rpm_s3,
573 578 },
574 579 payments: stripe,
580 + payment_caps,
575 581 email,
576 582 docs,
577 583 tier_prices,
@@ -205,21 +205,6 @@
205 205
206 206 // Connect
207 207 async fn create_connect_account(&self, email: &str) -> crate::error::Result<ProviderAccountId>;
208 - async fn create_account_link(
209 - &self,
210 - account_id: &str,
211 - return_url: &str,
212 - refresh_url: &str,
213 - ) -> crate::error::Result<String>;
214 - async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
215 - async fn create_subscription_product_and_price(
216 - &self,
217 - connected_account_id: &str,
218 - tier_name: &str,
219 - tier_description: Option<&str>,
220 - price_cents: i64,
221 - currency: crate::currency::SettlementCurrency,
222 - ) -> crate::error::Result<(String, String)>;
223 208 /// Balance in the account's own settlement currency. A connected account can
224 209 /// hold several currencies at once; summing across them would be adding
225 210 /// pounds to euros.
@@ -260,45 +245,6 @@
260 245 stripe_sub_id: &str,
261 246 cancel: bool,
262 247 ) -> crate::error::Result<()>;
263 - /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
264 - async fn create_billing_portal_session(
265 - &self,
266 - stripe_customer_id: &str,
267 - return_url: &str,
268 - ) -> crate::error::Result<String>;
269 -
270 - // Refunds, line-scoped: refunds `amount_cents` of the shared PaymentIntent
271 - // and tags the refund with the transaction id so the refund.created webhook
272 - // marks/revokes exactly that line (cart orders share one PaymentIntent).
273 - async fn create_refund_for_transaction(
274 - &self,
275 - payment_intent_id: &str,
276 - connected_account_id: &str,
277 - amount_cents: i64,
278 - transaction_id: crate::db::TransactionId,
279 - ) -> crate::error::Result<()>;
280 -
281 - // Platform-funded credit reimbursement, a platform -> connected transfer that
282 - // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it).
283 - // Deterministic idempotency key keeps replays/retries from double-paying.
284 - // Returns the transfer id so it can be reversed if the sale is refunded.
285 - async fn create_platform_credit_transfer(
286 - &self,
287 - connected_account_id: &str,
288 - amount_cents: i64,
289 - transaction_id: crate::db::TransactionId,
290 - currency: crate::currency::SettlementCurrency,
291 - ) -> crate::error::Result<String>;
292 -
293 - // Reverse a settled platform-credit transfer when its sale is refunded,
294 - // clawing the reimbursement back from the connected account to MNW.
295 - // Deterministic idempotency key keeps replays/retries from clawing back twice.
296 - async fn create_platform_credit_reversal(
297 - &self,
298 - transfer_id: &str,
299 - amount_cents: i64,
300 - transaction_id: crate::db::TransactionId,
301 - ) -> crate::error::Result<()>;
302 248
303 249 // Webhooks
304 250 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
@@ -308,23 +254,10 @@
308 254 signature: &str,
309 255 ) -> crate::error::Result<serde_json::Value>;
310 256
311 - // SyncKit v2 developer billing, one customer + subscription per app,
312 - // separate from creator-tier and Fan+ subscriptions. See
313 - // `synckit_billing.rs` for the rationale on per-app customers.
314 - async fn create_synckit_customer(
315 - &self,
316 - developer_user_id: crate::db::UserId,
317 - app_id: crate::db::SyncAppId,
318 - email: &str,
319 - app_name: &str,
320 - ) -> crate::error::Result<String>;
321 - async fn create_synckit_subscription(
322 - &self,
323 - customer_id: &str,
324 - app_id: crate::db::SyncAppId,
325 - app_name: &str,
326 - price_cents: i64,
327 - ) -> crate::error::Result<SynckitSubResult>;
257 + // SyncKit subscription re-pricing and cancellation. Creating the customer
258 + // and the subscription needs [`CustodialCustomers`]; changing the price of
259 + // one that already exists is an ordinary subscription edit, so it stays
260 + // here.
328 261 async fn update_synckit_subscription_price(
329 262 &self,
330 263 subscription_id: &str,
@@ -341,6 +274,34 @@
341 274 product_name: &str,
342 275 ) -> crate::error::Result<()>;
343 276 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>;
277 + }
278 +
279 + // ── Capability extensions ─────────────────────────────────────────────────
280 + //
281 + // Split out of `PaymentProvider` on 2026-08-28 (`9a452f48`, option (a)). Each
282 + // one is a capability a rail can genuinely lack, and the grouping is by the
283 + // *reason* it lacks it rather than by the shape of the method. A provider that
284 + // cannot host a portal does not implement [`HostedPortal`], so the compiler
285 + // says so at the wiring rather than the route saying so at runtime with an
286 + // `Err(Unsupported)` nobody can plan around.
287 +
288 + /// Provider-hosted billing pages the customer is redirected to.
289 + ///
290 + /// One trait for both entry points because they are one capability: the
291 + /// SyncKit portal call delegates straight to the general one
292 + /// (`StripeClient::create_synckit_billing_portal`), so a rail that can serve
293 + /// either can serve both.
294 + #[async_trait::async_trait]
295 + pub trait HostedPortal: Send + Sync {
296 + /// Create a provider-hosted billing portal session. Returns the URL to
297 + /// redirect to.
298 + async fn create_billing_portal_session(
299 + &self,
300 + customer_id: &str,
301 + return_url: &str,
302 + ) -> crate::error::Result<String>;
303 +
304 + /// The same portal for a SyncKit developer's per-app customer.
344 305 async fn create_synckit_billing_portal(
345 306 &self,
346 307 customer_id: &str,
@@ -348,6 +309,230 @@
348 309 ) -> crate::error::Result<String>;
349 310 }
350 311
312 + /// Provider-hosted onboarding for connected accounts, plus reading one back.
313 + ///
314 + /// A rail that onboards sellers out of band (a form we host, a contract, a bank
315 + /// enrolment) mints an account id without ever having a link to send anyone to,
316 + /// and has no account object of the provider's shape to fetch.
317 + #[async_trait::async_trait]
318 + pub trait ConnectOnboarding: Send + Sync {
319 + async fn create_account_link(
320 + &self,
321 + account_id: &str,
322 + return_url: &str,
323 + refresh_url: &str,
324 + ) -> crate::error::Result<String>;
325 +
326 + async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
327 + }
328 +
329 + /// A product/price catalogue held on the provider's side.
330 + ///
331 + /// A rail that prices at charge time has nothing to create here: the amount is
332 + /// an argument, not a stored object with an id.
333 + #[async_trait::async_trait]
334 + pub trait Catalogue: Send + Sync {
335 + async fn create_subscription_product_and_price(
336 + &self,
337 + connected_account_id: &str,
338 + tier_name: &str,
339 + tier_description: Option<&str>,
340 + price_cents: i64,
341 + currency: crate::currency::SettlementCurrency,
342 + ) -> crate::error::Result<(String, String)>;
343 + }
344 +
345 + /// Refunds initiated through the provider.
346 + ///
347 + /// Line-scoped: refunds `amount_cents` of the shared PaymentIntent and tags the
348 + /// refund with the transaction id so the `refund.created` webhook marks and
349 + /// revokes exactly that line (cart orders share one PaymentIntent).
350 + #[async_trait::async_trait]
351 + pub trait Refundable: Send + Sync {
352 + async fn create_refund_for_transaction(
353 + &self,
354 + payment_intent_id: &str,
355 + connected_account_id: &str,
356 + amount_cents: i64,
357 + transaction_id: crate::db::TransactionId,
358 + ) -> crate::error::Result<()>;
359 + }
360 +
361 + /// Platform-funded transfers into a connected account, and their reversal.
362 + ///
363 + /// This is money moving from MNW's own balance to a creator's, which only a
364 + /// rail that holds a platform balance can do. It makes the creator whole for a
365 + /// Fan+ credit applied to their sale. Deterministic idempotency keys keep
366 + /// replays and retries from double-paying or double-clawing.
367 + #[async_trait::async_trait]
368 + pub trait PlatformTransfers: Send + Sync {
369 + /// Reimburse a creator for a platform-funded credit. Returns the transfer
370 + /// id so it can be reversed if the sale is refunded.
371 + async fn create_platform_credit_transfer(
372 + &self,
373 + connected_account_id: &str,
374 + amount_cents: i64,
375 + transaction_id: crate::db::TransactionId,
376 + currency: crate::currency::SettlementCurrency,
377 + ) -> crate::error::Result<String>;
378 +
379 + /// Reverse a settled platform-credit transfer when its sale is refunded,
380 + /// clawing the reimbursement back from the connected account to MNW.
381 + async fn create_platform_credit_reversal(
382 + &self,
383 + transfer_id: &str,
384 + amount_cents: i64,
385 + transaction_id: crate::db::TransactionId,
386 + ) -> crate::error::Result<()>;
387 + }
388 +
389 + /// Customer records the provider holds on our behalf, and subscriptions billed
390 + /// against them.
391 + ///
392 + /// SyncKit v2 developer billing keeps one customer and one subscription per
393 + /// app, separate from creator-tier and Fan+ subscriptions; see
394 + /// `synckit_billing.rs` for the rationale on per-app customers. A rail that
395 + /// does not custody customers (charging a token per transaction, say) has
396 + /// nowhere to put one.
397 + #[async_trait::async_trait]
398 + pub trait CustodialCustomers: Send + Sync {
399 + async fn create_synckit_customer(
400 + &self,
401 + developer_user_id: crate::db::UserId,
402 + app_id: crate::db::SyncAppId,
403 + email: &str,
404 + app_name: &str,
405 + ) -> crate::error::Result<String>;
406 +
407 + async fn create_synckit_subscription(
408 + &self,
409 + customer_id: &str,
410 + app_id: crate::db::SyncAppId,
411 + app_name: &str,
412 + price_cents: i64,
413 + ) -> crate::error::Result<SynckitSubResult>;
414 + }
415 +
416 + /// A provider that implements the base trait and every capability, which is
417 + /// what a full-service rail like Stripe is.
418 + ///
419 + /// Wiring convenience only: [`PaymentCapabilities::all`] takes one `Arc` and
420 + /// hands back a handle per capability, so a deployment names its provider once.
421 + pub trait FullPaymentProvider:
422 + PaymentProvider
423 + + HostedPortal
424 + + ConnectOnboarding
425 + + Catalogue
426 + + Refundable
427 + + PlatformTransfers
428 + + CustodialCustomers
429 + + 'static
430 + {
431 + }
432 +
433 + impl<T> FullPaymentProvider for T where
434 + T: PaymentProvider
435 + + HostedPortal
436 + + ConnectOnboarding
437 + + Catalogue
438 + + Refundable
439 + + PlatformTransfers
440 + + CustodialCustomers
441 + + 'static
442 + {
443 + }
444 +
445 + /// Typed handles to whichever capabilities the wired provider implements.
446 + ///
447 + /// One field per extension trait, grouped the way `AppStorage` groups the
448 + /// buckets: the alternative was six more fields on each of `AppState`,
449 + /// `Billing` and `AppStateParts` plus their two propagation sites, which is
450 + /// eighteen declarations for six capabilities. Grouping keeps the reference
451 + /// typed (`state.payment_caps.require_hosted_portal()?` is one unwrap and no
452 + /// `Any`) without that.
453 + ///
454 + /// `None` means the deployment's provider cannot do it, which is the same
455 + /// answer as "no provider is configured at all" from a route's point of view.
456 + #[derive(Clone, Default)]
457 + pub struct PaymentCapabilities {
458 + pub hosted_portal: Option<std::sync::Arc<dyn HostedPortal>>,
459 + pub connect_onboarding: Option<std::sync::Arc<dyn ConnectOnboarding>>,
460 + pub catalogue: Option<std::sync::Arc<dyn Catalogue>>,
461 + pub refundable: Option<std::sync::Arc<dyn Refundable>>,
462 + pub platform_transfers: Option<std::sync::Arc<dyn PlatformTransfers>>,
463 + pub custodial_customers: Option<std::sync::Arc<dyn CustodialCustomers>>,
464 + }
465 +
466 + impl PaymentCapabilities {
467 + /// Every capability, all backed by the one provider value.
468 + pub fn all<P: FullPaymentProvider>(provider: std::sync::Arc<P>) -> Self {
469 + Self {
470 + hosted_portal: Some(provider.clone()),
471 + connect_onboarding: Some(provider.clone()),
472 + catalogue: Some(provider.clone()),
473 + refundable: Some(provider.clone()),
474 + platform_transfers: Some(provider.clone()),
475 + custodial_customers: Some(provider),
476 + }
477 + }
478 + }
479 +
480 + impl PaymentCapabilities {
481 + fn missing(what: &str) -> crate::error::AppError {
482 + crate::error::AppError::ServiceUnavailable(format!(
483 + "The configured payment provider does not support {what}"
484 + ))
485 + }
486 +
487 + /// The hosted-portal capability, or a 503 if the provider has none.
488 + pub fn require_hosted_portal(&self) -> crate::error::Result<&std::sync::Arc<dyn HostedPortal>> {
489 + self.hosted_portal
490 + .as_ref()
491 + .ok_or_else(|| Self::missing("hosted billing portals"))
492 + }
493 +
494 + /// The Connect-onboarding capability, or a 503 if the provider has none.
495 + pub fn require_connect_onboarding(
496 + &self,
497 + ) -> crate::error::Result<&std::sync::Arc<dyn ConnectOnboarding>> {
498 + self.connect_onboarding
499 + .as_ref()
500 + .ok_or_else(|| Self::missing("hosted account onboarding"))
501 + }
502 +
503 + /// The catalogue capability, or a 503 if the provider has none.
504 + pub fn require_catalogue(&self) -> crate::error::Result<&std::sync::Arc<dyn Catalogue>> {
505 + self.catalogue
506 + .as_ref()
507 + .ok_or_else(|| Self::missing("a hosted product catalogue"))
508 + }
509 +
510 + /// The refund capability, or a 503 if the provider has none.
511 + pub fn require_refundable(&self) -> crate::error::Result<&std::sync::Arc<dyn Refundable>> {
512 + self.refundable
513 + .as_ref()
514 + .ok_or_else(|| Self::missing("refunds"))
515 + }
516 +
517 + /// The platform-transfer capability, or a 503 if the provider has none.
518 + pub fn require_platform_transfers(
519 + &self,
520 + ) -> crate::error::Result<&std::sync::Arc<dyn PlatformTransfers>> {
521 + self.platform_transfers
522 + .as_ref()
523 + .ok_or_else(|| Self::missing("platform transfers"))
524 + }
525 +
526 + /// The custodial-customer capability, or a 503 if the provider has none.
527 + pub fn require_custodial_customers(
528 + &self,
529 + ) -> crate::error::Result<&std::sync::Arc<dyn CustodialCustomers>> {
530 + self.custodial_customers
531 + .as_ref()
532 + .ok_or_else(|| Self::missing("provider-held customer records"))
533 + }
534 + }
535 +
351 536 #[cfg(test)]
352 537 pub(crate) mod test_provider {
353 538 //! A crate-visible [`PaymentProvider`] double for lib tests.
@@ -558,27 +743,6 @@
558 743 ) -> crate::error::Result<ProviderAccountId> {
559 744 ScriptedProvider::create_connect_account(self)
560 745 }
561 - async fn create_account_link(
562 - &self,
563 - _account_id: &str,
564 - _return_url: &str,
565 - _refresh_url: &str,
566 - ) -> crate::error::Result<String> {
567 - ScriptedProvider::create_account_link(self)
568 - }
569 - async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
570 - ScriptedProvider::fetch_account(self)
571 - }
572 - async fn create_subscription_product_and_price(
573 - &self,
574 - _connected_account_id: &str,
575 - _tier_name: &str,
576 - _tier_description: Option<&str>,
577 - _price_cents: i64,
578 - _currency: crate::currency::SettlementCurrency,
579 - ) -> crate::error::Result<(String, String)> {
580 - ScriptedProvider::create_subscription_product_and_price(self)
581 - }
582 746 async fn get_balance(
583 747 &self,
584 748 _account_id: &str,
@@ -596,39 +760,6 @@
596 760 ) -> crate::error::Result<()> {
597 761 ScriptedProvider::set_platform_cancel_at_period_end(self)
598 762 }
599 - async fn create_billing_portal_session(
600 - &self,
601 - _customer_id: &str,
602 - _return_url: &str,
603 - ) -> crate::error::Result<String> {
604 - ScriptedProvider::create_billing_portal_session(self)
605 - }
606 - async fn create_refund_for_transaction(
607 - &self,
608 - _payment_intent_id: &str,
609 - _connected_account_id: &str,
610 - _amount_cents: i64,
611 - _transaction_id: crate::db::TransactionId,
612 - ) -> crate::error::Result<()> {
613 - ScriptedProvider::create_refund_for_transaction(self)
614 - }
615 - async fn create_platform_credit_transfer(
616 - &self,
617 - _connected_account_id: &str,
618 - _amount_cents: i64,
619 - _transaction_id: crate::db::TransactionId,
620 - _currency: crate::currency::SettlementCurrency,
621 - ) -> crate::error::Result<String> {
622 - ScriptedProvider::create_platform_credit_transfer(self)
623 - }
624 - async fn create_platform_credit_reversal(
625 - &self,
626 - _transfer_id: &str,
627 - _amount_cents: i64,
628 - _transaction_id: crate::db::TransactionId,
629 - ) -> crate::error::Result<()> {
630 - ScriptedProvider::create_platform_credit_reversal(self)
631 - }
632 763 fn verify_webhook(
633 764 &self,
634 765 _payload: &str,
@@ -643,24 +774,6 @@
643 774 ) -> crate::error::Result<serde_json::Value> {
644 775 ScriptedProvider::verify_webhook_v2(self)
645 776 }
646 - async fn create_synckit_customer(
647 - &self,
648 - _developer_user_id: crate::db::UserId,
649 - _app_id: crate::db::SyncAppId,
650 - _email: &str,
651 - _app_name: &str,
652 - ) -> crate::error::Result<String> {
653 - ScriptedProvider::create_synckit_customer(self)
654 - }
655 - async fn create_synckit_subscription(
656 - &self,
657 - _customer_id: &str,
658 - _app_id: crate::db::SyncAppId,
659 - _app_name: &str,
660 - _price_cents: i64,
661 - ) -> crate::error::Result<SynckitSubResult> {
662 - ScriptedProvider::create_synckit_subscription(self)
663 - }
664 777 async fn update_synckit_subscription_price(
665 778 &self,
666 779 _subscription_id: &str,
@@ -681,6 +794,21 @@
681 794 async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
682 795 ScriptedProvider::cancel_synckit_subscription(self)
683 796 }
797 + }
798 +
799 + // ── The capability extensions, every one of them a panic: no lib test
800 + // drives an extension yet, and `ScriptedProvider` implements them so the
801 + // double stays wirable wherever a full provider is expected.
802 +
803 + #[async_trait::async_trait]
804 + impl HostedPortal for ScriptedProvider {
805 + async fn create_billing_portal_session(
806 + &self,
807 + _customer_id: &str,
808 + _return_url: &str,
809 + ) -> crate::error::Result<String> {
810 + ScriptedProvider::create_billing_portal_session(self)
811 + }
684 812 async fn create_synckit_billing_portal(
685 813 &self,
686 814 _customer_id: &str,
@@ -689,6 +817,91 @@
689 817 ScriptedProvider::create_synckit_billing_portal(self)
690 818 }
691 819 }
820 +
821 + #[async_trait::async_trait]
822 + impl ConnectOnboarding for ScriptedProvider {
823 + async fn create_account_link(
824 + &self,
825 + _account_id: &str,
826 + _return_url: &str,
827 + _refresh_url: &str,
828 + ) -> crate::error::Result<String> {
829 + ScriptedProvider::create_account_link(self)
830 + }
831 + async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
832 + ScriptedProvider::fetch_account(self)
833 + }
834 + }
835 +
836 + #[async_trait::async_trait]
837 + impl Catalogue for ScriptedProvider {
838 + async fn create_subscription_product_and_price(
839 + &self,
840 + _connected_account_id: &str,
841 + _tier_name: &str,
842 + _tier_description: Option<&str>,
843 + _price_cents: i64,
844 + _currency: crate::currency::SettlementCurrency,
845 + ) -> crate::error::Result<(String, String)> {
846 + ScriptedProvider::create_subscription_product_and_price(self)
847 + }
848 + }
Lines truncated
@@ -19,7 +19,7 @@
19 19 use crate::auth::SessionUser;
20 20 use crate::db::{self, ItemId, TransactionId};
21 21 use crate::error::{AppError, Result};
22 - use crate::payments::PaymentProvider;
22 + use crate::payments::Refundable;
23 23 use crate::routes::api::verify_item_ownership;
24 24
25 25 /// Issue a line-scoped refund for one transaction on this item.
@@ -37,7 +37,7 @@
37 37 #[tracing::instrument(skip_all, name = "payments::refund")]
38 38 pub async fn refund(
39 39 db: &PgPool,
40 - stripe: Option<&Arc<dyn PaymentProvider>>,
40 + stripe: Option<&Arc<dyn Refundable>>,
41 41 user: &SessionUser,
42 42 item: ItemId,
43 43 transaction: TransactionId,
@@ -206,7 +206,7 @@
206 206 viewer
207 207 .block_on(crate::payments::refund::refund(
208 208 &viewer.app.db,
209 - viewer.app.payments.as_ref(),
209 + viewer.app.payment_caps.refundable.as_ref(),
210 210 &viewer.user,
211 211 item,
212 212 transaction,
@@ -692,6 +692,7 @@
692 692 Pricing {
693 693 billing: Billing {
694 694 payments: None,
695 + payment_caps: crate::payments::PaymentCapabilities::default(),
695 696 tier_prices: crate::tier_prices::TierPrices::global().clone(),
696 697 runway_config: crate::tier_prices::RunwayConfig {
697 698 quarters: 0,
@@ -121,12 +121,12 @@
121 121 // at receive time) and re-route. The handler re-fetches the object
122 122 // from Stripe and re-applies it idempotently.
123 123 match serde_json::from_str::<crate::payments::ThinEvent>(&event.payload) {
124 - Ok(thin) => match state.payments.as_ref() {
125 - Some(stripe) => {
124 + Ok(thin) => match state.payment_caps.connect_onboarding.as_ref() {
125 + Some(onboarding) => {
126 126 crate::routes::stripe::process_v2_thin_event(
127 127 &state.db,
128 128 state.wam.as_ref(),
129 - stripe.as_ref(),
129 + onboarding.as_ref(),
130 130 &state.config.signing_secret,
131 131 &thin,
132 132 )
@@ -290,7 +290,7 @@
290 290 /// [`escalate_stale_platform_credits`] rather than blindly retried.
291 291 #[tracing::instrument(skip_all, name = "scheduler::settle_platform_credits")]
292 292 pub(super) async fn settle_platform_credits(state: &AppState) {
293 - let Some(stripe) = state.payments.as_ref() else {
293 + let Some(transfers) = state.payment_caps.platform_transfers.as_ref() else {
294 294 return;
295 295 };
296 296
@@ -325,7 +325,7 @@
325 325 continue;
326 326 };
327 327
328 - match stripe
328 + match transfers
329 329 .create_platform_credit_transfer(
330 330 account,
331 331 credit.amount_cents.as_i64(),
@@ -369,7 +369,7 @@
369 369 /// settlement in the tick and is single-instance (scheduler advisory lock).
370 370 #[tracing::instrument(skip_all, name = "scheduler::reverse_refunded_platform_credits")]
371 371 pub(super) async fn reverse_refunded_platform_credits(state: &AppState) {
372 - let Some(stripe) = state.payments.as_ref() else {
372 + let Some(transfers) = state.payment_caps.platform_transfers.as_ref() else {
373 373 return;
374 374 };
375 375
@@ -387,7 +387,7 @@
387 387 };
388 388
389 389 for credit in reversible {
390 - match stripe
390 + match transfers
391 391 .create_platform_credit_reversal(
392 392 &credit.transfer_id,
393 393 credit.amount_cents.as_i64(),