use super::base::{Items, Paginated};
use crate::api::base::TastyResult;
use crate::api::query::{PageRequest, QueryBuilder};
use crate::api::url::encode_path_segment;
use crate::types::account_filter::{BalanceSnapshotFilter, PositionFilter, SnapshotRange};
use crate::types::balance::{Balance, BalanceSnapshot, SnapshotTimeOfDay};
use crate::types::capability::{ensure_legs_are_tradable, ensure_orders_are_tradable};
use crate::types::complex_order::{
ComplexOrder, ComplexOrderId, ComplexOrderRequest, PairsThresholdEdit,
};
use crate::types::margin::{
EffectiveMarginRequirement, MarginEstimate, MarginOrderRequest, MarginRequirementsReport,
PositionLimit,
};
use crate::types::net_liq::{NetLiqHistoryFilter, NetLiqOhlc};
use crate::types::order::{
DryRunResult, Order, OrderAmendment, OrderId, OrderPlacedResult, Warning,
};
use crate::types::order_filter::{LiveOrderFilter, OrderFilter};
use crate::types::trading_status::TradingStatus;
use crate::types::transaction::{TotalFees, Transaction, TransactionFilter};
use crate::{FullPosition, LiveOrderRecord, TastyTrade};
use chrono::{DateTime, FixedOffset, NaiveDate};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
#[derive(
DebugPretty, DisplaySimple, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone,
)]
#[serde(transparent)]
pub struct AccountNumber(pub String);
impl<T: AsRef<str>> From<T> for AccountNumber {
fn from(value: T) -> Self {
Self(value.as_ref().to_owned())
}
}
impl AccountNumber {
pub fn redacted(&self) -> String {
const KEEP_PREFIX: usize = 2;
const KEEP_SUFFIX: usize = 3;
let chars: Vec<char> = self.0.chars().collect();
if chars.len() <= KEEP_PREFIX + KEEP_SUFFIX {
return "*".repeat(chars.len().max(1));
}
let prefix: String = chars[..KEEP_PREFIX].iter().collect();
let suffix: String = chars[chars.len() - KEEP_SUFFIX..].iter().collect();
format!("{prefix}…{suffix}")
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AccountDetails {
pub account_number: AccountNumber,
pub external_id: Option<String>,
#[serde(with = "crate::types::wire::datetime")]
pub opened_at: DateTime<FixedOffset>,
pub nickname: String,
pub account_type_name: String,
pub day_trader_status: Option<bool>,
pub is_firm_error: Option<bool>,
pub is_firm_proprietary: Option<bool>,
#[serde(default)]
pub is_test_drive: bool,
pub margin_or_cash: String,
pub is_foreign: Option<bool>,
#[serde(default, with = "crate::types::wire::date_option")]
pub funding_date: Option<NaiveDate>,
pub is_closed: Option<bool>,
#[serde(default, with = "crate::types::wire::datetime_option")]
pub created_at: Option<DateTime<FixedOffset>>,
pub investment_objective: Option<String>,
pub is_futures_approved: Option<bool>,
pub suitable_options_level: Option<String>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct AccountInner {
pub account: AccountDetails,
#[serde(default)]
pub authority_level: Option<String>,
}
#[derive(Debug)]
pub struct DryRunReceipt {
account_number: AccountNumber,
origin: String,
order: Order,
result: DryRunResult,
}
impl DryRunReceipt {
pub fn result(&self) -> &DryRunResult {
&self.result
}
pub fn warnings(&self) -> &[Warning] {
&self.result.warnings
}
pub fn order(&self) -> &Order {
&self.order
}
pub fn is_clean(&self) -> bool {
self.result.warnings.is_empty()
}
pub fn accept(self) -> TastyResult<ReviewedOrder> {
if !self.is_clean() {
return Err(crate::TastyTradeError::Precondition(format!(
"the venue attached {} warning(s) to this order; read them and use \
accept_with_warnings to proceed deliberately",
self.result.warnings.len()
)));
}
Ok(ReviewedOrder {
account_number: self.account_number,
origin: self.origin,
order: self.order,
})
}
pub fn accept_with_warnings(self) -> ReviewedOrder {
ReviewedOrder {
account_number: self.account_number,
origin: self.origin,
order: self.order,
}
}
}
#[derive(Debug)]
pub struct ReviewedOrder {
account_number: AccountNumber,
origin: String,
order: Order,
}
impl ReviewedOrder {
pub fn account_number(&self) -> &AccountNumber {
&self.account_number
}
pub fn order(&self) -> &Order {
&self.order
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AmendmentIntent {
Replace,
Edit,
}
#[derive(Debug)]
pub struct AmendmentReceipt {
account_number: AccountNumber,
origin: String,
order_id: OrderId,
intent: AmendmentIntent,
amendment: OrderAmendment,
result: DryRunResult,
}
impl AmendmentReceipt {
pub fn result(&self) -> &DryRunResult {
&self.result
}
pub fn warnings(&self) -> &[Warning] {
&self.result.warnings
}
pub fn amendment(&self) -> &OrderAmendment {
&self.amendment
}
pub fn order_id(&self) -> OrderId {
self.order_id
}
pub fn intent(&self) -> AmendmentIntent {
self.intent
}
pub fn is_clean(&self) -> bool {
self.result.warnings.is_empty()
}
pub fn accept(self) -> TastyResult<ReviewedAmendment> {
if !self.is_clean() {
return Err(crate::TastyTradeError::Precondition(format!(
"the venue attached {} warning(s) to this amendment; read them and use \
accept_with_warnings to proceed deliberately",
self.result.warnings.len()
)));
}
Ok(self.into_reviewed())
}
pub fn accept_with_warnings(self) -> ReviewedAmendment {
self.into_reviewed()
}
fn into_reviewed(self) -> ReviewedAmendment {
ReviewedAmendment {
account_number: self.account_number,
origin: self.origin,
order_id: self.order_id,
intent: self.intent,
amendment: self.amendment,
}
}
}
#[derive(Debug)]
pub struct ReviewedAmendment {
account_number: AccountNumber,
origin: String,
order_id: OrderId,
intent: AmendmentIntent,
amendment: OrderAmendment,
}
impl ReviewedAmendment {
pub fn account_number(&self) -> &AccountNumber {
&self.account_number
}
pub fn order_id(&self) -> OrderId {
self.order_id
}
pub fn intent(&self) -> AmendmentIntent {
self.intent
}
}
#[derive(Debug)]
pub struct ComplexOrderReceipt {
account_number: AccountNumber,
origin: String,
request: ComplexOrderRequest,
result: DryRunResult,
}
impl ComplexOrderReceipt {
pub fn result(&self) -> &DryRunResult {
&self.result
}
pub fn warnings(&self) -> &[Warning] {
&self.result.warnings
}
pub fn request(&self) -> &ComplexOrderRequest {
&self.request
}
pub fn is_clean(&self) -> bool {
self.result.warnings.is_empty()
}
pub fn accept(self) -> TastyResult<ReviewedComplexOrder> {
if !self.is_clean() {
return Err(crate::TastyTradeError::Precondition(format!(
"the venue attached {} warning(s) to this complex order; read them and \
use accept_with_warnings to proceed deliberately",
self.result.warnings.len()
)));
}
Ok(self.into_reviewed())
}
pub fn accept_with_warnings(self) -> ReviewedComplexOrder {
self.into_reviewed()
}
fn into_reviewed(self) -> ReviewedComplexOrder {
ReviewedComplexOrder {
account_number: self.account_number,
origin: self.origin,
request: self.request,
}
}
}
#[derive(Debug)]
pub struct ReviewedComplexOrder {
account_number: AccountNumber,
origin: String,
request: ComplexOrderRequest,
}
impl ReviewedComplexOrder {
pub fn account_number(&self) -> &AccountNumber {
&self.account_number
}
}
#[derive(Debug)]
pub struct PairsThresholdReceipt {
account_number: AccountNumber,
origin: String,
complex_order_id: ComplexOrderId,
edit: PairsThresholdEdit,
result: DryRunResult,
}
impl PairsThresholdReceipt {
pub fn result(&self) -> &DryRunResult {
&self.result
}
pub fn warnings(&self) -> &[Warning] {
&self.result.warnings
}
pub fn is_clean(&self) -> bool {
self.result.warnings.is_empty()
}
pub fn accept(self) -> TastyResult<ReviewedPairsThreshold> {
if !self.is_clean() {
return Err(crate::TastyTradeError::Precondition(format!(
"the venue attached {} warning(s) to this threshold change; read them \
and use accept_with_warnings to proceed deliberately",
self.result.warnings.len()
)));
}
Ok(self.into_reviewed())
}
pub fn accept_with_warnings(self) -> ReviewedPairsThreshold {
self.into_reviewed()
}
fn into_reviewed(self) -> ReviewedPairsThreshold {
ReviewedPairsThreshold {
account_number: self.account_number,
origin: self.origin,
complex_order_id: self.complex_order_id,
edit: self.edit,
}
}
}
#[derive(Debug)]
pub struct ReviewedPairsThreshold {
account_number: AccountNumber,
origin: String,
complex_order_id: ComplexOrderId,
edit: PairsThresholdEdit,
}
impl ReviewedPairsThreshold {
pub fn account_number(&self) -> &AccountNumber {
&self.account_number
}
pub fn complex_order_id(&self) -> &ComplexOrderId {
&self.complex_order_id
}
}
pub struct Account<'t> {
pub(crate) inner: AccountInner,
pub(crate) tasty: &'t TastyTrade,
}
impl Account<'_> {
pub fn number(&self) -> AccountNumber {
self.inner.account.account_number.clone()
}
pub fn details(&self) -> &AccountDetails {
&self.inner.account
}
pub fn authority_level(&self) -> Option<&str> {
self.inner.authority_level.as_deref()
}
fn path(&self, suffix: &str) -> String {
format!(
"/accounts/{}{suffix}",
encode_path_segment(&self.inner.account.account_number.0)
)
}
pub async fn balances(&self) -> TastyResult<Vec<Balance>> {
let resp: Items<Balance> = self.tasty.get(&self.path("/balances")).await?;
resp.into_items()
}
pub async fn balance(&self) -> TastyResult<Balance> {
let mut rows = self.balances().await?;
if rows.len() == 1 {
return Ok(rows.swap_remove(0));
}
let currencies: Vec<&str> = rows
.iter()
.map(|row| row.currency.as_deref().unwrap_or("unnamed"))
.collect();
Err(crate::TastyTradeError::Precondition(format!(
"the account returned {} balance row(s) ({}), so there is no single \
balance to return; use balances() for all of them or \
balance_in(currency) for one",
rows.len(),
currencies.join(", ")
)))
}
pub async fn balance_in(&self, currency: &str) -> TastyResult<Balance> {
self.tasty
.get(&self.path(&format!("/balances/{}", encode_path_segment(currency))))
.await
}
pub async fn balance_snapshots(
&self,
filter: &BalanceSnapshotFilter,
) -> TastyResult<Paginated<BalanceSnapshot>> {
let query = filter.to_query();
self.tasty
.get_with_query::<Items<BalanceSnapshot>, _, _>(
&self.path("/balance-snapshots"),
&query.pairs(),
)
.await
}
#[deprecated(
since = "0.4.0",
note = "use `balance_snapshots(&BalanceSnapshotFilter)`, which reaches the whole \
documented query rather than four of its parameters"
)]
pub async fn balance_snapshot(
&self,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
tod: SnapshotTimeOfDay,
page_offset: usize,
) -> TastyResult<Paginated<BalanceSnapshot>> {
let page_offset = u32::try_from(page_offset).map_err(|_| {
crate::TastyTradeError::Precondition(format!(
"page offset {page_offset} does not fit the u32 the venue accepts"
))
})?;
self.balance_snapshots(
&BalanceSnapshotFilter::at(tod)
.with_range(SnapshotRange::Range {
start: Some(start_date),
end: Some(end_date),
})
.with_page(PageRequest::new().with_page_offset(page_offset)),
)
.await
}
pub async fn positions(&self) -> TastyResult<Vec<FullPosition>> {
self.positions_matching(&PositionFilter::new()).await
}
pub async fn positions_matching(
&self,
filter: &PositionFilter,
) -> TastyResult<Vec<FullPosition>> {
let query = filter.to_query();
let resp: Items<FullPosition> = self
.tasty
.get_with_query(&self.path("/positions"), &query.pairs())
.await?;
resp.into_items()
}
pub async fn transactions(
&self,
filter: &TransactionFilter,
) -> TastyResult<Paginated<Transaction>> {
let query = filter.to_query();
self.tasty
.get_with_query::<Items<Transaction>, _, _>(&self.path("/transactions"), &query.pairs())
.await
}
pub async fn transaction(&self, id: i64) -> TastyResult<Transaction> {
self.tasty
.get(&self.path(&format!("/transactions/{id}")))
.await
}
pub async fn total_fees(&self, date: Option<NaiveDate>) -> TastyResult<TotalFees> {
let mut query = QueryBuilder::new();
query.push_opt("date", date);
self.tasty
.get_with_query::<TotalFees, TotalFees, _>(
&self.path("/transactions/total-fees"),
&query.pairs(),
)
.await
}
pub async fn trading_status(&self) -> TastyResult<TradingStatus> {
self.tasty.get(&self.path("/trading-status")).await
}
pub async fn margin_requirements(&self) -> TastyResult<MarginRequirementsReport> {
self.tasty
.get(&format!(
"/margin/accounts/{}/requirements",
encode_path_segment(&self.inner.account.account_number.0)
))
.await
}
pub async fn estimate_margin(
&self,
request: &MarginOrderRequest,
) -> TastyResult<MarginEstimate> {
request.validate(&self.inner.account.account_number.0)?;
self.tasty
.post(
&format!(
"/margin/accounts/{}/dry-run",
encode_path_segment(&self.inner.account.account_number.0)
),
request,
)
.await
}
pub async fn effective_margin_requirement(
&self,
underlying_symbol: &str,
) -> TastyResult<EffectiveMarginRequirement> {
self.tasty
.get(&self.path(&format!(
"/margin-requirements/{}/effective",
encode_path_segment(underlying_symbol)
)))
.await
}
pub async fn position_limit(&self) -> TastyResult<PositionLimit> {
self.tasty.get(&self.path("/position-limit")).await
}
pub async fn net_liq_history(
&self,
filter: &NetLiqHistoryFilter,
) -> TastyResult<Vec<NetLiqOhlc>> {
let query = filter.to_query();
let resp: Items<NetLiqOhlc> = self
.tasty
.get_with_query(&self.path("/net-liq/history"), &query.pairs())
.await?;
resp.into_items()
}
pub async fn live_orders(&self) -> TastyResult<Vec<LiveOrderRecord>> {
let resp: Items<LiveOrderRecord> = self.tasty.get(&self.path("/orders/live")).await?;
resp.into_items()
}
pub async fn review_order(&self, order: &Order) -> TastyResult<DryRunReceipt> {
let result = self.dry_run(order).await?;
Ok(DryRunReceipt {
account_number: self.number(),
origin: self.tasty.config.base_url.clone(),
order: order.clone(),
result,
})
}
pub async fn place_reviewed_order(
&self,
reviewed: ReviewedOrder,
) -> TastyResult<OrderPlacedResult> {
if reviewed.account_number != self.number() {
return Err(crate::TastyTradeError::Precondition(
"this order was reviewed against a different account; \
review it again against the account you mean to trade"
.to_string(),
));
}
if reviewed.origin != self.tasty.config.base_url {
return Err(crate::TastyTradeError::Precondition(
"this order was reviewed against a different venue; \
a dry run on one environment says nothing about another"
.to_string(),
));
}
ensure_legs_are_tradable(reviewed.order.legs())?;
self.place_order(&reviewed.order).await
}
pub async fn dry_run(&self, order: &Order) -> TastyResult<DryRunResult> {
ensure_legs_are_tradable(order.legs())?;
let resp: DryRunResult = self
.tasty
.post(&self.path("/orders/dry-run"), order)
.await?;
Ok(resp)
}
pub async fn place_order(&self, order: &Order) -> TastyResult<OrderPlacedResult> {
ensure_legs_are_tradable(order.legs())?;
let resp: OrderPlacedResult = self.tasty.post(&self.path("/orders"), order).await?;
Ok(resp)
}
pub async fn order(&self, id: OrderId) -> TastyResult<LiveOrderRecord> {
self.tasty
.get(&self.path(&format!("/orders/{}", id.0)))
.await
}
pub async fn search_orders(
&self,
filter: &OrderFilter,
) -> TastyResult<Paginated<LiveOrderRecord>> {
let query = filter.to_query();
self.tasty
.get_with_query::<Items<LiveOrderRecord>, _, _>(&self.path("/orders"), &query.pairs())
.await
}
pub async fn live_orders_matching(
&self,
filter: &LiveOrderFilter,
) -> TastyResult<Paginated<LiveOrderRecord>> {
let query = filter.to_query();
self.tasty
.get_with_query::<Items<LiveOrderRecord>, _, _>(
&self.path("/orders/live"),
&query.pairs(),
)
.await
}
pub async fn review_amendment(
&self,
id: OrderId,
intent: AmendmentIntent,
amendment: &OrderAmendment,
) -> TastyResult<AmendmentReceipt> {
amendment.validate()?;
let result: DryRunResult = self
.tasty
.post(&self.path(&format!("/orders/{}/dry-run", id.0)), amendment)
.await?;
Ok(AmendmentReceipt {
account_number: self.number(),
origin: self.tasty.config.base_url.clone(),
order_id: id,
intent,
amendment: amendment.clone(),
result,
})
}
pub async fn place_reviewed_amendment(
&self,
reviewed: ReviewedAmendment,
) -> TastyResult<LiveOrderRecord> {
if reviewed.account_number != self.number() {
return Err(crate::TastyTradeError::Precondition(
"this amendment was reviewed against a different account; review it again against the account you mean to trade"
.to_string(),
));
}
if reviewed.origin != self.tasty.config.base_url {
return Err(crate::TastyTradeError::Precondition(
"this amendment was reviewed against a different venue; \
a dry run on one environment says nothing about another"
.to_string(),
));
}
let path = self.path(&format!("/orders/{}", reviewed.order_id.0));
match reviewed.intent {
AmendmentIntent::Replace => self.tasty.put(&path, &reviewed.amendment).await,
AmendmentIntent::Edit => self.tasty.patch(&path, &reviewed.amendment).await,
}
}
pub async fn complex_orders(&self, page: &PageRequest) -> TastyResult<Paginated<ComplexOrder>> {
let mut query = QueryBuilder::new();
page.write_into(&mut query);
self.tasty
.get_with_query::<Items<ComplexOrder>, _, _>(
&self.path("/complex-orders"),
&query.pairs(),
)
.await
}
pub async fn live_complex_orders(&self) -> TastyResult<Vec<ComplexOrder>> {
let resp: Items<ComplexOrder> = self.tasty.get(&self.path("/complex-orders/live")).await?;
resp.into_items()
}
pub async fn complex_order(&self, id: &ComplexOrderId) -> TastyResult<ComplexOrder> {
self.tasty
.get(&self.path(&format!("/complex-orders/{}", encode_path_segment(&id.0))))
.await
}
pub async fn review_complex_order(
&self,
request: &ComplexOrderRequest,
) -> TastyResult<ComplexOrderReceipt> {
request.validate()?;
ensure_orders_are_tradable(&request.orders)?;
let result: DryRunResult = self
.tasty
.post(&self.path("/complex-orders/dry-run"), request)
.await?;
Ok(ComplexOrderReceipt {
account_number: self.number(),
origin: self.tasty.config.base_url.clone(),
request: request.clone(),
result,
})
}
pub async fn place_reviewed_complex_order(
&self,
reviewed: ReviewedComplexOrder,
) -> TastyResult<ComplexOrder> {
self.check_origin(&reviewed.account_number, &reviewed.origin, "complex order")?;
ensure_orders_are_tradable(&reviewed.request.orders)?;
self.tasty
.post(&self.path("/complex-orders"), &reviewed.request)
.await
}
pub async fn cancel_complex_order(&self, id: &ComplexOrderId) -> TastyResult<ComplexOrder> {
self.tasty
.delete(&self.path(&format!("/complex-orders/{}", encode_path_segment(&id.0))))
.await
}
pub async fn review_pairs_threshold(
&self,
id: &ComplexOrderId,
edit: &PairsThresholdEdit,
) -> TastyResult<PairsThresholdReceipt> {
let result: DryRunResult = self
.tasty
.post(
&self.path(&format!(
"/complex-orders/{}/dry-run",
encode_path_segment(&id.0)
)),
edit,
)
.await?;
Ok(PairsThresholdReceipt {
account_number: self.number(),
origin: self.tasty.config.base_url.clone(),
complex_order_id: id.clone(),
edit: edit.clone(),
result,
})
}
pub async fn place_reviewed_pairs_threshold(
&self,
reviewed: ReviewedPairsThreshold,
) -> TastyResult<ComplexOrder> {
self.check_origin(
&reviewed.account_number,
&reviewed.origin,
"threshold change",
)?;
self.tasty
.patch(
&self.path(&format!(
"/complex-orders/{}",
encode_path_segment(&reviewed.complex_order_id.0)
)),
&reviewed.edit,
)
.await
}
fn check_origin(
&self,
account_number: &AccountNumber,
origin: &str,
what: &str,
) -> TastyResult<()> {
if account_number != &self.number() {
return Err(crate::TastyTradeError::Precondition(format!(
"this {what} was reviewed against a different account; \
review it again against the account you mean to trade"
)));
}
if origin != self.tasty.config.base_url {
return Err(crate::TastyTradeError::Precondition(format!(
"this {what} was reviewed against a different venue; \
a dry run on one environment says nothing about another"
)));
}
Ok(())
}
pub async fn cancel_order(&self, id: OrderId) -> TastyResult<LiveOrderRecord> {
self.tasty
.delete(&self.path(&format!("/orders/{}", id.0)))
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCOUNTS_CAPTURE: &str = include_str!("../../Doc/captures/accounts.json");
fn cert_account() -> String {
let listing: serde_json::Value =
serde_json::from_str(ACCOUNTS_CAPTURE).expect("the capture is valid JSON");
listing["items"][0]["account"].to_string()
}
const PRODUCTION_ACCOUNT: &str = r#"{
"account-number": "5WX54321",
"external-id": "A1b2C3",
"opened-at": "2024-03-02T09:00:00.000+00:00",
"nickname": "Main",
"account-type-name": "Individual",
"day-trader-status": false,
"is-firm-error": false,
"is-firm-proprietary": false,
"is-test-drive": false,
"margin-or-cash": "Margin",
"is-foreign": false,
"funding-date": "2024-03-05"
}"#;
#[test]
fn parses_the_certification_payload() {
let account: AccountDetails =
serde_json::from_str(&cert_account()).expect("certification accounts must parse");
assert_eq!(account.account_number.0, "REDACTED");
assert!(!account.is_test_drive);
assert_eq!(account.external_id, None);
assert_eq!(account.funding_date, None);
assert_eq!(account.day_trader_status, Some(false));
assert_eq!(account.is_firm_error, Some(false));
assert_eq!(account.is_closed, Some(false));
assert_eq!(account.is_futures_approved, Some(false));
assert!(account.suitable_options_level.is_some());
assert_eq!(
account.created_at.map(|t| t.to_rfc3339()),
Some("2020-01-01T00:00:00+00:00".to_string()),
"the timestamp must be parsed, not carried as text"
);
}
#[test]
fn an_omitted_flag_is_unknown_rather_than_false() {
const WITHOUT_FLAGS: &str = r#"{
"account-number": "5WX12345",
"account-type-name": "Individual",
"margin-or-cash": "Margin",
"nickname": "Individual",
"opened-at": "2025-01-14T10:22:41.000+00:00"
}"#;
let account: AccountDetails =
serde_json::from_str(WITHOUT_FLAGS).expect("missing flags must not be fatal");
assert_eq!(account.is_firm_error, None);
assert_eq!(account.is_firm_proprietary, None);
assert_eq!(account.day_trader_status, None);
assert_eq!(account.is_foreign, None);
}
#[test]
fn parses_the_production_payload() {
let account: AccountDetails =
serde_json::from_str(PRODUCTION_ACCOUNT).expect("production accounts must parse");
assert_eq!(account.account_number.0, "5WX54321");
assert!(!account.is_test_drive);
assert_eq!(account.external_id.as_deref(), Some("A1b2C3"));
assert_eq!(account.is_firm_error, Some(false));
assert_eq!(account.is_closed, None);
assert_eq!(account.investment_objective, None);
assert_eq!(account.created_at, None);
}
#[test]
fn certification_accounts_survive_the_items_envelope() {
let body = ACCOUNTS_CAPTURE.to_string();
let items: Items<AccountInner> =
serde_json::from_str(&body).expect("the envelope is well formed");
assert_eq!(items.items.len(), 1, "the sandbox account must survive");
assert_eq!(items.items[0].account.account_number.0, "REDACTED");
assert!(items.items[0].authority_level.is_some());
}
#[test]
fn an_absent_authority_level_decodes_as_unknown_rather_than_empty() {
let listed: AccountInner = serde_json::from_str(&format!(
r#"{{"account":{},"authority-level":"owner"}}"#,
cert_account()
))
.expect("the listing shape decodes");
assert_eq!(listed.authority_level.as_deref(), Some("owner"));
let alone: AccountInner =
serde_json::from_str(&format!(r#"{{"account":{}}}"#, cert_account()))
.expect("an account with no decorator must still decode");
assert_eq!(alone.authority_level, None);
let blank: AccountInner = serde_json::from_str(&format!(
r#"{{"account":{},"authority-level":""}}"#,
cert_account()
))
.expect("an empty level decodes");
assert_eq!(blank.authority_level.as_deref(), Some(""));
}
}
#[cfg(test)]
mod redaction_tests {
use super::*;
#[test]
fn a_redacted_number_identifies_without_revealing() {
let account = AccountNumber::from("5WX123456");
let redacted = account.redacted();
assert_eq!(redacted, "5W…456");
assert!(
!redacted.contains("X1234"),
"the middle must not survive: {redacted}"
);
}
#[test]
fn different_accounts_redact_differently() {
assert_ne!(
AccountNumber::from("5WX123456").redacted(),
AccountNumber::from("5WX123789").redacted()
);
}
#[test]
fn a_short_number_is_masked_entirely() {
for short in ["", "1", "12345"] {
let redacted = AccountNumber::from(short).redacted();
assert!(
redacted.chars().all(|c| c == '*'),
"{short:?} should be fully masked, got {redacted}"
);
}
}
}