Online Store (sale)
sale owns cart (basket), orders, payments, shipments, discounts, statuses, and history. Product master data, prices, stock live in catalog + iblock. Baseline: main 23.0+.
\Bitrix\Main\Loader::includeModule('sale');
\Bitrix\Main\Loader::includeModule('catalog'); // products, prices, stock, reservation
Choosing the API
| Task |
API |
| Create/change basket, order, payment, shipment |
Object model Bitrix\Sale\* (validates, saves collections, fires events, writes history) |
| Lists, reports, aggregates |
ORM Bitrix\Sale\Internals\*Table (OrderTable, BasketTable, PaymentTable, ShipmentTable) — read-only for order data |
| Settings/dictionaries via code |
Profile ORM: PersonTypeTable, OrderPropsTable, StatusTable (+StatusLangTable), OrderPropsGroupTable — writes allowed |
| Pick a configured service |
Managers: PaySystem\Manager, Delivery\Services\Manager, Cashbox\Manager/CheckManager, Services\Company\Manager, DiscountCouponsManager |
| Operations with no full D7 replacement |
Legacy CSale*: CSaleOrder::CanUser*() (rights), CSaleOrderChange (history read), CSaleDiscount::Add/Update (cart rules), CSaleOrderUserProps (buyer profiles), CSaleUserAccount (account balance), CSaleOrderTax (tax rows) |
Never change an order via OrderTable::update() or create payments/shipments as raw ORM rows — collections, recalcs, events, and history desync. Never Order::load() in a loop for a list — use OrderTable::getList() / Order::getList(). Don't mix legacy CSale* writes with a loaded Order object in memory.
FUSER (Cart Owner)
Anonymous and authorized carts are keyed by FUSER (Bitrix\Sale\Fuser), not USER_ID.
$fuserId = Fuser::getId(); // creates if missing
$fuserId = Fuser::getId(true); // skip create → null if none
$fuserId = Fuser::getIdByUserId($userId); // false if cannot resolve/create
USER_ID (site account, required on saved order) and FUSER_ID (basket owner) are different — don't substitute one for the other.
Basket
<?php declare(strict_types=1);
use Bitrix\Catalog\Product\Basket as CatalogBasket;
use Bitrix\Sale\Basket;
use Bitrix\Sale\Basket\RefreshFactory;
use Bitrix\Sale\Fuser;
$basket = Basket::loadItemsForFUser(Fuser::getId(), $siteId); // only rows with ORDER_ID = null
// Preferred for catalog products: sets module, provider, and product data itself
$r = CatalogBasket::addProductToBasket($basket, ['PRODUCT_ID' => $productId, 'QUANTITY' => 1], ['SITE_ID' => $siteId]);
// merges into an existing row by default; pass ['USE_MERGE' => 'N'] as 4th arg for a separate row
// Manual alternative:
$item = $basket->createItem('catalog', $productId);
$item->setFields(['QUANTITY' => 1, 'PRODUCT_PROVIDER_CLASS' => CatalogBasket::getDefaultProviderName()]);
$basket->refresh(RefreshFactory::createSingle($item->getBasketCode())); // provider fills PRICE/CURRENCY/NAME/VAT/weight
$result = $basket->save(); // only for a basket NOT bound to an order
- Don't set
PRICE/CURRENCY for catalog products — the provider does. Own pricing: CUSTOM_PRICE => 'Y' + PRICE + CURRENCY.
- With SKUs put the offer ID in
PRODUCT_ID, never the parent. Verify the element is a product (Bitrix\Catalog\ProductTable) before adding.
- Basket of a saved order: get via
$order->getBasket(), save via Order::save() — never loadItemsForFUser() / $basket->save() for it.
- Before order creation:
$basket->refresh() (refreshData() is deprecated), then $basket->getOrderableItems() — separate basket with only purchasable, non-delayed items.
- Item properties:
$item->getPropertyCollection()->createItem() / redefine(). Prices: getPrice(), getBasePrice(), getPriceWithVat(), getDiscountPrice().
- Pre-order discounts preview:
Discount::buildFromBasket($basket, new Discount\Context\Fuser($basket->getFUserId())) → calculate() → $basket->applyDiscount($data['BASKET_ITEMS']). Never for an order-bound basket.
Order Create (Pipeline)
Order of operations matters: basket → order → person type → basket in → properties → shipment → delivery calc → payment → doFinalAction(true) → sync payment SUM → re-check restrictions → save().
<?php declare(strict_types=1);
use Bitrix\Sale\Delivery\Services\Manager as DeliveryManager;
use Bitrix\Sale\Order;
use Bitrix\Sale\PaySystem\Manager as PaySystemManager;
use Bitrix\Sale\Services\Base\RestrictionManager;
$order = Order::create($siteId, $userId); // currency: site's, else base
$order->setPersonTypeId($personTypeId); // BEFORE getPropertyCollection(); not validated vs site
$order->setBasket($orderableBasket); // new (unsaved) order only
// Properties (set depends on person type)
$prop = $order->getPropertyCollection()->getItemByOrderPropertyCode('PHONE');
$prop?->setValue($phone); // each setValue returns Result
// Shipment: create user shipment, bind basket items, pick allowed delivery
$shipment = $order->getShipmentCollection()->createItem(); // system shipment exists already — never assign it a service
foreach ($order->getBasket() as $basketItem) {
$shipmentItem = $shipment->getShipmentItemCollection()->createItem($basketItem);
$shipmentItem->setQuantity($basketItem->getQuantity());
}
$deliveries = DeliveryManager::getRestrictedObjectsList($shipment);
$shipment->setDeliveryService($deliveries[$deliveryId] ?? throw new \RuntimeException('delivery unavailable'));
$order->getShipmentCollection()->calculateDelivery();
// Payment: create, preliminary SUM, pick allowed pay system
$payment = $order->getPaymentCollection()->createItem();
$payment->setField('SUM', $order->getPrice());
$allowed = PaySystemManager::getListWithRestrictions($payment, RestrictionManager::MODE_CLIENT);
isset($allowed[$paySystemId]) or throw new \RuntimeException('pay system unavailable');
$payment->setPaySystemService(PaySystemManager::getObjectById($paySystemId));
$order->doFinalAction(true); // discounts, taxes, totals — check Result
$payment->setField('SUM', $order->getPrice()); // sync after final calc
// re-check getRestrictedObjectsList / getListWithRestrictions here — totals may change availability
$saveResult = $order->save(); // check isSuccess() AND getWarningMessages()
$orderId = $saveResult->getId();
Payment and user shipment are optional at first save (digital goods, deferred flows) — skip those blocks; add later on the loaded order. Idempotency for integrations: store operation key yourself (XML_ID is not unique-constrained). Load later: Order::load($id), Order::loadByAccountNumber($number), Order::loadByFilter([...]); lock while editing with Order::lock()/isLocked()/unlock().
Order Update
Work on one loaded object, save once. After a change decide what to rerun:
| Change |
calculateDelivery |
doFinalAction(true) |
sync unpaid payments SUM |
| Status, cancel, mark, comment, tracking, allow-delivery |
– |
– |
– |
| Location/address in restrictions |
yes |
yes |
if price changed |
| Basket items/quantity; delivery service/cost; shipment removal |
yes |
yes |
if price changed |
| Coupon/discount/tax data |
if delivery affected |
yes |
if price changed |
- Quantity down: reduce
ShipmentItem::setQuantity() first, then BasketItem::setField('QUANTITY'); up: basket first, then shipment. Then refresh the item, recalc, save.
- Cancel via
setField('CANCELED', 'Y') (+ REASON_CANCELED); blocked while a paid payment or shipped shipment exists. Order::delete() is a service-only hard delete — never use for customer refusal.
PERSON_TYPE_ID change is a migration (property values are not remapped). CURRENCY/USER_ID are not changeable via setField(). Don't write SUM_PAID/PAYED directly.
Order Properties
Setting (OrderPropsTable, bound to a person type; ENTITY_TYPE ORDER/SHIPMENT) vs value in an order (PropertyValueCollection). Create settings via OrderPropsGroupTable::add() + OrderPropsTable::add() in migrations, never during checkout.
- Find values:
getItemByOrderPropertyCode() (first match), getItemByOrderPropertyId(), by role: getDeliveryLocation(), groups via getGroups().
LOCATION takes the internal location code, not a name. ENUM takes variant VALUE (options via $propertyValue->getPropertyObject()->getOptions()); MULTIPLE=Y takes an array. Files/forms: PropertyValueCollection::setValuesFromPost($_POST, $_FILES) + verify().
- Required check before save: iterate collection,
isRequired() + checkRequiredValue().
- Values save with
Order::save() only; never write OrderPropsValueTable directly.
Statuses, Permissions
- Order:
STATUS_ID, initial N, final F, class Bitrix\Sale\OrderStatus. Shipment: own STATUS_ID, DN→DF, class DeliveryStatus. Dictionary StatusTable (TYPE_ORDER/TYPE_SHIPMENT) + StatusLangTable names.
- Allowed transitions for a user:
OrderStatus::getAllowedUserStatuses($userId, $currentStatusId); operations per status: getStatusesUserCanDoOperations(), canGroupDoOperations() (operations: view, update, delete, cancel, mark, payment, delivery, deduction, from, to).
- Object API does not check rights. Before acting on a user request check the concrete order via legacy
CSaleOrder: CanUserViewOrder(), CanUserUpdateOrder() (pass 0, $groups, $siteId for create), CanUserCancelOrder(), CanUserChangeOrderStatus(), CanUserChangeOrderFlag($id, 'PERM_PAYMENT'|'PERM_DELIVERY'|'PERM_DEDUCTION', $groups), CanUserDeleteOrder(). Check view rights before Order::load().
- Module levels:
D denied, P company binding, U order processing (still needs site + status-task grants), W full.
- History: written by
OrderHistory on save; read via legacy CSaleOrderChange::GetList() (@TYPE => ['ORDER_STATUS_CHANGED', ...]).
Events
Register via EventManager in init.php. Key ones: OnSaleOrderBeforeSaved (may modify/deny), OnSaleOrderSaved (IS_NEW, IS_CHANGED; result ignored), deferred after save: OnSaleStatusOrderChange (VALUE/OLD_VALUE), OnSaleOrderPaid, OnSaleOrderCanceled, OnSaleStatusShipmentChange, OnShipmentDeducted, OnShipmentAllowDelivery, OnShipmentTrackingNumberChange; per-entity On[Before]Sale{BasketItem,Payment,Shipment,ShipmentItem,PropertyValue}SetField and OnSale*EntitySaved; basket: OnSaleBasketItemBeforeSaved/Saved, OnSaleBasketItemRefreshData; final calc: On{Before,After}SaleOrderFinalAction.
Never call $order->save() from OnSaleOrderSaved — recursion. Mutate in OnSaleOrderBeforeSaved instead, or queue a job that reloads the order. OnBefore* handlers returning EventResult::ERROR surface as setField()/save() errors.
Payments
- Create via
getPaymentCollection()->createItem($service); several payments per order = split/partial pay. Available: PaySystem\Manager::getListWithRestrictions($payment, MODE_CLIENT|MODE_MANAGER) (or getListWithRestrictionsByOrder() pre-payment).
- Run:
$payment->getPaySystem()->initiatePay($payment, $request, BaseServiceHandler::STRING) → ServiceResult (getTemplate(), getPaymentUrl(), QR). Manual confirm: $payment->setPaid('Y'); refund: $payment->setReturn(Payment::RETURN_PS|RETURN_INNER|RETURN_NONE), partial via Service::refund($payment, $sum) (handler must implement IRefund). Recurring: IRecurring, isRecurring()/repeatRecurrent().
- Internal account pay system:
PaySystem\Manager::getInnerPaySystemId(), Payment::isInner(). Balance itself: legacy CSaleUserAccount::GetByUserID() / UpdateAccount($userId, $delta, ...) — pass the delta, not the new total; journal read via Internals\UserTransactTable. Buyer aggregates: Bitrix\Sale\BuyerStatistic (per user+site+currency).
- Custom handlers:
/local/php_interface/include/sale_payment/<code>/ (handler.php extending PaySystem\ServiceHandler, .description.php, template/). Legacy /bitrix/modules/sale/payment/ unsupported since sale 22.200.0. Callback entry: /bitrix/tools/sale_ps_result.php (verify signature/sum/currency; handle repeated notifications idempotently). Custom restrictions: extend Services\Base\Restriction, register on onSalePaySystemRestrictionsClassNamesBuildList.
Delivery and Shipments
- Available services for a shipment:
Delivery\Services\Manager::getRestrictedObjectsList($shipment) or getRestrictedList($shipment, Restrictions\Manager::MODE_CLIENT). Single service object: getObjectById() — never trust a raw request ID without the restricted list.
- Cost:
ShipmentCollection::calculateDelivery() (all non-system shipments; skips CUSTOM_PRICE_DELIVERY='Y') or Manager::calculateDeliveryPrice($shipment, $deliveryId, $extraServices) → CalculationResult (price, period).
- The collection always holds a system shipment (
isSystem()) with undistributed quantity — never assign it a service or edit it. Partial/split shipments: distribute quantities; guard with getBasketItemDistributedQuantity().
- State:
allowDelivery()/disallowDelivery(), deduct via setField('DEDUCTED', 'Y'), TRACKING_NUMBER, setStoreId() for pickup. Custom handler: extend Delivery\Services\Base (calculateConcrete(), getConfigStructure()), register on onSaleDeliveryHandlersClassNamesBuildList, add via Manager::add(); restrictions on onSaleDeliveryRestrictionsClassNamesBuildList; extra services in Delivery\ExtraServices\* + Shipment::setExtraServices().
Discounts and Coupons
- Cart rules are created via legacy
CSaleDiscount::Add()/Update() (CONDITIONS/ACTIONS trees, PRIORITY+SORT, LAST_DISCOUNT) — no full D7 replacement; delete via Internals\DiscountTable::delete(). Never compute discounts by hand or write final prices.
- Calculation: standalone basket →
Discount::buildFromBasket() + calculate() + applyDiscount(); saved order → Order::doFinalAction(true) (never buildFromBasket() on an order basket).
- Coupons:
DiscountCouponsManager::init(MODE_CLIENT|MODE_MANAGER|MODE_ORDER [, userId/orderId]) → add($code). add() === true does not mean the discount applied — recalc, then get(true, ['COUPON' => $code], true, true) and check STATUS === STATUS_APPLYED. Coupon rows: Internals\DiscountCouponTable (TYPE_ONE_ORDER, TYPE_MULTI_ORDER + MAX_USE).
- Applied result:
$order->getDiscount()->getApplyResult(); saved orders: OrderDiscount::loadResultFromDb($orderId), rows in Internals\OrderRulesTable.
Reservation and Deduction
- Reserve a shipment:
Shipment::tryReserve() / tryUnreserve(); full-reserve check isReserved(). Per-item store rows: BasketItem::getReserveQuantityCollection() (create() → setStoreId() then setQuantity()). Always finish with Order::save() — never edit RESERVED* table fields.
- Deduct (write-off) =
Shipment::setField('DEDUCTED', 'Y'); catalog provider updates stock (StoreProductTable.AMOUNT/QUANTITY_RESERVED) on save. Set the store first when inventory management is on.
- Auto-reserve config:
Sale\Configuration::getProductReservationCondition() → ReserveCondition::ON_CREATE|ON_PAY|ON_FULL_PAY|ON_ALLOW_DELIVERY|ON_SHIP; TTL getProductReserveClearPeriod(); stale reserves cleaned by Helpers\ReservedProductCleaner. Available qty: Reservation\BasketReservationService::getAvailableCountForBasketItem()/ForOrder().
Reports, Archive, Performance
- Lists/aggregates: ORM with explicit
select, batch related tables by ORDER_ID array (no N+1); order-level flags PAYED/DEDUCTED avoid loading collections. Mass updates: pick IDs in chunks, then load/change/save each order.
- Archived orders disappear from active tables — read them via
Bitrix\Sale\Archive\Manager::getList()/getById(); returnArchivedOrder() returns a read-only object (don't save it as active). Combine active + archive explicitly in reports.
- One
doFinalAction(true) and one save() per logical operation; check Result::isSuccess() and getWarningMessages() (warnings can hide sub-object failures — reload and verify critical state).
Module REST / Controllers
sale enables controllers.restIntegration. Prefer thin Engine controllers + services reusing sale entities (bitrix-controllers); standard public checkout is bitrix:sale.order.ajax — customize business logic via the object model, not by patching component internals.
Checklist
Related skills
bitrix-catalog, bitrix-iblocks, bitrix-result-and-errors, bitrix-events, bitrix-controllers, bitrix-service-locator.
1---2name: bitrix-sale3description: Covers Sale module — API choice (D7 object model vs ORM vs CSale*), FUSER, Basket, Order create/update, properties, statuses and events, payments, delivery/shipments, discounts and coupons, reservation/deduction, permissions, buyer accounts. Applied for cart/checkout, order lifecycle, pay/ship integration, and order automation. Key terms — sale, Basket, Order, Fuser, Payment, Shipment, PaySystem\Manager, Delivery\Services\Manager, doFinalAction, STATUS_ID, DiscountCouponsManager, tryReserve, CanUserUpdateOrder.4---56# Online Store (`sale`)78`sale` owns cart (basket), orders, payments, shipments, discounts, statuses, and history. Product master data, prices, stock live in **`catalog` + `iblock`**. Baseline: main **23.0+**.910```php11\Bitrix\Main\Loader::includeModule('sale');12\Bitrix\Main\Loader::includeModule('catalog'); // products, prices, stock, reservation13```1415## Choosing the API1617| Task | API |18| --- | --- |19| Create/change basket, order, payment, shipment | Object model `Bitrix\Sale\*` (validates, saves collections, fires events, writes history) |20| Lists, reports, aggregates | ORM `Bitrix\Sale\Internals\*Table` (`OrderTable`, `BasketTable`, `PaymentTable`, `ShipmentTable`) — **read-only for order data** |21| Settings/dictionaries via code | Profile ORM: `PersonTypeTable`, `OrderPropsTable`, `StatusTable` (+`StatusLangTable`), `OrderPropsGroupTable` — writes allowed |22| Pick a configured service | Managers: `PaySystem\Manager`, `Delivery\Services\Manager`, `Cashbox\Manager`/`CheckManager`, `Services\Company\Manager`, `DiscountCouponsManager` |23| Operations with no full D7 replacement | Legacy `CSale*`: `CSaleOrder::CanUser*()` (rights), `CSaleOrderChange` (history read), `CSaleDiscount::Add/Update` (cart rules), `CSaleOrderUserProps` (buyer profiles), `CSaleUserAccount` (account balance), `CSaleOrderTax` (tax rows) |2425Never change an order via `OrderTable::update()` or create payments/shipments as raw ORM rows — collections, recalcs, events, and history desync. Never `Order::load()` in a loop for a list — use `OrderTable::getList()` / `Order::getList()`. Don't mix legacy `CSale*` writes with a loaded `Order` object in memory.2627## FUSER (Cart Owner)2829Anonymous and authorized carts are keyed by **FUSER** (`Bitrix\Sale\Fuser`), not `USER_ID`.3031```php32$fuserId = Fuser::getId(); // creates if missing33$fuserId = Fuser::getId(true); // skip create → null if none34$fuserId = Fuser::getIdByUserId($userId); // false if cannot resolve/create35```3637`USER_ID` (site account, required on saved order) and `FUSER_ID` (basket owner) are different — don't substitute one for the other.3839## Basket4041```php42<?php declare(strict_types=1);4344use Bitrix\Catalog\Product\Basket as CatalogBasket;45use Bitrix\Sale\Basket;46use Bitrix\Sale\Basket\RefreshFactory;47use Bitrix\Sale\Fuser;4849$basket = Basket::loadItemsForFUser(Fuser::getId(), $siteId); // only rows with ORDER_ID = null5051// Preferred for catalog products: sets module, provider, and product data itself52$r = CatalogBasket::addProductToBasket($basket, ['PRODUCT_ID' => $productId, 'QUANTITY' => 1], ['SITE_ID' => $siteId]);53// merges into an existing row by default; pass ['USE_MERGE' => 'N'] as 4th arg for a separate row5455// Manual alternative:56$item = $basket->createItem('catalog', $productId);57$item->setFields(['QUANTITY' => 1, 'PRODUCT_PROVIDER_CLASS' => CatalogBasket::getDefaultProviderName()]);58$basket->refresh(RefreshFactory::createSingle($item->getBasketCode())); // provider fills PRICE/CURRENCY/NAME/VAT/weight5960$result = $basket->save(); // only for a basket NOT bound to an order61```6263- Don't set `PRICE`/`CURRENCY` for catalog products — the provider does. Own pricing: `CUSTOM_PRICE => 'Y'` + `PRICE` + `CURRENCY`.64- With SKUs put the **offer ID** in `PRODUCT_ID`, never the parent. Verify the element is a product (`Bitrix\Catalog\ProductTable`) before adding.65- Basket of a saved order: get via `$order->getBasket()`, save via `Order::save()` — never `loadItemsForFUser()` / `$basket->save()` for it.66- Before order creation: `$basket->refresh()` (`refreshData()` is deprecated), then `$basket->getOrderableItems()` — separate basket with only purchasable, non-delayed items.67- Item properties: `$item->getPropertyCollection()->createItem()` / `redefine()`. Prices: `getPrice()`, `getBasePrice()`, `getPriceWithVat()`, `getDiscountPrice()`.68- Pre-order discounts preview: `Discount::buildFromBasket($basket, new Discount\Context\Fuser($basket->getFUserId()))` → `calculate()` → `$basket->applyDiscount($data['BASKET_ITEMS'])`. Never for an order-bound basket.6970## Order Create (Pipeline)7172Order of operations matters: basket → order → person type → basket in → properties → shipment → delivery calc → payment → `doFinalAction(true)` → sync payment SUM → re-check restrictions → `save()`.7374```php75<?php declare(strict_types=1);7677use Bitrix\Sale\Delivery\Services\Manager as DeliveryManager;78use Bitrix\Sale\Order;79use Bitrix\Sale\PaySystem\Manager as PaySystemManager;80use Bitrix\Sale\Services\Base\RestrictionManager;8182$order = Order::create($siteId, $userId); // currency: site's, else base83$order->setPersonTypeId($personTypeId); // BEFORE getPropertyCollection(); not validated vs site84$order->setBasket($orderableBasket); // new (unsaved) order only8586// Properties (set depends on person type)87$prop = $order->getPropertyCollection()->getItemByOrderPropertyCode('PHONE');88$prop?->setValue($phone); // each setValue returns Result8990// Shipment: create user shipment, bind basket items, pick allowed delivery91$shipment = $order->getShipmentCollection()->createItem(); // system shipment exists already — never assign it a service92foreach ($order->getBasket() as $basketItem) {93 $shipmentItem = $shipment->getShipmentItemCollection()->createItem($basketItem);94 $shipmentItem->setQuantity($basketItem->getQuantity());95}96$deliveries = DeliveryManager::getRestrictedObjectsList($shipment);97$shipment->setDeliveryService($deliveries[$deliveryId] ?? throw new \RuntimeException('delivery unavailable'));98$order->getShipmentCollection()->calculateDelivery();99100// Payment: create, preliminary SUM, pick allowed pay system101$payment = $order->getPaymentCollection()->createItem();102$payment->setField('SUM', $order->getPrice());103$allowed = PaySystemManager::getListWithRestrictions($payment, RestrictionManager::MODE_CLIENT);104isset($allowed[$paySystemId]) or throw new \RuntimeException('pay system unavailable');105$payment->setPaySystemService(PaySystemManager::getObjectById($paySystemId));106107$order->doFinalAction(true); // discounts, taxes, totals — check Result108$payment->setField('SUM', $order->getPrice()); // sync after final calc109// re-check getRestrictedObjectsList / getListWithRestrictions here — totals may change availability110111$saveResult = $order->save(); // check isSuccess() AND getWarningMessages()112$orderId = $saveResult->getId();113```114115Payment and user shipment are optional at first save (digital goods, deferred flows) — skip those blocks; add later on the loaded order. Idempotency for integrations: store operation key yourself (`XML_ID` is not unique-constrained). Load later: `Order::load($id)`, `Order::loadByAccountNumber($number)`, `Order::loadByFilter([...])`; lock while editing with `Order::lock()/isLocked()/unlock()`.116117## Order Update118119Work on one loaded object, save once. After a change decide what to rerun:120121| Change | calculateDelivery | doFinalAction(true) | sync unpaid payments SUM |122| --- | --- | --- | --- |123| Status, cancel, mark, comment, tracking, allow-delivery | – | – | – |124| Location/address in restrictions | yes | yes | if price changed |125| Basket items/quantity; delivery service/cost; shipment removal | yes | yes | if price changed |126| Coupon/discount/tax data | if delivery affected | yes | if price changed |127128- Quantity down: reduce `ShipmentItem::setQuantity()` **first**, then `BasketItem::setField('QUANTITY')`; up: basket first, then shipment. Then `refresh` the item, recalc, save.129- Cancel via `setField('CANCELED', 'Y')` (+ `REASON_CANCELED`); blocked while a paid payment or shipped shipment exists. `Order::delete()` is a service-only hard delete — never use for customer refusal.130- `PERSON_TYPE_ID` change is a migration (property values are not remapped). `CURRENCY`/`USER_ID` are not changeable via `setField()`. Don't write `SUM_PAID`/`PAYED` directly.131132## Order Properties133134Setting (`OrderPropsTable`, bound to a person type; `ENTITY_TYPE` ORDER/SHIPMENT) vs value in an order (`PropertyValueCollection`). Create settings via `OrderPropsGroupTable::add()` + `OrderPropsTable::add()` in migrations, never during checkout.135136- Find values: `getItemByOrderPropertyCode()` (first match), `getItemByOrderPropertyId()`, by role: `getDeliveryLocation()`, groups via `getGroups()`.137- `LOCATION` takes the internal location code, not a name. `ENUM` takes variant `VALUE` (options via `$propertyValue->getPropertyObject()->getOptions()`); `MULTIPLE=Y` takes an array. Files/forms: `PropertyValueCollection::setValuesFromPost($_POST, $_FILES)` + `verify()`.138- Required check before save: iterate collection, `isRequired()` + `checkRequiredValue()`.139- Values save with `Order::save()` only; never write `OrderPropsValueTable` directly.140141## Statuses, Permissions142143- Order: `STATUS_ID`, initial `N`, final `F`, class `Bitrix\Sale\OrderStatus`. Shipment: own `STATUS_ID`, `DN`→`DF`, class `DeliveryStatus`. Dictionary `StatusTable` (`TYPE_ORDER`/`TYPE_SHIPMENT`) + `StatusLangTable` names.144- Allowed transitions for a user: `OrderStatus::getAllowedUserStatuses($userId, $currentStatusId)`; operations per status: `getStatusesUserCanDoOperations()`, `canGroupDoOperations()` (operations: `view`, `update`, `delete`, `cancel`, `mark`, `payment`, `delivery`, `deduction`, `from`, `to`).145- **Object API does not check rights.** Before acting on a user request check the concrete order via legacy `CSaleOrder`: `CanUserViewOrder()`, `CanUserUpdateOrder()` (pass `0, $groups, $siteId` for create), `CanUserCancelOrder()`, `CanUserChangeOrderStatus()`, `CanUserChangeOrderFlag($id, 'PERM_PAYMENT'|'PERM_DELIVERY'|'PERM_DEDUCTION', $groups)`, `CanUserDeleteOrder()`. Check view rights **before** `Order::load()`.146- Module levels: `D` denied, `P` company binding, `U` order processing (still needs site + status-task grants), `W` full.147- History: written by `OrderHistory` on save; read via legacy `CSaleOrderChange::GetList()` (`@TYPE => ['ORDER_STATUS_CHANGED', ...]`).148149## Events150151Register via `EventManager` in `init.php`. Key ones: `OnSaleOrderBeforeSaved` (may modify/deny), `OnSaleOrderSaved` (`IS_NEW`, `IS_CHANGED`; result ignored), deferred after save: `OnSaleStatusOrderChange` (`VALUE`/`OLD_VALUE`), `OnSaleOrderPaid`, `OnSaleOrderCanceled`, `OnSaleStatusShipmentChange`, `OnShipmentDeducted`, `OnShipmentAllowDelivery`, `OnShipmentTrackingNumberChange`; per-entity `On[Before]Sale{BasketItem,Payment,Shipment,ShipmentItem,PropertyValue}SetField` and `OnSale*EntitySaved`; basket: `OnSaleBasketItemBeforeSaved/Saved`, `OnSaleBasketItemRefreshData`; final calc: `On{Before,After}SaleOrderFinalAction`.152153**Never call `$order->save()` from `OnSaleOrderSaved`** — recursion. Mutate in `OnSaleOrderBeforeSaved` instead, or queue a job that reloads the order. `OnBefore*` handlers returning `EventResult::ERROR` surface as `setField()`/`save()` errors.154155## Payments156157- Create via `getPaymentCollection()->createItem($service)`; several payments per order = split/partial pay. Available: `PaySystem\Manager::getListWithRestrictions($payment, MODE_CLIENT|MODE_MANAGER)` (or `getListWithRestrictionsByOrder()` pre-payment).158- Run: `$payment->getPaySystem()->initiatePay($payment, $request, BaseServiceHandler::STRING)` → `ServiceResult` (`getTemplate()`, `getPaymentUrl()`, QR). Manual confirm: `$payment->setPaid('Y')`; refund: `$payment->setReturn(Payment::RETURN_PS|RETURN_INNER|RETURN_NONE)`, partial via `Service::refund($payment, $sum)` (handler must implement `IRefund`). Recurring: `IRecurring`, `isRecurring()/repeatRecurrent()`.159- Internal account pay system: `PaySystem\Manager::getInnerPaySystemId()`, `Payment::isInner()`. Balance itself: legacy `CSaleUserAccount::GetByUserID()` / `UpdateAccount($userId, $delta, ...)` — **pass the delta, not the new total**; journal read via `Internals\UserTransactTable`. Buyer aggregates: `Bitrix\Sale\BuyerStatistic` (per user+site+currency).160- Custom handlers: `/local/php_interface/include/sale_payment/<code>/` (`handler.php` extending `PaySystem\ServiceHandler`, `.description.php`, `template/`). Legacy `/bitrix/modules/sale/payment/` unsupported since sale **22.200.0**. Callback entry: `/bitrix/tools/sale_ps_result.php` (verify signature/sum/currency; handle repeated notifications idempotently). Custom restrictions: extend `Services\Base\Restriction`, register on `onSalePaySystemRestrictionsClassNamesBuildList`.161162## Delivery and Shipments163164- Available services for a shipment: `Delivery\Services\Manager::getRestrictedObjectsList($shipment)` or `getRestrictedList($shipment, Restrictions\Manager::MODE_CLIENT)`. Single service object: `getObjectById()` — never trust a raw request ID without the restricted list.165- Cost: `ShipmentCollection::calculateDelivery()` (all non-system shipments; skips `CUSTOM_PRICE_DELIVERY='Y'`) or `Manager::calculateDeliveryPrice($shipment, $deliveryId, $extraServices)` → `CalculationResult` (price, period).166- The collection always holds a **system shipment** (`isSystem()`) with undistributed quantity — never assign it a service or edit it. Partial/split shipments: distribute quantities; guard with `getBasketItemDistributedQuantity()`.167- State: `allowDelivery()`/`disallowDelivery()`, deduct via `setField('DEDUCTED', 'Y')`, `TRACKING_NUMBER`, `setStoreId()` for pickup. Custom handler: extend `Delivery\Services\Base` (`calculateConcrete()`, `getConfigStructure()`), register on `onSaleDeliveryHandlersClassNamesBuildList`, add via `Manager::add()`; restrictions on `onSaleDeliveryRestrictionsClassNamesBuildList`; extra services in `Delivery\ExtraServices\*` + `Shipment::setExtraServices()`.168169## Discounts and Coupons170171- Cart rules are created via legacy `CSaleDiscount::Add()/Update()` (`CONDITIONS`/`ACTIONS` trees, `PRIORITY`+`SORT`, `LAST_DISCOUNT`) — no full D7 replacement; delete via `Internals\DiscountTable::delete()`. Never compute discounts by hand or write final prices.172- Calculation: standalone basket → `Discount::buildFromBasket()` + `calculate()` + `applyDiscount()`; saved order → `Order::doFinalAction(true)` (never `buildFromBasket()` on an order basket).173- Coupons: `DiscountCouponsManager::init(MODE_CLIENT|MODE_MANAGER|MODE_ORDER [, userId/orderId])` → `add($code)`. **`add() === true` does not mean the discount applied** — recalc, then `get(true, ['COUPON' => $code], true, true)` and check `STATUS === STATUS_APPLYED`. Coupon rows: `Internals\DiscountCouponTable` (`TYPE_ONE_ORDER`, `TYPE_MULTI_ORDER` + `MAX_USE`).174- Applied result: `$order->getDiscount()->getApplyResult()`; saved orders: `OrderDiscount::loadResultFromDb($orderId)`, rows in `Internals\OrderRulesTable`.175176## Reservation and Deduction177178- Reserve a shipment: `Shipment::tryReserve()` / `tryUnreserve()`; full-reserve check `isReserved()`. Per-item store rows: `BasketItem::getReserveQuantityCollection()` (`create()` → `setStoreId()` **then** `setQuantity()`). Always finish with `Order::save()` — never edit `RESERVED*` table fields.179- Deduct (write-off) = `Shipment::setField('DEDUCTED', 'Y')`; catalog provider updates stock (`StoreProductTable.AMOUNT/QUANTITY_RESERVED`) on save. Set the store first when inventory management is on.180- Auto-reserve config: `Sale\Configuration::getProductReservationCondition()` → `ReserveCondition::ON_CREATE|ON_PAY|ON_FULL_PAY|ON_ALLOW_DELIVERY|ON_SHIP`; TTL `getProductReserveClearPeriod()`; stale reserves cleaned by `Helpers\ReservedProductCleaner`. Available qty: `Reservation\BasketReservationService::getAvailableCountForBasketItem()/ForOrder()`.181182## Reports, Archive, Performance183184- Lists/aggregates: ORM with explicit `select`, batch related tables by `ORDER_ID` array (no N+1); order-level flags `PAYED`/`DEDUCTED` avoid loading collections. Mass updates: pick IDs in chunks, then load/change/save each order.185- Archived orders disappear from active tables — read them via `Bitrix\Sale\Archive\Manager::getList()/getById()`; `returnArchivedOrder()` returns a **read-only** object (don't save it as active). Combine active + archive explicitly in reports.186- One `doFinalAction(true)` and one `save()` per logical operation; check `Result::isSuccess()` **and** `getWarningMessages()` (warnings can hide sub-object failures — reload and verify critical state).187188## Module REST / Controllers189190`sale` enables `controllers.restIntegration`. Prefer thin Engine controllers + services reusing sale entities (`bitrix-controllers`); standard public checkout is `bitrix:sale.order.ajax` — customize business logic via the object model, not by patching component internals.191192## Checklist193194- [ ] `sale` (+ `catalog`) included; API level chosen per task (object model / ORM read / manager / legacy).195- [ ] Cart keyed by `Fuser`; catalog lines via `addProductToBasket` or provider class + `refresh`.196- [ ] Order pipeline: person type → basket → props → shipment+delivery calc → payment → `doFinalAction(true)` → SUM sync → restriction re-check → `save()`.197- [ ] Services chosen from restricted lists, never by raw ID from request.198- [ ] Rights checked (`CSaleOrder::CanUser*`, `getAllowedUserStatuses`) before user-driven load/changes.199- [ ] Cancel via `CANCELED='Y'`, not `Order::delete()`; no direct ORM writes to order tables.200- [ ] Coupon applied status verified (`STATUS_APPLYED`), not just `add()`.201- [ ] All `Result`s checked incl. warnings; no `save()` from `OnSaleOrderSaved`.202- [ ] Business logic in services; components/controllers stay thin.203204## Related skills205206`bitrix-catalog`, `bitrix-iblocks`, `bitrix-result-and-errors`, `bitrix-events`, `bitrix-controllers`, `bitrix-service-locator`.