use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::broker::settlements::{SettlementAssetClass, TransmitterInfo};
use crate::types::SupportedCurrencies;
use crate::types::wire::wire_enum;
wire_enum! {
pub enum JitReportType {
Detail => "detail",
NetSummary => "net_summary",
NetPayment => "net_payment",
NetPaymentFinal => "net_payment_final",
GrossSummary => "gross_summary",
GrossPayment => "gross_payment",
GrossPaymentFinal => "gross_payment_final",
Obligation => "obligation",
}
}
wire_enum! {
pub enum JitResponseType {
Inline => "inline",
DownloadUrl => "download_url",
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitLedger {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub ledger_name: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitLedgerTransaction {
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub account_no: Option<String>,
#[serde(default)]
pub account_name: Option<String>,
#[serde(default)]
pub contra_account_name: Option<String>,
#[serde(default)]
pub entry_type: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default, with = "crate::types::option_decimal")]
pub amount: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub balance: Option<Decimal>,
#[serde(default)]
pub system_date: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitLedgerBalances {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub ledger_name: Option<String>,
#[serde(default)]
pub ledger_no: Option<String>,
#[serde(default, with = "crate::types::option_decimal")]
pub starting_balance: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub ending_balance: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub activity_amount: Option<Decimal>,
#[serde(
default,
deserialize_with = "crate::types::serde_util::null_as_default"
)]
pub transactions: Vec<JitLedgerTransaction>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitTradingLimits {
#[serde(default)]
pub correspondent: Option<String>,
#[serde(default, with = "crate::types::option_decimal")]
pub daily_net_limit: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub in_use_limit: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub cash_held: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub executed_buys: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub executed_sells: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub open_buys: Option<Decimal>,
#[serde(default, with = "crate::types::option_decimal")]
pub open_sells: Option<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum JitReport {
Download(JitReportDownload),
Inline(Box<JitReportInline>),
}
impl Serialize for JitReport {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Download(download) => download.serialize(serializer),
Self::Inline(inline) => inline.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for JitReport {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let value = serde_json::Value::deserialize(deserializer)?;
if value.get("url").is_some() {
return JitReportDownload::deserialize(value)
.map(Self::Download)
.map_err(D::Error::custom);
}
JitReportInline::deserialize(value)
.map(|inline| Self::Inline(Box::new(inline)))
.map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct JitReportInline {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub net_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub net_payment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub net_payment_final: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gross_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gross_payment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gross_payment_final: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligation: Option<String>,
}
impl<'de> Deserialize<'de> for JitReportInline {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
detail: Option<String>,
#[serde(default)]
net_summary: Option<String>,
#[serde(default)]
net_payment: Option<String>,
#[serde(default)]
net_payment_final: Option<String>,
#[serde(default)]
gross_summary: Option<String>,
#[serde(default)]
gross_payment: Option<String>,
#[serde(default)]
gross_payment_final: Option<String>,
#[serde(default)]
obligation: Option<String>,
}
let raw = Raw::deserialize(deserializer)?;
let report = Self {
detail: raw.detail,
net_summary: raw.net_summary,
net_payment: raw.net_payment,
net_payment_final: raw.net_payment_final,
gross_summary: raw.gross_summary,
gross_payment: raw.gross_payment,
gross_payment_final: raw.gross_payment_final,
obligation: raw.obligation,
};
if report.report().is_none() {
return Err(serde::de::Error::custom(
"no known JIT report key in the response body; expected one of \
detail, net_summary, net_payment, net_payment_final, \
gross_summary, gross_payment, gross_payment_final, obligation",
));
}
Ok(report)
}
}
impl JitReportInline {
#[must_use]
pub fn report(&self) -> Option<&str> {
self.detail
.as_deref()
.or(self.net_summary.as_deref())
.or(self.net_payment.as_deref())
.or(self.net_payment_final.as_deref())
.or(self.gross_summary.as_deref())
.or(self.gross_payment.as_deref())
.or(self.gross_payment_final.as_deref())
.or(self.obligation.as_deref())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitReportDownload {
pub url: String,
pub filename: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GetJitReportRequest {
pub report_type: JitReportType,
pub system_date: NaiveDate,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_class: Option<SettlementAssetClass>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_type: Option<JitResponseType>,
}
impl GetJitReportRequest {
#[must_use]
pub fn new(report_type: JitReportType, system_date: NaiveDate) -> Self {
Self {
report_type,
system_date,
asset_class: None,
response_type: None,
}
}
#[must_use]
pub fn asset_class(mut self, asset_class: SettlementAssetClass) -> Self {
self.asset_class = Some(asset_class);
self
}
#[must_use]
pub fn response_type(mut self, response_type: JitResponseType) -> Self {
self.response_type = Some(response_type);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GetJitBalancesRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_date: Option<NaiveDate>,
}
impl GetJitBalancesRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn between(mut self, start: NaiveDate, end: NaiveDate) -> crate::Result<Self> {
if end < start {
return Err(crate::Error::InvalidRequest(format!(
"end_date ({end}) is before start_date ({start})"
)));
}
self.start_date = Some(start);
self.end_date = Some(end);
Ok(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JitSettlementAccount {
pub account_number: String,
#[serde(with = "crate::types::decimal")]
pub amount: Decimal,
pub transmitter_info: TransmitterInfo,
}
impl JitSettlementAccount {
#[must_use]
pub fn new(account_number: String, amount: Decimal, transmitter_info: TransmitterInfo) -> Self {
Self {
account_number,
amount,
transmitter_info,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CreateJitSettlementRequest {
pub accounts: Vec<JitSettlementAccount>,
pub asset_class: SettlementAssetClass,
pub currency: crate::types::SupportedCurrencies,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_info: Option<String>,
}
impl CreateJitSettlementRequest {
#[must_use]
pub fn new(
accounts: Vec<JitSettlementAccount>,
asset_class: SettlementAssetClass,
currency: SupportedCurrencies,
) -> Self {
Self {
accounts,
asset_class,
currency,
additional_info: None,
}
}
pub fn validate(&self) -> crate::Result<()> {
if self.accounts.is_empty() {
return Err(crate::Error::InvalidRequest(
"a settlement must name at least one account".to_owned(),
));
}
for account in &self.accounts {
if account.amount <= Decimal::ZERO {
return Err(crate::Error::InvalidRequest(format!(
"account {} settles {}, which is not a positive amount",
account.account_number, account.amount
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_balances_window_rejects_an_end_before_its_start() {
let start: NaiveDate = "2026-01-05".parse().unwrap();
let end: NaiveDate = "2026-01-01".parse().unwrap();
assert!(GetJitBalancesRequest::new().between(start, end).is_err());
let request = GetJitBalancesRequest::new().between(end, start).unwrap();
assert_eq!(request.start_date, Some(end));
assert_eq!(request.end_date, Some(start));
}
#[test]
fn a_report_decodes_as_either_shape() {
let download: JitReport = serde_json::from_value(serde_json::json!({
"url": "https://example.invalid/report.csv",
"filename": "report.csv",
"expires_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert!(matches!(download, JitReport::Download(_)));
let inline: JitReport = serde_json::from_value(serde_json::json!({
"net_summary": "account,amount\n123,1.00\n",
}))
.unwrap();
assert!(matches!(inline, JitReport::Inline(_)));
}
#[test]
fn a_settlement_that_settles_nothing_is_refused() {
let request = CreateJitSettlementRequest::new(
Vec::new(),
SettlementAssetClass::UsEquity,
SupportedCurrencies::Usd,
);
assert!(request.validate().is_err());
}
#[test]
fn a_negative_settlement_amount_is_refused() {
let request = CreateJitSettlementRequest::new(
vec![JitSettlementAccount {
account_number: "123".to_owned(),
amount: Decimal::new(-1, 0),
transmitter_info: TransmitterInfo::default(),
}],
SettlementAssetClass::Crypto,
SupportedCurrencies::Usd,
);
assert!(request.validate().is_err());
}
#[test]
fn a_jit_report_round_trips_in_both_shapes() {
let download = serde_json::json!({
"url": "https://example.test/report.csv",
"filename": "report.csv",
"expires_at": "2026-01-02T00:00:00Z"
});
let inline = serde_json::json!({"net_summary": "account,amount\n1,2\n"});
for wire in [download, inline] {
let report: JitReport = serde_json::from_value(wire.clone()).unwrap();
let encoded = serde_json::to_value(&report).unwrap();
assert_eq!(encoded, wire, "the encoded form must be what Alpaca sends");
let decoded: JitReport = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, report);
}
}
#[test]
fn a_body_with_no_known_report_key_is_an_error() {
let error = serde_json::from_value::<JitReport>(serde_json::json!({"surprise": "x"}))
.unwrap_err()
.to_string();
assert!(error.contains("no known JIT report key"), "{error}");
}
}