Quantity pricing rules
/docs/pricingcurrentEN· проверено 2026-08-22
Резюме по-русски.pricing_rulesзадаёт фиксированную коммерческую цену для конкретного товара или варианта при достижении количества. Это не акция: победившее правило выставляетpromotion_locked=true, поэтому скидки, промокоды, подарки и их бюджеты такую строку не меняют. Себестоимость, вознаграждение автору и реферальная выплата сюда не записываются — это будущие снимки финансового учёта рядом с транзакцией.
Status: current · public quantity pricing is active on storefront reads, cart, guest quote and checkout · Verified: 2026-08-22, resolver/unit tests, PostgreSQL up/down/up, public-surface/tenant/cart/order/receipt/lock integration tests · Owner: pricing line (Track: pricing) Serves: GET /docs/pricing
1. What exists now
The API can create, list, read, edit, explicitly activate/deactivate, soft-delete, preview and atomically mutate quantity rules. A newly created single or generic batch rule is always inactive even if a client sends active:true. Editing cannot change active state; use the explicit activation endpoint with the current expected_version.
An active public rule is applied at quantity=1 by GetProduct, GetProductBySlug, GetProducts, variants_info, wishlist, marketplace and moderation projections. The same resolver uses the real quantity in GetCart and QuoteCart; checkout repeats the calculation in its transaction. SEO, JSON-LD and vz-* HTML consume those normalized product DTOs and do not run a browser-side resolver. A matching line returns catalog_unit_price, price_source=pricing_rule, promotion_locked=true, pricing_min_quantity and pricing_quantity_scope. Rule identity and internal names are deliberately absent from buyer DTOs.
Those product surfaces also return quantity_prices: a sorted public ladder of future quantity thresholds. Each item contains only min_quantity, unit_price and quantity_scope. It is not a serialized PricingRule; the server resolves the winner anew at every live threshold and omits points that do not change the effective result. This lets the card truthfully show “from 30 pcs. — 95 ₽ / pc.” before the buyer changes cart quantity, without leaking rule id/code/name, future customer groups or administrative metadata. Configuration products never expose this ladder.
ProductPrice uses the analogous catalog_price field. Variant projections carry the same safe fields. The raw nullable ProductVariant.price endpoint is owner-only and remains an editor projection, not a buyer price.
GET /pricing-rules
GET /pricing-rules/{id}
POST /pricing-rules
PUT /pricing-rules/{id}
POST /pricing-rules/{id}/active
DELETE /pricing-rules/{id}
POST /pricing-rules/preview
POST /pricing-rules/batch
PUT /pricing-rules/collections/{collection_code}
POST /pricing-rules/collections/{collection_code}/activeEvery endpoint is private to the current token company. A foreign id returns 404, not a confirmation that the rule exists.
Generic batch upsert accepts create (id=0, expected_version=0) and update items and commits all of them or none. Collection replace treats items as the complete desired collection; expected_versions must exactly describe the complete current collection, including rows that will be removed. It can publish the desired collection with active=true in the same commit. Collection activation likewise compares the complete pre-image before changing any row.
All three bulk calls require an idempotency_key. Repeating the same request returns the stored result with replayed=true; reusing the key for different content fails with PRICING_IDEMPOTENCY_KEY_REUSED. Validation of tenant targets, currency, configuration exclusion and the complete future monotonic scale happens before writes. A buyer therefore observes either the old or the new commercial price list, never a partly imported one.
2. Separate price selection from promotions
The calculation is deliberately two-stage:
catalog / variant / combo server price
↓
best applicable pricing_rule → fixed unit price + promotion_locked
↓
promotion engine (skips locked lines completely)A pricing difference is not written to discount_total, does not consume a promotion campaign budget and does not create a coupon redemption. This is how reports can later distinguish “sold at the wholesale tier” from “gave a promotion discount”.
Configuration products are excluded. Their price is a trusted configuration snapshot and a pricing rule targeting one is rejected with CONFIGURATION_PRICING_RULE_NOT_ALLOWED.
3. Rule fields and v1 boundary
product_id is required; variant_id narrows the target. min_quantity is 1..9999 and unit_price is a non-negative integer in the shop currency. quantity_scope=line checks one exact cart line. quantity_scope=product sums non-configuration lines of the same product across variants; a variant-targeted rule still changes only that variant line.
MVP-A accepts audience=all. The schema reserves audience=customer_groups for MVP-B, but the API rejects it until store-local customers, group memberships and their private read projection are installed. These are buyer groups, not staff/organisation roles.
v1 targets exact products or variants and uses fixed prices. It intentionally does not add category-wide percentages such as “all chairs -20%”; promotions already own formula/category matching and duplicating it here would create a second conflict engine.
4. Deterministic winner
All applicable rules are ordered by:
- lowest
unit_price; - variant target before product target;
- larger
min_quantity; - customer-group audience before public audience (used after MVP-B);
- larger
priority; - lower rule id.
Priority therefore cannot make the buyer pay more than another applicable fixed price. For one target/audience/scope, a higher quantity may not increase the price; writes that break the scale fail with PRICING_SCALE_MUST_NOT_INCREASE.
PreviewPricingRules (POST /pricing-rules/preview) hydrates the current product/variant/combo price from the server. The request never supplies money. Its response explains every candidate with a stable reason such as matched, below_min_quantity, variant_mismatch, inactive_or_outside_period or configuration_excluded.
5. API-token and webhook boundary
Rules require dedicated sensitive prod scopes:
pricing:read— list/read/preview;pricing:write— create/edit/activate/delete and atomic batch/collection operations.
catalog:* and promotions:* do not grant pricing access. Only an owner/admin may issue either pricing scope. Dev-contour PATs cannot read or write the live commercial rule set.
Webhooks are notifications, not an alternate write API. Events pricing_rule.changed and pricing_rule.activated contain only company_id, rule id, action and version. They omit fixed prices, names, audiences and future group memberships. Subscribing to commercial events is an owner/admin operation; an integration reads the object separately with a live pricing:read PAT.
6. Checkout, order snapshot and accounting direction
Checkout takes a shared pricing lock and stores nullable order_items.pricing_snapshot in the same commit as the order, stock movement and promotion redemption. The internal snapshot contains catalog/resolved amounts, source, rule id/code/name/version, quantity basis and promotion_locked; buyer order APIs return only its safe projection. Existing orders remain unchanged.
When at least one rule applies, ConfirmCart.expected_total is mandatory. Missing confirmation fails with PRICE_CONFIRMATION_REQUIRED; a different amount fails with PRICE_CHANGED. The protobuf field is optional so a real zero total is distinguishable from an old client that omitted it.
Locked lines remain payable and participate in stock movements, but are excluded from product/bundle/order/key/gift and issue-coupon conditions. Order discount allocation in fiscal receipts uses the saved lock mask, so no part of a promotion is silently moved onto a fixed-price line.
Cost of materials, price floors, author/referral amounts, fees and payouts are not columns of pricing_rules. A later accounting subsystem attaches its own versioned cost snapshots and immutable transaction breakdown to the completed order. That separation lets one report assemble the whole money flow without letting internal cost data leak through buyer pricing APIs.
7. Public-reference boundaries
Not every number near a product is a checkout candidate. GiftPreview.value, combo component base_price/effective_price and owner-only raw variant values are explicitly reference/editor data. A pricing rule applies to the final derived combo line, never recursively to its components. Product list min_price/max_price and sort=price retain their established catalog-price query semantics in MVP-A; quantity tiers are resolved on the returned cards and at cart quantity. Changing pagination/filter semantics requires a separate materialized buyer-price index and is not implemented implicitly in R5.
8. Local visual demo
After the W5 backend and storefront are running, create or refresh the dedicated demo shop with:
./tools/pricing/seed-demo-store.shThe script creates pricing-demo, four public products and three kinds of surface: a two-step ladder (including the requested threshold from 30), a different two-step ladder, a product-scope threshold and a control product with no special price. It writes credentials and URLs to .w5-stand/pricing-demo.json and asserts the public API ladder before finishing.
Исходник: https://api.vizen.shop/docs/pricing