OpenPay (1 crate, ~2287 LOC, 15 tests)
op-dispute-evidence Chargeback lifecycle + evidence packaging across Visa
VCR (10.x/11.x/12.x/13.x), Mastercard Mastercom
(4837/4853/1240/1442), Amex SafeKey + DRR, Discover DRR,
PayPal. CE3.0 compelling-evidence qualifier (120-365d
window, ≥2 qualifiers, ≥2 linking matches, 10.4-only).
Phase state machine + ReasonCodeCatalog + sealed
EvidencePackageBuilder + win-rate scoring.
Smart Byte (1 crate, ~2479 LOC, 44 tests)
smart-byte-edv Encrypted Data Vaults (DIF v0.10).
JWE ECDH-ES P-256 + HKDF-SHA256-Concat-KDF + AES-256-GCM,
multi-recipient; HMAC-SHA-256 encrypted index with
AND-equality queries; full ZCAP-LD chain validation;
chunked AES-256-GCM streaming with per-chunk AAD;
Vault trait + InMemoryVault + one-way pull replication.
EOC (1 crate, ~2065 LOC, 29 tests)
eoc-agent Agentic loops with per-step joule accounting.
ReAct, Reflexion (PASS/FAIL judge + episodic memory),
Tree of Thoughts (BFS/DFS + self-scoring), LATS
(MCTS-UCT select/expand/simulate/backprop),
Plan-and-Execute, CodeAct (fenced-block + pluggable
runtime). Budget enforcement (tokens/joules/tool-calls),
trace spans, JouleCounter integration.
Three other batch-5 deferrals (op-ewallets-asia, smart-byte-zk, eoc-rag)
will land in wave A3.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
173 lines
4.9 KiB
Rust
173 lines
4.9 KiB
Rust
//! CE3.0 qualifying-transaction rules.
|
|
//!
|
|
//! Covers the four published constraints:
|
|
//!
|
|
//! 1. Reason code must be Visa 10.4.
|
|
//! 2. >= 2 prior, non-disputed transactions from same cardholder.
|
|
//! 3. Each qualifier 120-365 days old at the time of the disputed
|
|
//! transaction.
|
|
//! 4. >= 2 linking-data matches between disputed + qualifier.
|
|
|
|
use op_dispute_evidence::{
|
|
Ce3Qualifier, Network, QualifyingTransaction, ReasonCode, VisaReasonCode,
|
|
};
|
|
use op_dispute_evidence::ce3::{CE3_MAX_AGE, CE3_MIN_AGE, LinkingData};
|
|
use time::{Duration, OffsetDateTime};
|
|
|
|
fn now() -> OffsetDateTime {
|
|
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("ok")
|
|
}
|
|
|
|
fn make_qualifier(
|
|
id: &str,
|
|
age: Duration,
|
|
ip: Option<&str>,
|
|
device: Option<&str>,
|
|
shipping: Option<&str>,
|
|
disputed: bool,
|
|
) -> QualifyingTransaction {
|
|
QualifyingTransaction {
|
|
id: id.into(),
|
|
authorized_at: now() - age,
|
|
ip: ip.map(str::to_owned),
|
|
device_id: device.map(str::to_owned),
|
|
shipping_address: shipping.map(str::to_owned),
|
|
account_login: Some("user-42".into()),
|
|
was_disputed: disputed,
|
|
}
|
|
}
|
|
|
|
fn disputed_link() -> LinkingData {
|
|
LinkingData {
|
|
ip: Some("203.0.113.7".into()),
|
|
device_id: Some("dev-AAA".into()),
|
|
shipping_address: Some("addr-XYZ".into()),
|
|
account_login: Some("user-42".into()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ce3_eligible_when_two_qualifiers_with_two_links_each() {
|
|
let qualifiers = [
|
|
make_qualifier(
|
|
"q1",
|
|
Duration::days(150),
|
|
Some("203.0.113.7"),
|
|
Some("dev-AAA"),
|
|
None,
|
|
false,
|
|
),
|
|
make_qualifier(
|
|
"q2",
|
|
Duration::days(300),
|
|
None,
|
|
Some("dev-AAA"),
|
|
Some("addr-XYZ"),
|
|
false,
|
|
),
|
|
];
|
|
let result = Ce3Qualifier::evaluate(
|
|
ReasonCode::Visa(VisaReasonCode::F1040),
|
|
now(),
|
|
&disputed_link(),
|
|
&qualifiers,
|
|
)
|
|
.expect("evaluate");
|
|
assert!(result.eligible, "two qualifiers, each with 2 links, should be eligible");
|
|
assert_eq!(result.matched_qualifiers.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn ce3_ineligible_when_too_few_links() {
|
|
// Only IP matches on each qualifier (1 link < required 2).
|
|
let qualifiers = [
|
|
make_qualifier("q1", Duration::days(150), Some("203.0.113.7"), None, None, false),
|
|
make_qualifier("q2", Duration::days(300), Some("203.0.113.7"), None, None, false),
|
|
];
|
|
let mut link = disputed_link();
|
|
link.device_id = None;
|
|
link.shipping_address = None;
|
|
link.account_login = None;
|
|
let result = Ce3Qualifier::evaluate(
|
|
ReasonCode::Visa(VisaReasonCode::F1040),
|
|
now(),
|
|
&link,
|
|
&qualifiers,
|
|
)
|
|
.expect("evaluate");
|
|
assert!(!result.eligible);
|
|
assert_eq!(result.matched_qualifiers.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn ce3_ineligible_when_qualifiers_outside_window() {
|
|
// Both qualifiers are too recent — under the 120-day floor.
|
|
let qualifiers = [
|
|
make_qualifier("q1", Duration::days(30), Some("203.0.113.7"), Some("dev-AAA"), None, false),
|
|
make_qualifier("q2", Duration::days(60), Some("203.0.113.7"), Some("dev-AAA"), None, false),
|
|
];
|
|
let result = Ce3Qualifier::evaluate(
|
|
ReasonCode::Visa(VisaReasonCode::F1040),
|
|
now(),
|
|
&disputed_link(),
|
|
&qualifiers,
|
|
)
|
|
.expect("evaluate");
|
|
assert!(!result.eligible);
|
|
// Sanity-check the constants are exposed and self-consistent.
|
|
assert!(CE3_MIN_AGE < CE3_MAX_AGE);
|
|
}
|
|
|
|
#[test]
|
|
fn ce3_skips_previously_disputed_qualifiers() {
|
|
let qualifiers = [
|
|
make_qualifier(
|
|
"good",
|
|
Duration::days(150),
|
|
Some("203.0.113.7"),
|
|
Some("dev-AAA"),
|
|
None,
|
|
false,
|
|
),
|
|
make_qualifier(
|
|
"tainted",
|
|
Duration::days(200),
|
|
Some("203.0.113.7"),
|
|
Some("dev-AAA"),
|
|
None,
|
|
true,
|
|
),
|
|
];
|
|
let result = Ce3Qualifier::evaluate(
|
|
ReasonCode::Visa(VisaReasonCode::F1040),
|
|
now(),
|
|
&disputed_link(),
|
|
&qualifiers,
|
|
)
|
|
.expect("evaluate");
|
|
// Only one un-disputed qualifier passes — below the 2-min.
|
|
assert!(!result.eligible);
|
|
assert_eq!(result.matched_qualifiers, vec!["good".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ce3_only_applies_to_visa_10_4() {
|
|
let q = make_qualifier(
|
|
"q1",
|
|
Duration::days(150),
|
|
Some("203.0.113.7"),
|
|
Some("dev-AAA"),
|
|
None,
|
|
false,
|
|
);
|
|
let err = Ce3Qualifier::evaluate(
|
|
ReasonCode::Visa(VisaReasonCode::F1030),
|
|
now(),
|
|
&disputed_link(),
|
|
&[q],
|
|
)
|
|
.expect_err("non-10.4 must reject");
|
|
let _ = err; // surface that we got an error
|
|
// Network helper is exercised transitively.
|
|
assert_eq!(ReasonCode::Visa(VisaReasonCode::F1040).network(), Network::Visa);
|
|
}
|