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 dispute (a chargeback, or the inquiry before one) stands, in the
235/// shape issue #602 set out, plus the two states a Merchant of Record reports
236/// around it. The provider's own spelling is kept on
237/// [`Dispute::provider_status`]; [`DisputeStatus::phase`] is the coarse
238/// lifecycle a venture reacts to.
239#[derive(Debug, Clone, PartialEq, Eq)]
240#[non_exhaustive]
241pub enum DisputeStatus {
242    /// A pre-dispute signal from the card network (Polar `early_warning`); no
243    /// chargeback has been filed yet.
244    EarlyWarning,
245    /// An inquiry that wants a response (Stripe `warning_needs_response`).
246    WarningNeedsResponse,
247    /// An inquiry under review (Stripe `warning_under_review`).
248    WarningUnderReview,
249    /// An inquiry that closed without becoming a chargeback (Stripe
250    /// `warning_closed`).
251    WarningClosed,
252    /// A chargeback that wants evidence (`needs_response`).
253    NeedsResponse,
254    /// Evidence submitted, the issuer is deciding (`under_review`).
255    UnderReview,
256    /// Decided for the merchant: the funds come back.
257    Won,
258    /// Decided for the cardholder: the funds stay withdrawn.
259    Lost,
260    /// Headed off by a refund before it escalated (`prevented`): no
261    /// chargeback, and no dispute fee.
262    Prevented,
263    /// A status this version does not know; the raw value is kept.
264    Other(String),
265}
266
267/// The coarse dispute lifecycle a venture acts on: flag an account while a
268/// dispute is [`Open`](DisputePhase::Open), restore it when the dispute is
269/// [`Won`](DisputePhase::Won) or [`Closed`](DisputePhase::Closed) without a
270/// chargeback, and act on a [`Lost`](DisputePhase::Lost) one (revoke what the
271/// payment funded).
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum DisputePhase {
274    Open,
275    Won,
276    Lost,
277    /// Closed without a decision against either side: an inquiry that went
278    /// nowhere, or a dispute prevented by a refund.
279    Closed,
280}
281
282impl DisputeStatus {
283    /// Maps a provider status string (Polar's and Stripe's spellings) to a
284    /// status; anything unknown is kept as [`DisputeStatus::Other`].
285    #[must_use]
286    pub fn from_provider(status: &str) -> Self {
287        match status {
288            "early_warning" => Self::EarlyWarning,
289            "warning_needs_response" => Self::WarningNeedsResponse,
290            "warning_under_review" => Self::WarningUnderReview,
291            "warning_closed" => Self::WarningClosed,
292            "needs_response" => Self::NeedsResponse,
293            "under_review" => Self::UnderReview,
294            "won" => Self::Won,
295            "lost" => Self::Lost,
296            "prevented" => Self::Prevented,
297            other => Self::Other(other.to_owned()),
298        }
299    }
300
301    /// The lifecycle phase this status is in. An unknown status counts as
302    /// [`DisputePhase::Open`]: the cautious reading, since a venture that
303    /// flags on open keeps the flag until a known terminal status arrives.
304    #[must_use]
305    pub fn phase(&self) -> DisputePhase {
306        match self {
307            Self::EarlyWarning
308            | Self::WarningNeedsResponse
309            | Self::WarningUnderReview
310            | Self::NeedsResponse
311            | Self::UnderReview
312            | Self::Other(_) => DisputePhase::Open,
313            Self::Won => DisputePhase::Won,
314            Self::Lost => DisputePhase::Lost,
315            Self::WarningClosed | Self::Prevented => DisputePhase::Closed,
316        }
317    }
318}
319
320/// A dispute against one payment (issue #602's shape). `payment_ref` is the
321/// id [`RefundRequest::payment_ref`] takes for the same provider (a Stripe
322/// payment-intent, a Polar order), so a venture can tie the dispute to what
323/// it sold; `charge_ref` is the provider's underlying charge or payment id.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct Dispute {
326    pub id: String,
327    pub charge_ref: Option<String>,
328    pub payment_ref: String,
329    /// The customer charged, named the way the adapter is configured to name
330    /// customers (a provider id, or the venture's own id where the adapter
331    /// maps it), when the provider reports one.
332    pub customer_ref: Option<String>,
333    /// The disputed amount.
334    pub amount: Money,
335    /// The card network's reason (`fraudulent`, `product_not_received`, …),
336    /// once the provider reports it.
337    pub reason: Option<String>,
338    pub status: DisputeStatus,
339    /// The provider's status string, verbatim.
340    pub provider_status: String,
341    /// The evidence deadline, when a response is wanted.
342    pub evidence_due_by: Option<OffsetDateTime>,
343    /// Whether the disputed charge can still be refunded, when the provider
344    /// says (Stripe does; Polar does not).
345    pub is_charge_refundable: Option<bool>,
346    /// The provider's balance-transaction ids for the dispute, where it
347    /// exposes them (Stripe); empty otherwise.
348    pub balance_transactions: Vec<String>,
349}
350
351impl Dispute {
352    /// The dedup key for "this dispute reached this status". Claim it through
353    /// the [`Inbox`](crate::Inbox) and each transition is acted on once,
354    /// whether it arrived by webhook, by a poll of
355    /// [`Payments::list_disputes`], or both.
356    #[must_use]
357    pub fn event_key(&self) -> String {
358        format!("dispute:{}:{}", self.id, self.provider_status)
359    }
360}
361
362/// Which disputes [`Payments::list_disputes`] returns.
363#[derive(Debug, Clone, Default, PartialEq, Eq)]
364pub struct DisputeListRequest {
365    /// Only disputes still in [`DisputePhase::Open`].
366    pub open_only: bool,
367    /// The cursor from a previous [`DisputePage::next`], or `None` for the
368    /// first page.
369    pub cursor: Option<String>,
370}
371
372/// One page of disputes, newest first, and the cursor for the next page.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct DisputePage {
375    pub disputes: Vec<Dispute>,
376    pub next: Option<String>,
377}
378
379/// Payment failures. `NotConfigured` lets a venture build and run without
380/// Stripe (the port reports it rather than erroring); the rest map an upstream
381/// failure. `SignatureInvalid` is separated so a webhook handler answers `400`
382/// and never processes an unverified event.
383#[derive(Debug, Clone, Error)]
384pub enum PaymentsError {
385    /// No Stripe key configured: the caller should degrade, not fail.
386    #[error("payments are not configured")]
387    NotConfigured,
388    /// A webhook signature or timestamp did not verify: reject the request,
389    /// do not process the event.
390    #[error("webhook signature verification failed: {0}")]
391    SignatureInvalid(String),
392    /// Stripe rejected the request (a `4xx` that is not auth): not retryable
393    /// without a change.
394    #[error("payments request rejected: {0}")]
395    Rejected(String),
396    /// A transient failure (a `5xx`, a transport error): retry later.
397    #[error("payments request failed, retryable: {0}")]
398    Transient(String),
399    /// This adapter does not implement the operation — e.g. usage reporting
400    /// on an adapter that does not do metered billing. Not retryable: a
401    /// caller that needs it must select an adapter that supports it.
402    #[error("not supported by this payments adapter: {0}")]
403    Unsupported(&'static str),
404}
405
406/// Moves money for a venture. Stripe today; the trait names only Stripe
407/// identifiers and hosted URLs, never card data.
408#[async_trait]
409pub trait Payments: Send + Sync {
410    /// A one-time hosted checkout. Returns the URL to redirect the browser to.
411    async fn create_checkout(
412        &self,
413        request: &CheckoutRequest,
414    ) -> Result<CheckoutSession, PaymentsError>;
415
416    /// A recurring hosted checkout against a configured Stripe Price.
417    async fn create_subscription_checkout(
418        &self,
419        request: &SubscriptionCheckoutRequest,
420    ) -> Result<CheckoutSession, PaymentsError>;
421
422    /// A Connect onboarding link for a coach's account.
423    async fn create_connect_account_link(
424        &self,
425        request: &ConnectAccountLinkRequest,
426    ) -> Result<ConnectAccountLink, PaymentsError>;
427
428    /// A destination charge with an application fee (the platform's cut).
429    async fn charge_with_transfer(&self, request: &TransferCharge)
430    -> Result<Charge, PaymentsError>;
431
432    /// Refunds a prior payment, in whole or in part.
433    async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError>;
434
435    /// Verifies a webhook's signature and timestamp and returns the event.
436    /// `signature_header` is the raw `Stripe-Signature` header; `body` is the
437    /// exact bytes received (verification is over the raw body). Returns
438    /// [`PaymentsError::SignatureInvalid`] if verification fails.
439    async fn verify_webhook(
440        &self,
441        signature_header: &str,
442        body: &[u8],
443    ) -> Result<WebhookEvent, PaymentsError>;
444
445    /// Reports a metered usage amount to the billing provider (Stripe Billing
446    /// Meters) **idempotently**: the [`UsageReport::identifier`] makes a
447    /// repeated report a no-op at the provider, so retrying is safe and a
448    /// second report of the same window comes back as
449    /// [`UsageReported::already_reported`] rather than an error.
450    ///
451    /// The default reports [`PaymentsError::Unsupported`], so an adapter that
452    /// does not do metered billing compiles and runs unchanged.
453    async fn report_usage(&self, _report: &UsageReport) -> Result<UsageReported, PaymentsError> {
454        Err(PaymentsError::Unsupported("usage reporting"))
455    }
456
457    /// Verifies a webhook from its **request headers** and raw body. It
458    /// exists because some providers sign over more than one header:
459    /// Standard Webhooks (Polar, Svix) sends `webhook-id`,
460    /// `webhook-timestamp` and `webhook-signature`, which one
461    /// `signature_header` string cannot carry. A handler that calls this
462    /// instead of [`verify_webhook`](Payments::verify_webhook) stays the same
463    /// whichever adapter is composed in.
464    ///
465    /// The default reads `Stripe-Signature` and delegates to
466    /// [`verify_webhook`](Payments::verify_webhook); a missing header is
467    /// [`PaymentsError::SignatureInvalid`].
468    async fn verify_webhook_request(
469        &self,
470        headers: &http::HeaderMap,
471        body: &[u8],
472    ) -> Result<WebhookEvent, PaymentsError> {
473        let signature = headers
474            .get("stripe-signature")
475            .and_then(|value| value.to_str().ok())
476            .ok_or_else(|| PaymentsError::SignatureInvalid("no signature header".to_owned()))?;
477        self.verify_webhook(signature, body).await
478    }
479
480    /// A hosted customer-portal session (issue #589). The default reports
481    /// [`PaymentsError::Unsupported`].
482    async fn create_portal_session(
483        &self,
484        _request: &PortalSessionRequest,
485    ) -> Result<PortalSession, PaymentsError> {
486        Err(PaymentsError::Unsupported("customer portal sessions"))
487    }
488
489    /// Reads one dispute (issue #602). The default reports
490    /// [`PaymentsError::Unsupported`].
491    async fn get_dispute(&self, _dispute_ref: &str) -> Result<Dispute, PaymentsError> {
492        Err(PaymentsError::Unsupported("disputes"))
493    }
494
495    /// Lists disputes, newest first: the poll a venture runs on a schedule
496    /// when its provider sends no dispute webhooks (Polar), or to reconcile
497    /// missed ones. Dedup each result through [`Dispute::event_key`]. The
498    /// default reports [`PaymentsError::Unsupported`].
499    async fn list_disputes(
500        &self,
501        _request: &DisputeListRequest,
502    ) -> Result<DisputePage, PaymentsError> {
503        Err(PaymentsError::Unsupported("disputes"))
504    }
505
506    /// Accepts a dispute, conceding the chargeback, which settles it as lost
507    /// (issue #602). The default reports [`PaymentsError::Unsupported`].
508    async fn close_dispute(
509        &self,
510        _dispute_ref: &str,
511        _idempotency_key: &str,
512    ) -> Result<Dispute, PaymentsError> {
513        Err(PaymentsError::Unsupported("disputes"))
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use time::format_description::well_known::Rfc3339;
521
522    fn at(text: &str) -> OffsetDateTime {
523        OffsetDateTime::parse(text, &Rfc3339).expect("test instant parses")
524    }
525
526    /// An adapter that implements only the required methods: every new
527    /// optional method must answer with its default.
528    struct Minimal;
529
530    #[async_trait]
531    impl Payments for Minimal {
532        async fn create_checkout(
533            &self,
534            _: &CheckoutRequest,
535        ) -> Result<CheckoutSession, PaymentsError> {
536            Err(PaymentsError::NotConfigured)
537        }
538        async fn create_subscription_checkout(
539            &self,
540            _: &SubscriptionCheckoutRequest,
541        ) -> Result<CheckoutSession, PaymentsError> {
542            Err(PaymentsError::NotConfigured)
543        }
544        async fn create_connect_account_link(
545            &self,
546            _: &ConnectAccountLinkRequest,
547        ) -> Result<ConnectAccountLink, PaymentsError> {
548            Err(PaymentsError::NotConfigured)
549        }
550        async fn charge_with_transfer(&self, _: &TransferCharge) -> Result<Charge, PaymentsError> {
551            Err(PaymentsError::NotConfigured)
552        }
553        async fn refund(&self, _: &RefundRequest) -> Result<Refund, PaymentsError> {
554            Err(PaymentsError::NotConfigured)
555        }
556        async fn verify_webhook(
557            &self,
558            signature_header: &str,
559            _: &[u8],
560        ) -> Result<WebhookEvent, PaymentsError> {
561            Ok(WebhookEvent {
562                id: signature_header.to_owned(),
563                kind: "seen".to_owned(),
564                data: Value::Null,
565            })
566        }
567    }
568
569    #[test]
570    fn new_optional_methods_default_without_breaking_an_adapter() {
571        let mut headers = http::HeaderMap::new();
572        headers.insert("stripe-signature", "t=1,v1=ab".parse().unwrap());
573        // The request-level verifier reads `Stripe-Signature` by default.
574        let event = pollster::block_on(Minimal.verify_webhook_request(&headers, b"{}")).unwrap();
575        assert_eq!(event.id, "t=1,v1=ab");
576        assert!(matches!(
577            pollster::block_on(Minimal.verify_webhook_request(&http::HeaderMap::new(), b"{}")),
578            Err(PaymentsError::SignatureInvalid(_))
579        ));
580        let portal = PortalSessionRequest {
581            customer_ref: "c".to_owned(),
582            return_url: "https://x".to_owned(),
583            idempotency_key: "k".to_owned(),
584        };
585        assert!(matches!(
586            pollster::block_on(Minimal.create_portal_session(&portal)),
587            Err(PaymentsError::Unsupported(_))
588        ));
589        assert!(matches!(
590            pollster::block_on(Minimal.get_dispute("d")),
591            Err(PaymentsError::Unsupported(_))
592        ));
593        assert!(matches!(
594            pollster::block_on(Minimal.list_disputes(&DisputeListRequest::default())),
595            Err(PaymentsError::Unsupported(_))
596        ));
597        assert!(matches!(
598            pollster::block_on(Minimal.close_dispute("d", "k")),
599            Err(PaymentsError::Unsupported(_))
600        ));
601    }
602
603    #[test]
604    fn dispute_phases_and_keys() {
605        assert_eq!(
606            DisputeStatus::from_provider("needs_response").phase(),
607            DisputePhase::Open
608        );
609        assert_eq!(
610            DisputeStatus::from_provider("won").phase(),
611            DisputePhase::Won
612        );
613        assert_eq!(
614            DisputeStatus::from_provider("lost").phase(),
615            DisputePhase::Lost
616        );
617        assert_eq!(
618            DisputeStatus::from_provider("prevented").phase(),
619            DisputePhase::Closed
620        );
621        // An unknown status is read cautiously: still open.
622        assert_eq!(
623            DisputeStatus::from_provider("new_thing").phase(),
624            DisputePhase::Open
625        );
626        let dispute = Dispute {
627            id: "dp_1".to_owned(),
628            charge_ref: None,
629            payment_ref: "pi_1".to_owned(),
630            customer_ref: None,
631            amount: Money::new(100, "usd"),
632            reason: None,
633            status: DisputeStatus::Won,
634            provider_status: "won".to_owned(),
635            evidence_due_by: None,
636            is_charge_refundable: None,
637            balance_transactions: Vec::new(),
638        };
639        assert_eq!(dispute.event_key(), "dispute:dp_1:won");
640    }
641
642    #[test]
643    fn hourly_identifier_names_the_utc_hour() {
644        let report = UsageReport::hourly(
645            "sub_1",
646            "extra_avatar_minutes",
647            "cus_1",
648            5,
649            at("2026-05-04T13:59:59Z"),
650        );
651        assert_eq!(report.timestamp, at("2026-05-04T13:00:00Z"));
652        assert_eq!(
653            report.identifier,
654            format!(
655                "sub_1:extra_avatar_minutes:{}",
656                report.timestamp.unix_timestamp()
657            )
658        );
659
660        // Any instant in the hour, and any value, name the same window.
661        let same_hour = UsageReport::hourly(
662            "sub_1",
663            "extra_avatar_minutes",
664            "cus_1",
665            9,
666            at("2026-05-04T13:00:01Z"),
667        );
668        assert_eq!(report.identifier, same_hour.identifier);
669        assert_eq!(report.timestamp, same_hour.timestamp);
670
671        // A non-UTC spelling of an instant names the same UTC hour:
672        // 18:45 +05:30 is 13:15 UTC, the same window as 13:59:59Z above.
673        let local = UsageReport::hourly(
674            "sub_1",
675            "extra_avatar_minutes",
676            "cus_1",
677            5,
678            at("2026-05-04T18:45:00+05:30"),
679        );
680        assert_eq!(report.timestamp, local.timestamp);
681        assert_eq!(report.identifier, local.identifier);
682
683        // The next hour is a new window and a new identifier.
684        let next_hour = UsageReport::hourly(
685            "sub_1",
686            "extra_avatar_minutes",
687            "cus_1",
688            5,
689            at("2026-05-04T14:00:00Z"),
690        );
691        assert_ne!(report.identifier, next_hour.identifier);
692        assert_ne!(report.timestamp, next_hour.timestamp);
693    }
694}