diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea9da9..7ca1a1c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,13 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py + + pricing-fixtures: + name: Pricing discussion fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check_pricing_fixtures.py diff --git a/docs/proposals/pricing-commitment-arithmetic.md b/docs/proposals/pricing-commitment-arithmetic.md new file mode 100644 index 0000000..772f212 --- /dev/null +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -0,0 +1,120 @@ +# Account for seller fees after selecting one price + +Discussion wording for [#94](https://github.com/IABTechLab/OpenProposal/issues/94) +and [#115](https://github.com/IABTechLab/OpenProposal/issues/115), against draft +`44dfaaf618736219426c311ec997b6d37e94b950`. The arithmetic also exercises the +final-rate reading discussed in [#30](https://github.com/IABTechLab/OpenProposal/issues/30). +This is a proposed profile, not an adopted requirement or an OpenProposal wire +validator. The normative specification is unchanged. + +## The fee interpretation changes the available media spend + +#94 treats `pricing[]` entries as alternatives. #115 adds seller fees as extra +entries and includes them in `commitment.amount`. An extra alternative cannot +simultaneously be an additive fee. Summing all entries instead loses the choice +of cost method, currency and transaction mechanism. + +At USD 25 CPM, a USD 50,000 commitment containing a USD 2,500 production fee +leaves USD 47,500 for media: + +``` +media_spend = 50,000 - 2,500 = 47,500 USD +derived_units = 47,500 / 25 * 1,000 = 1,900,000 impressions +``` + +Two million impressions would cost USD 50,000 before the fee, or USD 52,500 +in total. Therefore #115's statement that `derived_units` is unaffected needs +revision if `amount` includes charges outside the unit rate. + +## Proposed wording for sections 3.1, 5.6 and 5.6.1 + +This wording assumes adoption of the alternative-selection rule in #94. + +`pricing[]` entries are alternative prices. A commitment MUST resolve to +exactly one entry under that rule. An implementation MUST NOT sum alternative +entries to compute the buyer's charge. + +A selected option's `delta_flat` is a one-time seller charge for that line +item commitment. It MUST NOT enter the unit rate or become another pricing +alternative. A seller fee MUST NOT be a `pricing[]` entry. A cost already +included in the unit rate MUST NOT also be added as a flat charge. Each selected option that is not +already included contributes its charge once. + +For `basis = spend`, `amount` is the seller total, on the same terms as +`agreed_rate`, including selected `delta_flat` charges (section 3.1). This does +not choose gross or net commission treatment, or decide the tax question in +#115. For a selected unit-rate method: + +``` +flat_charges = sum of applicable one-time seller charges +media_spend = commitment.amount - flat_charges +derived_units = media_spend / agreed_rate * rate_per +``` + +`rate_per` is 1,000 for CPM and CPMV, and 1 for CPC. For `basis = spend`, the +agreed rate MUST be positive. Flat charges MUST NOT exceed the spend commitment. +Zero remaining media spend yields zero informative units and still has to satisfy any +applicable minimum-unit requirement. + +For `basis = units`, the committed units remain binding. The agreed rate MAY +be zero for added-value units, and MUST NOT be negative: + +``` +media_spend = commitment.amount / rate_per * agreed_rate +seller_total = media_spend + flat_charges +``` + +`min_spend` and budget bounds apply to the seller total on the same basis as +`agreed_rate`. The selected entry's currency applies to rates, flat charges and +budget bounds. No currency conversion is implied. Informative unit estimates MUST NOT +be rounded in a way that changes the committed amount or authorizes extra spend. + +## Arithmetic inputs do not establish acceptance + +The adapter consumes resolved, field-qualified adjustments that are not already +included. Its provisional #30 calculation starts with the chosen `gross_rate`, +adds applicable `delta_cpm`, then multiplies by `(1 + delta_pct)` factors. +`0.20` means a 20 percent increase. Flat charges remain separate. + +If the caller supplies an already accepted final-rate counter, that rate replaces +the composed rate without applying option rate deltas again. The caller must +have established acceptance for the bound proposal revision and selection. +`selection_key` is an opaque fixture binding, not an acceptance protocol. + +The adapter does not evaluate floor eligibility. Every successful result carries +`floor_check: not_checked`. A posted floor is checked only for numeric shape and +nonnegativity. In particular, an explicitly accepted USD 19 rate against a posted +USD 20 floor remains USD 19 in the calculation. The floor does not veto acceptance +already established by the caller. + +How a counter resolves automatically, how selected adjustments compose the floor, +and how seller assent is authenticated remain in #30. That discussion proposes +applying rate adjustments to the base floor. This profile neither adopts that +proposal nor silently compares a final rate with an uncomposed floor. A successful +calculation never authorizes a commitment, reservation or agreed order. + +## Scope and reproduction + +``` +python3 scripts/check_pricing_fixtures.py +python3 scripts/check_pricing_fixtures.py --json +``` + +[`fixtures/pricing/cases.json`](../../fixtures/pricing/cases.json) contains fixed +expected outcomes independent of the calculator. Decimal strings and rational +outputs avoid binary floating point rounding. `"1000/3"` is an exact informative +quantity, not a proposed JSON wire type. Currency checks cover agreement and +shape, not ISO-4217 membership. The caller supplies a mechanism-eligible choice. + +The profile implements CPM, CPMV and CPC on a tax-exclusive basis only. That +fixture limit does not decide the normative tax basis. It rejects tax-inclusive +inputs, #103 print adjustments and flat credits. It cannot infer a mandatory fee +that is not represented by the resolved options. General fee objects, tax amounts, +invoice rounding and cost-method extensions need separate decisions. Impression +bounds are checked only for CPM; clicks and viewable impressions are not silently +treated as the same quantity. Unsupported cases are not declarations that an +OpenProposal document is invalid. + +If the fee wording is adopted, update the affected body sections, field +descriptions in Appendices A and B, and committed examples together. The executable +profile is supporting discussion evidence, not a replacement for those changes. diff --git a/fixtures/pricing/cases.json b/fixtures/pricing/cases.json new file mode 100644 index 0000000..958ad4b --- /dev/null +++ b/fixtures/pricing/cases.json @@ -0,0 +1,1901 @@ +{ + "profile": "pricing-discussion-3", + "cases": [ + { + "id": "cpm-no-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "seller-fee-inside-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "1900000", + "floor_check": "not_checked" + } + } + }, + { + "id": "units-add-fee-to-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "2000000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "50000", + "seller_total": "52500", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "units-total-exceeds-budget", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "2000000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "max_budget": "50000" + } + }, + "expected": { + "error": "outside_max_budget" + } + }, + { + "id": "spend-fee-does-not-increase-total", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "max_budget": "50000", + "min_budget": "50000", + "min_spend": "50000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "1900000", + "floor_check": "not_checked" + } + } + }, + { + "id": "fees-exceed-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "2499", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "fees_exceed_spend" + } + }, + { + "id": "fees-equal-spend-zero-media", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "2500", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "2500", + "media_spend": "0", + "seller_total": "2500", + "units": "0", + "floor_check": "not_checked" + } + } + }, + { + "id": "two-fees-count-once", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "data/package", + "delta_flat": "500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "3000", + "media_spend": "47000", + "seller_total": "50000", + "units": "1880000", + "floor_check": "not_checked" + } + } + }, + { + "id": "duplicate-adjustment-rejected", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "error": "duplicate_adjustment" + } + }, + { + "id": "flat-credit-out-of-scope", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "rebate", + "delta_flat": "-1" + } + ] + }, + "expected": { + "error": "unsupported_flat_credit" + } + }, + { + "id": "three-rate-deltas", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "38500", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "77/2", + "flat_charges": "0", + "media_spend": "38500", + "seller_total": "38500", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "accepted-final-counter-no-double-add", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "counter-keeps-flat-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "32500", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "2500", + "media_spend": "30000", + "seller_total": "32500", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "counter-on-changed-selection", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abcd", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "error": "stale_counter" + } + }, + { + "id": "counter-without-binding", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "error": "counter_binding_required" + } + }, + { + "id": "accepted-counter-below-posted-floor", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "19", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "19", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "30000000/19", + "floor_check": "not_checked" + } + } + }, + { + "id": "accepted-counter-at-posted-floor", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "20", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1500000", + "floor_check": "not_checked" + } + } + }, + { + "id": "percent-after-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "36000", + "currency": "USD", + "adjustments": [ + { + "id": "format", + "delta_cpm": "5" + }, + { + "id": "rights", + "delta_pct": "0.20" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "36", + "flat_charges": "0", + "media_spend": "36000", + "seller_total": "36000", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "percentage-compounds-and-excludes-flat", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "29500", + "currency": "USD", + "adjustments": [ + { + "id": "rights", + "delta_pct": "0.20" + }, + { + "id": "discount", + "delta_pct": "-0.10" + }, + { + "id": "production/build", + "delta_flat": "2500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "27", + "flat_charges": "2500", + "media_spend": "27000", + "seller_total": "29500", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "zero-percentage-factor", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "discount", + "delta_pct": "-1" + } + ] + }, + "expected": { + "error": "nonpositive_percentage_factor" + } + }, + { + "id": "percent-not-reapplied-to-counter", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "30", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "30000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + }, + { + "id": "rights", + "delta_pct": "0.20" + } + ], + "selection_key": "revision-7/cpm-usd/options-abc", + "accepted_counter": { + "rate": "30", + "selection_key": "revision-7/cpm-usd/options-abc" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "30", + "flat_charges": "0", + "media_spend": "30000", + "seller_total": "30000", + "units": "1000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "alternatives-require-choice", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "pricing_choice_required" + } + }, + { + "id": "choose-cpc-without-summing-cpm", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "pricing_id": "cpc-usd" + }, + "expected": { + "result": { + "pricing_id": "cpc-usd", + "currency": "USD", + "agreed_rate": "2", + "flat_charges": "2500", + "media_spend": "47500", + "seller_total": "50000", + "units": "23750", + "floor_check": "not_checked" + } + } + }, + { + "id": "choose-cpm-without-summing-cpc", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "cpc-rejects-cpm-delta", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "audiences/a", + "delta_cpm": "4" + }, + { + "id": "audiences/b", + "delta_cpm": "1.5" + }, + { + "id": "formats/30s", + "delta_cpm": "3" + } + ] + }, + "expected": { + "error": "incompatible_delta_cpm" + } + }, + { + "id": "cpc-does-not-treat-clicks-as-impressions", + "input": { + "pricing": [ + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_impressions": "1" + } + }, + "expected": { + "error": "unsupported_impression_bound" + } + }, + { + "id": "duplicate-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "cpm-usd" + }, + "expected": { + "error": "duplicate_pricing_id" + } + }, + { + "id": "unknown-pricing-id", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "pricing_id": "absent" + }, + "expected": { + "error": "unknown_pricing_id" + } + }, + { + "id": "currency-mismatch", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "EUR", + "adjustments": [] + }, + "expected": { + "error": "currency_mismatch" + } + }, + { + "id": "select-eur-alternative", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + }, + { + "pricing_id": "cpc-usd", + "cost_method": "CPC", + "gross_rate": "2", + "currency": "USD" + }, + { + "pricing_id": "cpm-eur", + "cost_method": "cpm", + "gross_rate": "20", + "currency": "EUR" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "EUR", + "adjustments": [], + "pricing_id": "cpm-eur" + }, + "expected": { + "result": { + "pricing_id": "cpm-eur", + "currency": "EUR", + "agreed_rate": "20", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2500000", + "floor_check": "not_checked" + } + } + }, + { + "id": "zero-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "negative-rate", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "-1", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "zero-spend", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "0", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "negative-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "-1", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "nonpositive_amount" + } + }, + { + "id": "float-not-fixture-decimal", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": 50000.0, + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "nonfinite-number", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "NaN", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "invalid_number" + } + }, + { + "id": "exact-decimal-no-binary-rounding", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0.1", + "currency": "USD", + "floor": "0" + } + ], + "basis": "spend", + "amount": "0.3", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "1/10", + "flat_charges": "0", + "media_spend": "3/10", + "seller_total": "3/10", + "units": "3000", + "floor_check": "not_checked" + } + } + }, + { + "id": "fractional-units-not-rounded-up", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "3", + "currency": "USD", + "floor": "0" + } + ], + "basis": "spend", + "amount": "1", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "3", + "flat_charges": "0", + "media_spend": "1", + "seller_total": "1", + "units": "1000/3", + "floor_check": "not_checked" + } + } + }, + { + "id": "impression-minimum-after-fees", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "2500" + } + ], + "bounds": { + "min_impressions": "2000000" + } + }, + "expected": { + "error": "outside_min_impressions" + } + }, + { + "id": "impression-maximum", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "max_impressions": "1999999" + } + }, + "expected": { + "error": "outside_max_impressions" + } + }, + { + "id": "inverted-budget-bounds", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_budget": "60000", + "max_budget": "50000" + } + }, + "expected": { + "error": "inverted_bounds" + } + }, + { + "id": "unknown-extension-is-not-implemented", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "seller.example:engagement", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "flat-cost-method-is-not-a-flat-fee", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "FLAT", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "unsupported_cost_method" + } + }, + { + "id": "tax-inclusive-needs-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "tax_inclusive": true + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "print-adjustments-need-separate-rule", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [ + { + "id": "agency", + "value_pct": "-15" + } + ] + }, + "expected": { + "error": "unsupported_field" + } + }, + { + "id": "cpmv-uses-thousand-viewable-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "cpmv-does-not-imply-all-impressions", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPMV", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [], + "bounds": { + "min_impressions": "1" + } + }, + "expected": { + "error": "unsupported_impression_bound" + } + }, + { + "id": "posted-floor-does-not-establish-eligibility", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "30" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "agreed_rate": "25", + "flat_charges": "0", + "media_spend": "50000", + "seller_total": "50000", + "units": "2000000", + "floor_check": "not_checked" + } + } + }, + { + "id": "negative-posted-floor-is-malformed-metadata", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "-1" + } + ], + "basis": "spend", + "amount": "50000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "error": "negative_floor" + } + }, + { + "id": "units-fee-inclusive-min_spend-11000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "11000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_spend-11500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "11500" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_spend-12000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_spend": "12000" + } + }, + "expected": { + "error": "outside_min_spend" + } + }, + { + "id": "units-fee-inclusive-min_budget-11000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "11000" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_budget-11500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "11500" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "25", + "flat_charges": "1500", + "media_spend": "10000", + "seller_total": "11500", + "units": "400000" + } + } + }, + { + "id": "units-fee-inclusive-min_budget-12000", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "bounds": { + "min_budget": "12000" + } + }, + "expected": { + "error": "outside_min_budget" + } + }, + { + "id": "zero-rate-added-value-units-fee-0", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "0", + "media_spend": "0", + "seller_total": "0", + "units": "400000" + } + } + }, + { + "id": "zero-rate-added-value-units-fee-1500", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "0", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "1500", + "media_spend": "0", + "seller_total": "1500", + "units": "400000" + } + } + }, + { + "id": "units-accepted-counter-zero", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "selection_key": "accepted-free-units", + "accepted_counter": { + "rate": "0", + "selection_key": "accepted-free-units" + } + }, + "expected": { + "result": { + "pricing_id": "cpm-usd", + "currency": "USD", + "floor_check": "not_checked", + "agreed_rate": "0", + "flat_charges": "1500", + "media_spend": "0", + "seller_total": "1500", + "units": "400000" + } + } + }, + { + "id": "units-accepted-counter-negative", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "25", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ], + "selection_key": "accepted-free-units", + "accepted_counter": { + "rate": "-1", + "selection_key": "accepted-free-units" + } + }, + "expected": { + "error": "nonpositive_rate" + } + }, + { + "id": "negative-rate-units", + "input": { + "pricing": [ + { + "pricing_id": "cpm-usd", + "cost_method": "CPM", + "gross_rate": "-1", + "currency": "USD", + "floor": "20" + } + ], + "basis": "units", + "amount": "400000", + "currency": "USD", + "adjustments": [ + { + "id": "production/build", + "delta_flat": "1500" + } + ] + }, + "expected": { + "error": "nonpositive_rate" + } + } + ] +} diff --git a/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py new file mode 100644 index 0000000..a943f76 --- /dev/null +++ b/scripts/check_pricing_fixtures.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Executable discussion profile for #30, #94 and #115, not a wire validator.""" + +import argparse +import json +import re +from fractions import Fraction +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DECIMAL = re.compile(r"-?\d{1,18}(?:\.\d{1,9})?\Z") + + +class ProfileError(ValueError): + pass + + +def require(condition, code): + if not condition: + raise ProfileError(code) + + +def number(value): + # Decimal strings make these test vectors independent of binary floats. + require(isinstance(value, str) and DECIMAL.fullmatch(value), "invalid_number") + return Fraction(value) + + +def shape(obj, required, optional=()): + require(isinstance(obj, dict), "invalid_shape") + require(set(required) <= obj.keys(), "missing_field") + require(obj.keys() <= set(required) | set(optional), "unsupported_field") + + +def resolve(case): + """Resolve already selected adjustments using exact rational arithmetic. + + Input names are fixture adapter keys, not proposed OpenProposal fields. + Only a caller that has established acceptance may supply accepted_counter. + """ + shape(case, ("pricing", "basis", "amount", "currency", "adjustments"), + ("pricing_id", "accepted_counter", "selection_key", "bounds")) + rows = case["pricing"] + require(isinstance(rows, list) and rows, "missing_pricing") + for row in rows: + shape(row, ("pricing_id", "cost_method", "gross_rate", "currency"), ("floor",)) + require(isinstance(row["pricing_id"], str) and row["pricing_id"], "invalid_pricing_id") + ids = [row["pricing_id"] for row in rows] + require(len(ids) == len(set(ids)), "duplicate_pricing_id") + require(len(rows) == 1 or "pricing_id" in case, "pricing_choice_required") + chosen = case.get("pricing_id", ids[0]) + require(chosen in ids, "unknown_pricing_id") + row = rows[ids.index(chosen)] + method = row["cost_method"] + require(isinstance(method, str), "invalid_shape") + method = method.upper() + require(method in ("CPM", "CPMV", "CPC"), "unsupported_cost_method") + require(isinstance(row["currency"], str) and re.fullmatch(r"[A-Z]{3}", row["currency"]), + "invalid_currency") + require(case["currency"] == row["currency"], "currency_mismatch") + require(case["basis"] in ("spend", "units"), "unsupported_basis") + amount = number(case["amount"]) + require(amount > 0, "nonpositive_amount") + rate = number(row["gross_rate"]) + require(rate >= 0, "nonpositive_rate") + cpm_delta, flat, factors = Fraction(0), Fraction(0), [] + adjustments = case["adjustments"] + require(isinstance(adjustments, list), "invalid_shape") + adjustment_ids = set() + for adjustment in adjustments: + shape(adjustment, ("id",), ("delta_cpm", "delta_pct", "delta_flat")) + key = adjustment["id"] + require(isinstance(key, str) and key, "invalid_adjustment_id") + require(key not in adjustment_ids, "duplicate_adjustment") + adjustment_ids.add(key) + require(len(adjustment) > 1, "empty_adjustment") + if "delta_cpm" in adjustment: + require(method in ("CPM", "CPMV"), "incompatible_delta_cpm") + cpm_delta += number(adjustment["delta_cpm"]) + if "delta_flat" in adjustment: + fee = number(adjustment["delta_flat"]) + require(fee >= 0, "unsupported_flat_credit") + flat += fee + if "delta_pct" in adjustment: + factor = 1 + number(adjustment["delta_pct"]) + require(factor > 0, "nonpositive_percentage_factor") + factors.append(factor) + rate += cpm_delta + for factor in factors: + rate *= factor + if "accepted_counter" in case: + counter = case["accepted_counter"] + shape(counter, ("rate", "selection_key")) + require(isinstance(case.get("selection_key"), str) and case["selection_key"], + "counter_binding_required") + require(counter["selection_key"] == case["selection_key"], "stale_counter") + # The accepted counter is the final composed rate, not a new base rate. + rate = number(counter["rate"]) + require(rate > 0 if case["basis"] == "spend" else rate >= 0, "nonpositive_rate") + floor = number(row.get("floor", "0")) + require(floor >= 0, "negative_floor") + # A posted automatic-resolution floor cannot veto explicit acceptance. + # This arithmetic adapter does not choose or evaluate floor semantics. + rate_per = 1000 if method in ("CPM", "CPMV") else 1 + if case["basis"] == "spend": + total = amount + require(total >= flat, "fees_exceed_spend") + media = total - flat + units = media / rate * rate_per + else: + units = amount + media = units / rate_per * rate + total = media + flat + bounds = case.get("bounds", {}) + shape(bounds, (), ("min_budget", "max_budget", "min_spend", "min_impressions", "max_impressions")) + values = {key: number(value) for key, value in bounds.items()} + require(all(value >= 0 for value in values.values()), "negative_bound") + for low, high in (("min_budget", "max_budget"), ("min_impressions", "max_impressions")): + require(low not in values or high not in values or values[low] <= values[high], + "inverted_bounds") + require("min_spend" not in values or "max_budget" not in values or + values["min_spend"] <= values["max_budget"], "inverted_bounds") + for name, value in values.items(): + actual = total + if name.endswith("impressions"): + require(method == "CPM", "unsupported_impression_bound") + actual = units + require(actual >= value if name.startswith("min_") else actual <= value, + "outside_" + name) + return {"pricing_id": chosen, "currency": row["currency"], "floor_check": "not_checked", + **{key: str(value) for key, value in { + "agreed_rate": rate, "flat_charges": flat, "media_spend": media, + "seller_total": total, "units": units}.items()}} + + +def evaluate(case): + try: + return {"result": resolve(case)} + except ProfileError as error: + return {"error": str(error)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="emit per-vector evidence") + args = parser.parse_args() + corpus = json.loads((ROOT / "fixtures/pricing/cases.json").read_text()) + results = [] + for vector in corpus["cases"]: + actual = evaluate(vector["input"]) + results.append({"id": vector["id"], "passed": actual == vector["expected"], + "actual": actual, "expected": vector["expected"]}) + passed = sum(result["passed"] for result in results) + if args.json: + print(json.dumps({"profile": corpus["profile"], "passed": passed, + "total": len(results), "cases": results}, indent=2)) + else: + for result in results: + print(("PASS " if result["passed"] else "FAIL ") + result["id"]) + if not result["passed"]: + print(json.dumps(result, indent=2)) + print(f"{passed}/{len(results)} pricing discussion vectors pass") + return int(passed != len(results)) + + +if __name__ == "__main__": + raise SystemExit(main())