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}