From 27e03e893e7ea58422369aeaeea2fc6bc69d7ef7 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Thu, 20 Aug 2026 14:12:01 +0200 Subject: [PATCH 1/2] refactor(iyzico): one word meant two things in one crate, so name the other one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kasapay-iyzico` had both meanings of `RefundStatus` in scope at once. `classic/client.rs` and `in_store/client.rs` import `kasapay_core::RefundStatus` — where one refund has got to. `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`. A reader with the first in scope who meets the second gets no warning from anything. Renamed to `PaymentRefundStatus`, which is iyzico's own name for the field it mirrors — `paymentRefundStatus` on `payment/details`. Variants, wire words and behaviour unchanged. I measured a workspace-wide check for this before writing one, and it is refused by its own measurement: 15 public type names are defined in more than one crate today, and 14 are correct — `Config`, `Credentials`, `Payment`, `Refund` and `Capture` are each the right name inside their own adapter. One rule, fourteen false positives. So this is a rename and not a ratchet. CONTEXT.md is new, and holds only which word wins where several circulate: payer over shopper, `Buyer` being the details where `customer` is the provider's opaque handle, release over void or reversal, -ise in prose except where somebody else spelled it -ize. It defines no behaviour — each entry names the type that does. CLAUDE.md points at it from "one claim, one home", which is the same rule one level down. `shopper` went with it: 6 uses against 301 of `payer`, all meaning the same person. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++ CLAUDE.md | 5 ++ CONTEXT.md | 81 +++++++++++++++++++ crates/kasapay-iyzico/src/errors.rs | 8 +- crates/kasapay-iyzico/src/reporting/client.rs | 22 ++--- crates/kasapay-iyzico/src/reporting/mod.rs | 4 +- crates/kasapay-iyzico/src/terminal/mod.rs | 2 +- crates/kasapay-stripe/tests/payment_intent.rs | 2 +- 8 files changed, 118 insertions(+), 19 deletions(-) create mode 100644 CONTEXT.md 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..b595cc2 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}; @@ -971,11 +971,11 @@ 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); + assert_eq!(PaymentRefundStatus::from(name).to_string(), name); } assert_eq!( - RefundStatus::from("WRITTEN_OFF"), - RefundStatus::Other("WRITTEN_OFF".into()) + PaymentRefundStatus::from("WRITTEN_OFF"), + PaymentRefundStatus::Other("WRITTEN_OFF".into()) ); for name in ["CANCEL", "PAYMENT", "REFUND"] { assert_eq!(TransactionType::from(name).to_string(), name); 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. From 6c7833706eb5d992091f0381479d3b2b48c6328d Mon Sep 17 00:00:00 2001 From: productdevbook Date: Thu, 20 Aug 2026 14:51:57 +0200 Subject: [PATCH 2/2] test(iyzico): assert the variant, because the round-trip passed without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught it on a line this branch touched: `assert_eq!(T::from(w).to_string(), w)` cannot fail for the mapping it names. `from` sends an unknown word to `Other(word)` and `as_str` hands that same word back, so the assertion holds with every named arm deleted. Twelve tests in the workspace are this shape, across four crates, and all twelve types echo the input — measured, not guessed. Two are in this file and are fixed here; the other ten are their own change rather than swept into a branch about vocabulary. `the_documented_payment_status_codes_round_trip_and_the_rest_are_kept`, forty lines up in this same file, was already doing it right against `From`. The string-based ones drifted from a pattern that was here all along. Co-Authored-By: Claude Opus 5 (1M context) --- crates/kasapay-iyzico/src/reporting/client.rs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/kasapay-iyzico/src/reporting/client.rs b/crates/kasapay-iyzico/src/reporting/client.rs index b595cc2..1e7bd60 100644 --- a/crates/kasapay-iyzico/src/reporting/client.rs +++ b/crates/kasapay-iyzico/src/reporting/client.rs @@ -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!(PaymentRefundStatus::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!( + PaymentRefundStatus::from("NOT_REFUNDED"), + PaymentRefundStatus::NotRefunded + ); + assert_eq!( + PaymentRefundStatus::from("PARTIALLY_REFUNDED"), + PaymentRefundStatus::PartiallyRefunded + ); + assert_eq!( + PaymentRefundStatus::from("TOTALLY_REFUNDED"), + PaymentRefundStatus::TotallyRefunded + ); assert_eq!( PaymentRefundStatus::from("WRITTEN_OFF"), PaymentRefundStatus::Other("WRITTEN_OFF".into()) ); - for name in ["CANCEL", "PAYMENT", "REFUND"] { - assert_eq!(TransactionType::from(name).to_string(), name); - } + + 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").to_string(), - "CHARGEBACK" + 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]