Skip to main content

kasapay_core/
provider.rs

1//! The trait every payment provider implements.
2
3use std::fmt;
4
5use crate::charge::{Charge, ChargeRequest, IdempotencyKey, OrderRef};
6use crate::error::Error;
7use crate::id::PaymentId;
8use crate::instrument::Instrument;
9use crate::money::Money;
10use crate::refund::{Refund, RefundRequest};
11
12/// Names a provider.
13///
14/// A string rather than an enum so a provider living outside this workspace is
15/// a first-class one.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct ProviderId(&'static str);
18
19impl ProviderId {
20    /// Stripe.
21    pub const STRIPE: Self = Self("stripe");
22    /// iyzico.
23    pub const IYZICO: Self = Self("iyzico");
24
25    /// Names a provider this workspace does not ship.
26    #[must_use]
27    pub const fn new(name: &'static str) -> Self {
28        Self(name)
29    }
30
31    /// The name as text.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        self.0
35    }
36}
37
38impl fmt::Display for ProviderId {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(self.0)
41    }
42}
43
44/// What a provider will do, asked before there is a payment to ask it about.
45///
46/// This and [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) answer
47/// different questions and both have to exist. This one is for planning: a
48/// checkout deciding whether to offer authorise-now-capture-later needs the
49/// answer before it has a payment. `Unsupported` is for enforcement, and stays
50/// the thing that actually refuses the call.
51///
52/// **A capability that says yes and a call that then fails is a bug in the
53/// adapter**, and so is the reverse. An adapter's tests are where that is
54/// held to.
55///
56/// Every field is public and the struct is open, for the same reason
57/// [`Charge`] is: an adapter in someone else's repository has to be able to
58/// build one.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
60#[expect(
61    clippy::struct_excessive_bools,
62    reason = "each is an independent yes or no about one provider; a state machine would invent an order between them that does not exist"
63)]
64pub struct Capabilities {
65    /// Funds can be held at authorisation and taken later by
66    /// [`Provider::capture`].
67    ///
68    /// False says the provider takes the money at authorisation and has no
69    /// capture step — not that capture failed. Distinguishing those two is the
70    /// whole reason this type exists.
71    pub separate_capture: bool,
72    /// [`Provider::capture`] accepts an amount below the one authorised.
73    ///
74    /// Only meaningful where `separate_capture` is true.
75    pub partial_capture: bool,
76    /// A payment can be refunded for less than it was captured for.
77    pub partial_refund: bool,
78    /// A payment can be refunded more than once, up to what was captured.
79    pub repeated_refund: bool,
80    /// [`Provider::lookup`] can answer what became of a request keyed by the
81    /// caller's own reference.
82    ///
83    /// What a crash-recovery path reads before it decides between asking and
84    /// calling again. False does not mean the provider forgot the reference —
85    /// it means this adapter has no call that finds a payment by it, so a
86    /// caller whose request timed out has nothing to ask and must rely on
87    /// whatever idempotency the provider offers instead.
88    pub lookup_by_order: bool,
89    /// [`Provider::resume`] can read back a flow by the token
90    /// [`NextAction::Redirect`](crate::NextAction::Redirect) handed over,
91    /// without the payment ever having been named.
92    ///
93    /// What a caller reads when the payer comes back from a hosted form. False
94    /// does not mean the flow cannot be finished — it means the provider named
95    /// the payment when it opened the flow, so
96    /// [`Provider::charge_status`] is what finishes it and the continuation is
97    /// not needed. True is the case that has no payment id yet at all.
98    pub resume_by_continuation: bool,
99    /// An instrument [`Provider::instruments`] lists can be charged, through a
100    /// call of this adapter's own — with the payer entering nothing.
101    ///
102    /// What a checkout reads before it offers "use my saved card". This
103    /// describes *charging*, not *listing*: every adapter answers
104    /// [`Provider::instruments`] regardless of this flag, and the two do not
105    /// have to agree. PayTR's hosted form does store a card — a vault exists —
106    /// but nothing here can list it or charge it, so both answer
107    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), for two
108    /// different reasons that happen to give the same result: `false` here
109    /// says specifically that this adapter has no call that charges one, which
110    /// is the answer a checkout needs before it offers the button.
111    ///
112    /// The charging call itself is the adapter's own: it needs what that
113    /// provider demands around a saved-instrument payment, which is not the
114    /// same list twice at any two of them, and neither [`Provider::charge`]
115    /// nor [`Provider::instruments`] carries any of it.
116    pub saved_instruments: bool,
117}
118
119/// Marks an implementation of [`Provider`] so its `async fn`s compile.
120///
121/// Re-exported because the version has to match the one this trait was defined
122/// with, and matching it by hand is a footgun for anyone writing a provider
123/// outside this workspace.
124pub use async_trait::async_trait;
125
126/// Takes a payment and reports on it.
127///
128/// Implementations are cheap to clone and safe to share: hold one per process,
129/// not one per request.
130#[async_trait]
131pub trait Provider: fmt::Debug + Send + Sync {
132    /// Which provider this is.
133    fn id(&self) -> ProviderId;
134
135    /// Starts a charge.
136    ///
137    /// A returned [`Charge`] is not a completed payment. Read its
138    /// [`status`](Charge::status) and its
139    /// [`next_action`](Charge::next_action): a provider that redirects the
140    /// payer answers [`Status::RequiresAction`](crate::Status::RequiresAction)
141    /// here, and the payment is only decided once they come back.
142    ///
143    /// # A request that satisfies one provider may not satisfy another
144    ///
145    /// Everything past the order reference and the amount is optional on
146    /// [`ChargeRequest`], because what is mandatory is the provider's
147    /// decision rather than a payment's. iyzico's classic API refuses a
148    /// payment without a buyer's identity number, an address and an itemised
149    /// basket; PayTR refuses one without the payer's own IP address; Stripe
150    /// and Mollie ask for none of it.
151    ///
152    /// An adapter that is not given a field it needs answers
153    /// [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest)
154    /// **naming the field**, before a socket opens. So the request that works
155    /// everywhere is the one carrying what the strictest provider asks, and
156    /// swapping to a laxer one costs nothing: the extra fields are ignored.
157    async fn charge(&self, request: &ChargeRequest) -> Result<Charge, Error>;
158
159    /// Finishes a flow [`Provider::charge`] started, by the token it handed
160    /// over.
161    ///
162    /// # The one call that needs it
163    ///
164    /// A hosted form the payer has not finished is not a payment: the provider
165    /// has nothing to name it by, so [`Charge::id`] is `None` and
166    /// [`Provider::charge_status`] has nothing to take. What it does have is
167    /// the `continuation` on
168    /// [`NextAction::Redirect`](crate::NextAction::Redirect), and this is the
169    /// call that takes one.
170    ///
171    /// # How a caller decides which to use, without naming a provider
172    ///
173    /// [`Capabilities::resume_by_continuation`]. True is a provider that names
174    /// the payment only once the payer is done, so the continuation is the
175    /// only handle there is and this is what reads it. False is a provider
176    /// that named the payment when it opened the flow, so
177    /// [`Provider::charge_status`] finishes it and this answers
178    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) saying so.
179    ///
180    /// # It finishes what `charge` started, and nothing else
181    ///
182    /// Not every flow an adapter can open comes back through here. iyzico's
183    /// classic API has one result endpoint for a form that takes the money and
184    /// a form that holds it, and its answer does not say which was opened — so
185    /// reading a hold back through the wrong one writes a sale into a ledger
186    /// for money nobody has taken. [`Provider::charge`] opens the form that
187    /// takes the money, this reads that one back, and a hold opened by the
188    /// adapter's own call is read back by the adapter's own call.
189    async fn resume(&self, continuation: &str) -> Result<Charge, Error>;
190
191    /// Reads a charge back.
192    ///
193    /// `id` is a [`Charge::id`] this provider produced. A provider that names a
194    /// payment by nothing at all — no identifier of its own and nothing to
195    /// compose one from — answers
196    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) rather than
197    /// accepting an identifier it cannot honour.
198    ///
199    /// A flow that is not yet a payment is not read here at all. iyzico's
200    /// classic checkout form has only its own token until the payer finishes,
201    /// and that token is a different [`IdKind`](crate::IdKind), so it has its
202    /// own call rather than a signature this one cannot honestly take.
203    async fn charge_status(&self, id: &PaymentId) -> Result<Charge, Error>;
204
205    /// Takes funds an authorisation is only holding.
206    ///
207    /// A shop authorises when the order is placed and captures when the parcel
208    /// leaves. `amount` of `None` takes the lot; `Some` takes part of it, which
209    /// is what a partial shipment needs, and requires
210    /// [`Capabilities::partial_capture`].
211    ///
212    /// The returned [`Charge`] carries the amount that was captured, not the
213    /// amount that was authorised.
214    ///
215    /// Capture has no inverse. Captured money is refunded, not un-captured.
216    ///
217    /// A provider whose [`Capabilities::separate_capture`] is false took the
218    /// money at authorisation and answers
219    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) here.
220    ///
221    /// `idempotency` makes a replayed capture safe where the provider offers
222    /// it — read [`ErrorKind::is_retryable`](crate::ErrorKind::is_retryable)
223    /// before retrying one without a key: unlike
224    /// [`Provider::charge`](crate::Provider::charge), a repeated capture can
225    /// take the same money twice, and not every provider protects against it.
226    ///
227    /// A provider that cannot honour a key **refuses the capture** with
228    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) rather than
229    /// sending it without one. That is the same rule
230    /// [`ChargeRequest::idempotency_key`](crate::ChargeRequest::idempotency_key)
231    /// and [`RefundRequest::idempotency_key`](crate::RefundRequest::idempotency_key)
232    /// state, and it is at its sharpest here: a capture is the call that takes
233    /// the money, so a key accepted and dropped reads as a guarantee against
234    /// taking it twice where there is none. iyzico's classic API is the one
235    /// that refuses; a provider with no capture step at all answers
236    /// `Unsupported` for the capture itself and never reaches the question.
237    ///
238    /// The refusal comes before the request, not after it. A key that is
239    /// discovered to be unusable only once the capture has been sent has
240    /// already taken the money.
241    async fn capture(
242        &self,
243        id: &PaymentId,
244        amount: Option<Money>,
245        idempotency: Option<&IdempotencyKey>,
246    ) -> Result<Charge, Error>;
247
248    /// Releases an authorisation that will never be taken.
249    ///
250    /// Cancelling a payment whose funds are already captured is
251    /// [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest) rather
252    /// than a silent success: giving that money back is a refund, a different
253    /// act with a different entry in the ledger.
254    ///
255    /// No idempotency key: repeating a cancel is harmless. The second call
256    /// meets a hold that is already released and answers
257    /// [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest) rather
258    /// than releasing anything twice, which is the whole reason
259    /// [`Provider::capture`] carries a key and this does not.
260    async fn cancel(&self, id: &PaymentId) -> Result<Charge, Error>;
261
262    /// Gives money back off a payment.
263    ///
264    /// Capture has no inverse — captured money is refunded, not un-captured —
265    /// so this is the only way money goes back, and a
266    /// [`Provider`](crate::Provider) offering
267    /// [`capture`](Provider::capture) and not this is one a shop cannot use.
268    ///
269    /// Three refunds against one payment is ordinary: three returned items on
270    /// one order. Whether this provider allows that is
271    /// [`Capabilities::repeated_refund`], and whether it allows one for less
272    /// than was captured is [`Capabilities::partial_refund`]; both are
273    /// answerable before there is a payment to ask about.
274    ///
275    /// # `amount: None` is not one call everywhere
276    ///
277    /// `None` means all of it, and two providers have no request that says so
278    /// — they take an amount and only an amount. What each adapter does:
279    ///
280    /// | | `amount: None` | its own idempotency |
281    /// |---|---|---|
282    /// | Stripe | refunds what is left, in one call | `Idempotency-Key` |
283    /// | iyzico `classic` | [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest): send the amount | none — a key is refused |
284    /// | iyzico `in_store` | refunds all of it, in one call | none — a key is refused |
285    /// | PayTR | [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest): send the amount | none — a key is refused |
286    /// | Mollie | reads the payment's `amountRemaining` first, so **two** calls | `Idempotency-Key` |
287    /// | PayPal | refunds what is left, and reads the order first to find the capture, so **two** calls | `PayPal-Request-Id` |
288    ///
289    /// A provider that cannot honour
290    /// [`RefundRequest::idempotency_key`] refuses the refund with
291    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) rather than
292    /// sending it without one. That is
293    /// [`ChargeRequest::idempotency_key`](crate::ChargeRequest::idempotency_key)'s
294    /// own rule, and it matters more here: accepting a key and dropping it
295    /// reads as a guarantee against giving the money back twice, which is the
296    /// one thing the caller asked for.
297    ///
298    /// **A refund that cannot be replayed safely is read back, not resent.**
299    /// Each adapter has a call that lists what has already gone back —
300    /// `Stripe::refunds`, `PayTr::refunds`, Mollie's `amountRefunded` — and
301    /// reading is always safe.
302    async fn refund(&self, request: &RefundRequest) -> Result<Refund, Error>;
303
304    /// Asks what became of a request the caller sent under this reference.
305    ///
306    /// **For the call whose answer never arrived.** A charge that times out is
307    /// the one case where nobody knows whether the money moved: the request may
308    /// have been received and acted on, and the reply lost on the way back.
309    /// Calling [`Provider::charge`] again is only safe where the provider
310    /// honours an idempotency key, and
311    /// [`Provider::charge_status`](Provider::charge_status) cannot be used
312    /// either — it takes the provider's own identifier for the payment, which
313    /// is precisely what a lost reply never delivered.
314    ///
315    /// So this is keyed by [`ChargeRequest::order`], the caller's own
316    /// reference, which they had before they sent anything.
317    ///
318    /// - `Ok(None)` — the provider has no record of a payment under this
319    ///   reference. Nothing was taken, and sending the charge again is safe.
320    /// - `Ok(Some(charge))` — this is what became of it. Read
321    ///   [`Charge::status`]; sending the charge again would open a second one.
322    /// - `Err(_)` — the question could not be answered. **Not** the same as
323    ///   `Ok(None)`, and the difference is a double payment.
324    ///
325    /// # Two of the five can answer it
326    ///
327    /// [`Capabilities::lookup_by_order`] says which before there is a request
328    /// to ask about, and the four answers are different questions:
329    ///
330    /// | | |
331    /// |---|---|
332    /// | iyzico `classic` | yes — reporting reads a payment back by the `conversationId` it was made with |
333    /// | PayTR | yes — its status query is keyed by `merchant_oid`, which is the reference itself |
334    /// | Stripe | **no, on purpose** — the search API is the only way to find an intent by metadata, and Stripe documents it as eventually consistent and says not to use it in read-after-write flows. Retry the charge with the same `ChargeRequest::idempotency_key` instead: Stripe answers the original PaymentIntent rather than opening a second |
335    /// | Mollie | no — nothing finds a payment by its metadata. Its `Idempotency-Key` replays the first answer for an hour, which covers the same case for as long as it lasts |
336    /// | PayPal | no — Orders v2 has no lookup by `PayPal-Request-Id` or `custom_id`. Replaying with the same request id answers the original order |
337    /// | iyzico `in_store` | no — its query takes iyzico's own `paymentId` and nothing else |
338    ///
339    /// **A `false` here is not a gap to work around with a search that might
340    /// be stale.** An answer of "no record" that is merely late is how a
341    /// caller authorises twice, which is the failure this method exists to
342    /// prevent.
343    async fn lookup(&self, order: &OrderRef) -> Result<Option<Charge>, Error>;
344
345    /// Lists what a customer has saved with this provider.
346    ///
347    /// `customer` is the provider's own name for them — the same string
348    /// [`ChargeRequest::customer`] carries, and, for iyzico's classic API,
349    /// the `cardUserKey` that names the vault rather than a payer as such.
350    ///
351    /// This is the shape every provider can answer: an identity and something
352    /// to show somebody choosing between them. It is not a card number and
353    /// carries no field one could go in. What it is not, on purpose, is a way
354    /// to charge one or to forget one — those stay each adapter's own call,
355    /// because forgetting a card needs iyzico's `cardUserKey` *and* its token
356    /// where Stripe's needs only the instrument, and charging one takes a
357    /// buyer and a basket at iyzico, an `off_session` flag at Stripe, a
358    /// `sequenceType` at Mollie — three requests this trait cannot honestly
359    /// narrow to one signature. See [`Capabilities::saved_instruments`] for
360    /// what that leaves this trait able to say about charging one.
361    ///
362    /// A provider with no vault at all — or one this crate has no working call
363    /// against, which is PayTR's case: it does store a card, but nothing here
364    /// signs a request against it — answers
365    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) rather than
366    /// an empty list, because an empty list would read as "this customer has
367    /// nothing saved" instead of "asking is not possible here".
368    ///
369    /// No default: a provider outside this workspace has to answer, the same
370    /// as every other method here.
371    async fn instruments(&self, customer: &str) -> Result<Vec<Instrument>, Error>;
372
373    /// What this provider will do, before there is a payment to ask about.
374    fn capabilities(&self) -> Capabilities;
375}