Skip to main content

nautilus_betfair/common/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities that convert Betfair payloads into Nautilus domain models.
17
18use anyhow::Context;
19use chrono::DateTime;
20use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND};
21use nautilus_model::{
22    enums::AccountType,
23    events::AccountState,
24    identifiers::{AccountId, InstrumentId, Symbol},
25    instruments::{BettingInstrument, InstrumentAny},
26    types::{AccountBalance, Currency, Money, Price, Quantity},
27};
28use rust_decimal::{Decimal, prelude::ToPrimitive};
29use ustr::Ustr;
30
31use super::{
32    consts::{
33        BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN, BETFAIR_PRICE_PRECISION, BETFAIR_QUANTITY_PRECISION,
34        BETFAIR_VENUE, DEFAULT_BETTING_TYPE, DEFAULT_MARKET_TYPE,
35    },
36    types::SelectionId,
37};
38use crate::{
39    http::models::{AccountFundsResponse, MarketCatalogue},
40    stream::messages::MarketDefinition,
41};
42
43/// Constructs a Nautilus [`Symbol`] from Betfair market and selection identifiers.
44///
45/// Format: `"{market_id}-{selection_id}"` or `"{market_id}-{selection_id}-{handicap}"`
46/// when handicap is non-zero.
47#[must_use]
48pub fn make_symbol(market_id: &str, selection_id: u64, handicap: Decimal) -> Symbol {
49    if handicap == Decimal::ZERO {
50        Symbol::new(format!("{market_id}-{selection_id}"))
51    } else {
52        Symbol::new(format!("{market_id}-{selection_id}-{handicap}"))
53    }
54}
55
56/// Constructs a Nautilus [`InstrumentId`] from Betfair market and selection identifiers.
57///
58/// Format: `"{market_id}-{selection_id}.BETFAIR"` or
59/// `"{market_id}-{selection_id}-{handicap}.BETFAIR"` when handicap is non-zero.
60#[must_use]
61pub fn make_instrument_id(market_id: &str, selection_id: u64, handicap: Decimal) -> InstrumentId {
62    let symbol = make_symbol(market_id, selection_id, handicap);
63    InstrumentId::new(symbol, *BETFAIR_VENUE)
64}
65
66/// Parses an RFC 3339 / ISO 8601 timestamp string into [`UnixNanos`].
67///
68/// Handles both UTC (`"2023-11-27T05:43:00Z"`) and offset
69/// (`"2021-03-19T12:07:00+10:00"`) formats.
70///
71/// # Errors
72///
73/// Returns an error if the string is not a valid RFC 3339 datetime.
74///
75/// # Panics
76///
77/// Panics if the parsed datetime cannot be represented as nanoseconds.
78pub fn parse_betfair_timestamp(s: &str) -> anyhow::Result<UnixNanos> {
79    let dt = DateTime::parse_from_rfc3339(s)
80        .or_else(|_| {
81            // Betfair sometimes uses ".000Z" millis suffix
82            DateTime::parse_from_rfc3339(&s.replace(".000Z", "Z"))
83        })
84        .with_context(|| format!("invalid Betfair timestamp: {s}"))?;
85    Ok(UnixNanos::from(dt.timestamp_nanos_opt().unwrap() as u64))
86}
87
88/// Converts a millisecond epoch timestamp (as used in stream `pt` field) into [`UnixNanos`].
89#[must_use]
90pub fn parse_millis_timestamp(timestamp_ms: u64) -> UnixNanos {
91    UnixNanos::from(timestamp_ms * NANOSECONDS_IN_MILLISECOND)
92}
93
94/// Truncates a client order ID to a Betfair `customer_order_ref`.
95///
96/// Takes the last 32 characters to preserve the high-entropy UUID suffix.
97/// Returns the full string if it is already 32 characters or shorter.
98#[must_use]
99pub fn make_customer_order_ref(client_order_id: &str) -> String {
100    let len = client_order_id.len();
101    if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
102        client_order_id.to_string()
103    } else {
104        client_order_id[len - BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN..].to_string()
105    }
106}
107
108/// Legacy truncation that takes the first 32 characters.
109///
110/// Pre-existing orders may use this format. Register both truncations
111/// on reconnect to match orders regardless of which convention was used.
112#[must_use]
113pub fn make_customer_order_ref_legacy(client_order_id: &str) -> String {
114    let len = client_order_id.len();
115    if len <= BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN {
116        client_order_id.to_string()
117    } else {
118        client_order_id[..BETFAIR_CUSTOMER_ORDER_REF_MAX_LEN].to_string()
119    }
120}
121
122/// Parses a Betfair [`MarketCatalogue`] into a vec of [`InstrumentAny`].
123///
124/// Each runner in the catalogue becomes a separate [`BettingInstrument`].
125///
126/// # Errors
127///
128/// Returns an error if required fields are missing or instrument construction fails.
129pub fn parse_market_catalogue(
130    catalogue: &MarketCatalogue,
131    currency: Currency,
132    ts_init: UnixNanos,
133    min_notional: Option<Money>,
134) -> anyhow::Result<Vec<InstrumentAny>> {
135    let runners = catalogue
136        .runners
137        .as_ref()
138        .context("MarketCatalogue missing runners")?;
139
140    let market_id = &catalogue.market_id;
141
142    let (event_type_id, event_type_name) = match &catalogue.event_type {
143        Some(et) => (
144            et.id
145                .as_ref()
146                .and_then(|id| id.parse::<u64>().ok())
147                .unwrap_or(0),
148            Ustr::from(et.name.as_deref().unwrap_or("")),
149        ),
150        None => (0, Ustr::from("")),
151    };
152
153    let (competition_id, competition_name) = match &catalogue.competition {
154        Some(c) => (
155            c.id.as_ref()
156                .and_then(|id| id.parse::<u64>().ok())
157                .unwrap_or(0),
158            Ustr::from(c.name.as_deref().unwrap_or("")),
159        ),
160        None => (0, Ustr::from("")),
161    };
162
163    let (event_id, event_name, event_country_code, event_open_date) = match &catalogue.event {
164        Some(e) => {
165            let eid =
166                e.id.as_ref()
167                    .and_then(|id| id.parse::<u64>().ok())
168                    .unwrap_or(0);
169            let ename = Ustr::from(e.name.as_deref().unwrap_or(""));
170            let cc = e.country_code.unwrap_or_else(|| Ustr::from(""));
171            let open_date = e
172                .open_date
173                .as_deref()
174                .and_then(|d| parse_betfair_timestamp(d).ok())
175                .unwrap_or_default();
176            (eid, ename, cc, open_date)
177        }
178        None => (0, Ustr::from(""), Ustr::from(""), UnixNanos::default()),
179    };
180
181    let (betting_type, market_type, market_base_rate) = match &catalogue.description {
182        Some(desc) => (
183            Ustr::from(&format!("{}", desc.betting_type)),
184            desc.market_type,
185            desc.market_base_rate,
186        ),
187        None => (
188            Ustr::from(DEFAULT_BETTING_TYPE),
189            Ustr::from(DEFAULT_MARKET_TYPE),
190            Decimal::ZERO,
191        ),
192    };
193
194    let market_name = Ustr::from(&catalogue.market_name);
195    let market_start_time = catalogue
196        .market_start_time
197        .as_deref()
198        .and_then(|t| parse_betfair_timestamp(t).ok())
199        .unwrap_or_default();
200
201    // Convert market base rate from percentage to decimal fraction
202    let fee_rate = market_base_rate / Decimal::ONE_HUNDRED;
203
204    let tick = Decimal::new(1, 2); // 0.01
205    let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
206    let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
207
208    let mut instruments = Vec::with_capacity(runners.len());
209
210    for runner in runners {
211        let handicap = runner.handicap;
212        let instrument_id = make_instrument_id(market_id, runner.selection_id, handicap);
213        let raw_symbol = make_symbol(market_id, runner.selection_id, handicap);
214
215        let instrument = BettingInstrument::new_checked(
216            instrument_id,
217            raw_symbol,
218            event_type_id,
219            event_type_name,
220            competition_id,
221            competition_name,
222            event_id,
223            event_name,
224            event_country_code,
225            event_open_date,
226            betting_type,
227            Ustr::from(market_id.as_str()),
228            market_name,
229            market_type,
230            market_start_time,
231            runner.selection_id,
232            Ustr::from(&runner.runner_name),
233            handicap.to_f64().unwrap_or(0.0),
234            currency,
235            BETFAIR_PRICE_PRECISION,
236            BETFAIR_QUANTITY_PRECISION,
237            price_increment,
238            size_increment,
239            None,               // max_quantity
240            None,               // min_quantity
241            None,               // max_notional
242            min_notional,       // min_notional
243            None,               // max_price
244            None,               // min_price
245            Some(Decimal::ONE), // margin_init (pre-funded)
246            Some(Decimal::ONE), // margin_maint
247            Some(fee_rate),     // maker_fee
248            Some(fee_rate),     // taker_fee
249            None,               // info
250            ts_init,            // ts_event
251            ts_init,            // ts_init
252        )
253        .with_context(|| {
254            format!(
255                "failed to create BettingInstrument for {market_id}/{}/{}",
256                runner.selection_id, runner.runner_name
257            )
258        })?;
259
260        instruments.push(InstrumentAny::Betting(instrument));
261    }
262
263    Ok(instruments)
264}
265
266/// Parses a stream [`MarketDefinition`] into a vec of [`InstrumentAny`].
267///
268/// Each runner definition becomes a separate [`BettingInstrument`].
269/// Stream definitions have many optional fields — missing values are
270/// defaulted gracefully.
271///
272/// # Errors
273///
274/// Returns an error if runners are missing or instrument construction fails.
275pub fn parse_market_definition(
276    market_id: &str,
277    def: &MarketDefinition,
278    currency: Currency,
279    ts_init: UnixNanos,
280    min_notional: Option<Money>,
281) -> anyhow::Result<Vec<InstrumentAny>> {
282    let runners = def
283        .runners
284        .as_ref()
285        .context("MarketDefinition missing runners")?;
286
287    let event_type_id = def
288        .event_type_id
289        .as_deref()
290        .and_then(|id| id.parse::<u64>().ok())
291        .unwrap_or(0);
292    let event_type_name = def.event_type_name.unwrap_or_else(|| Ustr::from(""));
293
294    let competition_id = def
295        .competition_id
296        .as_deref()
297        .and_then(|id| id.parse::<u64>().ok())
298        .unwrap_or(0);
299    let competition_name = Ustr::from(def.competition_name.as_deref().unwrap_or(""));
300
301    let event_id = def
302        .event_id
303        .as_deref()
304        .and_then(|id| id.parse::<u64>().ok())
305        .unwrap_or(0);
306    let event_name = Ustr::from(def.event_name.as_deref().unwrap_or(""));
307    let event_country_code = def.country_code.unwrap_or_else(|| Ustr::from(""));
308    let event_open_date = def
309        .open_date
310        .as_deref()
311        .and_then(|d| parse_betfair_timestamp(d).ok())
312        .unwrap_or_default();
313
314    let betting_type = match &def.betting_type {
315        Some(bt) => Ustr::from(&format!("{bt}")),
316        None => Ustr::from(DEFAULT_BETTING_TYPE),
317    };
318    let market_name = Ustr::from(def.market_name.as_deref().unwrap_or(""));
319    let market_type = def
320        .market_type
321        .unwrap_or_else(|| Ustr::from(DEFAULT_MARKET_TYPE));
322    let market_start_time = def
323        .market_time
324        .as_deref()
325        .and_then(|t| parse_betfair_timestamp(t).ok())
326        .unwrap_or_default();
327
328    let fee_rate = def
329        .market_base_rate
330        .map(|r| r / Decimal::ONE_HUNDRED)
331        .unwrap_or_default();
332
333    let tick = Decimal::new(1, 2); // 0.01
334    let price_increment = Price::from_decimal_dp(tick, BETFAIR_PRICE_PRECISION)?;
335    let size_increment = Quantity::from_decimal_dp(tick, BETFAIR_QUANTITY_PRECISION)?;
336
337    let market_id_ustr = Ustr::from(market_id);
338
339    let mut instruments = Vec::with_capacity(runners.len());
340
341    for runner in runners {
342        let handicap = runner.hc.unwrap_or(Decimal::ZERO);
343
344        let instrument_id = make_instrument_id(market_id, runner.id, handicap);
345        let raw_symbol = make_symbol(market_id, runner.id, handicap);
346        let runner_name = Ustr::from(runner.name.as_deref().unwrap_or(""));
347
348        let instrument = BettingInstrument::new_checked(
349            instrument_id,
350            raw_symbol,
351            event_type_id,
352            event_type_name,
353            competition_id,
354            competition_name,
355            event_id,
356            event_name,
357            event_country_code,
358            event_open_date,
359            betting_type,
360            market_id_ustr,
361            market_name,
362            market_type,
363            market_start_time,
364            runner.id,
365            runner_name,
366            handicap.to_f64().unwrap_or(0.0),
367            currency,
368            BETFAIR_PRICE_PRECISION,
369            BETFAIR_QUANTITY_PRECISION,
370            price_increment,
371            size_increment,
372            None,               // max_quantity
373            None,               // min_quantity
374            None,               // max_notional
375            min_notional,       // min_notional
376            None,               // max_price
377            None,               // min_price
378            Some(Decimal::ONE), // margin_init
379            Some(Decimal::ONE), // margin_maint
380            Some(fee_rate),     // maker_fee
381            Some(fee_rate),     // taker_fee
382            None,               // info
383            ts_init,            // ts_event
384            ts_init,            // ts_init
385        )
386        .with_context(|| {
387            format!(
388                "failed to create BettingInstrument for {market_id}/{}",
389                runner.id
390            )
391        })?;
392
393        instruments.push(InstrumentAny::Betting(instrument));
394    }
395
396    Ok(instruments)
397}
398
399/// Parses a Betfair [`AccountFundsResponse`] into a Nautilus [`AccountState`].
400///
401/// # Errors
402///
403/// Returns an error if monetary values cannot be converted.
404pub fn parse_account_state(
405    funds: &AccountFundsResponse,
406    account_id: AccountId,
407    currency: Currency,
408    ts_event: UnixNanos,
409    ts_init: UnixNanos,
410) -> anyhow::Result<AccountState> {
411    let available = funds.available_to_bet_balance.unwrap_or_default();
412    let exposure = funds.exposure.unwrap_or_default().abs();
413    let total = available + exposure;
414
415    let balance = AccountBalance::from_total_and_locked(total, exposure, currency)?;
416
417    Ok(AccountState::new(
418        account_id,
419        AccountType::Betting,
420        vec![balance],
421        vec![],
422        true,
423        UUID4::new(),
424        ts_event,
425        ts_init,
426        Some(currency),
427    ))
428}
429
430/// Extracts the Betfair market ID from a Nautilus instrument ID.
431///
432/// Instrument IDs follow the format `{market_id}-{selection_id}.BETFAIR`
433/// or `{market_id}-{selection_id}-{handicap}.BETFAIR`.
434///
435/// # Errors
436///
437/// Returns an error if the symbol does not contain a hyphen separator.
438pub fn extract_market_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
439    let symbol = instrument_id.symbol.as_str();
440    let parts: Vec<&str> = symbol.splitn(3, '-').collect();
441    if parts.len() >= 2 {
442        Ok(parts[0].to_string())
443    } else {
444        anyhow::bail!("Cannot extract market ID from {instrument_id}")
445    }
446}
447
448/// Extracts the selection ID and handicap from a Nautilus instrument ID.
449///
450/// # Errors
451///
452/// Returns an error if the symbol cannot be parsed into the expected format.
453pub fn extract_selection_id(
454    instrument_id: &InstrumentId,
455) -> anyhow::Result<(SelectionId, Decimal)> {
456    let symbol = instrument_id.symbol.as_str();
457    let parts: Vec<&str> = symbol.splitn(3, '-').collect();
458    if parts.len() < 2 {
459        anyhow::bail!("Cannot extract selection ID from {instrument_id}");
460    }
461
462    let selection_id: SelectionId = parts[1]
463        .parse()
464        .with_context(|| format!("invalid selection ID in {instrument_id}"))?;
465
466    let handicap = if parts.len() == 3 {
467        parts[2]
468            .parse::<Decimal>()
469            .with_context(|| format!("invalid handicap in {instrument_id}"))?
470    } else {
471        Decimal::ZERO
472    };
473
474    Ok((selection_id, handicap))
475}
476
477#[cfg(test)]
478mod tests {
479    use rstest::rstest;
480
481    use super::*;
482    use crate::{common::testing::load_test_json, stream::messages::StreamMessage};
483
484    #[rstest]
485    fn test_make_instrument_id_no_handicap() {
486        let id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
487        assert_eq!(id.to_string(), "1.180737206-19248890.BETFAIR");
488    }
489
490    #[rstest]
491    fn test_make_instrument_id_with_handicap() {
492        let id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
493        assert_eq!(id.to_string(), "1.180737206-19248890-1.5.BETFAIR");
494    }
495
496    #[rstest]
497    fn test_make_symbol_no_handicap() {
498        let sym = make_symbol("1.180737206", 19248890, Decimal::ZERO);
499        assert_eq!(sym.to_string(), "1.180737206-19248890");
500    }
501
502    #[rstest]
503    fn test_make_symbol_with_handicap() {
504        let sym = make_symbol("1.180737206", 19248890, Decimal::new(-5, 1));
505        assert_eq!(sym.to_string(), "1.180737206-19248890--0.5");
506    }
507
508    #[rstest]
509    fn test_parse_betfair_timestamp_utc() {
510        let ts = parse_betfair_timestamp("2023-11-27T05:43:00Z").unwrap();
511        assert!(ts.as_u64() > 0);
512    }
513
514    #[rstest]
515    fn test_parse_betfair_timestamp_with_offset() {
516        let ts = parse_betfair_timestamp("2021-03-19T12:07:00+10:00").unwrap();
517        assert!(ts.as_u64() > 0);
518    }
519
520    #[rstest]
521    fn test_parse_betfair_timestamp_with_millis() {
522        let ts = parse_betfair_timestamp("2021-03-19T08:50:00.000Z").unwrap();
523        assert!(ts.as_u64() > 0);
524    }
525
526    #[rstest]
527    fn test_parse_millis_timestamp() {
528        let ts = parse_millis_timestamp(1_471_370_159_007);
529        assert_eq!(ts.as_u64(), 1_471_370_159_007 * 1_000_000);
530    }
531
532    #[rstest]
533    fn test_parse_market_catalogue() {
534        let data = load_test_json("rest/list_market_catalogue.json");
535        let catalogue: MarketCatalogue = serde_json::from_str(&data).unwrap();
536        let instruments =
537            parse_market_catalogue(&catalogue, Currency::GBP(), UnixNanos::default(), None)
538                .unwrap();
539
540        assert_eq!(instruments.len(), 3);
541
542        // Verify first instrument
543        if let InstrumentAny::Betting(inst) = &instruments[0] {
544            assert_eq!(inst.market_id.as_str(), "1.221718403");
545            assert_eq!(inst.selection_id, 20075720);
546            assert_eq!(inst.selection_name.as_str(), "1. Searover");
547            assert_eq!(inst.event_type_name.as_str(), "Horse Racing");
548            assert_eq!(inst.event_name.as_str(), "Globe Derby (AUS) 27th Nov");
549            assert_eq!(inst.event_country_code.as_str(), "AU");
550            assert_eq!(inst.market_type.as_str(), "WIN");
551            assert_eq!(inst.betting_type.as_str(), "ODDS");
552            assert_eq!(inst.price_precision, 2);
553            assert_eq!(inst.size_precision, 2);
554            assert_eq!(inst.currency, Currency::GBP());
555        } else {
556            panic!("expected BettingInstrument");
557        }
558    }
559
560    #[rstest]
561    fn test_parse_market_catalogue_batch() {
562        let data = load_test_json("rest/betting_list_market_catalogue.json");
563        let catalogues: Vec<MarketCatalogue> = serde_json::from_str(&data).unwrap();
564
565        let mut total = 0;
566
567        for cat in &catalogues {
568            let instruments =
569                parse_market_catalogue(cat, Currency::GBP(), UnixNanos::default(), None).unwrap();
570            total += instruments.len();
571        }
572        assert!(total > 0);
573    }
574
575    #[rstest]
576    fn test_parse_market_definition_from_stream() {
577        let data = load_test_json("stream/mcm_SUB_IMAGE.json");
578        let msg: StreamMessage = serde_json::from_str(&data).unwrap();
579
580        if let StreamMessage::MarketChange(mcm) = msg {
581            let mc = mcm.mc.as_ref().expect("market changes");
582            let change = &mc[0];
583            let def = change
584                .market_definition
585                .as_ref()
586                .expect("market definition");
587
588            let instruments = parse_market_definition(
589                &change.id,
590                def,
591                Currency::GBP(),
592                parse_millis_timestamp(mcm.pt),
593                None,
594            )
595            .unwrap();
596
597            assert_eq!(instruments.len(), 7);
598
599            if let InstrumentAny::Betting(inst) = &instruments[0] {
600                assert_eq!(inst.market_id.as_str(), "1.180737206");
601                assert_eq!(inst.market_type.as_str(), "WIN");
602            } else {
603                panic!("expected BettingInstrument");
604            }
605        } else {
606            panic!("expected MarketChange message");
607        }
608    }
609
610    #[rstest]
611    fn test_parse_account_state() {
612        let data = load_test_json("rest/account_funds_with_exposure.json");
613        let funds: AccountFundsResponse = serde_json::from_str(&data).unwrap();
614
615        let state = parse_account_state(
616            &funds,
617            AccountId::from("BETFAIR-001"),
618            Currency::GBP(),
619            UnixNanos::default(),
620            UnixNanos::default(),
621        )
622        .unwrap();
623
624        assert_eq!(state.account_type, AccountType::Betting);
625        assert_eq!(state.balances.len(), 1);
626        assert!(state.is_reported);
627        assert_eq!(state.base_currency, Some(Currency::GBP()));
628    }
629
630    #[rstest]
631    fn test_extract_market_id_no_handicap() {
632        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
633        let market_id = extract_market_id(&instrument_id).unwrap();
634        assert_eq!(market_id, "1.180737206");
635    }
636
637    #[rstest]
638    fn test_extract_market_id_with_handicap() {
639        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
640        let market_id = extract_market_id(&instrument_id).unwrap();
641        assert_eq!(market_id, "1.180737206");
642    }
643
644    #[rstest]
645    fn test_extract_selection_id_no_handicap() {
646        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
647        let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
648        assert_eq!(selection_id, 19248890);
649        assert_eq!(handicap, Decimal::ZERO);
650    }
651
652    #[rstest]
653    fn test_extract_selection_id_with_handicap() {
654        let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::new(15, 1));
655        let (selection_id, handicap) = extract_selection_id(&instrument_id).unwrap();
656        assert_eq!(selection_id, 19248890);
657        assert_eq!(handicap, Decimal::new(15, 1));
658    }
659
660    #[rstest]
661    fn test_make_customer_order_ref_short_id() {
662        let result = make_customer_order_ref("O-20240101-001");
663        assert_eq!(result, "O-20240101-001");
664    }
665
666    #[rstest]
667    fn test_make_customer_order_ref_exactly_32_chars() {
668        let id = "12345678901234567890123456789012";
669        assert_eq!(id.len(), 32);
670        let result = make_customer_order_ref(id);
671        assert_eq!(result, id);
672    }
673
674    #[rstest]
675    fn test_make_customer_order_ref_truncates_to_last_32() {
676        // UUID-style ID longer than 32 chars
677        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
678        assert!(id.len() > 32);
679        let result = make_customer_order_ref(id);
680        assert_eq!(result.len(), 32);
681        // Should keep the last 32 characters (high-entropy UUID tail)
682        assert_eq!(result, &id[id.len() - 32..]);
683    }
684
685    #[rstest]
686    fn test_make_customer_order_ref_legacy_short_id() {
687        let result = make_customer_order_ref_legacy("O-20240101-001");
688        assert_eq!(result, "O-20240101-001");
689    }
690
691    #[rstest]
692    fn test_make_customer_order_ref_legacy_truncates_to_first_32() {
693        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
694        assert!(id.len() > 32);
695        let result = make_customer_order_ref_legacy(id);
696        assert_eq!(result.len(), 32);
697        assert_eq!(result, &id[..32]);
698    }
699
700    #[rstest]
701    fn test_legacy_and_current_differ_for_long_ids() {
702        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
703        let current = make_customer_order_ref(id);
704        let legacy = make_customer_order_ref_legacy(id);
705        assert_ne!(current, legacy);
706    }
707
708    #[rstest]
709    fn test_legacy_and_current_same_for_short_ids() {
710        let id = "O-20240101-001";
711        let current = make_customer_order_ref(id);
712        let legacy = make_customer_order_ref_legacy(id);
713        assert_eq!(current, legacy);
714    }
715}