Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions crates/kasapay-iyzico/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
53 changes: 34 additions & 19 deletions crates/kasapay-iyzico/src/reporting/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ pub struct PaymentDetail {
/// two cannot be the same field.
pub payment_status: Option<PaymentStatus>,
/// How much of it has been refunded.
pub refund_status: Option<RefundStatus>,
pub refund_status: Option<PaymentRefundStatus>,
/// The basket total.
pub price: Option<Money>,
/// What was actually collected.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -759,7 +759,7 @@ impl From<i64> 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.
Expand All @@ -770,7 +770,7 @@ pub enum RefundStatus {
Other(Box<str>),
}

impl RefundStatus {
impl PaymentRefundStatus {
/// The word iyzico uses on the wire.
#[must_use]
pub fn as_str(&self) -> &str {
Expand All @@ -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,
Expand Down Expand Up @@ -905,8 +905,8 @@ fn money(value: Option<&str>, currency: Option<Currency>) -> Option<Money> {
#[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};

Expand Down Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions crates/kasapay-iyzico/src/reporting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
2 changes: 1 addition & 1 deletion crates/kasapay-iyzico/src/terminal/mod.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/kasapay-stripe/tests/payment_intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down