swift_mt_message/fields/field33b.rs
1use crate::{SwiftField, ValidationError, ValidationResult};
2use serde::{Deserialize, Serialize};
3
4/// # Field 33B: Currency/Instructed Amount
5///
6/// ## Overview
7/// Field 33B specifies the original ordered amount in currency conversions and
8/// multi-currency transactions. This field is used when the instructed amount differs
9/// from the settlement amount, typically in foreign exchange transactions, currency
10/// conversions, or when fees are deducted from the principal amount. It provides
11/// transparency about the original instruction versus the actual settlement.
12///
13/// ## Format Specification
14/// **Format**: `3!a15d`
15/// - **3!a**: Currency code (3 alphabetic characters, ISO 4217)
16/// - **15d**: Amount with up to 15 digits including decimal places
17///
18/// ### Component Details
19/// 1. **Currency Code (3!a)**:
20/// - ISO 4217 standard currency codes
21/// - Exactly 3 alphabetic characters
22/// - Case-insensitive input, stored as uppercase
23/// - Must be valid and active currency code
24/// - Examples: USD, EUR, GBP, JPY, CHF
25///
26/// 2. **Amount (15d)**:
27/// - Up to 15 digits including decimal places
28/// - Decimal separator: comma (,) in SWIFT format
29/// - No thousands separators allowed
30/// - Must be non-negative (≥ 0)
31/// - Precision varies by currency (typically 2 decimal places)
32///
33/// ## Usage Context
34/// Field 33B appears in various SWIFT MT message types where currency conversion
35/// or amount differentiation is required:
36///
37/// ### Primary Usage
38/// - **MT103**: Single Customer Credit Transfer - when original amount differs from settlement
39/// - **MT202**: General Financial Institution Transfer - for currency conversion scenarios
40/// - **MT202COV**: Cover for customer credit transfer - original instructed amount
41/// - **MT205**: Financial Institution Transfer - when amounts differ due to charges
42///
43/// ### Secondary Usage
44/// - **MT400**: Advice of Payment - original payment instruction amount
45/// - **MT410**: Acknowledgement - acknowledged original amount
46/// - **MT420**: Tracer - original traced amount
47/// - **MT900/910**: Confirmation messages - original instruction amount
48///
49/// ## Business Applications
50/// - **Currency conversion**: Original amount before FX conversion
51/// - **Charge deduction**: Principal amount before fee deduction
52/// - **Multi-currency processing**: Cross-currency transaction handling
53/// - **Reconciliation**: Matching original instructions with settlements
54/// - **Audit trails**: Maintaining complete transaction history
55/// - **Compliance reporting**: Regulatory reporting of original amounts
56/// - **Customer transparency**: Showing original vs. settled amounts
57/// - **FX risk management**: Tracking exposure in original currency
58///
59/// ## Related Fields
60/// Field 33B works in conjunction with other amount fields:
61///
62/// ### Field 32A (Value Date/Currency/Amount)
63/// - **32A**: Settlement amount and currency
64/// - **33B**: Original instructed amount and currency
65/// - **Relationship**: 33B shows original, 32A shows final settlement
66///
67/// ### Field 71A (Details of Charges)
68/// - **71A**: Charge allocation (OUR/BEN/SHA)
69/// - **33B**: Amount before charge deduction
70/// - **Usage**: When charges affect the settlement amount
71///
72/// ### Field 36 (Exchange Rate)
73/// - **36**: Exchange rate applied
74/// - **33B**: Amount in original currency
75/// - **Usage**: FX transactions showing rate and original amount
76///
77/// ## Currency Conversion Scenarios
78/// 1. **Customer instructs**: EUR 100,000
79/// 2. **Bank converts to**: USD 108,500 (at rate 1.085)
80/// 3. **Field 33B**: EUR100000,00 (original instruction)
81/// 4. **Field 32A**: USD108500,00 (settlement amount)
82///
83/// ## Charge Deduction Scenarios
84/// 1. **Customer instructs**: USD 50,000
85/// 2. **Bank deducts charges**: USD 25 (wire fee)
86/// 3. **Field 33B**: USD50000,00 (original amount)
87/// 4. **Field 32A**: USD49975,00 (net settlement)
88///
89/// ## Validation Rules
90/// 1. **Currency format**: Must be exactly 3 alphabetic characters
91/// 2. **Currency validity**: Should be valid ISO 4217 code
92/// 3. **Amount format**: Must follow SWIFT decimal format (comma separator)
93/// 4. **Amount value**: Must be non-negative (zero allowed for certain scenarios)
94/// 5. **Precision**: Should match currency-specific decimal place rules
95/// 6. **Consistency**: Should be logically consistent with Field 32A
96///
97/// ## Network Validated Rules (SWIFT Standards)
98/// - Currency code must be exactly 3 characters (Error: T52)
99/// - Currency must be valid ISO 4217 code (Error: T52)
100/// - Amount must be properly formatted (Error: T40)
101/// - Amount cannot be negative (Error: T13)
102/// - Field should be present when amounts differ (Warning: recommended)
103/// - Currency should be actively traded (Warning: recommended)
104///
105///
106/// ## Examples
107/// ```text
108/// :33B:EUR100000,00
109/// └─── Original instruction: EUR 100,000.00
110///
111/// :33B:USD50000,00
112/// └─── Before charges: USD 50,000.00
113///
114/// :33B:GBP25000,50
115/// └─── Original amount: GBP 25,000.50
116/// ```
117///
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct Field33B {
121 /// ISO 4217 currency code (3 alphabetic characters)
122 ///
123 /// Specifies the currency of the original instructed amount using the
124 /// international standard ISO 4217 currency codes. This represents the
125 /// currency in which the customer originally instructed the transaction.
126 ///
127 /// **Format**: Exactly 3 uppercase alphabetic characters
128 /// **Standard**: ISO 4217 (International Organization for Standardization)
129 /// **Case handling**: Automatically converted to uppercase
130 /// **Validation**: Must be valid and preferably active currency code
131 ///
132 /// # Common Scenarios
133 /// - **FX conversion**: Original currency before conversion to settlement currency
134 /// - **Multi-currency**: Different from settlement currency in Field 32A
135 /// - **Charge scenarios**: Same as settlement currency but different amount
136 ///
137 /// # Examples
138 /// - `"EUR"` - Euro (original instruction currency)
139 /// - `"USD"` - US Dollar (before conversion to EUR settlement)
140 /// - `"GBP"` - British Pound (customer's account currency)
141 pub currency: String,
142
143 /// Original instructed amount as decimal value
144 ///
145 /// The monetary amount as originally instructed by the customer or
146 /// ordering party, before any currency conversion, charge deduction,
147 /// or other modifications that result in a different settlement amount.
148 ///
149 /// **Range**: Must be non-negative (≥ 0.0)
150 /// **Precision**: Should follow currency-specific decimal place rules
151 /// **Usage**: Represents the "gross" or "original" amount
152 ///
153 /// # Business Context
154 /// - **Before FX**: Amount before currency conversion
155 /// - **Before charges**: Amount before fee/charge deduction
156 /// - **Customer view**: Amount as seen by the ordering customer
157 /// - **Audit trail**: Original instruction for compliance purposes
158 ///
159 /// # Examples
160 /// - `100000.00` - EUR 100,000 before conversion to USD
161 /// - `50000.00` - USD 50,000 before $25 wire fee deduction
162 /// - `25000.50` - GBP 25,000.50 original instruction amount
163 pub amount: f64,
164
165 /// Raw amount string as received (preserves original formatting)
166 ///
167 /// Maintains the exact string representation of the amount as received
168 /// in the SWIFT message, preserving the original formatting including
169 /// decimal separator, precision, and any leading/trailing characters.
170 ///
171 /// **Format**: SWIFT standard with comma as decimal separator
172 /// **Preservation**: Exact reproduction of original message format
173 /// **Usage**: For message reconstruction and audit purposes
174 ///
175 /// # Format Examples
176 /// - `"100000,00"` - SWIFT format with comma separator
177 /// - `"50000,00"` - Two decimal places preserved
178 /// - `"25000,50"` - Original precision maintained
179 /// - `"0,01"` - Minimum amount with leading zero
180 pub raw_amount: String,
181}
182
183impl Field33B {
184 /// Create a new Field33B with validation
185 ///
186 /// Creates a new Field33B instance with comprehensive validation of both
187 /// currency code and amount. The currency is normalized to uppercase and
188 /// the amount is formatted according to SWIFT standards.
189 ///
190 /// # Arguments
191 /// * `currency` - ISO 4217 currency code (will be converted to uppercase)
192 /// * `amount` - Original instructed amount (must be non-negative)
193 ///
194 /// # Returns
195 /// Result containing the Field33B instance or validation error
196 ///
197 /// # Example
198 /// ```rust
199 /// # use swift_mt_message::fields::Field33B;
200 /// let field = Field33B::new("EUR", 100000.00).unwrap();
201 /// assert_eq!(field.currency(), "EUR");
202 /// assert_eq!(field.amount(), 100000.00);
203 /// ```
204 pub fn new(currency: impl Into<String>, amount: f64) -> Result<Self, crate::ParseError> {
205 let currency = currency.into().to_uppercase();
206
207 // Validate currency code
208 if currency.len() != 3 {
209 return Err(crate::ParseError::InvalidFieldFormat {
210 field_tag: "33B".to_string(),
211 message: "Currency code must be exactly 3 characters".to_string(),
212 });
213 }
214
215 if !currency.chars().all(|c| c.is_alphabetic() && c.is_ascii()) {
216 return Err(crate::ParseError::InvalidFieldFormat {
217 field_tag: "33B".to_string(),
218 message: "Currency code must contain only alphabetic characters".to_string(),
219 });
220 }
221
222 // Validate amount
223 if amount < 0.0 {
224 return Err(crate::ParseError::InvalidFieldFormat {
225 field_tag: "33B".to_string(),
226 message: "Amount cannot be negative".to_string(),
227 });
228 }
229
230 let raw_amount = Self::format_amount(amount);
231
232 Ok(Field33B {
233 currency,
234 amount,
235 raw_amount,
236 })
237 }
238
239 /// Create from raw amount string
240 ///
241 /// Creates a Field33B instance from a raw amount string, preserving
242 /// the original formatting while parsing the numeric value.
243 ///
244 /// # Arguments
245 /// * `currency` - ISO 4217 currency code
246 /// * `raw_amount` - Amount string in SWIFT format
247 ///
248 /// # Returns
249 /// Result containing the Field33B instance or parse error
250 ///
251 /// # Example
252 /// ```rust
253 /// # use swift_mt_message::fields::Field33B;
254 /// let field = Field33B::from_raw("USD", "50000,00").unwrap();
255 /// assert_eq!(field.amount(), 50000.00);
256 /// assert_eq!(field.raw_amount(), "50000,00");
257 /// ```
258 pub fn from_raw(
259 currency: impl Into<String>,
260 raw_amount: impl Into<String>,
261 ) -> Result<Self, crate::ParseError> {
262 let currency = currency.into().to_uppercase();
263 let raw_amount = raw_amount.into();
264
265 let amount = Self::parse_amount(&raw_amount)?;
266
267 Ok(Field33B {
268 currency,
269 amount,
270 raw_amount: raw_amount.to_string(),
271 })
272 }
273
274 /// Get the currency code
275 ///
276 /// Returns the ISO 4217 currency code for the original instructed amount.
277 ///
278 /// # Returns
279 /// Currency code as string slice
280 ///
281 /// # Example
282 /// ```rust
283 /// # use swift_mt_message::fields::Field33B;
284 /// let field = Field33B::new("GBP", 25000.50).unwrap();
285 /// assert_eq!(field.currency(), "GBP");
286 /// ```
287 pub fn currency(&self) -> &str {
288 &self.currency
289 }
290
291 /// Get the amount value
292 ///
293 /// Returns the original instructed amount as a floating-point number.
294 ///
295 /// # Returns
296 /// Amount as f64
297 ///
298 /// # Example
299 /// ```rust
300 /// # use swift_mt_message::fields::Field33B;
301 /// let field = Field33B::new("USD", 75000.25).unwrap();
302 /// assert_eq!(field.amount(), 75000.25);
303 /// ```
304 pub fn amount(&self) -> f64 {
305 self.amount
306 }
307
308 /// Get the raw amount string
309 ///
310 /// Returns the original amount string as received, preserving
311 /// the exact formatting from the SWIFT message.
312 ///
313 /// # Returns
314 /// Raw amount string
315 ///
316 /// # Example
317 /// ```rust
318 /// # use swift_mt_message::fields::Field33B;
319 /// let field = Field33B::from_raw("EUR", "1000,50").unwrap();
320 /// assert_eq!(field.raw_amount(), "1000,50");
321 /// ```
322 pub fn raw_amount(&self) -> &str {
323 &self.raw_amount
324 }
325
326 /// Format amount for SWIFT output (with comma as decimal separator)
327 ///
328 /// Formats a decimal amount according to SWIFT standards using
329 /// comma as the decimal separator.
330 ///
331 /// # Arguments
332 /// * `amount` - Amount to format
333 ///
334 /// # Returns
335 /// Formatted amount string
336 ///
337 /// # Example
338 /// ```rust
339 /// # use swift_mt_message::fields::Field33B;
340 /// let formatted = Field33B::format_amount(1234.56);
341 /// assert_eq!(formatted, "1234,56");
342 /// ```
343 pub fn format_amount(amount: f64) -> String {
344 format!("{:.2}", amount).replace('.', ",")
345 }
346
347 /// Parse amount from string (handles both comma and dot as decimal separator)
348 fn parse_amount(amount_str: &str) -> Result<f64, crate::ParseError> {
349 let normalized_amount = amount_str.replace(',', ".");
350
351 normalized_amount
352 .parse::<f64>()
353 .map_err(|_| crate::ParseError::InvalidFieldFormat {
354 field_tag: "33B".to_string(),
355 message: "Invalid amount format".to_string(),
356 })
357 }
358
359 /// Check if this is a valid ISO 4217 currency code (basic validation)
360 ///
361 /// Performs basic format validation to check if the currency code
362 /// follows ISO 4217 standards (3 alphabetic characters).
363 ///
364 /// # Returns
365 /// `true` if the currency code format is valid
366 ///
367 /// # Example
368 /// ```rust
369 /// # use swift_mt_message::fields::Field33B;
370 /// let field = Field33B::new("USD", 1000.00).unwrap();
371 /// assert!(field.is_valid_currency());
372 /// ```
373 pub fn is_valid_currency(&self) -> bool {
374 self.currency.len() == 3 && self.currency.chars().all(|c| c.is_alphabetic())
375 }
376
377 /// Check if the currency is a major currency
378 ///
379 /// Determines if the currency is one of the major internationally
380 /// traded currencies with high liquidity and frequent usage.
381 ///
382 /// # Returns
383 /// `true` if the currency is a major currency
384 ///
385 /// # Example
386 /// ```rust
387 /// # use swift_mt_message::fields::Field33B;
388 /// let usd_field = Field33B::new("USD", 1000.00).unwrap();
389 /// assert!(usd_field.is_major_currency());
390 ///
391 /// let exotic_field = Field33B::new("XYZ", 1000.00).unwrap();
392 /// assert!(!exotic_field.is_major_currency());
393 /// ```
394 pub fn is_major_currency(&self) -> bool {
395 matches!(
396 self.currency.as_str(),
397 "USD" | "EUR" | "GBP" | "JPY" | "CHF" | "CAD" | "AUD" | "NZD" | "SEK" | "NOK" | "DKK"
398 )
399 }
400
401 /// Check if the currency typically has decimal places
402 ///
403 /// Determines if the currency typically uses decimal places in
404 /// amount representation. Some currencies like JPY typically
405 /// don't use decimal places.
406 ///
407 /// # Returns
408 /// `true` if the currency typically uses decimal places
409 ///
410 /// # Example
411 /// ```rust
412 /// # use swift_mt_message::fields::Field33B;
413 /// let usd_field = Field33B::new("USD", 1000.00).unwrap();
414 /// assert!(usd_field.has_decimal_places());
415 ///
416 /// let jpy_field = Field33B::new("JPY", 1000.00).unwrap();
417 /// assert!(!jpy_field.has_decimal_places());
418 /// ```
419 pub fn has_decimal_places(&self) -> bool {
420 !matches!(
421 self.currency.as_str(),
422 "JPY" | "KRW" | "VND" | "IDR" | "CLP" | "PYG" | "UGX" | "RWF" | "GNF" | "MGA"
423 )
424 }
425
426 /// Get the typical decimal places for this currency
427 ///
428 /// Returns the number of decimal places typically used for
429 /// this currency in financial transactions.
430 ///
431 /// # Returns
432 /// Number of decimal places (0, 2, or 3)
433 ///
434 /// # Example
435 /// ```rust
436 /// # use swift_mt_message::fields::Field33B;
437 /// let usd_field = Field33B::new("USD", 1000.00).unwrap();
438 /// assert_eq!(usd_field.decimal_places(), 2);
439 ///
440 /// let jpy_field = Field33B::new("JPY", 1000.00).unwrap();
441 /// assert_eq!(jpy_field.decimal_places(), 0);
442 /// ```
443 pub fn decimal_places(&self) -> u8 {
444 match self.currency.as_str() {
445 // Currencies with no decimal places
446 "JPY" | "KRW" | "VND" | "IDR" | "CLP" | "PYG" | "UGX" | "RWF" | "GNF" | "MGA" => 0,
447 // Currencies with 3 decimal places
448 "BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => 3,
449 // Most currencies use 2 decimal places
450 _ => 2,
451 }
452 }
453
454 /// Check if the amount is a high-value transaction
455 ///
456 /// Determines if the original instructed amount exceeds typical
457 /// high-value thresholds that may require special handling or reporting.
458 ///
459 /// # Returns
460 /// `true` if this is considered a high-value transaction
461 ///
462 /// # Example
463 /// ```rust
464 /// # use swift_mt_message::fields::Field33B;
465 /// let high_value = Field33B::new("USD", 1500000.00).unwrap();
466 /// assert!(high_value.is_high_value_transaction());
467 ///
468 /// let normal_value = Field33B::new("USD", 50000.00).unwrap();
469 /// assert!(!normal_value.is_high_value_transaction());
470 /// ```
471 pub fn is_high_value_transaction(&self) -> bool {
472 // High-value thresholds vary by currency
473 let threshold = match self.currency.as_str() {
474 "USD" | "EUR" | "GBP" | "CHF" | "CAD" | "AUD" => 1_000_000.0,
475 "JPY" => 100_000_000.0,
476 "SEK" | "NOK" | "DKK" => 10_000_000.0,
477 _ => 1_000_000.0, // Default threshold
478 };
479
480 self.amount >= threshold
481 }
482
483 /// Check if this represents a currency conversion scenario
484 ///
485 /// This method would typically be used in conjunction with Field 32A
486 /// to determine if the transaction involves currency conversion.
487 ///
488 /// # Arguments
489 /// * `settlement_currency` - Currency from Field 32A for comparison
490 ///
491 /// # Returns
492 /// `true` if currencies differ, indicating conversion
493 ///
494 /// # Example
495 /// ```rust
496 /// # use swift_mt_message::fields::Field33B;
497 /// let field = Field33B::new("EUR", 100000.00).unwrap();
498 /// assert!(field.is_currency_conversion("USD"));
499 /// assert!(!field.is_currency_conversion("EUR"));
500 /// ```
501 pub fn is_currency_conversion(&self, settlement_currency: &str) -> bool {
502 self.currency != settlement_currency.to_uppercase()
503 }
504
505 /// Calculate potential FX exposure
506 ///
507 /// Estimates the foreign exchange exposure based on the original
508 /// amount and currency. This is useful for risk management purposes.
509 ///
510 /// # Returns
511 /// Exposure category as string
512 ///
513 /// # Example
514 /// ```rust
515 /// # use swift_mt_message::fields::Field33B;
516 /// let field = Field33B::new("USD", 2000000.00).unwrap();
517 /// assert_eq!(field.fx_exposure_category(), "High");
518 /// ```
519 pub fn fx_exposure_category(&self) -> &'static str {
520 if self.is_high_value_transaction() {
521 if self.is_major_currency() {
522 "High"
523 } else {
524 "Very High" // High value in exotic currency
525 }
526 } else if self.is_major_currency() {
527 "Low"
528 } else {
529 "Medium" // Exotic currency but lower amount
530 }
531 }
532
533 /// Format amount with proper currency precision
534 ///
535 /// Formats the amount according to the typical precision
536 /// rules for the currency.
537 ///
538 /// # Returns
539 /// Formatted amount string
540 ///
541 /// # Example
542 /// ```rust
543 /// # use swift_mt_message::fields::Field33B;
544 /// let usd_field = Field33B::new("USD", 1234.56).unwrap();
545 /// assert_eq!(usd_field.formatted_amount(), "1234.56");
546 ///
547 /// let jpy_field = Field33B::new("JPY", 1234.00).unwrap();
548 /// assert_eq!(jpy_field.formatted_amount(), "1234");
549 /// ```
550 pub fn formatted_amount(&self) -> String {
551 let decimal_places = self.decimal_places();
552 match decimal_places {
553 0 => format!("{:.0}", self.amount),
554 2 => format!("{:.2}", self.amount),
555 3 => format!("{:.3}", self.amount),
556 _ => format!("{:.2}", self.amount), // Default to 2
557 }
558 }
559
560 /// Get transaction purpose classification
561 ///
562 /// Provides a classification of the likely transaction purpose
563 /// based on amount and currency characteristics.
564 ///
565 /// # Returns
566 /// Transaction purpose category
567 ///
568 /// # Example
569 /// ```rust
570 /// # use swift_mt_message::fields::Field33B;
571 /// let field = Field33B::new("USD", 50000.00).unwrap();
572 /// let purpose = field.transaction_purpose();
573 /// assert!(!purpose.is_empty());
574 /// ```
575 pub fn transaction_purpose(&self) -> &'static str {
576 if self.is_high_value_transaction() {
577 if self.is_major_currency() {
578 "Corporate/Institutional Transfer"
579 } else {
580 "High-Value Cross-Border Transfer"
581 }
582 } else if self.amount < 10000.0 {
583 "Personal/Retail Transfer"
584 } else if self.is_major_currency() {
585 "Commercial Transfer"
586 } else {
587 "Cross-Border Commercial Transfer"
588 }
589 }
590
591 /// Get human-readable description
592 ///
593 /// Returns a comprehensive description of the original instructed
594 /// amount including currency, amount, and transaction characteristics.
595 ///
596 /// # Returns
597 /// Formatted description string
598 ///
599 /// # Example
600 /// ```rust
601 /// # use swift_mt_message::fields::Field33B;
602 /// let field = Field33B::new("EUR", 100000.00).unwrap();
603 /// let desc = field.description();
604 /// assert!(desc.contains("EUR"));
605 /// assert!(desc.contains("100000.00"));
606 /// ```
607 pub fn description(&self) -> String {
608 format!(
609 "Currency/Instructed Amount: {} {:.2}",
610 self.currency, self.amount
611 )
612 }
613
614 /// Get comprehensive transaction analysis
615 ///
616 /// Returns a detailed analysis of the transaction including currency
617 /// characteristics, amount classification, and risk assessment.
618 ///
619 /// # Returns
620 /// Formatted analysis string
621 ///
622 /// # Example
623 /// ```rust
624 /// # use swift_mt_message::fields::Field33B;
625 /// let field = Field33B::new("USD", 1500000.00).unwrap();
626 /// let analysis = field.comprehensive_analysis();
627 /// assert!(analysis.contains("USD"));
628 /// assert!(analysis.contains("High-value"));
629 /// assert!(analysis.contains("Major currency"));
630 /// ```
631 pub fn comprehensive_analysis(&self) -> String {
632 let currency_type = if self.is_major_currency() {
633 "Major currency"
634 } else {
635 "Other currency"
636 };
637
638 let amount_category = if self.is_high_value_transaction() {
639 "High-value"
640 } else {
641 "Standard"
642 };
643
644 let fx_exposure = self.fx_exposure_category();
645 let purpose = self.transaction_purpose();
646
647 format!(
648 "Original Amount: {} {} ({}) | Category: {} | FX Exposure: {} | Purpose: {}",
649 self.formatted_amount(),
650 self.currency,
651 currency_type,
652 amount_category,
653 fx_exposure,
654 purpose
655 )
656 }
657}
658
659impl SwiftField for Field33B {
660 fn parse(value: &str) -> Result<Self, crate::ParseError> {
661 let content = if let Some(stripped) = value.strip_prefix(":33B:") {
662 stripped // Remove ":33B:" prefix
663 } else if let Some(stripped) = value.strip_prefix("33B:") {
664 stripped // Remove "33B:" prefix
665 } else {
666 value
667 };
668
669 let content = content.trim();
670
671 if content.len() < 4 {
672 return Err(crate::ParseError::InvalidFieldFormat {
673 field_tag: "33B".to_string(),
674 message: "Field content too short (minimum 4 characters: CCCAMOUNT)".to_string(),
675 });
676 }
677
678 // Parse components: first 3 characters are currency, rest is amount
679 let currency_str = &content[0..3];
680 let amount_str = &content[3..];
681
682 let currency = currency_str.to_uppercase();
683
684 // Validate currency
685 if !currency.chars().all(|c| c.is_alphabetic() && c.is_ascii()) {
686 return Err(crate::ParseError::InvalidFieldFormat {
687 field_tag: "33B".to_string(),
688 message: "Currency code must contain only alphabetic characters".to_string(),
689 });
690 }
691
692 let amount = Self::parse_amount(amount_str)?;
693
694 if amount < 0.0 {
695 return Err(crate::ParseError::InvalidFieldFormat {
696 field_tag: "33B".to_string(),
697 message: "Amount cannot be negative".to_string(),
698 });
699 }
700
701 Ok(Field33B {
702 currency,
703 amount,
704 raw_amount: amount_str.to_string(),
705 })
706 }
707
708 fn to_swift_string(&self) -> String {
709 format!(":33B:{}{}", self.currency, self.raw_amount)
710 }
711
712 fn validate(&self) -> ValidationResult {
713 let mut errors = Vec::new();
714
715 // Validate currency code
716 if self.currency.len() != 3 {
717 errors.push(ValidationError::LengthValidation {
718 field_tag: "33B".to_string(),
719 expected: "3 characters".to_string(),
720 actual: self.currency.len(),
721 });
722 }
723
724 if !self
725 .currency
726 .chars()
727 .all(|c| c.is_alphabetic() && c.is_ascii())
728 {
729 errors.push(ValidationError::FormatValidation {
730 field_tag: "33B".to_string(),
731 message: "Currency code must contain only alphabetic characters".to_string(),
732 });
733 }
734
735 // Validate amount
736 if self.amount < 0.0 {
737 errors.push(ValidationError::ValueValidation {
738 field_tag: "33B".to_string(),
739 message: "Amount cannot be negative".to_string(),
740 });
741 }
742
743 // Validate raw amount format
744 if self.raw_amount.is_empty() {
745 errors.push(ValidationError::ValueValidation {
746 field_tag: "33B".to_string(),
747 message: "Amount cannot be empty".to_string(),
748 });
749 }
750
751 ValidationResult {
752 is_valid: errors.is_empty(),
753 errors,
754 warnings: Vec::new(),
755 }
756 }
757
758 fn format_spec() -> &'static str {
759 "3!a15d"
760 }
761}
762
763impl std::fmt::Display for Field33B {
764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765 write!(f, "{} {}", self.currency, self.raw_amount)
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn test_field33b_creation() {
775 let field = Field33B::new("USD", 1234.56).unwrap();
776 assert_eq!(field.currency(), "USD");
777 assert_eq!(field.amount(), 1234.56);
778 assert_eq!(field.raw_amount(), "1234,56");
779 }
780
781 #[test]
782 fn test_field33b_from_raw() {
783 let field = Field33B::from_raw("EUR", "999,99").unwrap();
784 assert_eq!(field.currency(), "EUR");
785 assert_eq!(field.amount(), 999.99);
786 assert_eq!(field.raw_amount(), "999,99");
787 }
788
789 #[test]
790 fn test_field33b_parse() {
791 let field = Field33B::parse("USD1234,56").unwrap();
792 assert_eq!(field.currency(), "USD");
793 assert_eq!(field.amount(), 1234.56);
794 assert_eq!(field.raw_amount(), "1234,56");
795 }
796
797 #[test]
798 fn test_field33b_parse_with_prefix() {
799 let field = Field33B::parse(":33B:EUR500,00").unwrap();
800 assert_eq!(field.currency(), "EUR");
801 assert_eq!(field.amount(), 500.0);
802 assert_eq!(field.raw_amount(), "500,00");
803 }
804
805 #[test]
806 fn test_field33b_to_swift_string() {
807 let field = Field33B::new("GBP", 750.25).unwrap();
808 assert_eq!(field.to_swift_string(), ":33B:GBP750,25");
809 }
810
811 #[test]
812 fn test_field33b_invalid_currency_length() {
813 let result = Field33B::new("US", 100.0);
814 assert!(result.is_err());
815
816 let result = Field33B::new("USDD", 100.0);
817 assert!(result.is_err());
818 }
819
820 #[test]
821 fn test_field33b_invalid_currency_characters() {
822 let result = Field33B::new("U$D", 100.0);
823 assert!(result.is_err());
824
825 let result = Field33B::new("123", 100.0);
826 assert!(result.is_err());
827 }
828
829 #[test]
830 fn test_field33b_negative_amount() {
831 let result = Field33B::new("USD", -100.0);
832 assert!(result.is_err());
833 }
834
835 #[test]
836 fn test_field33b_parse_invalid_format() {
837 let result = Field33B::parse("USD");
838 assert!(result.is_err());
839
840 let result = Field33B::parse("US1234,56");
841 assert!(result.is_err());
842 }
843
844 #[test]
845 fn test_field33b_validation() {
846 let field = Field33B::new("USD", 1000.0).unwrap();
847 let validation = field.validate();
848 assert!(validation.is_valid);
849 assert!(validation.errors.is_empty());
850 }
851
852 #[test]
853 fn test_field33b_display() {
854 let field = Field33B::new("CHF", 2500.75).unwrap();
855 assert_eq!(format!("{}", field), "CHF 2500,75");
856 }
857
858 #[test]
859 fn test_field33b_is_valid_currency() {
860 let field = Field33B::new("USD", 100.0).unwrap();
861 assert!(field.is_valid_currency());
862 }
863
864 #[test]
865 fn test_field33b_description() {
866 let field = Field33B::new("EUR", 1500.0).unwrap();
867 assert_eq!(
868 field.description(),
869 "Currency/Instructed Amount: EUR 1500.00"
870 );
871 }
872
873 #[test]
874 fn test_field33b_parse_dot_decimal() {
875 let field = Field33B::parse("USD1234.56").unwrap();
876 assert_eq!(field.amount(), 1234.56);
877 assert_eq!(field.raw_amount(), "1234.56");
878 }
879
880 #[test]
881 fn test_field33b_major_currencies() {
882 let major_currencies = [
883 "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "SEK", "NOK", "DKK",
884 ];
885
886 for currency in major_currencies {
887 let field = Field33B::new(currency, 1000.00).unwrap();
888 assert!(
889 field.is_major_currency(),
890 "Currency {} should be major currency",
891 currency
892 );
893 }
894
895 let exotic_currencies = ["XYZ", "ABC", "THB", "MXN"];
896 for currency in exotic_currencies {
897 let field = Field33B::new(currency, 1000.00).unwrap();
898 assert!(
899 !field.is_major_currency(),
900 "Currency {} should not be major currency",
901 currency
902 );
903 }
904 }
905
906 #[test]
907 fn test_field33b_decimal_places() {
908 // Currencies with no decimal places
909 let no_decimal_currencies = ["JPY", "KRW", "VND"];
910 for currency in no_decimal_currencies {
911 let field = Field33B::new(currency, 1000.00).unwrap();
912 assert_eq!(
913 field.decimal_places(),
914 0,
915 "Currency {} should have 0 decimal places",
916 currency
917 );
918 assert!(!field.has_decimal_places());
919 }
920
921 // Currencies with 3 decimal places
922 let three_decimal_currencies = ["BHD", "JOD", "KWD"];
923 for currency in three_decimal_currencies {
924 let field = Field33B::new(currency, 1000.00).unwrap();
925 assert_eq!(
926 field.decimal_places(),
927 3,
928 "Currency {} should have 3 decimal places",
929 currency
930 );
931 assert!(field.has_decimal_places());
932 }
933
934 // Currencies with 2 decimal places (most common)
935 let two_decimal_currencies = ["USD", "EUR", "GBP"];
936 for currency in two_decimal_currencies {
937 let field = Field33B::new(currency, 1000.00).unwrap();
938 assert_eq!(
939 field.decimal_places(),
940 2,
941 "Currency {} should have 2 decimal places",
942 currency
943 );
944 assert!(field.has_decimal_places());
945 }
946 }
947
948 #[test]
949 fn test_field33b_high_value_transactions() {
950 // High-value USD transaction
951 let high_usd = Field33B::new("USD", 1_500_000.00).unwrap();
952 assert!(high_usd.is_high_value_transaction());
953
954 let normal_usd = Field33B::new("USD", 500_000.00).unwrap();
955 assert!(!normal_usd.is_high_value_transaction());
956
957 // High-value JPY transaction (different threshold)
958 let high_jpy = Field33B::new("JPY", 150_000_000.00).unwrap();
959 assert!(high_jpy.is_high_value_transaction());
960
961 let normal_jpy = Field33B::new("JPY", 50_000_000.00).unwrap();
962 assert!(!normal_jpy.is_high_value_transaction());
963 }
964
965 #[test]
966 fn test_field33b_currency_conversion() {
967 let field = Field33B::new("EUR", 100000.00).unwrap();
968
969 // Different currencies indicate conversion
970 assert!(field.is_currency_conversion("USD"));
971 assert!(field.is_currency_conversion("GBP"));
972
973 // Same currency indicates no conversion
974 assert!(!field.is_currency_conversion("EUR"));
975 assert!(!field.is_currency_conversion("eur")); // Case insensitive
976 }
977
978 #[test]
979 fn test_field33b_fx_exposure_category() {
980 // High value, major currency
981 let high_major = Field33B::new("USD", 2_000_000.00).unwrap();
982 assert_eq!(high_major.fx_exposure_category(), "High");
983
984 // High value, exotic currency
985 let high_exotic = Field33B::new("THB", 2_000_000.00).unwrap();
986 assert_eq!(high_exotic.fx_exposure_category(), "Very High");
987
988 // Low value, major currency
989 let low_major = Field33B::new("EUR", 50_000.00).unwrap();
990 assert_eq!(low_major.fx_exposure_category(), "Low");
991
992 // Low value, exotic currency
993 let low_exotic = Field33B::new("MXN", 50_000.00).unwrap();
994 assert_eq!(low_exotic.fx_exposure_category(), "Medium");
995 }
996
997 #[test]
998 fn test_field33b_formatted_amount() {
999 // USD with 2 decimal places
1000 let usd_field = Field33B::new("USD", 1234.56).unwrap();
1001 assert_eq!(usd_field.formatted_amount(), "1234.56");
1002
1003 // JPY with 0 decimal places
1004 let jpy_field = Field33B::new("JPY", 1234.00).unwrap();
1005 assert_eq!(jpy_field.formatted_amount(), "1234");
1006
1007 // KWD with 3 decimal places
1008 let kwd_field = Field33B::new("KWD", 1234.567).unwrap();
1009 assert_eq!(kwd_field.formatted_amount(), "1234.567");
1010 }
1011
1012 #[test]
1013 fn test_field33b_transaction_purpose() {
1014 // High-value major currency
1015 let corporate = Field33B::new("USD", 2_000_000.00).unwrap();
1016 assert_eq!(
1017 corporate.transaction_purpose(),
1018 "Corporate/Institutional Transfer"
1019 );
1020
1021 // High-value exotic currency
1022 let cross_border_high = Field33B::new("THB", 2_000_000.00).unwrap();
1023 assert_eq!(
1024 cross_border_high.transaction_purpose(),
1025 "High-Value Cross-Border Transfer"
1026 );
1027
1028 // Small amount
1029 let personal = Field33B::new("USD", 5_000.00).unwrap();
1030 assert_eq!(personal.transaction_purpose(), "Personal/Retail Transfer");
1031
1032 // Medium amount, major currency
1033 let commercial = Field33B::new("EUR", 50_000.00).unwrap();
1034 assert_eq!(commercial.transaction_purpose(), "Commercial Transfer");
1035
1036 // Medium amount, exotic currency
1037 let cross_border_commercial = Field33B::new("MXN", 50_000.00).unwrap();
1038 assert_eq!(
1039 cross_border_commercial.transaction_purpose(),
1040 "Cross-Border Commercial Transfer"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_field33b_comprehensive_analysis() {
1046 let field = Field33B::new("USD", 1_500_000.00).unwrap();
1047 let analysis = field.comprehensive_analysis();
1048
1049 assert!(analysis.contains("USD"));
1050 assert!(analysis.contains("1500000.00"));
1051 assert!(analysis.contains("Major currency"));
1052 assert!(analysis.contains("High-value"));
1053 assert!(analysis.contains("High")); // FX exposure
1054 assert!(analysis.contains("Corporate/Institutional Transfer"));
1055 }
1056
1057 #[test]
1058 fn test_field33b_format_amount_static() {
1059 assert_eq!(Field33B::format_amount(1234.56), "1234,56");
1060 assert_eq!(Field33B::format_amount(100.00), "100,00");
1061 assert_eq!(Field33B::format_amount(0.01), "0,01");
1062 }
1063
1064 #[test]
1065 fn test_field33b_serialization() {
1066 let field = Field33B::new("EUR", 1234.56).unwrap();
1067 let serialized = serde_json::to_string(&field).unwrap();
1068 let deserialized: Field33B = serde_json::from_str(&serialized).unwrap();
1069
1070 assert_eq!(field.currency(), deserialized.currency());
1071 assert_eq!(field.amount(), deserialized.amount());
1072 assert_eq!(field.raw_amount(), deserialized.raw_amount());
1073 assert_eq!(field.is_major_currency(), deserialized.is_major_currency());
1074 }
1075
1076 #[test]
1077 fn test_field33b_business_logic_combinations() {
1078 // Major currency, high-value transaction
1079 let field = Field33B::new("USD", 2_000_000.00).unwrap();
1080 assert!(field.is_major_currency());
1081 assert!(field.is_high_value_transaction());
1082 assert_eq!(field.fx_exposure_category(), "High");
1083 assert_eq!(
1084 field.transaction_purpose(),
1085 "Corporate/Institutional Transfer"
1086 );
1087 assert_eq!(field.decimal_places(), 2);
1088
1089 // Exotic currency, normal value transaction
1090 let field = Field33B::new("THB", 100_000.00).unwrap();
1091 assert!(!field.is_major_currency());
1092 assert!(!field.is_high_value_transaction());
1093 assert_eq!(field.fx_exposure_category(), "Medium");
1094 assert_eq!(
1095 field.transaction_purpose(),
1096 "Cross-Border Commercial Transfer"
1097 );
1098 assert_eq!(field.decimal_places(), 2);
1099 }
1100
1101 #[test]
1102 fn test_field33b_edge_cases() {
1103 // Zero amount (allowed)
1104 let zero_field = Field33B::new("USD", 0.0).unwrap();
1105 assert_eq!(zero_field.amount(), 0.0);
1106 assert!(!zero_field.is_high_value_transaction());
1107
1108 // Very small amount
1109 let small_field = Field33B::new("USD", 0.01).unwrap();
1110 assert_eq!(small_field.amount(), 0.01);
1111 assert_eq!(
1112 small_field.transaction_purpose(),
1113 "Personal/Retail Transfer"
1114 );
1115
1116 // Very large amount
1117 let large_field = Field33B::new("USD", 999_999_999.99).unwrap();
1118 assert!(large_field.is_high_value_transaction());
1119 assert_eq!(
1120 large_field.transaction_purpose(),
1121 "Corporate/Institutional Transfer"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_field33b_real_world_scenarios() {
1127 // Scenario 1: FX conversion (EUR to USD)
1128 let fx_conversion = Field33B::new("EUR", 100_000.00).unwrap();
1129 assert!(fx_conversion.is_currency_conversion("USD"));
1130 assert!(fx_conversion.is_major_currency());
1131 assert!(!fx_conversion.is_high_value_transaction());
1132 assert_eq!(fx_conversion.transaction_purpose(), "Commercial Transfer");
1133
1134 // Scenario 2: High-value corporate transfer
1135 let corporate_transfer = Field33B::new("USD", 5_000_000.00).unwrap();
1136 assert!(corporate_transfer.is_high_value_transaction());
1137 assert_eq!(corporate_transfer.fx_exposure_category(), "High");
1138 assert_eq!(
1139 corporate_transfer.transaction_purpose(),
1140 "Corporate/Institutional Transfer"
1141 );
1142
1143 // Scenario 3: Personal remittance
1144 let remittance = Field33B::new("USD", 2_500.00).unwrap();
1145 assert_eq!(remittance.transaction_purpose(), "Personal/Retail Transfer");
1146 assert_eq!(remittance.fx_exposure_category(), "Low");
1147
1148 // Scenario 4: Exotic currency transaction
1149 let exotic_transfer = Field33B::new("THB", 3_000_000.00).unwrap();
1150 assert!(!exotic_transfer.is_major_currency());
1151 assert!(exotic_transfer.is_high_value_transaction());
1152 assert_eq!(exotic_transfer.fx_exposure_category(), "Very High");
1153 assert_eq!(
1154 exotic_transfer.transaction_purpose(),
1155 "High-Value Cross-Border Transfer"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_field33b_currency_specific_behavior() {
1161 let test_cases = [
1162 ("USD", true, 2, 1_000_000.0),
1163 ("EUR", true, 2, 1_000_000.0),
1164 ("JPY", true, 0, 100_000_000.0),
1165 ("KWD", false, 3, 1_000_000.0),
1166 ("THB", false, 2, 1_000_000.0),
1167 ];
1168
1169 for (currency, is_major, decimal_places, high_value_threshold) in test_cases {
1170 let field = Field33B::new(currency, 1000.00).unwrap();
1171
1172 assert_eq!(
1173 field.is_major_currency(),
1174 is_major,
1175 "Major currency check failed for {}",
1176 currency
1177 );
1178 assert_eq!(
1179 field.decimal_places(),
1180 decimal_places,
1181 "Decimal places check failed for {}",
1182 currency
1183 );
1184
1185 // Test high-value threshold
1186 let high_value_field = Field33B::new(currency, high_value_threshold).unwrap();
1187 assert!(
1188 high_value_field.is_high_value_transaction(),
1189 "High value threshold check failed for {}",
1190 currency
1191 );
1192 }
1193 }
1194
1195 #[test]
1196 fn test_field33b_cross_field_integration() {
1197 // Test scenarios that would typically involve Field 32A integration
1198 let field33b = Field33B::new("EUR", 100_000.00).unwrap();
1199
1200 // Currency conversion scenario
1201 assert!(field33b.is_currency_conversion("USD"));
1202 assert!(!field33b.is_currency_conversion("EUR"));
1203
1204 // Charge deduction scenario (same currency, different amount)
1205 assert!(!field33b.is_currency_conversion("EUR"));
1206 // In real usage, amount comparison with Field 32A would show charge deduction
1207
1208 // Multi-currency analysis
1209 let analysis = field33b.comprehensive_analysis();
1210 assert!(analysis.contains("EUR"));
1211 assert!(analysis.contains("Major currency"));
1212 assert!(analysis.contains("Standard")); // Not high-value
1213 assert!(analysis.contains("Low")); // FX exposure for major currency, standard amount
1214 }
1215}