open-standards/openpay/crates/op-openbanking/src/pisp.rs
Transaction Science 6ea90afbed ingest wave A4: 3 crates · smart-byte-atproto + op-openbanking + eoc-distributed
Smart Byte (1 crate, 2127 LOC, 35 tests)
  smart-byte-atproto    ATProto (Bluesky federation) adapter.
                        PLC + did:web resolution; XRPC client; CARv1 encode/decode
                        with CIDv1 (dag-cbor + raw, SHA-256); sketch MST with
                        order-independent BLAKE3 root; Repo + UnsignedCommit /
                        SignedCommit (Ed25519 over CBOR); firehose transport
                        trait + in-memory impl; AT URI ↔ smart-byte AID bridge
                        with SAID commitment.

OpenPay (1 crate, 2397 LOC, 44 tests)
  op-openbanking        UK OBIE R/W v3.1.10/3.1.11, Berlin Group NextGenPSD2
                        XS2A v1.3.13, STET PSD2 v1.7, Australia CDR Banking v1.31,
                        FDX v5/v6, SGFinDex shapes. AISP / PISP / PIIS / VRP
                        traits. FAPI 1.0 Advanced (PS256/ES256, mTLS, RFC 8705
                        certificate binding, JWK registration). Operators wire
                        their own JWS signer + HTTP transport — keeps the crate
                        KMS-agnostic.

EOC (1 crate, 2179 LOC, 32 tests)
  eoc-distributed       Distributed-inference scheduling. Worker trait with
                        Capability + Accelerator (CPU/GPU/TPU/NPU/Mixed) +
                        Load.busyness()/projected_micro_joules()/projected_g_co2e().
                        RoundRobin / LeastBusy / LatencyWeighted / JouleWeighted
                        (default) / CarbonWeighted routers. Work-stealing
                        scheduler. vLLM-style continuous batching with KV-block
                        budget. Ray-Serve-style auto-scaler with cooldown.
                        KV-cache-aware sticky-session routing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 12:22:09 -04:00

204 lines
7.1 KiB
Rust

