# Quantity pricing rules

> **Резюме по-русски.** `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.

```text
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}/active
```

Every 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:

```text
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:

1. lowest `unit_price`;
2. variant target before product target;
3. larger `min_quantity`;
4. customer-group audience before public audience (used after MVP-B);
5. larger `priority`;
6. 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:

```bash
./tools/pricing/seed-demo-store.sh
```

The 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.
