open-standards/openpay/crates/op-refund/src/lib.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

69 lines
3 KiB
Rust

//! # `op-refund` — refund domain
//!
//! Every payment-acceptance stack needs refunds: the operator
//! initiates a partial or full return of value to a customer after a
//! payment has settled. This crate is the **state machine and
//! durability surface** for that flow — the bookkeeping that says
//! "this refund was requested, sent to the rail, approved, settled."
//!
//! ## What this crate owns
//!
//! - The [`Refund`] domain type and its [`Status`] state machine.
//! - The [`RefundStore`] trait for persistence and the in-memory
//! reference implementation.
//! - Validation invariants (non-negative amount, terminal-state
//! guard, idempotency by external id).
//!
//! ## What this crate does NOT own
//!
//! - **The rail call.** Sending the refund instruction to the PSP /
//! acquirer is the operator's `RailAdapter`'s job, same shape as
//! the original payment. This crate doesn't know HTTP from gRPC.
//! - **The ledger reversal.** When a refund settles, the operator
//! posts a reversing [`op_ledger::Transaction`] via their
//! `LedgerStore`. That coupling — refund id ↔ ledger tx — is the
//! operator's choice; we don't impose it. Most deployments use
//! the refund's `external_id` as the reversal tx's `external_id`,
//! the same convention Phase 19 uses for routing-signal joins.
//! - **The customer notification.** Out of scope. Operator's app.
//!
//! ## Why a separate crate, not part of `op-orchestrator`
//!
//! Refunds are conceptually parallel to payment intents — both are
//! coordinated PSP operations with their own state machines — but
//! they're triggered on a different cadence (days or weeks after
//! the original payment), often through a different operator UI,
//! and they don't need fraud scoring or rail-routing. Keeping them
//! in their own crate matches the codebase's "one concern per
//! crate" pattern (the same reason `op-dispute` lives next door).
//!
//! ## Core invariants
//!
//! 1. **Amount non-negative.** A refund of 0 minor units is legal
//! (representing "no money moved, status workflow only") because
//! some PSPs use that for void; negative is rejected.
//! 2. **Terminal states are terminal.** Once a refund is `Settled`,
//! `Declined`, or `Failed`, no further transitions.
//! 3. **Idempotency by `external_id`.** Posting a refund with an
//! `external_id` that was already used returns the existing
//! refund iff the body matches; otherwise
//! [`Error::IdempotencyMismatch`].
//! 4. **Refund total ≤ original transaction amount.** Enforcement
//! happens at the store level by summing prior refunds against
//! the same `original_tx_id`. Out-of-scope for an
//! `InMemoryRefundStore` test build; documented for production.
#![deny(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
pub mod error;
pub mod reason;
pub mod refund;
pub mod store;
pub use error::{Error, Result};
pub use reason::RefundReason;
pub use refund::{Refund, RefundId, Status};
pub use store::{InMemoryRefundStore, RefundStore};