//! Payment Initiation Service Provider (PISP) surface.
//!
//! Implements the vendor-neutral version of:
//!
//! - UK Open Banking R/W v3.1 § Domestic / International / Standing-Order
//! Payment Consents and Payments
//! - Berlin Group NextGenPSD2 § 5.1 (payment-initiation service)
//! - STET PSD2 § Payment Initiation
//! - SGFinDex equivalents are out of scope for v1 (data-only).
//!
//! ## Payment kinds
//!
//! The standards converge on five concrete kinds: immediate single,
//! future-dated single, recurring (standing order), bulk, and
//! international. [`PaymentKind`] is that taxonomy.
use op_core::Money;
use serde::{Deserialize, Serialize};
use crate::aisp::ConsentId;
use crate::error::Result;
use crate::fapi::OAuth2Token;
/// The five PISP payment kinds that survive across UK / Berlin / STET.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaymentKind {
/// Immediate single payment (UK `DomesticPayment`, Berlin
/// `payments/sepa-credit-transfers`, STET `Payment Request`).
ImmediateSingle,
/// Future-dated single payment, value-date in the future.
FutureDated,
/// Standing order — recurring payment with a schedule. The
/// schedule is bound to the consent, not the payment.
StandingOrder,
/// Bulk file of payments (UK `FilePayment`, Berlin
/// `bulk-payments`). Many ASPSPs gate this behind extra
/// commercial agreements.
Bulk,
/// International payment (UK `InternationalPayment`, Berlin
/// `cross-border-credit-transfers`). Adds correspondent-bank
/// and FX-quote fields.
International,
}
/// Reference returned by the ASPSP after a successful payment-initiation
/// post. UK OBIE: `Data.DomesticPaymentId`. Berlin Group:
/// `paymentId`. STET: `paymentRequestResourceId`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PaymentRef(pub String);
/// Lifecycle status of a payment initiation.
///
/// Aligned to ISO 20022 `ExternalPaymentTransactionStatus1Code` plus
/// UK OBIE's `AcceptedSettlementInProcess` distinction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PaymentInitiationStatus {
/// Consent created; PSU not yet authenticated.
AwaitingAuthorisation,
/// PSU authenticated; awaiting submission.
Authorised,
/// Submitted to the rail; awaiting clearing.
AcceptedSettlementInProcess,
/// Settled into the beneficiary's account.
Settled,
/// Consent rejected by PSU or ASPSP.
Rejected,
/// Consent revoked or expired before submission.
Cancelled,
}
/// A vendor-neutral payment-initiation request.
///
/// Bindings translate this into:
/// - UK: `OBWriteDomesticConsent4` + `OBWriteDomestic2`.
/// - Berlin Group: a `PaymentInitiationXMLPart` (pain.001) for the
/// chosen `payment-product` (`sepa-credit-transfers`,
/// `instant-sepa-credit-transfers`, ...).
/// - STET: a `Payment Request Resource`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentInitiation {
/// Payment kind.
pub kind: PaymentKind,
/// Debtor account identifier (binding-specific format).
pub debtor_account: String,
/// Creditor account identifier (binding-specific format).
pub creditor_account: String,
/// Creditor display name (statement narrative).
pub creditor_name: String,
/// Amount to transfer.
pub amount: Money,
/// End-to-end reference (UK `EndToEndIdentification`, Berlin
/// `endToEndIdentification`, ISO `EndToEndId`).
pub end_to_end_id: String,
/// Free-text remittance information (max length is
/// binding-specific; UK = 140, SEPA = 140, FedNow = 140).
pub remittance: Option<String>,
/// Requested execution date for FutureDated / StandingOrder.
/// Ignored for ImmediateSingle.
pub requested_execution_date: Option<time::Date>,
}
impl PaymentInitiation {
/// Basic shape validation. Each binding adds its own constraints
/// on top (IBAN check digits, sort-code format, allowed
/// remittance length).
pub fn validate(&self) -> Result<()> {
if self.amount.minor_units <= 0 {
return Err(crate::Error::PaymentInitiationInvalid {
reason: "amount must be strictly positive".into(),
});
}
if self.creditor_account.trim().is_empty() {
return Err(crate::Error::PaymentInitiationInvalid {
reason: "creditor account is empty".into(),
});
}
if self.end_to_end_id.is_empty() {
return Err(crate::Error::PaymentInitiationInvalid {
reason: "end-to-end id required (ISO 20022 EndToEndId)".into(),
});
}
if matches!(
self.kind,
PaymentKind::FutureDated | PaymentKind::StandingOrder
) && self.requested_execution_date.is_none()
{
return Err(crate::Error::PaymentInitiationInvalid {
reason: "future-dated / standing-order requires execution date".into(),
});
}
Ok(())
}
}
/// PISP service trait — vendor-neutral payment-initiation surface.
///
/// Bindings implement this in terms of their standard's wire format.
/// Like [`crate::AccountInfoService`], the trait is intentionally
/// synchronous; operators wrap their driver in an `async` wrapper.
pub trait PaymentInitiationService: Send + Sync {
/// Create a payment-initiation consent. Returns the consent id
/// the operator passes back through the PSU authorisation flow.
fn create_consent(
&self,
token: &OAuth2Token,
payment: &PaymentInitiation,
) -> Result<ConsentId>;
/// Submit a previously-authorised payment for execution.
fn submit(
&self,
consent: &ConsentId,
token: &OAuth2Token,
payment: &PaymentInitiation,
) -> Result<PaymentRef>;
/// Poll the status of a submitted payment.
fn status(
&self,
token: &OAuth2Token,
payment_ref: &PaymentRef,
) -> Result<PaymentInitiationStatus>;
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::Currency;
fn happy() -> PaymentInitiation {
PaymentInitiation {
kind: PaymentKind::ImmediateSingle,
debtor_account: "GB29NWBK60161331926819".into(),
creditor_account: "GB33BUKB20201555555555".into(),
creditor_name: "Acme Widgets".into(),
amount: Money::from_minor(10_000, Currency::GBP),
end_to_end_id: "INV-2026-001".into(),
remittance: Some("Invoice 001".into()),
requested_execution_date: None,
}
}
#[test]
fn happy_path_validates() {
happy().validate().expect("ok");
}
#[test]
fn zero_amount_rejected() {
let mut p = happy();
p.amount = Money::from_minor(0, Currency::GBP);
assert!(p.validate().is_err());
}
#[test]
fn future_dated_needs_date() {
let mut p = happy();
p.kind = PaymentKind::FutureDated;
assert!(p.validate().is_err());
p.requested_execution_date =
Some(time::Date::from_ordinal_date(2026, 1).expect("date"));
p.validate().expect("ok with date");
}
}