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}