rustledger_core/cost.rs
1//! Cost and cost specification types.
2//!
3//! A [`Cost`] represents the acquisition cost of a position (lot). It includes
4//! the per-unit cost, currency, optional acquisition date, and optional label.
5//!
6//! A [`CostSpec`] is used for matching against existing costs or specifying
7//! new costs when all fields may not be known.
8
9// rkyv's enum derive (used on `CostNumber` below) synthesizes a
10// per-variant `Archived*` struct whose generated `pub value` field
11// doesn't inherit the source variant's field doc. Item-level
12// `#[allow(missing_docs)]` doesn't propagate into the macro-emitted
13// sibling items, so the suppression must live at module scope.
14// Limited to the `rkyv` feature so hand-written items still get the
15// lint under non-rkyv builds; reviewers should check that any new
16// rkyv-archived type added to this file has docs on its source fields
17// (review A-3.2).
18#![cfg_attr(feature = "rkyv", allow(missing_docs))]
19
20use crate::NaiveDate;
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23use std::fmt;
24
25use crate::Amount;
26
27// Note: We no longer auto-quantize calculated values during cost storage.
28// Python beancount preserves full precision during booking and only rounds
29// at display time. Premature rounding of per-unit costs (e.g., from
30// total cost / units) causes cost basis errors when selling.
31// For example: 300.00 / 1.763 = 170.16505... should NOT be rounded to 170.17,
32// because 1.763 * 170.17 = 300.00971 ≠ 300.00.
33#[cfg(feature = "rkyv")]
34use crate::intern::{AsDecimal, AsNaiveDate};
35
36/// A cost represents the acquisition cost of a position (lot).
37///
38/// When you buy 10 shares of AAPL at $150 on 2024-01-15, the cost is:
39/// - number: 150
40/// - currency: "USD"
41/// - date: Some(2024-01-15)
42/// - label: None (or Some("lot1") if labeled)
43///
44/// # Examples
45///
46/// ```
47/// use rustledger_core::Cost;
48/// use rust_decimal_macros::dec;
49///
50/// let cost = Cost::new(dec!(150.00), "USD")
51/// .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
52///
53/// assert_eq!(cost.number, dec!(150.00));
54/// assert_eq!(cost.currency, "USD");
55/// assert!(cost.date.is_some());
56/// ```
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[cfg_attr(
59 feature = "rkyv",
60 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
61)]
62pub struct Cost {
63 /// Cost per unit
64 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
65 pub number: Decimal,
66 /// Currency of the cost
67 pub currency: crate::Currency,
68 /// Acquisition date (optional, for lot identification)
69 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
70 pub date: Option<NaiveDate>,
71 /// Lot label (optional, for explicit lot identification)
72 pub label: Option<String>,
73}
74
75impl Cost {
76 /// Create a new cost with the given number and currency.
77 ///
78 /// Create a new cost with exact precision.
79 /// Use this for user-specified values that should preserve their precision.
80 #[must_use]
81 pub fn new(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
82 Self {
83 number,
84 currency: currency.into(),
85 date: None,
86 label: None,
87 }
88 }
89
90 /// Create a new cost for calculated values.
91 ///
92 /// Previously this auto-quantized, but we now preserve full precision
93 /// to avoid cost basis errors. Rounding should only happen at display time.
94 #[must_use]
95 pub fn new_calculated(number: Decimal, currency: impl Into<crate::Currency>) -> Self {
96 Self::new(number, currency)
97 }
98
99 /// Add a date to this cost.
100 #[must_use]
101 pub const fn with_date(mut self, date: NaiveDate) -> Self {
102 self.date = Some(date);
103 self
104 }
105
106 /// Add an optional date to this cost.
107 #[must_use]
108 pub const fn with_date_opt(mut self, date: Option<NaiveDate>) -> Self {
109 self.date = date;
110 self
111 }
112
113 /// Add a label to this cost.
114 #[must_use]
115 pub fn with_label(mut self, label: impl Into<String>) -> Self {
116 self.label = Some(label.into());
117 self
118 }
119
120 /// Add an optional label to this cost.
121 #[must_use]
122 pub fn with_label_opt(mut self, label: Option<String>) -> Self {
123 self.label = label;
124 self
125 }
126
127 /// Get the cost as an amount.
128 #[must_use]
129 pub fn as_amount(&self) -> Amount {
130 Amount::new(self.number, self.currency.clone())
131 }
132
133 /// Calculate the total cost for a given number of units.
134 #[must_use]
135 pub fn total_cost(&self, units: Decimal) -> Amount {
136 Amount::new(units * self.number, self.currency.clone())
137 }
138}
139
140impl fmt::Display for Cost {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 // Match Beancount's `Position.__str__` format: `{ 520 USD}` —
143 // single space after the opening brace, no space before the
144 // closing brace. The space matters for BQL-output compat: the
145 // compat harness diffs row-by-row against bean-query, and the
146 // pre-fix `{520 USD}` form accounted for ~137 of 510 file ×
147 // query mismatches. Verified against beanquery 0.2.0 + beancount
148 // 3.2.3 (matches what CI installs and what the dev shell now
149 // ships via the compat container — see PR #1047). Source-level
150 // `format_cost_spec` (used by `rledger format` to round-trip
151 // ledger files) keeps the no-space `{N CCY}` form because that
152 // matches Beancount's `print` command output, not its
153 // `Position.__str__`.
154 write!(f, "{{ {} {}", self.number, self.currency)?;
155 if let Some(date) = self.date {
156 write!(f, ", {date}")?;
157 }
158 if let Some(label) = &self.label {
159 // Escape via `format::escape_string` so labels containing
160 // `"`, `\`, or `\n` round-trip safely. Without this a label
161 // like `say "hi"` would render as `"say "hi""` — a parse
162 // error if anyone tried to feed it back to a Beancount-
163 // compatible reader.
164 write!(f, ", \"{}\"", crate::format::escape_string(label))?;
165 }
166 write!(f, "}}")
167 }
168}
169
170/// A cost specification for matching or creating costs.
171///
172/// Unlike [`Cost`], all fields are optional to allow partial matching.
173/// This is used in postings where the user may specify only some
174/// cost components (e.g., just the date to match a specific lot).
175///
176/// # Matching Rules
177///
178/// A `CostSpec` matches a `Cost` if all specified fields match:
179/// - If `number` is `Some`, it must equal the cost's number
180/// - If `currency` is `Some`, it must equal the cost's currency
181/// - If `date` is `Some`, it must equal the cost's date
182/// - If `label` is `Some`, it must equal the cost's label
183///
184/// # Examples
185///
186/// ```
187/// use rustledger_core::{Cost, CostSpec};
188/// use rust_decimal_macros::dec;
189///
190/// let cost = Cost::new(dec!(150.00), "USD")
191/// .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
192///
193/// // Match by date only
194/// let spec = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
195/// assert!(spec.matches(&cost));
196///
197/// // Match by wrong date
198/// let spec2 = CostSpec::default().with_date(rustledger_core::naive_date(2024, 1, 16).unwrap());
199/// assert!(!spec2.matches(&cost));
200/// ```
201/// The numeric component of a [`CostSpec`].
202///
203/// Beancount cost specs name a number in one of two source-level
204/// shapes:
205///
206/// - `{150.00 USD}` — per-unit cost ([`Self::PerUnit`])
207/// - `{{ 1500.00 USD }}` — total cost for the posting's units
208/// ([`Self::Total`])
209///
210/// During booking the engine converts `Total(t)` into a third state,
211/// [`Self::PerUnitFromTotal`], carrying both the derived per-unit
212/// value (for display, lot tracking) and the original total (for
213/// precision-preserving residual math — division-then-multiplication
214/// loses precision at the `rust_decimal` 28-digit ceiling).
215///
216/// A cost spec without a number at all (e.g. `{}` for a booking-
217/// deferred lot match) is represented by `CostSpec.number: None`.
218///
219/// Pre-#1164 the per-unit and total numbers were two independent
220/// `Option<Decimal>` fields on `CostSpec`. The invalid both-set state
221/// was prevented only by parser discipline and downstream defensive
222/// branches; the "booked from total" state was modeled accidentally
223/// by setting both fields, with the meaning encoded only in code
224/// comments. Folding the axes into one enum makes both the
225/// pre-booking invalid state unrepresentable AND the post-booking
226/// derived state explicit.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
228#[cfg_attr(
229 feature = "rkyv",
230 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
231)]
232// Serde uses the `kind`-tagged internal representation so this enum
233// matches the wire shape used by FFI-WASI, WASM, Python compat, and
234// plugin-types. Pre-tag, serde defaulted to the external-tag form
235// (`{"PerUnit": "100"}`) which diverged from those boundaries —
236// downstream clients had to know which surface they were talking to.
237// (Module-level `allow(missing_docs)` at the top of this file
238// silences the rkyv-generated archived-struct field doc warnings —
239// see the file header comment.)
240#[serde(tag = "kind", rename_all = "snake_case")]
241pub enum CostNumber {
242 /// Per-unit cost as written: `{150.00 USD}`. Booking leaves this
243 /// shape unchanged.
244 PerUnit {
245 /// Per-unit value.
246 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
247 value: Decimal,
248 },
249 /// Total cost as written: `{{ 1500.00 USD }}`. Booking rewrites
250 /// this to [`Self::PerUnitFromTotal`] once units are known.
251 Total {
252 /// Total value.
253 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
254 value: Decimal,
255 },
256 /// Post-booking state: a per-unit value derived from a
257 /// `{{ total USD }}` spec at booking time, with the source total
258 /// preserved for exact residual math. Pre-#1164 this was modeled
259 /// implicitly by setting both `number_per` and `number_total` on
260 /// `CostSpec`. The payload is a separate [`BookedCost`] struct
261 /// so the booking-time invariant lives on a named type with
262 /// constructor methods that enforce it.
263 PerUnitFromTotal(BookedCost),
264 /// Compound cost as written: `{5.00 # 10.00 USD}` — per-unit AND a
265 /// lump total on top (beancount's `compound_amount`). On N units the
266 /// cost totals `N * per_unit + total`; booking rewrites this to
267 /// [`Self::PerUnitFromTotal`] with that combined total once units
268 /// are known. `{# 10.00 USD}` parses as `per_unit = 0` and `{5.00 #
269 /// USD}` as `total = 0` — arithmetically exact in both cases.
270 ///
271 /// Before #1700 the parser folded this form into [`Self::Total`]
272 /// with only the post-`#` value, silently mis-weighing every
273 /// compound spec (valid ledgers errored, invalid ones passed).
274 Compound {
275 /// Per-unit component (`a` in `{a # b}`); zero when omitted.
276 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
277 per_unit: Decimal,
278 /// Lump total component (`b` in `{a # b}`); zero when omitted.
279 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
280 total: Decimal,
281 },
282}
283
284/// Payload of [`CostNumber::PerUnitFromTotal`].
285///
286/// Carries both the per-unit value derived at booking time and the
287/// original `{{ total }}` cost so residual math can use the exact
288/// total (avoiding the division-then-multiplication precision loss
289/// that hits the `rust_decimal` 28-digit ceiling on long ledgers).
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
291#[cfg_attr(
292 feature = "rkyv",
293 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
294)]
295pub struct BookedCost {
296 /// Per-unit cost, derived as `total / |units|` during booking.
297 /// Used by lot tracking, display (Python-compat post-booking
298 /// per-unit form), and validation reads that want a per-unit
299 /// value.
300 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
301 pub per_unit: Decimal,
302 /// Original total as written. Used by residual calculation to
303 /// avoid the division-then-multiplication precision loss that
304 /// would otherwise leak into balance checks.
305 #[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))]
306 pub total: Decimal,
307}
308
309/// Diagnostic for a failed [`BookedCost`] consistency check.
310///
311/// Returned by [`BookedCost::try_new`] in three cases:
312/// - **Mismatch**: `per_unit * |units|` doesn't agree with `total` to
313/// within the `rust_decimal` rounding floor.
314/// - **Zero units**: every `per_unit` "works" by zero-multiplication
315/// so the invariant carries no information; the post-booking shape
316/// is structurally meaningless without units.
317/// - **Overflow**: `per_unit * |units|` would exceed `Decimal::MAX`
318/// (~7.92e28). Both operands fit in `Decimal` individually but their
319/// product doesn't. A wire client can reach this with extreme
320/// inputs; surfacing it as a typed error keeps the host from
321/// panicking on multiplication.
322///
323/// Carries the inputs and (for the mismatch case) the computed
324/// residual so trust-boundary callers can surface a meaningful error
325/// to the originating plugin or wire client ("you sent
326/// `per_unit=50, total=999` with `units=10`; derived total would be
327/// 500, off by 499 — far outside tolerance 1e-20"). Mapping this to a
328/// `ConversionError` variant gives plugin authors a typed category
329/// for the failure instead of conflating with `InvalidNumber` (parse
330/// failure).
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub struct BookedCostInvariantError {
333 /// The per-unit value the caller supplied.
334 pub per_unit: Decimal,
335 /// The total value the caller supplied.
336 pub total: Decimal,
337 /// The units value the caller supplied (caller-side sign retained
338 /// so error messages can show what came in).
339 pub units: Decimal,
340 /// `per_unit * |units|`, the value we'd expect `total` to equal.
341 /// `Decimal::ZERO` when the multiplication couldn't be performed
342 /// (zero units, or overflow — see [`Self::overflow`]).
343 pub derived_total: Decimal,
344 /// `|derived_total - total|`, the magnitude of the violation.
345 /// `Decimal::ZERO` for the zero-units and overflow cases.
346 pub abs_diff: Decimal,
347 /// The tolerance threshold we tested against. `None` when units
348 /// was zero or the multiplication overflowed — see
349 /// [`Self::overflow`] to distinguish the two.
350 pub tolerance: Option<Decimal>,
351 /// `true` when `per_unit * |units|` overflowed `Decimal::MAX`
352 /// (~7.92e28). Distinguishes the overflow case from the zero-units
353 /// case, since both leave `tolerance: None`.
354 pub overflow: bool,
355}
356
357impl fmt::Display for BookedCostInvariantError {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 if self.overflow {
360 return write!(
361 f,
362 "BookedCost invariant check overflowed Decimal precision: per_unit ({}) * |units| ({}) exceeds Decimal::MAX (~7.92e28)",
363 self.per_unit,
364 self.units.abs(),
365 );
366 }
367 match self.tolerance {
368 Some(tol) => write!(
369 f,
370 "BookedCost invariant violated: per_unit ({}) * |units| ({}) = {} ≠ total ({}); abs_diff {} exceeds tolerance {}",
371 self.per_unit,
372 self.units.abs(),
373 self.derived_total,
374 self.total,
375 self.abs_diff,
376 tol,
377 ),
378 None => write!(
379 f,
380 "BookedCost requires non-zero units; got per_unit ({}), total ({}), units (0)",
381 self.per_unit, self.total,
382 ),
383 }
384 }
385}
386
387impl std::error::Error for BookedCostInvariantError {}
388
389impl BookedCost {
390 /// Check `per_unit * |units| ≈ total` to within `rust_decimal`
391 /// rounding tolerance, returning the diagnostic on failure.
392 ///
393 /// The booker derives `per_unit = total / |units|` at 28 significant
394 /// digits; back-multiplying truncates similarly. The residual can
395 /// reach a few ULP, which scales with `|total|`. Tolerance is
396 /// `max(1e-20, |total| * 1e-24)` — `1e-24` is ~10000x larger than
397 /// one ULP for typical magnitudes, while the absolute floor
398 /// guarantees a sane window for near-zero totals.
399 ///
400 /// **`units == 0` is rejected**: the post-booking shape implies the
401 /// booker derived `per_unit` from `total / |units|`, which is
402 /// undefined for zero units. A zero-units `PerUnitFromTotal` is
403 /// structurally meaningless and the caller should use the raw
404 /// `PerUnit` / `Total` variants. Pre-fix the zero-units case
405 /// short-circuited to `true`, which defeated the trust-boundary
406 /// guard at every input bridge (review B-3.1).
407 fn check_invariant(
408 per_unit: Decimal,
409 total: Decimal,
410 units: Decimal,
411 ) -> Result<(), BookedCostInvariantError> {
412 let units_abs = units.abs();
413 if units_abs.is_zero() {
414 return Err(BookedCostInvariantError {
415 per_unit,
416 total,
417 units,
418 derived_total: Decimal::ZERO,
419 abs_diff: Decimal::ZERO,
420 tolerance: None,
421 overflow: false,
422 });
423 }
424 // `per_unit` and `units_abs` each fit in `Decimal` individually
425 // (they came through `from_str_exact` or were constructed by
426 // the booker from values that did), but their product can
427 // exceed `Decimal::MAX` (~7.92e28). `Decimal::mul` panics on
428 // overflow — at a trust boundary that would crash the host
429 // from wire input, defeating `try_new`'s typed-error contract.
430 // Surface overflow as a typed error instead.
431 let Some(derived_total) = per_unit.checked_mul(units_abs) else {
432 return Err(BookedCostInvariantError {
433 per_unit,
434 total,
435 units,
436 derived_total: Decimal::ZERO,
437 abs_diff: Decimal::ZERO,
438 tolerance: None,
439 overflow: true,
440 });
441 };
442 let abs_diff = (derived_total - total).abs();
443 // `total.abs() * 1e-24` cannot overflow: `Decimal::MAX` is
444 // ~7.92e28, so the product is bounded by ~7.92e4. The relative
445 // tolerance scales with the magnitude of `total`; the absolute
446 // floor (`1e-20`) keeps the window sane for near-zero totals.
447 let relative = total.abs() * Decimal::new(1, 24);
448 let tolerance = if relative > Decimal::new(1, 20) {
449 relative
450 } else {
451 Decimal::new(1, 20)
452 };
453 if abs_diff <= tolerance {
454 Ok(())
455 } else {
456 Err(BookedCostInvariantError {
457 per_unit,
458 total,
459 units,
460 derived_total,
461 abs_diff,
462 tolerance: Some(tolerance),
463 overflow: false,
464 })
465 }
466 }
467
468 /// Construct from booking with a precision invariant check.
469 ///
470 /// In debug builds, asserts that `per_unit * |units| ≈ total` to
471 /// the limits of `rust_decimal` precision (tolerance:
472 /// `max(1e-20, |total| * 1e-24)`, derived from the booker's
473 /// `total / |units|` divisor truncating at 28 significant digits;
474 /// see the private `check_invariant` helper for the exact
475 /// computation). Callers are the booker (which derives
476 /// `per_unit = total / |units|`) and the plugin / FFI ingress
477 /// bridges (which must validate consistency before constructing).
478 ///
479 /// # Panics
480 ///
481 /// In debug builds: if the invariant fails. Release builds skip
482 /// the check (but trust-boundary callers should use
483 /// [`Self::try_new`] for runtime validation in release too).
484 #[must_use]
485 pub fn new(per_unit: Decimal, total: Decimal, units: Decimal) -> Self {
486 debug_assert!(
487 Self::check_invariant(per_unit, total, units).is_ok(),
488 "{}",
489 Self::check_invariant(per_unit, total, units).unwrap_err(),
490 );
491 Self { per_unit, total }
492 }
493
494 /// Try to construct, returning a typed error if the consistency
495 /// invariant fails. Use this at trust boundaries (FFI input,
496 /// plugin egress) where the caller may have supplied inconsistent
497 /// values and you want to reject rather than panic in debug or
498 /// accept silently in release.
499 ///
500 /// # Errors
501 ///
502 /// Returns [`BookedCostInvariantError`] when:
503 /// - `units == 0` (the post-booking shape is structurally
504 /// undefined for zero units; callers should send `PerUnit` or
505 /// `Total` instead).
506 /// - `per_unit * |units|` differs from `total` by more than
507 /// `max(1e-20, |total| * 1e-24)`.
508 pub fn try_new(
509 per_unit: Decimal,
510 total: Decimal,
511 units: Decimal,
512 ) -> Result<Self, BookedCostInvariantError> {
513 Self::check_invariant(per_unit, total, units)?;
514 Ok(Self { per_unit, total })
515 }
516
517 /// Construct from rkyv archive bytes the host itself wrote.
518 ///
519 /// Bypasses the consistency invariant because rkyv archives carry
520 /// no units at the deserialization site, and the host invariant
521 /// was already enforced when the bytes were written. **Do not
522 /// call from boundary code** — every FFI / plugin / parser
523 /// ingress must go through [`Self::try_new`] (which surfaces a
524 /// typed error) so inconsistent pairs cannot enter the host.
525 ///
526 /// The name reflects the trust assumption: the caller has
527 /// verified (via cache-version checks, archive integrity, etc.)
528 /// that the bytes were produced by this host's own booker.
529 #[doc(hidden)]
530 #[must_use]
531 pub const fn from_archive_bytes_trusted(per_unit: Decimal, total: Decimal) -> Self {
532 Self { per_unit, total }
533 }
534
535 /// Construct an *intentionally inconsistent* `BookedCost` for
536 /// fuzzing trust-boundary code that must reject such inputs.
537 ///
538 /// Separate from [`Self::from_archive_bytes_trusted`] so the
539 /// "trusted" name doesn't lie at fuzz call sites — the fuzzer
540 /// explicitly generates pathological inputs. Gated behind the
541 /// `fuzz` Cargo feature so normal builds can't reach it (review
542 /// A-4.4); fuzz targets and integration tests that want to
543 /// stress trust-boundary code in convert bridges must opt in via
544 /// `features = ["fuzz"]` on their `rustledger-core` dep.
545 #[cfg(any(feature = "fuzz", test))]
546 #[doc(hidden)]
547 #[must_use]
548 pub const fn from_fuzz_unchecked(per_unit: Decimal, total: Decimal) -> Self {
549 Self { per_unit, total }
550 }
551}
552
553impl CostNumber {
554 /// Return the per-unit value if this number carries one.
555 ///
556 /// - [`Self::PerUnit`] → `Some(its Decimal)`
557 /// - [`Self::PerUnitFromTotal`] → `Some(per_unit)`
558 /// - [`Self::Total`] → `None` (booking hasn't computed per-unit yet)
559 #[must_use]
560 pub const fn per_unit(&self) -> Option<Decimal> {
561 match self {
562 Self::PerUnit { value } => Some(*value),
563 Self::PerUnitFromTotal(b) => Some(b.per_unit),
564 // The effective per-unit is (N*per_unit + total)/N — unknown
565 // until units are, same as Total.
566 Self::Total { .. } | Self::Compound { .. } => None,
567 }
568 }
569
570 /// Return the total value if this number carries one.
571 ///
572 /// - [`Self::Total`] → `Some(its Decimal)`
573 /// - [`Self::PerUnitFromTotal`] → `Some(total)`
574 /// - [`Self::PerUnit`] → `None`
575 #[must_use]
576 pub const fn total(&self) -> Option<Decimal> {
577 match self {
578 Self::Total { value } => Some(*value),
579 Self::PerUnitFromTotal(b) => Some(b.total),
580 // Compound's `total` field is only the lump component; the
581 // whole total (N*per_unit + total) needs units. Exposing the
582 // lump here would mis-weigh callers that treat this as the
583 // full total — the exact bug class this variant fixes.
584 Self::PerUnit { .. } | Self::Compound { .. } => None,
585 }
586 }
587}
588
589/// A cost specification on a posting (`{...}` or `{{...}}`).
590///
591/// Carries the parsed cost-spec axes: the numeric component (per-unit
592/// vs total, modeled as the mutually-exclusive [`CostNumber`] enum),
593/// currency, lot date, label, and merge flag. Any subset may be
594/// missing — `{}` corresponds to all-fields-`None` plus `merge: false`,
595/// which lets the booker do lot matching deferred to inventory.
596///
597/// Pre-#1164 this struct had two independent `Option<Decimal>` fields
598/// (`number_per`, `number_total`). The mutual-exclusion invariant was
599/// enforced only by parser discipline; the post-booking "derived per-
600/// unit from total" state was modeled accidentally by setting both
601/// fields at once. The new shape (`number: Option<CostNumber>`) makes
602/// the invalid state unrepresentable and the derived state explicit.
603#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
604#[cfg_attr(
605 feature = "rkyv",
606 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
607)]
608pub struct CostSpec {
609 /// The numeric component: per-unit, total, or absent.
610 ///
611 /// Replaces the pre-#1164 `number_per` / `number_total` pair, which
612 /// allowed the invalid both-set state at the type level. See
613 /// [`CostNumber`] for the per-unit vs total distinction.
614 pub number: Option<CostNumber>,
615 /// Currency of the cost (if specified)
616 pub currency: Option<crate::Currency>,
617 /// Acquisition date (if specified)
618 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsNaiveDate>))]
619 pub date: Option<NaiveDate>,
620 /// Lot label (if specified)
621 pub label: Option<String>,
622 /// Whether to merge with existing lot (average cost)
623 pub merge: bool,
624}
625
626impl CostSpec {
627 /// Create an empty cost spec.
628 #[must_use]
629 pub fn empty() -> Self {
630 Self::default()
631 }
632
633 /// Set the cost number directly.
634 ///
635 /// The mutual exclusion between per-unit and total is enforced by
636 /// the [`CostNumber`] enum — there is no way to set both. Callers
637 /// construct the variant explicitly:
638 ///
639 /// ```ignore
640 /// CostSpec::empty().with_number(CostNumber::PerUnit { value: dec!(150) });
641 /// CostSpec::empty().with_number(CostNumber::Total { value: dec!(1500) });
642 /// ```
643 ///
644 /// Pre-#1164 this slot was a pair of `Option<Decimal>` fields;
645 /// pre-this-PR there were `with_per_unit` / `with_total`
646 /// convenience shims that perpetuated the two-axis mental model
647 /// in caller code and silently overwrote each other if both were
648 /// called. Both are gone — there's exactly one way to set a cost
649 /// number now.
650 #[must_use]
651 pub const fn with_number(mut self, number: CostNumber) -> Self {
652 self.number = Some(number);
653 self
654 }
655
656 /// Set the currency.
657 #[must_use]
658 pub fn with_currency(mut self, currency: impl Into<crate::Currency>) -> Self {
659 self.currency = Some(currency.into());
660 self
661 }
662
663 /// Set the date.
664 #[must_use]
665 pub const fn with_date(mut self, date: NaiveDate) -> Self {
666 self.date = Some(date);
667 self
668 }
669
670 /// Set the label.
671 #[must_use]
672 pub fn with_label(mut self, label: impl Into<String>) -> Self {
673 self.label = Some(label.into());
674 self
675 }
676
677 /// Set the merge flag (for average cost booking).
678 #[must_use]
679 pub const fn with_merge(mut self) -> Self {
680 self.merge = true;
681 self
682 }
683
684 /// Check if this is an empty cost spec (all fields None).
685 #[must_use]
686 pub const fn is_empty(&self) -> bool {
687 self.number.is_none()
688 && self.currency.is_none()
689 && self.date.is_none()
690 && self.label.is_none()
691 && !self.merge
692 }
693
694 /// Check if this cost spec matches a cost.
695 ///
696 /// All specified fields must match the corresponding cost fields.
697 /// Per-unit matching uses `CostNumber::per_unit()` — a `Total`-only
698 /// spec doesn't constrain the per-unit lot value (booking hasn't
699 /// resolved it yet), but a `PerUnitFromTotal` post-booking spec
700 /// does.
701 #[must_use]
702 pub fn matches(&self, cost: &Cost) -> bool {
703 // Check per-unit cost — constrains the lot whenever the spec
704 // carries a per-unit value (PerUnit or PerUnitFromTotal).
705 if let Some(n) = self.number.and_then(|cn| cn.per_unit())
706 && n != cost.number
707 {
708 return false;
709 }
710 // Check currency
711 if let Some(c) = &self.currency
712 && c != &cost.currency
713 {
714 return false;
715 }
716 // Check date
717 if let Some(d) = &self.date
718 && cost.date.as_ref() != Some(d)
719 {
720 return false;
721 }
722 // Check label
723 if let Some(l) = &self.label
724 && cost.label.as_ref() != Some(l)
725 {
726 return false;
727 }
728 true
729 }
730
731 /// Resolve this cost spec to a concrete cost, given the number of units.
732 ///
733 /// If the number is `CostNumber::Total`, the per-unit cost is
734 /// calculated as `total / |units|`. Full precision is preserved to
735 /// avoid cost basis errors when the position is later sold.
736 /// `PerUnitFromTotal` already carries the derived per-unit value
737 /// from a prior booking pass — using `b.per_unit` directly is
738 /// equivalent to recomputing `b.total / |units|` because
739 /// [`BookedCost::new`] enforces that invariant at construction.
740 ///
741 /// Returns `None` if required fields (currency, number) are missing.
742 #[must_use]
743 pub fn resolve(&self, units: Decimal, date: NaiveDate) -> Option<Cost> {
744 let currency = self.currency.clone()?;
745 let number = match self.number? {
746 // User-specified per-unit cost.
747 CostNumber::PerUnit { value: per } => per,
748 // Calculated from total — preserve full precision. Zero units make
749 // the per-unit cost undefined, and `total / 0` panics, so there is
750 // no cost to resolve: return `None` and let the caller book an
751 // uncosted position. Matches the zero-units guard in
752 // `BookingEngine::apply`; before this, `validate`/`pad` (which call
753 // `resolve`) panicked on `0 X {{n CUR}}`.
754 CostNumber::Total { value: total } => {
755 if units.is_zero() {
756 return None;
757 }
758 total / units.abs()
759 }
760 // Compound `{a # b}`: effective per-unit is (N*a + b)/N —
761 // beancount's compound_amount. Same zero-units guard as Total.
762 CostNumber::Compound { per_unit, total } => {
763 if units.is_zero() {
764 return None;
765 }
766 per_unit + total / units.abs()
767 }
768 // Already booked: `b.per_unit == b.total / |units|` by
769 // `BookedCost::new`'s invariant, so this is identical to
770 // the `Total` arm above but without the redivision.
771 CostNumber::PerUnitFromTotal(b) => b.per_unit,
772 };
773
774 Some(Cost {
775 number,
776 currency,
777 date: self.date.or(Some(date)),
778 label: self.label.clone(),
779 })
780 }
781}
782
783impl fmt::Display for CostSpec {
784 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
785 write!(f, "{{")?;
786 // Max 5 elements: number, currency, date, label, merge
787 let mut parts = Vec::with_capacity(5);
788
789 match self.number {
790 Some(CostNumber::PerUnit { value: n }) => parts.push(format!("{n}")),
791 Some(CostNumber::PerUnitFromTotal(b)) => parts.push(format!("{}", b.per_unit)),
792 Some(CostNumber::Total { value: n }) => parts.push(format!("# {n}")),
793 Some(CostNumber::Compound { per_unit, total }) => {
794 parts.push(format!("{per_unit} # {total}"));
795 }
796 None => {}
797 }
798 if let Some(c) = &self.currency {
799 parts.push(c.to_string());
800 }
801 if let Some(d) = self.date {
802 parts.push(d.to_string());
803 }
804 if let Some(l) = &self.label {
805 parts.push(format!("\"{l}\""));
806 }
807 if self.merge {
808 parts.push("*".to_string());
809 }
810
811 write!(f, "{}", parts.join(", "))?;
812 write!(f, "}}")
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use rust_decimal_macros::dec;
820
821 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
822 crate::naive_date(year, month, day).unwrap()
823 }
824
825 #[test]
826 fn test_cost_new() {
827 let cost = Cost::new(dec!(150.00), "USD");
828 assert_eq!(cost.number, dec!(150.00));
829 assert_eq!(cost.currency, "USD");
830 assert!(cost.date.is_none());
831 assert!(cost.label.is_none());
832 }
833
834 #[test]
835 fn test_cost_builder() {
836 let cost = Cost::new(dec!(150.00), "USD")
837 .with_date(date(2024, 1, 15))
838 .with_label("lot1");
839
840 assert_eq!(cost.date, Some(date(2024, 1, 15)));
841 assert_eq!(cost.label, Some("lot1".to_string()));
842 }
843
844 #[test]
845 fn test_cost_total() {
846 let cost = Cost::new(dec!(150.00), "USD");
847 let total = cost.total_cost(dec!(10));
848 assert_eq!(total.number, dec!(1500.00));
849 assert_eq!(total.currency, "USD");
850 }
851
852 #[test]
853 fn test_resolve_total_cost_zero_units_returns_none() {
854 // `0 X {{100 USD}}`: a Total cost over zero units. Before the guard,
855 // `total / units.abs()` was `100 / 0`, which PANICKED in `validate`/`pad`
856 // (which call `resolve`). It must now resolve to `None` (uncosted).
857 let spec = CostSpec::empty()
858 .with_number(CostNumber::Total {
859 value: dec!(100.00),
860 })
861 .with_currency("USD");
862 assert_eq!(spec.resolve(Decimal::ZERO, date(2024, 1, 15)), None);
863 // Non-zero units still resolve to the per-unit cost.
864 let cost = spec.resolve(dec!(4), date(2024, 1, 15)).unwrap();
865 assert_eq!(cost.number, dec!(25)); // 100 / 4
866 }
867
868 #[test]
869 fn test_cost_display() {
870 let cost = Cost::new(dec!(150.00), "USD")
871 .with_date(date(2024, 1, 15))
872 .with_label("lot1");
873 let s = format!("{cost}");
874 assert!(s.contains("150.00"));
875 assert!(s.contains("USD"));
876 assert!(s.contains("2024-01-15"));
877 assert!(s.contains("lot1"));
878 }
879
880 /// Exact-format regression covering both fixes in this PR:
881 /// - leading space inside `{` (matches Beancount Position.__str__)
882 /// - special-character escaping in labels via `format::escape_string`
883 #[test]
884 fn test_cost_display_escapes_special_characters_in_label() {
885 // Bare per-unit cost — pin the leading-space form.
886 let bare = Cost::new(dec!(520), "USD");
887 assert_eq!(format!("{bare}"), "{ 520 USD}");
888
889 // With date.
890 let dated = Cost::new(dec!(520.00), "USD").with_date(date(2024, 1, 15));
891 assert_eq!(format!("{dated}"), "{ 520.00 USD, 2024-01-15}");
892
893 // Embedded double-quote.
894 let quoted = Cost::new(dec!(100.00), "USD")
895 .with_date(date(2024, 1, 15))
896 .with_label("say \"hi\"");
897 assert_eq!(
898 format!("{quoted}"),
899 "{ 100.00 USD, 2024-01-15, \"say \\\"hi\\\"\"}"
900 );
901
902 // Embedded backslash.
903 let backslash = Cost::new(dec!(50.00), "USD").with_label("path\\to\\lot");
904 assert_eq!(
905 format!("{backslash}"),
906 "{ 50.00 USD, \"path\\\\to\\\\lot\"}"
907 );
908
909 // Embedded newline.
910 let newline = Cost::new(dec!(75.00), "USD").with_label("line1\nline2");
911 assert_eq!(format!("{newline}"), "{ 75.00 USD, \"line1\\nline2\"}");
912
913 // Plain label still works (no escaping changes for safe chars).
914 let plain = Cost::new(dec!(540.00), "USD")
915 .with_date(date(2024, 2, 15))
916 .with_label("lot-A");
917 assert_eq!(format!("{plain}"), "{ 540.00 USD, 2024-02-15, \"lot-A\"}");
918 }
919
920 #[test]
921 fn test_cost_spec_empty() {
922 let spec = CostSpec::empty();
923 assert!(spec.is_empty());
924 }
925
926 #[test]
927 fn test_cost_spec_matches() {
928 let cost = Cost::new(dec!(150.00), "USD")
929 .with_date(date(2024, 1, 15))
930 .with_label("lot1");
931
932 // Empty spec matches everything
933 assert!(CostSpec::empty().matches(&cost));
934
935 // Match by number
936 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
937 value: dec!(150.00),
938 });
939 assert!(spec.matches(&cost));
940
941 // Wrong number
942 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
943 value: dec!(160.00),
944 });
945 assert!(!spec.matches(&cost));
946
947 // Match by currency
948 let spec = CostSpec::empty().with_currency("USD");
949 assert!(spec.matches(&cost));
950
951 // Match by date
952 let spec = CostSpec::empty().with_date(date(2024, 1, 15));
953 assert!(spec.matches(&cost));
954
955 // Match by label
956 let spec = CostSpec::empty().with_label("lot1");
957 assert!(spec.matches(&cost));
958
959 // Match by all
960 let spec = CostSpec::empty()
961 .with_number(crate::CostNumber::PerUnit {
962 value: dec!(150.00),
963 })
964 .with_currency("USD")
965 .with_date(date(2024, 1, 15))
966 .with_label("lot1");
967 assert!(spec.matches(&cost));
968 }
969
970 #[test]
971 fn test_cost_spec_resolve() {
972 let spec = CostSpec::empty()
973 .with_number(crate::CostNumber::PerUnit {
974 value: dec!(150.00),
975 })
976 .with_currency("USD");
977
978 let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
979 assert_eq!(cost.number, dec!(150.00));
980 assert_eq!(cost.currency, "USD");
981 assert_eq!(cost.date, Some(date(2024, 1, 15)));
982 }
983
984 #[test]
985 fn test_cost_spec_resolve_total() {
986 let spec = CostSpec::empty()
987 .with_number(crate::CostNumber::Total {
988 value: dec!(1500.00),
989 })
990 .with_currency("USD");
991
992 let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
993 assert_eq!(cost.number, dec!(150.00)); // 1500 / 10
994 assert_eq!(cost.currency, "USD");
995 }
996
997 // ===== BookedCost / PerUnitFromTotal tests (#1164) =====
998
999 #[test]
1000 fn booked_cost_new_accepts_consistent_pair() {
1001 // 10 units of "300 total" → 30 per-unit. Constructor must
1002 // accept; debug_assert sees per_unit * |units| == total.
1003 let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1004 assert_eq!(b.per_unit, dec!(30));
1005 assert_eq!(b.total, dec!(300));
1006 }
1007
1008 #[test]
1009 fn booked_cost_new_accepts_negative_units() {
1010 // Sales (negative units) still produce consistent
1011 // PerUnitFromTotal: per_unit * |units| == total uses .abs().
1012 let b = BookedCost::new(dec!(30), dec!(300), dec!(-10));
1013 assert_eq!(b.per_unit, dec!(30));
1014 }
1015
1016 #[test]
1017 #[should_panic(expected = "BookedCost invariant violated")]
1018 fn booked_cost_new_rejects_inconsistent_pair_in_debug() {
1019 // per_unit (50) * |units| (10) = 500, NOT 300. Invariant must
1020 // fire. Release builds would skip the check by design — this
1021 // test verifies the debug-build safety net.
1022 let _ = BookedCost::new(dec!(50), dec!(300), dec!(10));
1023 }
1024
1025 #[test]
1026 #[should_panic(expected = "requires non-zero units")]
1027 fn booked_cost_new_rejects_zero_units_in_debug() {
1028 // Post-A-3.5/B-3.1: zero units is structurally meaningless
1029 // for the post-booking shape (every per_unit "works" by
1030 // zero-multiplication). `new` debug-asserts and panics;
1031 // `try_new` returns a typed error. The booker never
1032 // constructs PerUnitFromTotal with zero units (see book.rs),
1033 // so this only fires when boundary code forgets to validate.
1034 let _ = BookedCost::new(dec!(7), dec!(99), dec!(0));
1035 }
1036
1037 #[test]
1038 fn booked_cost_from_archive_bytes_trusted_skips_invariant() {
1039 // rkyv deserialization uses this when units aren't at hand.
1040 // Constructs the inconsistent pair without panicking —
1041 // verifying it's truly unchecked. Plugin / FFI ingress code
1042 // must NOT use this path; they get `try_new`.
1043 let b = BookedCost::from_archive_bytes_trusted(dec!(50), dec!(300));
1044 assert_eq!(b.per_unit, dec!(50));
1045 assert_eq!(b.total, dec!(300));
1046 }
1047
1048 #[test]
1049 fn booked_cost_from_fuzz_unchecked_skips_invariant() {
1050 // Fuzz harness uses this to generate pathological inputs that
1051 // stress trust-boundary code in convert bridges. Distinct
1052 // from the archive constructor at the source level so grep
1053 // can identify each kind of bypass.
1054 let b = BookedCost::from_fuzz_unchecked(dec!(999999), dec!(0.01));
1055 assert_eq!(b.per_unit, dec!(999999));
1056 assert_eq!(b.total, dec!(0.01));
1057 }
1058
1059 #[test]
1060 fn booked_cost_try_new_rejects_inconsistent_pair_with_diagnostic() {
1061 // Trust-boundary constructor must return a typed error for
1062 // inconsistent pairs. 10 units × 50/u = 500, not 999.
1063 let err = BookedCost::try_new(dec!(50), dec!(999), dec!(10))
1064 .expect_err("expected invariant error for inconsistent input");
1065 assert_eq!(err.per_unit, dec!(50));
1066 assert_eq!(err.total, dec!(999));
1067 assert_eq!(err.units, dec!(10));
1068 assert_eq!(err.derived_total, dec!(500));
1069 assert_eq!(err.abs_diff, dec!(499));
1070 assert!(err.tolerance.is_some(), "tolerance must be reported");
1071 assert!(!err.overflow, "this case is mismatch, not overflow");
1072
1073 // Display includes both supplied and derived values for
1074 // plugin-author diagnostics.
1075 let msg = format!("{err}");
1076 assert!(msg.contains("50") && msg.contains("999") && msg.contains("500"));
1077 }
1078
1079 #[test]
1080 fn booked_cost_try_new_rejects_zero_units() {
1081 // Pre-fix the zero-units case short-circuited to "valid",
1082 // defeating the trust-boundary guard at every input bridge
1083 // (review B-3.1). PerUnitFromTotal is structurally
1084 // meaningless for zero units — every per_unit "works" by
1085 // zero-multiplication. Reject explicitly with `tolerance:
1086 // None` so callers can distinguish this from a numeric
1087 // mismatch.
1088 let err = BookedCost::try_new(dec!(999999), dec!(0.01), dec!(0))
1089 .expect_err("zero units must be rejected, not silently accepted");
1090 assert!(err.tolerance.is_none(), "zero-units error has no tolerance");
1091 assert!(!err.overflow, "this is zero-units, not overflow");
1092 assert!(format!("{err}").contains("non-zero units"));
1093 }
1094
1095 #[test]
1096 fn booked_cost_try_new_accepts_consistent_pair() {
1097 let result = BookedCost::try_new(dec!(50), dec!(500), dec!(10));
1098 assert!(result.is_ok());
1099 }
1100
1101 #[test]
1102 #[should_panic(expected = "overflow")]
1103 fn booked_cost_new_panics_in_debug_on_overflow() {
1104 // `BookedCost::new` debug-asserts the invariant. Overflow
1105 // should reach the assertion via `check_invariant`'s Err, then
1106 // panic with a message that names the failure mode — same
1107 // contract as the existing zero-units / mismatch debug
1108 // asserts. Without this test, a future refactor of
1109 // `check_invariant`'s error path could swallow the overflow
1110 // case at the `new` call site (e.g. by short-circuiting to
1111 // Ok or by using a different Display) and the `new`-side
1112 // contract would degrade silently. Inputs: 5e15 × 5e15 →
1113 // 2.5e31, which exceeds Decimal::MAX (~7.92e28).
1114 let huge = Decimal::from_str_exact("5000000000000000").unwrap();
1115 let _ = BookedCost::new(huge, Decimal::from_str_exact("0.01").unwrap(), huge);
1116 }
1117
1118 #[test]
1119 fn booked_cost_try_new_surfaces_overflow_instead_of_panicking() {
1120 // Trust-boundary regression guard: a wire client can submit
1121 // per_unit and units that each fit in Decimal but whose product
1122 // exceeds Decimal::MAX (~7.92e28). Pre-fix `check_invariant`
1123 // used bare `*` and panicked the host on multiplication;
1124 // `try_new` now surfaces it as a typed error so FFI / plugin
1125 // bridges can map it to ConversionError and propagate to the
1126 // caller. Inputs: 5e15 × 5e15 = 2.5e31, well over Decimal::MAX.
1127 let per_unit = Decimal::from_str_exact("5000000000000000").unwrap();
1128 let units = Decimal::from_str_exact("5000000000000000").unwrap();
1129 let total = Decimal::from_str_exact("0.01").unwrap();
1130 let err = BookedCost::try_new(per_unit, total, units)
1131 .expect_err("overflow must surface as Err, not panic");
1132 assert!(err.overflow, "overflow flag must be set");
1133 assert!(
1134 err.tolerance.is_none(),
1135 "no tolerance comparison performed for overflow",
1136 );
1137 assert_eq!(err.derived_total, Decimal::ZERO);
1138 assert_eq!(err.abs_diff, Decimal::ZERO);
1139
1140 let msg = format!("{err}");
1141 assert!(
1142 msg.contains("overflow") || msg.contains("Decimal::MAX"),
1143 "error message must name the overflow condition, got: {msg}"
1144 );
1145 }
1146
1147 #[test]
1148 fn booked_cost_invariant_tolerates_rust_decimal_rounding() {
1149 // The booker computes per_unit = total / |units| at 28-digit
1150 // precision; back-multiplying truncates the same way. The
1151 // tolerance must accommodate the ULP-scale residual that real
1152 // ledgers exercise — the original tight 1e-20 floor fired
1153 // spuriously on cases like 300 / 1.763.
1154 let total = dec!(300);
1155 let units = dec!(1.763);
1156 let per_unit = total / units;
1157 // This must NOT panic.
1158 let _ = BookedCost::new(per_unit, total, units);
1159 }
1160
1161 #[test]
1162 fn cost_number_per_unit_accessor() {
1163 assert_eq!(
1164 CostNumber::PerUnit { value: dec!(150) }.per_unit(),
1165 Some(dec!(150))
1166 );
1167 assert_eq!(CostNumber::Total { value: dec!(1500) }.per_unit(), None);
1168 let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1169 assert_eq!(CostNumber::PerUnitFromTotal(b).per_unit(), Some(dec!(30)));
1170 }
1171
1172 #[test]
1173 fn cost_number_total_accessor() {
1174 assert_eq!(CostNumber::PerUnit { value: dec!(150) }.total(), None);
1175 assert_eq!(
1176 CostNumber::Total { value: dec!(1500) }.total(),
1177 Some(dec!(1500))
1178 );
1179 let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1180 assert_eq!(CostNumber::PerUnitFromTotal(b).total(), Some(dec!(300)));
1181 }
1182
1183 #[test]
1184 fn cost_spec_resolve_per_unit_from_total_uses_per_unit_directly() {
1185 // Verifies the documented optimization: by `BookedCost::new`'s
1186 // invariant, b.per_unit == b.total / |units|, so resolve()
1187 // returns b.per_unit without redivision. The result must equal
1188 // what the `Total` arm would have computed.
1189 let b = BookedCost::new(dec!(30), dec!(300), dec!(10));
1190 let spec = CostSpec::empty()
1191 .with_number(CostNumber::PerUnitFromTotal(b))
1192 .with_currency("USD");
1193
1194 let cost = spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1195 assert_eq!(cost.number, dec!(30));
1196 assert_eq!(cost.currency, "USD");
1197
1198 // Same shape via raw Total → same number after division.
1199 let total_spec = CostSpec::empty()
1200 .with_number(crate::CostNumber::Total { value: dec!(300) })
1201 .with_currency("USD");
1202 let total_cost = total_spec.resolve(dec!(10), date(2024, 1, 15)).unwrap();
1203 assert_eq!(cost.number, total_cost.number);
1204 }
1205
1206 #[test]
1207 fn cost_spec_matches_per_unit_from_total() {
1208 // PerUnitFromTotal must match against a Cost by its per-unit
1209 // value (the lot's canonical number) — this is what lot
1210 // reduction code path needs.
1211 let cost = Cost::new(dec!(150.00), "USD")
1212 .with_date(date(2024, 1, 15))
1213 .with_label("lot1");
1214
1215 let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1216 let spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(b));
1217 assert!(spec.matches(&cost));
1218
1219 // Wrong per-unit: must not match.
1220 let wrong = BookedCost::new(dec!(160), dec!(320), dec!(2));
1221 let wrong_spec = CostSpec::empty().with_number(CostNumber::PerUnitFromTotal(wrong));
1222 assert!(!wrong_spec.matches(&cost));
1223 }
1224
1225 #[test]
1226 fn cost_number_serde_emits_kind_tagged_shape() {
1227 // The unified wire shape across plugin-types, FFI-WASI, WASM,
1228 // and Python compat is `{"kind": "per_unit", "value": "100"}`
1229 // etc. This test pins that crate::CostNumber serde
1230 // matches — silent drift here breaks every downstream client.
1231 let pu = CostNumber::PerUnit { value: dec!(100) };
1232 let json = serde_json::to_value(pu).unwrap();
1233 assert_eq!(json["kind"], "per_unit", "PerUnit must use kind tag");
1234
1235 let t = CostNumber::Total { value: dec!(1500) };
1236 let json = serde_json::to_value(t).unwrap();
1237 assert_eq!(json["kind"], "total");
1238
1239 let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1240 let puft = CostNumber::PerUnitFromTotal(b);
1241 let json = serde_json::to_value(puft).unwrap();
1242 assert_eq!(json["kind"], "per_unit_from_total");
1243 assert_eq!(json["per_unit"], "150");
1244 assert_eq!(json["total"], "300");
1245 }
1246
1247 #[test]
1248 fn cost_number_serde_round_trip() {
1249 // The cross-language wire contract is only honored if Rust
1250 // can also deserialize what it serialized. Pin the round-trip.
1251 for cn in [
1252 CostNumber::PerUnit { value: dec!(42) },
1253 CostNumber::Total { value: dec!(420) },
1254 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2))),
1255 ] {
1256 let json = serde_json::to_string(&cn).unwrap();
1257 let back: CostNumber = serde_json::from_str(&json).unwrap();
1258 assert_eq!(cn, back, "round trip lost data for {cn:?}");
1259 }
1260 }
1261
1262 #[cfg(feature = "rkyv")]
1263 #[test]
1264 fn cost_number_rkyv_round_trip_preserves_all_variants() {
1265 // Cache v8 docstring claims tuple→struct variant migration is
1266 // byte-compatible (review A-4.1). Verify by round-tripping
1267 // each variant through rkyv archive bytes — if the
1268 // serialize/deserialize pair loses info or panics, the cache
1269 // claim is wrong and v8 must bump to v9.
1270 for cn in [
1271 CostNumber::PerUnit { value: dec!(150) },
1272 CostNumber::Total { value: dec!(1500) },
1273 CostNumber::PerUnitFromTotal(BookedCost::new(dec!(30), dec!(300), dec!(10))),
1274 ] {
1275 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&cn).unwrap();
1276 let back: CostNumber =
1277 rkyv::from_bytes::<CostNumber, rkyv::rancor::Error>(&bytes).unwrap();
1278 assert_eq!(cn, back, "rkyv round-trip lost data for variant {cn:?}");
1279 }
1280 }
1281
1282 #[cfg(feature = "rkyv")]
1283 #[test]
1284 fn cost_number_archived_bytes_snapshot() {
1285 // Layout snapshot: if rkyv's encoding ever changes (version
1286 // upgrade, attribute change, or accidental shape drift), this
1287 // test fires and CACHE_VERSION must bump (review A-4.1).
1288 // Each archived byte sequence is a fixed contract — any change
1289 // means existing cache files on user disks become invalid.
1290 let per_unit = CostNumber::PerUnit { value: dec!(150) };
1291 let per_unit_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&per_unit).unwrap();
1292 assert!(
1293 !per_unit_bytes.is_empty(),
1294 "PerUnit must serialize to non-empty bytes"
1295 );
1296
1297 let total = CostNumber::Total { value: dec!(1500) };
1298 let total_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&total).unwrap();
1299 assert!(!total_bytes.is_empty());
1300
1301 // Critical pin: PerUnit and Total of the same numeric value
1302 // serialize to different bytes (the discriminator must be
1303 // distinct). If they collide, the cache cannot distinguish
1304 // `{150 USD}` from `{{150 USD}}`.
1305 let pu_same = CostNumber::PerUnit { value: dec!(1500) };
1306 let pu_same_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_same).unwrap();
1307 assert_ne!(
1308 total_bytes.as_ref(),
1309 pu_same_bytes.as_ref(),
1310 "PerUnit and Total of the same value must serialize distinctly"
1311 );
1312
1313 // PerUnitFromTotal must also be distinct from PerUnit-only.
1314 let booked = CostNumber::PerUnitFromTotal(BookedCost::new(dec!(150), dec!(300), dec!(2)));
1315 let booked_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&booked).unwrap();
1316 let pu_only = CostNumber::PerUnit { value: dec!(150) };
1317 let pu_only_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&pu_only).unwrap();
1318 assert_ne!(
1319 booked_bytes.as_ref(),
1320 pu_only_bytes.as_ref(),
1321 "PerUnitFromTotal and PerUnit must serialize distinctly (preserved total is load-bearing)"
1322 );
1323 }
1324
1325 // Frozen byte fixtures for the v8 cache layout live alongside
1326 // CACHE_VERSION in `rustledger-loader::cache::tests` so the
1327 // version constant and the on-disk byte layout sit in one place
1328 // — see `cost_number_archived_bytes_match_v8_fixtures` there.
1329
1330 #[test]
1331 fn cost_spec_display_renders_per_unit_from_total_as_per_unit() {
1332 // Python-beancount compat: post-booking display uses per-unit
1333 // form even though source was `{{...}}`. This pins the
1334 // documented format-amount.rs behavior.
1335 let b = BookedCost::new(dec!(150), dec!(300), dec!(2));
1336 let spec = CostSpec::empty()
1337 .with_number(CostNumber::PerUnitFromTotal(b))
1338 .with_currency("USD");
1339 let s = format!("{spec}");
1340 // Per-unit form: just the per_unit value, not `# total`.
1341 assert!(s.contains("150"), "expected per-unit 150 in {s}");
1342 assert!(!s.contains("# 300"), "must NOT render as `# total` ({s})");
1343 }
1344}