cratefield_core/ports/payments.rs
1//! The `Payments` port (issue #102): the first thing in the harness that moves
2//! money. Stripe today, over the runtime's `HttpClient`.
3//!
4//! **Card data never crosses the harness.** Every method here names a Stripe
5//! identifier or a hosted URL — a checkout session the browser is redirected
6//! to, a customer/account/payment id — never a card number, CVC, or expiry.
7//! The card details are entered on Stripe's own hosted pages; the harness only
8//! ever holds Stripe's identifiers for them (see `docs/PAYMENTS.md`).
9//!
10//! The trait names only what a billing module needs. Interpreting events
11//! (trials, entitlements, payout schedules) is venture code: [`verify_webhook`]
12//! returns a verified [`WebhookEvent`] and the module decides what it means.
13//!
14//! [`verify_webhook`]: Payments::verify_webhook
15
16use async_trait::async_trait;
17use serde_json::Value;
18use std::collections::BTreeMap;
19use thiserror::Error;
20
21/// An amount in a currency's minor units (cents), the way Stripe takes and
22/// reports money. `currency` is a lowercase ISO-4217 code (`"usd"`).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Money {
25 pub minor_units: i64,
26 pub currency: String,
27}
28
29impl Money {
30 #[must_use]
31 pub fn new(minor_units: i64, currency: impl Into<String>) -> Self {
32 Self {
33 minor_units,
34 currency: currency.into(),
35 }
36 }
37}
38
39/// One line on a checkout: a name shown to the buyer and its price.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LineItem {
42 pub name: String,
43 pub amount: Money,
44 pub quantity: u32,
45}
46
47/// A one-time hosted checkout (Stripe Checkout in `payment` mode).
48#[derive(Debug, Clone)]
49pub struct CheckoutRequest {
50 /// A known Stripe customer id, if the venture has one for this buyer.
51 pub customer_ref: Option<String>,
52 /// The buyer's email, so Stripe can create/attach a customer.
53 pub customer_email: Option<String>,
54 pub line: LineItem,
55 pub success_url: String,
56 pub cancel_url: String,
57 /// Copied onto the resulting objects, echoed back on the webhook.
58 pub metadata: BTreeMap<String, String>,
59 /// Makes the create idempotent under retries; the caller owns its shape.
60 pub idempotency_key: String,
61}
62
63/// A recurring hosted checkout (Stripe Checkout in `subscription` mode) against
64/// a Stripe Price the venture configured (e.g. `$15/mo` with a trial).
65#[derive(Debug, Clone)]
66pub struct SubscriptionCheckoutRequest {
67 pub customer_ref: Option<String>,
68 pub customer_email: Option<String>,
69 /// The Stripe Price id to subscribe to.
70 pub price_ref: String,
71 /// Free-trial length in days, if any.
72 pub trial_days: Option<u32>,
73 pub success_url: String,
74 pub cancel_url: String,
75 pub metadata: BTreeMap<String, String>,
76 pub idempotency_key: String,
77}
78
79/// The hosted page to send the browser to, and the session id to reconcile on.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CheckoutSession {
82 pub id: String,
83 pub url: String,
84}
85
86/// Onboards a Connect account (a coach) and returns a hosted onboarding link.
87#[derive(Debug, Clone)]
88pub struct ConnectAccountLinkRequest {
89 /// An existing Connect account id to refresh, or `None` to create one.
90 pub account_ref: Option<String>,
91 pub refresh_url: String,
92 pub return_url: String,
93 pub idempotency_key: String,
94}
95
96/// The Connect account id (persist it) and the hosted onboarding URL.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ConnectAccountLink {
99 pub account_id: String,
100 pub url: String,
101}
102
103/// A destination charge with an application fee: the buyer is charged
104/// `amount`, `application_fee` is kept by the platform, and the remainder is
105/// transferred to `destination_account` (the coach's Connect account).
106#[derive(Debug, Clone)]
107pub struct TransferCharge {
108 pub customer_ref: Option<String>,
109 pub amount: Money,
110 pub destination_account: String,
111 pub application_fee: Money,
112 pub metadata: BTreeMap<String, String>,
113 pub idempotency_key: String,
114}
115
116/// A created charge/payment-intent and its status as Stripe reported it.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Charge {
119 pub id: String,
120 pub status: String,
121}
122
123/// A refund of a prior payment: the whole amount when `amount` is `None`, else
124/// a partial refund.
125#[derive(Debug, Clone)]
126pub struct RefundRequest {
127 /// The payment-intent (or charge) id to refund.
128 pub payment_ref: String,
129 pub amount: Option<Money>,
130 pub idempotency_key: String,
131}
132
133/// A created refund.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Refund {
136 pub id: String,
137}
138
139/// A webhook event the adapter has **verified** (signature + timestamp) before
140/// returning. `kind` is Stripe's event type (`"checkout.session.completed"`);
141/// `data` is the event's `data.object` for the module to interpret.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct WebhookEvent {
144 pub id: String,
145 pub kind: String,
146 pub data: Value,
147}
148
149/// Payment failures. `NotConfigured` lets a venture build and run without
150/// Stripe (the port reports it rather than erroring); the rest map an upstream
151/// failure. `SignatureInvalid` is separated so a webhook handler answers `400`
152/// and never processes an unverified event.
153#[derive(Debug, Clone, Error)]
154pub enum PaymentsError {
155 /// No Stripe key configured: the caller should degrade, not fail.
156 #[error("payments are not configured")]
157 NotConfigured,
158 /// A webhook signature or timestamp did not verify: reject the request,
159 /// do not process the event.
160 #[error("webhook signature verification failed: {0}")]
161 SignatureInvalid(String),
162 /// Stripe rejected the request (a `4xx` that is not auth): not retryable
163 /// without a change.
164 #[error("payments request rejected: {0}")]
165 Rejected(String),
166 /// A transient failure (a `5xx`, a transport error): retry later.
167 #[error("payments request failed, retryable: {0}")]
168 Transient(String),
169}
170
171/// Moves money for a venture. Stripe today; the trait names only Stripe
172/// identifiers and hosted URLs, never card data.
173#[async_trait]
174pub trait Payments: Send + Sync {
175 /// A one-time hosted checkout. Returns the URL to redirect the browser to.
176 async fn create_checkout(
177 &self,
178 request: &CheckoutRequest,
179 ) -> Result<CheckoutSession, PaymentsError>;
180
181 /// A recurring hosted checkout against a configured Stripe Price.
182 async fn create_subscription_checkout(
183 &self,
184 request: &SubscriptionCheckoutRequest,
185 ) -> Result<CheckoutSession, PaymentsError>;
186
187 /// A Connect onboarding link for a coach's account.
188 async fn create_connect_account_link(
189 &self,
190 request: &ConnectAccountLinkRequest,
191 ) -> Result<ConnectAccountLink, PaymentsError>;
192
193 /// A destination charge with an application fee (the platform's cut).
194 async fn charge_with_transfer(&self, request: &TransferCharge)
195 -> Result<Charge, PaymentsError>;
196
197 /// Refunds a prior payment, in whole or in part.
198 async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError>;
199
200 /// Verifies a webhook's signature and timestamp and returns the event.
201 /// `signature_header` is the raw `Stripe-Signature` header; `body` is the
202 /// exact bytes received (verification is over the raw body). Returns
203 /// [`PaymentsError::SignatureInvalid`] if verification fails.
204 async fn verify_webhook(
205 &self,
206 signature_header: &str,
207 body: &[u8],
208 ) -> Result<WebhookEvent, PaymentsError>;
209}