Moving portrait pricing into OCaml, Phases 2 and 3: Matching Behavior and Validating Inputs
An article plan for a concise Engineering Lab update and companion LinkedIn draft, grounded in the supplied implementation, tests, documentation, and author context.
Published:
Updated:
Four failing comparison cases gave this OCaml experiment its next direction: correct the pricing mismatches, then define which inputs the domain should accept. Those are separate responsibilities, and the next two phases address each explicitly.
From four mismatches to explicit behavior
In Phase 1, I sketched a reusable OCaml pricing domain. Phases 2 and 3 develop the highlighted rules below; production still uses TypeScript.
The initial characterization tests exposed three percentage-rounding mismatches and an incorrect shipping charge for Germany (see what the first tests found in Phase 1). All four are now fixed. Phase 2 completes the covered pricing behavior. Phase 3 establishes validated inputs and explicit arithmetic failures, including deliberate differences from what the TypeScript helpers allow.
Phase 2 — Complete pricing behavior and fix the characterized mismatches
The clearest regression involved five cents. An A4 base price of 14000 cents, plus a five-cent adjustment, with 10% off originally returned 12605 in OCaml. TypeScript returned 12604. The current OCaml calculation also returns 12604.
The mechanism matters: calculate the discount as cents × (percent / 100), round that discount amount, then subtract it. For this example, the 1400.5-cent discount rounds to 1401. Preserving TypeScript’s floating-point operation order also matters near rounding boundaries. Monetary values themselves remain integer cents.
I made Pricing.apply_discount public and shared it with commission pricing. Pricing.calculate first combines the base price, charges for people beyond the first, optional complexity, and adjustments; it then passes the subtotal to that function. A standalone or shop price can use the same discount behavior without constructing a commission. Both entry points return results that callers must handle.
The domain now represents all nine advertised formats: A8, mini-magnet, A7, A6, square-medium, A5-sketchbook, A5, A4, and A3. Prices remain caller-supplied. The tests contain a catalogue snapshot; the library does not maintain a synchronized production catalogue.
Pricing.all_inclusive is a separate estimate: base price, one additional-person charge, complexity, maximum estimated adjustment, and optional automatic shipping. It defaults to shipping included for the EU and applies no discount. The maximum adjustment is an estimate input, not a cap on actual commission adjustments. This function does not calculate a cart total.
Automatic shipping now matches the site’s configured rates: Germany 0, EU 1500, US 3000, and Ukraine 2500 cents. Lookup is case-insensitive; missing or unknown regions use a caller-supplied fallback, with 1500 as the production default. Explicit manual shipping overrides the automatic rate, including zero. That zero case establishes helper behavior, not acceptance by the admin form schema. Shipping remains outside Pricing.calculate and its discount.
let apply_discount price discount =
match discount with
| None -> Ok price
| Some (Discount.Fixed amount) -> Money.subtract price amount
| Some (Discount.Percent percent) ->
let cents = Money.to_cents price in
(* Preserve TS operation order and round the discount, not the final price.
Comparing the fraction avoids floor (amount + 0.5) double rounding. *)
let amount = float_of_int cents *. (percent /. 100.) in
let whole = floor amount in
let rounded = whole +. (if amount -. whole >= 0.5 then 1. else 0.) in
let* discount_amount = Money.of_float_cents rounded in
Money.subtract price discount_amountocaml-pricing-domain/lib/pricing.ml:8–20
Phase 3 — Validate domain inputs and make arithmetic failures explicit
Matching valid prices left another question: what should happen when a caller supplies values the domain cannot meaningfully use?
Money.t is now opaque and represents nonnegative whole cents. Its constructors validate inputs at runtime: 0.5 cents, negative amounts, NaN, infinity, and values beyond the supported range return typed errors instead of being silently rounded or truncated. The upper bound is the smaller of OCaml’s max_int and JavaScript’s maximum safe integer, 9007199254740991. This is a representation limit, not a business price ceiling.
The same separation applies to portrait subjects. People_count.of_int and People_count.of_float require a positive whole number within the supported range; zero and 1.5 fail. Pricing.calculate now requires the opaque People_count.t. The constructor establishes the invariant at runtime, and the interface prevents ordinary callers from substituting an arbitrary integer afterward. The compiler does not establish every business rule.
Percentages have a different policy: Discount.percent accepts finite values from 0 through 100, including 12.5%, and returns a result. Fractional percentages are meaningful even though fractional cents and fractional people are rejected.
An excessive fixed discount makes the policy distinction concrete. Previously, OCaml clamped it to zero; the TypeScript helper can return a negative price. I now return Money.Negative_result. This is an intentional domain decision, separate from matching covered valid prices.
Validated inputs can still overflow when combined. Addition checks the remaining capacity before adding; multiplication checks the bound before multiplying; subtraction rejects a negative result. Pricing propagates these calculation errors through typed results. Every intermediate subtotal must fit—even when a later 100% discount could reduce the final amount to zero.
These checks concern subjects within one portrait and unit-price arithmetic. Product quantities, empty orders, cart totals, and once-per-order shipping composition remain outside the implemented OCaml domain.
(** Positive whole people counts, bounded by OCaml int and JS safe integers. *)
type t
type error = Non_positive | Too_large | Non_integer | Non_finite
val of_int : int -> (t, error) result
val of_float : float -> (t, error) result
val to_int : t -> intocaml-pricing-domain/lib/people_count.mli:1–6
let add a b =
if b > max_cents - a then Error Overflow else Ok (a + b)
let subtract a b =
if b > a then Error Negative_result else Ok (a - b)
let multiply money amount =
if amount < 0 then Error Negative_multiplier
else if money <> 0 && amount > max_cents / money then Error Overflow
else Ok (money * amount)ocaml-pricing-domain/lib/money.ml:29–38
What the evidence demonstrates, and the next step
My local verification on 2026-09-18 passed all 93 OCaml tests: 7 original examples, 56 characterization cases, and 30 boundary tests. The three selected TypeScript suites passed 82 tests, including currency-presentation tests.
Those totals describe separate suites, not 93 one-to-one comparisons. Matching explicit vectors demonstrate parity for covered valid inputs; boundary tests demonstrate intentional rejection policies. Neither establishes exhaustive parity or end-to-end checkout behavior.
The next step is a separate JSON adapter and local comparison runner. A proposed adapter contract and a checkout-composition source review prepare that work, but no decoder, HTTP service, website integration, or deployment has been implemented here. Keeping price comparisons and rejected-input differences distinct will carry the same separation into that next step.
Companion LinkedIn draft direction
Matching valid prices and rejecting invalid inputs are separate engineering responsibilities.
I have been continuing a small OCaml experiment with the pricing rules from my portrait commission website. The live site still calculates prices in TypeScript.
Phase 2 fixed the four original comparison failures and expanded formats, estimates, and shipping behavior. One regression was only a cent: an A4 base of 14000 cents plus a five-cent adjustment, with 10% off, originally returned 12605 in OCaml. It now matches TypeScript’s 12604 by rounding the discount amount before subtraction.
Phase 3 added validated money and portrait-subject counts, fractional percentages, and checked arithmetic. A fixed discount larger than the price now returns an error—an intentional difference from the TypeScript helper’s negative result.
My latest local verification passed 93 OCaml tests and 82 TypeScript tests. These are separate suites: matching vectors support covered valid prices, while boundary tests support rejection policies. They do not establish exhaustive or checkout parity.
Next comes a separate JSON adapter and local comparison runner. For now, the progress is in the domain rules and the evidence supporting them.