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}