Skip to main content

cratefield_core/ports/
payments.rs

1//! The `Payments` port (issue #102): the first thing in the harness that moves
2//! money. Stripe, and Polar as a Merchant of Record (issue #690, ADR 0027),
3//! both over the runtime's `HttpClient`.
4//!
5//! **Card data never crosses the harness.** Every method here names a Stripe
6//! identifier or a hosted URL — a checkout session the browser is redirected
7//! to, a customer/account/payment id — never a card number, CVC, or expiry.
8//! The card details are entered on Stripe's own hosted pages; the harness only
9//! ever holds Stripe's identifiers for them (see `docs/PAYMENTS.md`).
10//!
11//! The trait names only what a billing module needs. [`verify_webhook`]
12//! returns a verified [`WebhookEvent`] and the module decides what it means.
13//! The billing *lifecycle* — trials, entitlements, renewals, disputes — is
14//! no longer venture code: it lives in `cratefield-module-billing` (ADR
15//! 0025). A store-billing aggregator such as `RevenueCat` is the separate
16//! `InAppPurchases` port, not this one, because it reports purchases the
17//! stores made rather than moving money. Payout schedules stay venture
18//! code where they are a venture's own policy.
19//!
20//! [`verify_webhook`]: Payments::verify_webhook
21
22use async_trait::async_trait;
23use serde_json::Value;
24use std::collections::BTreeMap;
25use thiserror::Error;
26use time::{OffsetDateTime, UtcOffset};
27
28/// An amount in a currency's minor units (cents), the way Stripe takes and
29/// reports money. `currency` is a lowercase ISO-4217 code (`"usd"`).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Money {
32    pub minor_units: i64,
33    pub currency: String,
34}
35
36impl Money {
37    #[must_use]
38    pub fn new(minor_units: i64, currency: impl Into<String>) -> Self {
39        Self {
40            minor_units,
41            currency: currency.into(),
42        }
43    }
44}
45
46/// One line on a checkout: a name shown to the buyer and its price.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LineItem {
49    pub name: String,
50    pub amount: Money,
51    pub quantity: u32,
52}
53
54/// A one-time hosted checkout (Stripe Checkout in `payment` mode).
55#[derive(Debug, Clone)]
56pub struct CheckoutRequest {
57    /// A known Stripe customer id, if the venture has one for this buyer.
58    pub customer_ref: Option<String>,
59    /// The buyer's email, so Stripe can create/attach a customer.
60    pub customer_email: Option<String>,
61    pub line: LineItem,
62    pub success_url: String,
63    pub cancel_url: String,
64    /// Copied onto the resulting objects, echoed back on the webhook.
65    pub metadata: BTreeMap<String, String>,
66    /// Makes the create idempotent under retries; the caller owns its shape.
67    pub idempotency_key: String,
68}
69
70/// A recurring hosted checkout (Stripe Checkout in `subscription` mode) against
71/// a Stripe Price the venture configured (e.g. `$15/mo` with a trial).
72#[derive(Debug, Clone)]
73pub struct SubscriptionCheckoutRequest {
74    pub customer_ref: Option<String>,
75    pub customer_email: Option<String>,
76    /// The Stripe Price id to subscribe to.
77    pub price_ref: String,
78    /// Free-trial length in days, if any.
79    pub trial_days: Option<u32>,
80    pub success_url: String,
81    pub cancel_url: String,
82    pub metadata: BTreeMap<String, String>,
83    pub idempotency_key: String,
84}
85
86/// The hosted page to send the browser to, and the session id to reconcile on.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct CheckoutSession {
89    pub id: String,
90    pub url: String,
91}
92
93/// Onboards a Connect account (a coach) and returns a hosted onboarding link.
94#[derive(Debug, Clone)]
95pub struct ConnectAccountLinkRequest {
96    /// An existing Connect account id to refresh, or `None` to create one.
97    pub account_ref: Option<String>,
98    pub refresh_url: String,
99    pub return_url: String,
100    pub idempotency_key: String,
101}
102
103/// The Connect account id (persist it) and the hosted onboarding URL.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ConnectAccountLink {
106    pub account_id: String,
107    pub url: String,
108}
109
110/// A destination charge with an application fee: the buyer is charged
111/// `amount`, `application_fee` is kept by the platform, and the remainder is
112/// transferred to `destination_account` (the coach's Connect account).
113#[derive(Debug, Clone)]
114pub struct TransferCharge {
115    pub customer_ref: Option<String>,
116    pub amount: Money,
117    pub destination_account: String,
118    pub application_fee: Money,
119    pub metadata: BTreeMap<String, String>,
120    pub idempotency_key: String,
121}
122
123/// A created charge/payment-intent and its status as Stripe reported it.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Charge {
126    pub id: String,
127    pub status: String,
128}
129
130/// A refund of a prior payment: the whole amount when `amount` is `None`, else
131/// a partial refund.
132#[derive(Debug, Clone)]
133pub struct RefundRequest {
134    /// The payment-intent (or charge) id to refund.
135    pub payment_ref: String,
136    pub amount: Option<Money>,
137    pub idempotency_key: String,
138}
139
140/// A created refund.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Refund {
143    pub id: String,
144}
145
146/// A metered usage amount to report to the billing provider (Stripe Billing
147/// Meters). `meter_event_name` names a meter the venture configured;
148/// `customer_ref` is the provider customer id the meter maps the event to;
149/// `value` is the amount counted, in the meter's own unit; `identifier` is the
150/// caller-chosen exactly-once key; `timestamp` is when the usage happened.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct UsageReport {
153    pub meter_event_name: String,
154    pub customer_ref: String,
155    pub value: u64,
156    pub identifier: String,
157    pub timestamp: OffsetDateTime,
158}
159
160impl UsageReport {
161    /// A report for one **UTC hour**: `timestamp` is the start of the hour
162    /// containing `at`, and `identifier` is
163    /// `{subject}:{meter_event_name}:{start unix seconds}`. Every report for
164    /// the same subject, meter and hour therefore carries the same identifier,
165    /// so a retry, a re-delivery, or a concurrent drainer reports that window
166    /// once. The identifier is only safe when the window's `value` is **final**
167    /// — report a window once it has closed (see `docs/PAYMENTS.md`); two
168    /// reports for the same open hour with different values would collide, and
169    /// the provider would count only the first. The next hour is a new window
170    /// and a new identifier.
171    #[must_use]
172    pub fn hourly(
173        subject: &str,
174        meter_event_name: &str,
175        customer_ref: &str,
176        value: u64,
177        at: OffsetDateTime,
178    ) -> Self {
179        let at = at.to_offset(UtcOffset::UTC);
180        let start = at
181            .replace_minute(0)
182            .and_then(|t| t.replace_second(0))
183            .and_then(|t| t.replace_nanosecond(0))
184            .unwrap_or(at);
185        Self {
186            meter_event_name: meter_event_name.to_owned(),
187            customer_ref: customer_ref.to_owned(),
188            value,
189            identifier: format!("{subject}:{meter_event_name}:{}", start.unix_timestamp()),
190            timestamp: start,
191        }
192    }
193}
194
195/// The outcome of a [`report_usage`](Payments::report_usage): the identifier
196/// that was reported and whether the provider said it had **already** been
197/// reported. A duplicate is success, not failure — the window was counted
198/// exactly once — so a caller completes the work either way.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct UsageReported {
201    pub identifier: String,
202    pub already_reported: bool,
203}
204
205/// A webhook event the adapter has **verified** (signature + timestamp) before
206/// returning. `kind` is Stripe's event type (`"checkout.session.completed"`);
207/// `data` is the event's `data.object` for the module to interpret.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct WebhookEvent {
210    pub id: String,
211    pub kind: String,
212    pub data: Value,
213}
214
215/// A hosted customer-portal session (issue #589): the page where a customer
216/// changes plan, updates the payment method, reads invoices and cancels.
217/// `customer_ref` is the provider customer the adapter is configured to name
218/// (a provider id, or the venture's own id where the adapter maps it).
219#[derive(Debug, Clone)]
220pub struct PortalSessionRequest {
221    pub customer_ref: String,
222    /// Where the portal's back link returns to.
223    pub return_url: String,
224    pub idempotency_key: String,
225}
226
227/// The hosted portal URL to send the customer to. Short-lived: create one per
228/// visit, never store it.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct PortalSession {
231    pub url: String,
232}
233
234/// Where a recurring subscription stands (issue #589), in the provider's own
235/// vocabulary — Stripe's subscription statuses today, plus
236/// [`Other`](SubscriptionStatus::Other) for one this version does not know.
237/// This is the provider's word, not an entitlement: a billing module reads it
238/// and decides what access it grants (ADR 0025).
239#[derive(Debug, Clone, PartialEq, Eq)]
240#[non_exhaustive]
241pub enum SubscriptionStatus {
242    /// In a free trial; the first invoice has not been raised yet.
243    Trialing,
244    /// Paid and current.
245    Active,
246    /// A renewal payment failed; the provider is still retrying.
247    PastDue,
248    /// Ended, by the customer or after collection gave up.
249    Canceled,
250    /// The final invoice went unpaid, after the retries ran out.
251    Unpaid,
252    /// Created, but the first payment has not completed yet.
253    Incomplete,
254    /// The first payment was never completed in time; the subscription is
255    /// dead and cannot be revived.
256    IncompleteExpired,
257    /// Paused: billing is suspended and no invoices are raised.
258    Paused,
259    /// A status this version does not know; the raw value is kept.
260    Other(String),
261}
262
263impl SubscriptionStatus {
264    /// Maps a provider status string (Stripe's spellings) to a status;
265    /// anything unknown is kept as [`SubscriptionStatus::Other`].
266    #[must_use]
267    pub fn from_provider(status: &str) -> Self {
268        match status {
269            "trialing" => Self::Trialing,
270            "active" => Self::Active,
271            "past_due" => Self::PastDue,
272            "canceled" => Self::Canceled,
273            "unpaid" => Self::Unpaid,
274            "incomplete" => Self::Incomplete,
275            "incomplete_expired" => Self::IncompleteExpired,
276            "paused" => Self::Paused,
277            other => Self::Other(other.to_owned()),
278        }
279    }
280
281    /// The provider's status string, the inverse of
282    /// [`from_provider`](SubscriptionStatus::from_provider), so a status read
283    /// from a provider and written back keeps its spelling.
284    #[must_use]
285    pub fn as_str(&self) -> &str {
286        match self {
287            Self::Trialing => "trialing",
288            Self::Active => "active",
289            Self::PastDue => "past_due",
290            Self::Canceled => "canceled",
291            Self::Unpaid => "unpaid",
292            Self::Incomplete => "incomplete",
293            Self::IncompleteExpired => "incomplete_expired",
294            Self::Paused => "paused",
295            Self::Other(other) => other,
296        }
297    }
298}
299
300/// A recurring subscription as the provider reports it (issue #589).
301/// `customer_ref` names the customer the way the adapter is configured to name
302/// customers (a provider id, or the venture's own id where the adapter maps
303/// it); the fields mirror what a billing module reconciles against, never an
304/// entitlement.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct Subscription {
307    pub id: String,
308    pub customer_ref: String,
309    pub status: SubscriptionStatus,
310    /// The price the subscription is on (a Stripe Price id), when the
311    /// provider reports one.
312    pub price_ref: Option<String>,
313    /// The number of units subscribed. `1` when the provider reports none —
314    /// a metered price, for example, has no fixed quantity.
315    pub quantity: u32,
316    /// When the current period ends; `None` when the provider does not say.
317    pub current_period_end: Option<OffsetDateTime>,
318    /// Whether the subscription is set to end when the current period does.
319    pub cancel_at_period_end: bool,
320    /// The metadata the provider holds on the subscription.
321    pub metadata: BTreeMap<String, String>,
322}
323
324/// Where a dispute (a chargeback, or the inquiry before one) stands, in the
325/// shape issue #602 set out, plus the two states a Merchant of Record reports
326/// around it. The provider's own spelling is kept on
327/// [`Dispute::provider_status`]; [`DisputeStatus::phase`] is the coarse
328/// lifecycle a venture reacts to.
329#[derive(Debug, Clone, PartialEq, Eq)]
330#[non_exhaustive]
331pub enum DisputeStatus {
332    /// A pre-dispute signal from the card network (Polar `early_warning`); no
333    /// chargeback has been filed yet.
334    EarlyWarning,
335    /// An inquiry that wants a response (Stripe `warning_needs_response`).
336    WarningNeedsResponse,
337    /// An inquiry under review (Stripe `warning_under_review`).
338    WarningUnderReview,
339    /// An inquiry that closed without becoming a chargeback (Stripe
340    /// `warning_closed`).
341    WarningClosed,
342    /// A chargeback that wants evidence (`needs_response`).
343    NeedsResponse,
344    /// Evidence submitted, the issuer is deciding (`under_review`).
345    UnderReview,
346    /// Decided for the merchant: the funds come back.
347    Won,
348    /// Decided for the cardholder: the funds stay withdrawn.
349    Lost,
350    /// Headed off by a refund before it escalated (`prevented`): no
351    /// chargeback, and no dispute fee.
352    Prevented,
353    /// A status this version does not know; the raw value is kept.
354    Other(String),
355}
356
357/// The coarse dispute lifecycle a venture acts on: flag an account while a
358/// dispute is [`Open`](DisputePhase::Open), restore it when the dispute is
359/// [`Won`](DisputePhase::Won) or [`Closed`](DisputePhase::Closed) without a
360/// chargeback, and act on a [`Lost`](DisputePhase::Lost) one (revoke what the
361/// payment funded).
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum DisputePhase {
364    Open,
365    Won,
366    Lost,
367    /// Closed without a decision against either side: an inquiry that went
368    /// nowhere, or a dispute prevented by a refund.
369    Closed,
370}
371
372impl DisputeStatus {
373    /// Maps a provider status string (Polar's and Stripe's spellings) to a
374    /// status; anything unknown is kept as [`DisputeStatus::Other`].
375    #[must_use]
376    pub fn from_provider(status: &str) -> Self {
377        match status {
378            "early_warning" => Self::EarlyWarning,
379            "warning_needs_response" => Self::WarningNeedsResponse,
380            "warning_under_review" => Self::WarningUnderReview,
381            "warning_closed" => Self::WarningClosed,
382            "needs_response" => Self::NeedsResponse,
383            "under_review" => Self::UnderReview,
384            "won" => Self::Won,
385            "lost" => Self::Lost,
386            "prevented" => Self::Prevented,
387            other => Self::Other(other.to_owned()),
388        }
389    }
390
391    /// The lifecycle phase this status is in. An unknown status counts as
392    /// [`DisputePhase::Open`]: the cautious reading, since a venture that
393    /// flags on open keeps the flag until a known terminal status arrives.
394    #[must_use]
395    pub fn phase(&self) -> DisputePhase {
396        match self {
397            Self::EarlyWarning
398            | Self::WarningNeedsResponse
399            | Self::WarningUnderReview
400            | Self::NeedsResponse
401            | Self::UnderReview
402            | Self::Other(_) => DisputePhase::Open,
403            Self::Won => DisputePhase::Won,
404            Self::Lost => DisputePhase::Lost,
405            Self::WarningClosed | Self::Prevented => DisputePhase::Closed,
406        }
407    }
408}
409
410/// A dispute against one payment (issue #602's shape). `payment_ref` is the
411/// id [`RefundRequest::payment_ref`] takes for the same provider (a Stripe
412/// payment-intent, a Polar order), so a venture can tie the dispute to what
413/// it sold; `charge_ref` is the provider's underlying charge or payment id.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct Dispute {
416    pub id: String,
417    pub charge_ref: Option<String>,
418    pub payment_ref: String,
419    /// The customer charged, named the way the adapter is configured to name
420    /// customers (a provider id, or the venture's own id where the adapter
421    /// maps it), when the provider reports one.
422    pub customer_ref: Option<String>,
423    /// The disputed amount.
424    pub amount: Money,
425    /// The card network's reason (`fraudulent`, `product_not_received`, …),
426    /// once the provider reports it.
427    pub reason: Option<String>,
428    pub status: DisputeStatus,
429    /// The provider's status string, verbatim.
430    pub provider_status: String,
431    /// The evidence deadline, when a response is wanted.
432    pub evidence_due_by: Option<OffsetDateTime>,
433    /// Whether the disputed charge can still be refunded, when the provider
434    /// says (Stripe does; Polar does not).
435    pub is_charge_refundable: Option<bool>,
436    /// The provider's balance-transaction ids for the dispute, where it
437    /// exposes them (Stripe); empty otherwise.
438    pub balance_transactions: Vec<String>,
439}
440
441impl Dispute {
442    /// The dedup key for "this dispute reached this status". Claim it through
443    /// the [`Inbox`](crate::Inbox) and each transition is acted on once,
444    /// whether it arrived by webhook, by a poll of
445    /// [`Payments::list_disputes`], or both.
446    #[must_use]
447    pub fn event_key(&self) -> String {
448        format!("dispute:{}:{}", self.id, self.provider_status)
449    }
450}
451
452/// Which disputes [`Payments::list_disputes`] returns.
453#[derive(Debug, Clone, Default, PartialEq, Eq)]
454pub struct DisputeListRequest {
455    /// Only disputes still in [`DisputePhase::Open`].
456    pub open_only: bool,
457    /// The cursor from a previous [`DisputePage::next`], or `None` for the
458    /// first page.
459    pub cursor: Option<String>,
460}
461
462/// One page of disputes, newest first, and the cursor for the next page.
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct DisputePage {
465    pub disputes: Vec<Dispute>,
466    pub next: Option<String>,
467}
468
469/// Payment failures. `NotConfigured` lets a venture build and run without
470/// Stripe (the port reports it rather than erroring); the rest map an upstream
471/// failure. `SignatureInvalid` is separated so a webhook handler answers `400`
472/// and never processes an unverified event.
473#[derive(Debug, Clone, Error)]
474pub enum PaymentsError {
475    /// No Stripe key configured: the caller should degrade, not fail.
476    #[error("payments are not configured")]
477    NotConfigured,
478    /// A webhook signature or timestamp did not verify: reject the request,
479    /// do not process the event.
480    #[error("webhook signature verification failed: {0}")]
481    SignatureInvalid(String),
482    /// Stripe rejected the request (a `4xx` that is not auth): not retryable
483    /// without a change.
484    #[error("payments request rejected: {0}")]
485    Rejected(String),
486    /// A transient failure (a `5xx`, a transport error): retry later.
487    #[error("payments request failed, retryable: {0}")]
488    Transient(String),
489    /// This adapter does not implement the operation — e.g. usage reporting
490    /// on an adapter that does not do metered billing. Not retryable: a
491    /// caller that needs it must select an adapter that supports it.
492    #[error("not supported by this payments adapter: {0}")]
493    Unsupported(&'static str),
494}
495
496/// Moves money for a venture. Stripe today; the trait names only Stripe
497/// identifiers and hosted URLs, never card data.
498#[async_trait]
499pub trait Payments: Send + Sync {
500    /// A one-time hosted checkout. Returns the URL to redirect the browser to.
501    async fn create_checkout(
502        &self,
503        request: &CheckoutRequest,
504    ) -> Result<CheckoutSession, PaymentsError>;
505
506    /// A recurring hosted checkout against a configured Stripe Price.
507    async fn create_subscription_checkout(
508        &self,
509        request: &SubscriptionCheckoutRequest,
510    ) -> Result<CheckoutSession, PaymentsError>;
511
512    /// A Connect onboarding link for a coach's account.
513    async fn create_connect_account_link(
514        &self,
515        request: &ConnectAccountLinkRequest,
516    ) -> Result<ConnectAccountLink, PaymentsError>;
517
518    /// A destination charge with an application fee (the platform's cut).
519    async fn charge_with_transfer(&self, request: &TransferCharge)
520    -> Result<Charge, PaymentsError>;
521
522    /// Refunds a prior payment, in whole or in part.
523    async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError>;
524
525    /// Verifies a webhook's signature and timestamp and returns the event.
526    /// `signature_header` is the raw `Stripe-Signature` header; `body` is the
527    /// exact bytes received (verification is over the raw body). Returns
528    /// [`PaymentsError::SignatureInvalid`] if verification fails.
529    async fn verify_webhook(
530        &self,
531        signature_header: &str,
532        body: &[u8],
533    ) -> Result<WebhookEvent, PaymentsError>;
534
535    /// Reports a metered usage amount to the billing provider (Stripe Billing
536    /// Meters) **idempotently**: the [`UsageReport::identifier`] makes a
537    /// repeated report a no-op at the provider, so retrying is safe and a
538    /// second report of the same window comes back as
539    /// [`UsageReported::already_reported`] rather than an error.
540    ///
541    /// The default reports [`PaymentsError::Unsupported`], so an adapter that
542    /// does not do metered billing compiles and runs unchanged.
543    async fn report_usage(&self, _report: &UsageReport) -> Result<UsageReported, PaymentsError> {
544        Err(PaymentsError::Unsupported("usage reporting"))
545    }
546
547    /// Verifies a webhook from its **request headers** and raw body. It
548    /// exists because some providers sign over more than one header:
549    /// Standard Webhooks (Polar, Svix) sends `webhook-id`,
550    /// `webhook-timestamp` and `webhook-signature`, which one
551    /// `signature_header` string cannot carry. A handler that calls this
552    /// instead of [`verify_webhook`](Payments::verify_webhook) stays the same
553    /// whichever adapter is composed in.
554    ///
555    /// The default reads `Stripe-Signature` and delegates to
556    /// [`verify_webhook`](Payments::verify_webhook); a missing header is
557    /// [`PaymentsError::SignatureInvalid`].
558    async fn verify_webhook_request(
559        &self,
560        headers: &http::HeaderMap,
561        body: &[u8],
562    ) -> Result<WebhookEvent, PaymentsError> {
563        let signature = headers
564            .get("stripe-signature")
565            .and_then(|value| value.to_str().ok())
566            .ok_or_else(|| PaymentsError::SignatureInvalid("no signature header".to_owned()))?;
567        self.verify_webhook(signature, body).await
568    }
569
570    /// A hosted customer-portal session (issue #589). The default reports
571    /// [`PaymentsError::Unsupported`].
572    async fn create_portal_session(
573        &self,
574        _request: &PortalSessionRequest,
575    ) -> Result<PortalSession, PaymentsError> {
576        Err(PaymentsError::Unsupported("customer portal sessions"))
577    }
578
579    /// Reads one subscription by its provider id (issue #589): the lookup a
580    /// billing module makes when a webhook was missed, arrived out of order,
581    /// or left the module unsure of the current state. The default reports
582    /// [`PaymentsError::Unsupported`].
583    async fn get_subscription(
584        &self,
585        _subscription_ref: &str,
586    ) -> Result<Subscription, PaymentsError> {
587        Err(PaymentsError::Unsupported("subscriptions"))
588    }
589
590    /// Lists a customer's subscriptions (issue #589): the poll a venture runs
591    /// to reconcile what a webhook stream missed, including canceled
592    /// subscriptions where the provider would otherwise hide them. The default
593    /// reports [`PaymentsError::Unsupported`].
594    async fn list_subscriptions(
595        &self,
596        _customer_ref: &str,
597    ) -> Result<Vec<Subscription>, PaymentsError> {
598        Err(PaymentsError::Unsupported("subscriptions"))
599    }
600
601    /// Reads one dispute (issue #602). The default reports
602    /// [`PaymentsError::Unsupported`].
603    async fn get_dispute(&self, _dispute_ref: &str) -> Result<Dispute, PaymentsError> {
604        Err(PaymentsError::Unsupported("disputes"))
605    }
606
607    /// Lists disputes, newest first: the poll a venture runs on a schedule
608    /// when its provider sends no dispute webhooks (Polar), or to reconcile
609    /// missed ones. Dedup each result through [`Dispute::event_key`]. The
610    /// default reports [`PaymentsError::Unsupported`].
611    async fn list_disputes(
612        &self,
613        _request: &DisputeListRequest,
614    ) -> Result<DisputePage, PaymentsError> {
615        Err(PaymentsError::Unsupported("disputes"))
616    }
617
618    /// Accepts a dispute, conceding the chargeback, which settles it as lost
619    /// (issue #602). The default reports [`PaymentsError::Unsupported`].
620    async fn close_dispute(
621        &self,
622        _dispute_ref: &str,
623        _idempotency_key: &str,
624    ) -> Result<Dispute, PaymentsError> {
625        Err(PaymentsError::Unsupported("disputes"))
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use time::format_description::well_known::Rfc3339;
633
634    fn at(text: &str) -> OffsetDateTime {
635        OffsetDateTime::parse(text, &Rfc3339).expect("test instant parses")
636    }
637
638    /// An adapter that implements only the required methods: every new
639    /// optional method must answer with its default.
640    struct Minimal;
641
642    #[async_trait]
643    impl Payments for Minimal {
644        async fn create_checkout(
645            &self,
646            _: &CheckoutRequest,
647        ) -> Result<CheckoutSession, PaymentsError> {
648            Err(PaymentsError::NotConfigured)
649        }
650        async fn create_subscription_checkout(
651            &self,
652            _: &SubscriptionCheckoutRequest,
653        ) -> Result<CheckoutSession, PaymentsError> {
654            Err(PaymentsError::NotConfigured)
655        }
656        async fn create_connect_account_link(
657            &self,
658            _: &ConnectAccountLinkRequest,
659        ) -> Result<ConnectAccountLink, PaymentsError> {
660            Err(PaymentsError::NotConfigured)
661        }
662        async fn charge_with_transfer(&self, _: &TransferCharge) -> Result<Charge, PaymentsError> {
663            Err(PaymentsError::NotConfigured)
664        }
665        async fn refund(&self, _: &RefundRequest) -> Result<Refund, PaymentsError> {
666            Err(PaymentsError::NotConfigured)
667        }
668        async fn verify_webhook(
669            &self,
670            signature_header: &str,
671            _: &[u8],
672        ) -> Result<WebhookEvent, PaymentsError> {
673            Ok(WebhookEvent {
674                id: signature_header.to_owned(),
675                kind: "seen".to_owned(),
676                data: Value::Null,
677            })
678        }
679    }
680
681    #[test]
682    fn new_optional_methods_default_without_breaking_an_adapter() {
683        let mut headers = http::HeaderMap::new();
684        headers.insert("stripe-signature", "t=1,v1=ab".parse().unwrap());
685        // The request-level verifier reads `Stripe-Signature` by default.
686        let event = pollster::block_on(Minimal.verify_webhook_request(&headers, b"{}")).unwrap();
687        assert_eq!(event.id, "t=1,v1=ab");
688        assert!(matches!(
689            pollster::block_on(Minimal.verify_webhook_request(&http::HeaderMap::new(), b"{}")),
690            Err(PaymentsError::SignatureInvalid(_))
691        ));
692        let portal = PortalSessionRequest {
693            customer_ref: "c".to_owned(),
694            return_url: "https://x".to_owned(),
695            idempotency_key: "k".to_owned(),
696        };
697        assert!(matches!(
698            pollster::block_on(Minimal.create_portal_session(&portal)),
699            Err(PaymentsError::Unsupported(_))
700        ));
701        assert!(matches!(
702            pollster::block_on(Minimal.get_dispute("d")),
703            Err(PaymentsError::Unsupported(_))
704        ));
705        assert!(matches!(
706            pollster::block_on(Minimal.list_disputes(&DisputeListRequest::default())),
707            Err(PaymentsError::Unsupported(_))
708        ));
709        assert!(matches!(
710            pollster::block_on(Minimal.close_dispute("d", "k")),
711            Err(PaymentsError::Unsupported(_))
712        ));
713        assert!(matches!(
714            pollster::block_on(Minimal.get_subscription("sub_1")),
715            Err(PaymentsError::Unsupported(_))
716        ));
717        assert!(matches!(
718            pollster::block_on(Minimal.list_subscriptions("cus_1")),
719            Err(PaymentsError::Unsupported(_))
720        ));
721    }
722
723    #[test]
724    fn subscription_status_round_trips_stripe_spellings() {
725        for status in [
726            "trialing",
727            "active",
728            "past_due",
729            "canceled",
730            "unpaid",
731            "incomplete",
732            "incomplete_expired",
733            "paused",
734        ] {
735            assert_eq!(SubscriptionStatus::from_provider(status).as_str(), status);
736        }
737        // An unknown status is kept, not lost or coerced.
738        let other = SubscriptionStatus::from_provider("new_thing");
739        assert_eq!(other, SubscriptionStatus::Other("new_thing".to_owned()));
740        assert_eq!(other.as_str(), "new_thing");
741    }
742
743    #[test]
744    fn dispute_phases_and_keys() {
745        assert_eq!(
746            DisputeStatus::from_provider("needs_response").phase(),
747            DisputePhase::Open
748        );
749        assert_eq!(
750            DisputeStatus::from_provider("won").phase(),
751            DisputePhase::Won
752        );
753        assert_eq!(
754            DisputeStatus::from_provider("lost").phase(),
755            DisputePhase::Lost
756        );
757        assert_eq!(
758            DisputeStatus::from_provider("prevented").phase(),
759            DisputePhase::Closed
760        );
761        // An unknown status is read cautiously: still open.
762        assert_eq!(
763            DisputeStatus::from_provider("new_thing").phase(),
764            DisputePhase::Open
765        );
766        let dispute = Dispute {
767            id: "dp_1".to_owned(),
768            charge_ref: None,
769            payment_ref: "pi_1".to_owned(),
770            customer_ref: None,
771            amount: Money::new(100, "usd"),
772            reason: None,
773            status: DisputeStatus::Won,
774            provider_status: "won".to_owned(),
775            evidence_due_by: None,
776            is_charge_refundable: None,
777            balance_transactions: Vec::new(),
778        };
779        assert_eq!(dispute.event_key(), "dispute:dp_1:won");
780    }
781
782    #[test]
783    fn hourly_identifier_names_the_utc_hour() {
784        let report = UsageReport::hourly(
785            "sub_1",
786            "extra_avatar_minutes",
787            "cus_1",
788            5,
789            at("2026-05-04T13:59:59Z"),
790        );
791        assert_eq!(report.timestamp, at("2026-05-04T13:00:00Z"));
792        assert_eq!(
793            report.identifier,
794            format!(
795                "sub_1:extra_avatar_minutes:{}",
796                report.timestamp.unix_timestamp()
797            )
798        );
799
800        // Any instant in the hour, and any value, name the same window.
801        let same_hour = UsageReport::hourly(
802            "sub_1",
803            "extra_avatar_minutes",
804            "cus_1",
805            9,
806            at("2026-05-04T13:00:01Z"),
807        );
808        assert_eq!(report.identifier, same_hour.identifier);
809        assert_eq!(report.timestamp, same_hour.timestamp);
810
811        // A non-UTC spelling of an instant names the same UTC hour:
812        // 18:45 +05:30 is 13:15 UTC, the same window as 13:59:59Z above.
813        let local = UsageReport::hourly(
814            "sub_1",
815            "extra_avatar_minutes",
816            "cus_1",
817            5,
818            at("2026-05-04T18:45:00+05:30"),
819        );
820        assert_eq!(report.timestamp, local.timestamp);
821        assert_eq!(report.identifier, local.identifier);
822
823        // The next hour is a new window and a new identifier.
824        let next_hour = UsageReport::hourly(
825            "sub_1",
826            "extra_avatar_minutes",
827            "cus_1",
828            5,
829            at("2026-05-04T14:00:00Z"),
830        );
831        assert_ne!(report.identifier, next_hour.identifier);
832        assert_ne!(report.timestamp, next_hour.timestamp);
833    }
834}