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]>
266 lines
9.1 KiB
Rust
266 lines
9.1 KiB
Rust
//! Variable Recurring Payments (VRP).
|
|
//!
|
|
//! VRP is the UK Open Banking standard for a single long-lived consent
|
|
//! that authorises *many* subsequent payments inside operator-supplied
|
|
//! limits. Two flavours:
|
|
//!
|
|
//! - **Sweeping VRP.** Movement between the PSU's own accounts.
|
|
//! Free of charge, mandated under CMA9 retail-banking remedies.
|
|
//! - **Non-sweeping (commercial) VRP.** Operator-to-merchant
|
|
//! recurring payments. Commercially negotiated between TPP and
|
|
//! ASPSP; not regulated under CMA9.
|
|
//!
|
|
//! Reference: OBIE Domestic VRP Profile v3.1.10 (2025).
|
|
|
|
use op_core::Money;
|
|
use serde::{Deserialize, Serialize};
|
|
use time::Duration;
|
|
|
|
use crate::aisp::ConsentId;
|
|
use crate::error::Result;
|
|
use crate::fapi::OAuth2Token;
|
|
use crate::pisp::{PaymentInitiationStatus, PaymentRef};
|
|
|
|
/// VRP kind: sweeping (own-account, CMA9) or commercial (non-sweeping).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum VrpKind {
|
|
/// Movement between the PSU's own accounts. CMA9 mandates this
|
|
/// for free and bounds ASPSP discretion.
|
|
Sweeping,
|
|
/// Operator-to-merchant. Commercially negotiated.
|
|
NonSweeping,
|
|
}
|
|
|
|
/// Time window over which an aggregate cap applies. UK OBIE specifies
|
|
/// `Day`, `Week`, `Fortnight`, `Month`, `HalfYear`, `Year`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum VrpWindow {
|
|
/// 24-hour rolling window from first payment in the window.
|
|
Day,
|
|
/// 7-day window.
|
|
Week,
|
|
/// 14-day window.
|
|
Fortnight,
|
|
/// Calendar-month window.
|
|
Month,
|
|
/// 6-month window.
|
|
HalfYear,
|
|
/// Calendar-year window.
|
|
Year,
|
|
}
|
|
|
|
impl VrpWindow {
|
|
/// Duration of the window. Calendar windows are approximated as
|
|
/// fixed durations for cap arithmetic; ASPSPs handle calendar
|
|
/// alignment server-side.
|
|
#[must_use]
|
|
pub const fn approx_duration(self) -> Duration {
|
|
match self {
|
|
Self::Day => Duration::days(1),
|
|
Self::Week => Duration::days(7),
|
|
Self::Fortnight => Duration::days(14),
|
|
Self::Month => Duration::days(30),
|
|
Self::HalfYear => Duration::days(182),
|
|
Self::Year => Duration::days(365),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cap parameters bound to a VRP consent. The ASPSP enforces these
|
|
/// at submission time and rejects payments that exceed any limit.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct VrpControlParameters {
|
|
/// Maximum per-payment amount.
|
|
pub max_individual_amount: Money,
|
|
/// Maximum aggregate amount over the [`Self::window`].
|
|
pub max_period_amount: Money,
|
|
/// Window over which [`Self::max_period_amount`] is computed.
|
|
pub window: VrpWindow,
|
|
/// Optional hard expiry of the consent. After this date the
|
|
/// consent is no longer usable regardless of caps.
|
|
pub valid_until: Option<time::OffsetDateTime>,
|
|
}
|
|
|
|
/// VRP consent — long-lived, repeatedly-debitable.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct VrpConsent {
|
|
/// Consent identifier minted by the ASPSP.
|
|
pub id: ConsentId,
|
|
/// Sweeping vs commercial.
|
|
pub kind: VrpKind,
|
|
/// Debtor account identifier.
|
|
pub debtor_account: String,
|
|
/// Creditor account identifier.
|
|
pub creditor_account: String,
|
|
/// Cap parameters the ASPSP will enforce.
|
|
pub controls: VrpControlParameters,
|
|
}
|
|
|
|
/// A single VRP payment instruction.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct VrpExecution {
|
|
/// Amount to transfer on this payment.
|
|
pub amount: Money,
|
|
/// End-to-end ID for the payment.
|
|
pub end_to_end_id: String,
|
|
/// Operator-side remittance reference.
|
|
pub remittance: Option<String>,
|
|
/// RFC 3339 timestamp the operator records as "submission time"
|
|
/// for window arithmetic. The ASPSP's clock is authoritative.
|
|
pub submitted_at: time::OffsetDateTime,
|
|
}
|
|
|
|
/// Sweeping helper: pairs an execution with the window context for
|
|
/// local pre-checking before submission.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct VrpSweep {
|
|
/// The consent the sweep is debited against.
|
|
pub consent: VrpConsent,
|
|
/// The execution about to be submitted.
|
|
pub execution: VrpExecution,
|
|
/// Aggregate already-spent inside the current window.
|
|
pub already_spent_in_window: Money,
|
|
}
|
|
|
|
impl VrpSweep {
|
|
/// Pre-check the execution against the consent's caps.
|
|
///
|
|
/// This is a client-side hint; the ASPSP remains authoritative.
|
|
/// Catching cap violations locally avoids burning interaction
|
|
/// credits at the ASPSP for predictably-rejected payments.
|
|
pub fn check_caps(&self) -> Result<()> {
|
|
if self.execution.amount.currency != self.consent.controls.max_individual_amount.currency {
|
|
return Err(crate::Error::CurrencyMismatch(format!(
|
|
"execution currency {} != consent currency {}",
|
|
self.execution.amount.currency, self.consent.controls.max_individual_amount.currency,
|
|
)));
|
|
}
|
|
if self.execution.amount.minor_units
|
|
> self.consent.controls.max_individual_amount.minor_units
|
|
{
|
|
return Err(crate::Error::VrpLimitExceeded {
|
|
reason: "per-payment cap exceeded".into(),
|
|
});
|
|
}
|
|
let projected_total = self
|
|
.already_spent_in_window
|
|
.checked_add(self.execution.amount)
|
|
.map_err(|_| crate::Error::Overflow)?;
|
|
if projected_total.minor_units > self.consent.controls.max_period_amount.minor_units {
|
|
return Err(crate::Error::VrpLimitExceeded {
|
|
reason: "per-period cap exceeded".into(),
|
|
});
|
|
}
|
|
if let Some(expiry) = self.consent.controls.valid_until {
|
|
if self.execution.submitted_at > expiry {
|
|
return Err(crate::Error::ConsentStateInvalid {
|
|
reason: "consent expired".into(),
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// VRP service trait.
|
|
pub trait VrpService: Send + Sync {
|
|
/// Create a VRP consent.
|
|
fn create_consent(&self, token: &OAuth2Token, consent: &VrpConsent) -> Result<ConsentId>;
|
|
|
|
/// Submit a single execution against an existing consent.
|
|
fn execute(
|
|
&self,
|
|
consent: &ConsentId,
|
|
token: &OAuth2Token,
|
|
execution: &VrpExecution,
|
|
) -> Result<PaymentRef>;
|
|
|
|
/// Poll the status of a VRP execution.
|
|
fn status(
|
|
&self,
|
|
token: &OAuth2Token,
|
|
payment_ref: &PaymentRef,
|
|
) -> Result<PaymentInitiationStatus>;
|
|
|
|
/// Revoke a VRP consent. No further executions accepted after
|
|
/// the ASPSP acknowledges revocation.
|
|
fn revoke(&self, consent: &ConsentId, token: &OAuth2Token) -> Result<()>;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use op_core::Currency;
|
|
|
|
fn consent() -> VrpConsent {
|
|
VrpConsent {
|
|
id: ConsentId("vrp-1".into()),
|
|
kind: VrpKind::Sweeping,
|
|
debtor_account: "GB29NWBK60161331926819".into(),
|
|
creditor_account: "GB33BUKB20201555555555".into(),
|
|
controls: VrpControlParameters {
|
|
max_individual_amount: Money::from_minor(100_00, Currency::GBP),
|
|
max_period_amount: Money::from_minor(500_00, Currency::GBP),
|
|
window: VrpWindow::Month,
|
|
valid_until: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn within_caps_passes() {
|
|
let sweep = VrpSweep {
|
|
consent: consent(),
|
|
execution: VrpExecution {
|
|
amount: Money::from_minor(50_00, Currency::GBP),
|
|
end_to_end_id: "E1".into(),
|
|
remittance: None,
|
|
submitted_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
},
|
|
already_spent_in_window: Money::from_minor(200_00, Currency::GBP),
|
|
};
|
|
sweep.check_caps().expect("ok");
|
|
}
|
|
|
|
#[test]
|
|
fn per_payment_cap_fires() {
|
|
let sweep = VrpSweep {
|
|
consent: consent(),
|
|
execution: VrpExecution {
|
|
amount: Money::from_minor(200_00, Currency::GBP),
|
|
end_to_end_id: "E1".into(),
|
|
remittance: None,
|
|
submitted_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
},
|
|
already_spent_in_window: Money::zero(Currency::GBP),
|
|
};
|
|
assert!(matches!(
|
|
sweep.check_caps().unwrap_err(),
|
|
crate::Error::VrpLimitExceeded { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn period_cap_fires() {
|
|
let sweep = VrpSweep {
|
|
consent: consent(),
|
|
execution: VrpExecution {
|
|
amount: Money::from_minor(50_00, Currency::GBP),
|
|
end_to_end_id: "E1".into(),
|
|
remittance: None,
|
|
submitted_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
},
|
|
already_spent_in_window: Money::from_minor(490_00, Currency::GBP),
|
|
};
|
|
assert!(matches!(
|
|
sweep.check_caps().unwrap_err(),
|
|
crate::Error::VrpLimitExceeded { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_have_sensible_durations() {
|
|
assert_eq!(VrpWindow::Day.approx_duration(), Duration::days(1));
|
|
assert_eq!(VrpWindow::Year.approx_duration(), Duration::days(365));
|
|
}
|
|
}
|