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..4e8ac39 --- /dev/null +++ b/docs/proposals/pricing-commitment-arithmetic.md @@ -0,0 +1,117 @@ +# 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 solely to make it additive. 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 total pre-tax amount payable to the seller +for this line item, including applicable seller flat charges. It excludes +amounts payable by the buyer to other parties. 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. 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: + +``` +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 pre-tax +basis. 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 only. It rejects unsupported 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..94c6ed8 --- /dev/null +++ b/fixtures/pricing/cases.json @@ -0,0 +1,1529 @@ +{ + "profile": "pricing-discussion-2", + "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" + } + } + ] +} diff --git a/scripts/check_pricing_fixtures.py b/scripts/check_pricing_fixtures.py new file mode 100644 index 0000000..44f3fc2 --- /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, "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())