diff --git a/CHANGELOG.md b/CHANGELOG.md index 042a2ad..d18efc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,19 @@ order releases happen, newest first. ### Changed +- **`kasapay_iyzico::reporting::RefundStatus` is now `PaymentRefundStatus`.** A + rename and nothing else: the variants, the words it reads off the wire and + everything it does are unchanged, so a caller who names the type changes the + name and stops there. + + `kasapay-iyzico` held both meanings of the word at once. `classic` and + `in_store` import `kasapay_core::RefundStatus`, which is where one refund has + got to — pending, succeeded, failed. `reporting` exported its own, which is + not a refund's status at all but how much of a *payment* has gone back: + `NotRefunded`, `PartiallyRefunded`, `TotallyRefunded`. One crate, one word, + two concepts, and a reader with the first in scope meeting the second gets no + warning. The new name is iyzico's own for the field, `paymentRefundStatus`. + - **`Provider::cancel` answers a `Release` rather than a `Charge`, and now works everywhere a hold exists** (#169). It was `ErrorKind::Unsupported` at three of the four providers whose `Capabilities::separate_capture` is diff --git a/CLAUDE.md b/CLAUDE.md index d0cbb74..d93dc46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,11 @@ So four rules, in the order they pay. **One claim, one home.** `Provider::cancel` said the opposite of what it did in six places at once, because the same fact had been written six times. Every copy is a thing that can rot on its own. A second place cites the first. +Which *word* to use is the same rule one level down, and its home is +[`CONTEXT.md`](CONTEXT.md): not what a type does, which the type says, but +which of several circulating words to reach for — a payer rather than a +shopper, a `Buyer` being the details where a `customer` is the provider's +handle, releasing a hold rather than voiding it. **A claim that can be checked becomes a check.** Not a promise to remember it. `conformance.rs` counts its own roster against `impl Provider for` in the diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..b6ac2ed --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,81 @@ +# kasapay + +One payment API over any payment provider. + +This file settles which word to use where several are in circulation. It does +not define behaviour: each entry names the type or module that does, and that +one is the authority. + +## The people + +**Payer**: +The person paying. The role, in prose. +_Avoid_: shopper, user + +**Buyer**: +The payer's details a provider demands before it will take a payment. The +type, `kasapay_core::Buyer` — never the role. +_Avoid_: customer, purchaser + +**Customer**: +A provider's own opaque handle for a payer it holds on file — Stripe's +`customer`, Mollie's `customerId`, iyzico In-Store's `userId`. Never a person, +and never a record kasapay keeps. See `ChargeRequest::customer`. +_Avoid_: account, user id + +**Cardholder**: +The payer, where what is being said is true of cards and not of other +instruments. + +## The acts + +**Charge**: +Taking money, and the record of having asked. `Provider::charge` starts one; +`kasapay_core::Charge` is how a provider currently sees it. A `Charge` is not +a completed payment. +_Avoid_: transaction, sale + +**Payment**: +The object a provider names with a `PaymentId`. What kasapay asked for is a +charge; what the provider holds is a payment. + +**Hold**: +Money an authorisation reserves and has not taken. +_Avoid_: blocked funds, pending amount + +**Capture**: +Taking money a hold reserved. It has no inverse — captured money is refunded, +never un-captured. + +**Release**: +Giving up a hold that will never be captured. `Provider::cancel` is the call; +`kasapay_core::Release` is what comes back. +_Avoid_: void (PayPal's word), reversal (iyzico's), cancellation + +**Refund**: +Sending captured money back. Its own object with its own life, never a status +the payment arrives in. + +## The code + +**Provider**: +A payment service — Stripe, iyzico, Mollie, PayPal, PayTR — and the trait an +adapter implements for one. + +**Adapter**: +The crate that implements `Provider` for one provider. A provider is who takes +the money; an adapter is the code that asks them to. + +**Order reference**: +The caller's own name for an order, held by `OrderRef`. Not a `PaymentId`, even +where the two carry the same characters. +_Avoid_: order id, merchant reference + +## Spelling + +Prose uses **-ise**: authorise, authorisation, cancelling. + +**-ize** only where it names something somebody else spelled that way — the +HTTP `Authorization` header, PayPal's `authorization` object, iyzico's Authorize +service, or a Rust identifier such as `Status::Authorized`, whose variants carry +the provider's spelling rather than this one. diff --git a/crates/kasapay-iyzico/src/errors.rs b/crates/kasapay-iyzico/src/errors.rs index 298d124..942c535 100644 --- a/crates/kasapay-iyzico/src/errors.rs +++ b/crates/kasapay-iyzico/src/errors.rs @@ -10,7 +10,7 @@ //! # iyzico's own `Retry` column is not this //! //! Their error list has a `Retry` flag, and it says `true` for -//! "Email is mandatory". It means *the shopper can correct this and try +//! "Email is mandatory". It means *the payer can correct this and try //! again*, not *this same request may succeed*. Mapping it onto //! [`Error::is_retryable`](kasapay_core::Error::is_retryable) would tell a //! caller's retry loop to hammer a request that will fail identically forever. @@ -35,8 +35,8 @@ const RETRYABLE: &[&str] = &[ /// The bank understood and said no. /// -/// A caller shows these to the shopper: try another card, ring your bank. They -/// are not a fault in the request, and telling a shopper their details were +/// A caller shows these to the payer: try another card, ring your bank. They +/// are not a fault in the request, and telling a payer their details were /// invalid when their limit was exceeded sends them round a loop that cannot end. const DECLINED: &[&str] = &[ "10034", // FRAUD_SUSPECT @@ -81,7 +81,7 @@ mod tests { #[test] fn a_declined_card_is_a_decline_and_not_a_bad_request() { - // 10051 is NOT_SUFFICIENT_FUNDS. Telling a shopper their details were + // 10051 is NOT_SUFFICIENT_FUNDS. Telling a payer their details were // invalid sends them round a loop that cannot end. let kind = kind_for(Some("10051"), ErrorKind::InvalidRequest); assert_eq!(kind, ErrorKind::Declined); diff --git a/crates/kasapay-iyzico/src/reporting/client.rs b/crates/kasapay-iyzico/src/reporting/client.rs index 4ba83c0..1e7bd60 100644 --- a/crates/kasapay-iyzico/src/reporting/client.rs +++ b/crates/kasapay-iyzico/src/reporting/client.rs @@ -207,7 +207,7 @@ pub struct PaymentDetail { /// two cannot be the same field. pub payment_status: Option, /// How much of it has been refunded. - pub refund_status: Option, + pub refund_status: Option, /// The basket total. pub price: Option, /// What was actually collected. @@ -300,7 +300,7 @@ impl PaymentDetail { refund_status: item .payment_refund_status .as_deref() - .map(RefundStatus::from), + .map(PaymentRefundStatus::from), price: money(item.price.as_deref(), currency), paid_price: money(item.paid_price.as_deref(), currency), installment: item.installment, @@ -759,7 +759,7 @@ impl From for PaymentStatus { /// runs anywhere else. This is that fact, kept as its own field. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] -pub enum RefundStatus { +pub enum PaymentRefundStatus { /// Nothing has been refunded. NotRefunded, /// Part of the payment has been refunded. @@ -770,7 +770,7 @@ pub enum RefundStatus { Other(Box), } -impl RefundStatus { +impl PaymentRefundStatus { /// The word iyzico uses on the wire. #[must_use] pub fn as_str(&self) -> &str { @@ -783,13 +783,13 @@ impl RefundStatus { } } -impl fmt::Display for RefundStatus { +impl fmt::Display for PaymentRefundStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl From<&str> for RefundStatus { +impl From<&str> for PaymentRefundStatus { fn from(value: &str) -> Self { match value { "NOT_REFUNDED" => Self::NotRefunded, @@ -905,8 +905,8 @@ fn money(value: Option<&str>, currency: Option) -> Option { #[cfg(test)] mod tests { use super::{ - PaymentQuery, PaymentStatus, RefundStatus, TransactionApprovalStatus, TransactionType, - money, query_value, + PaymentQuery, PaymentRefundStatus, PaymentStatus, TransactionApprovalStatus, + TransactionType, money, query_value, }; use kasapay_core::{Currency, Money}; @@ -969,21 +969,36 @@ mod tests { } #[test] - fn the_words_iyzico_uses_round_trip_and_the_rest_are_kept() { - for name in ["NOT_REFUNDED", "PARTIALLY_REFUNDED", "TOTALLY_REFUNDED"] { - assert_eq!(RefundStatus::from(name).to_string(), name); - } + fn the_words_iyzico_uses_are_read_and_the_rest_are_kept() { + // Not a round-trip: `Other` echoes an unknown word, so one passes with every arm below deleted. assert_eq!( - RefundStatus::from("WRITTEN_OFF"), - RefundStatus::Other("WRITTEN_OFF".into()) + PaymentRefundStatus::from("NOT_REFUNDED"), + PaymentRefundStatus::NotRefunded + ); + assert_eq!( + PaymentRefundStatus::from("PARTIALLY_REFUNDED"), + PaymentRefundStatus::PartiallyRefunded + ); + assert_eq!( + PaymentRefundStatus::from("TOTALLY_REFUNDED"), + PaymentRefundStatus::TotallyRefunded ); - for name in ["CANCEL", "PAYMENT", "REFUND"] { - assert_eq!(TransactionType::from(name).to_string(), name); - } assert_eq!( - TransactionType::from("CHARGEBACK").to_string(), - "CHARGEBACK" + PaymentRefundStatus::from("WRITTEN_OFF"), + PaymentRefundStatus::Other("WRITTEN_OFF".into()) ); + + assert_eq!(TransactionType::from("CANCEL"), TransactionType::Cancel); + assert_eq!(TransactionType::from("PAYMENT"), TransactionType::Payment); + assert_eq!(TransactionType::from("REFUND"), TransactionType::Refund); + assert_eq!( + TransactionType::from("CHARGEBACK"), + TransactionType::Other("CHARGEBACK".into()) + ); + + // The words still have to reach the wire unchanged. + assert_eq!(PaymentRefundStatus::NotRefunded.to_string(), "NOT_REFUNDED"); + assert_eq!(TransactionType::Refund.to_string(), "REFUND"); } #[test] diff --git a/crates/kasapay-iyzico/src/reporting/mod.rs b/crates/kasapay-iyzico/src/reporting/mod.rs index 76b7c20..8587cc0 100644 --- a/crates/kasapay-iyzico/src/reporting/mod.rs +++ b/crates/kasapay-iyzico/src/reporting/mod.rs @@ -78,6 +78,6 @@ mod wire; #[doc(inline)] pub use crate::reporting::client::{ Cancel, Client, ConvertedPayout, DailyTransactionItem, DailyTransactions, ItemTransaction, - PaymentDetail, PaymentQuery, PaymentStatus, Refund, RefundStatus, TransactionApprovalStatus, - TransactionType, + PaymentDetail, PaymentQuery, PaymentRefundStatus, PaymentStatus, Refund, + TransactionApprovalStatus, TransactionType, }; diff --git a/crates/kasapay-iyzico/src/terminal/mod.rs b/crates/kasapay-iyzico/src/terminal/mod.rs index 977ad48..5e2e77b 100644 --- a/crates/kasapay-iyzico/src/terminal/mod.rs +++ b/crates/kasapay-iyzico/src/terminal/mod.rs @@ -1,6 +1,6 @@ //! iyzico's Terminal API — a cash register driving a physical POS device. //! -//! Nothing else in this crate looks like this. There is no shopper with a +//! Nothing else in this crate looks like this. There is no payer with a //! browser and no callback address: a till sends a request, a person standing //! at a counter presents a card to a terminal named by `deviceUniqueId`, and //! the answer to that same request says whether the bank approved it. iyzico diff --git a/crates/kasapay-stripe/tests/payment_intent.rs b/crates/kasapay-stripe/tests/payment_intent.rs index f83f362..b4c1f06 100644 --- a/crates/kasapay-stripe/tests/payment_intent.rs +++ b/crates/kasapay-stripe/tests/payment_intent.rs @@ -178,7 +178,7 @@ async fn a_refused_card_becomes_a_decline() { assert_eq!(error.kind(), ErrorKind::Declined); assert!(!error.is_retryable()); - // The specific reason, which is what a shop shows the shopper — not the + // The specific reason, which is what a shop shows the payer — not the // general `card_declined`. assert_eq!(error.code(), Some("insufficient_funds")); // Stripe's own sentence, not a Debug dump of their error struct.