{"slug":"bitrix-sale","title":"bitrix-sale","summary":"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 lifec","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-08-28T17:01:59.051396Z","repo":{"url":"https://github.com/bxmaximum/bitrix-framework-skills","stars":32,"forks":5,"license":null,"updatedAt":"2026-08-25T17:45:16Z"},"bodyHtml":"<hr>\n<h2>name: bitrix-sale\ndescription: 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.</h2>\n<h1>Online Store (<code>sale</code>)</h1>\n<p><code>sale</code> owns cart (basket), orders, payments, shipments, discounts, statuses, and history. Product master data, prices, stock live in <strong><code>catalog</code> + <code>iblock</code></strong>. Baseline: main <strong>23.0+</strong>.</p>\n<pre><code>\\Bitrix\\Main\\Loader::includeModule('sale');\n\\Bitrix\\Main\\Loader::includeModule('catalog'); // products, prices, stock, reservation\n</code></pre>\n<h2>Choosing the API</h2>\n<table>\n<thead>\n<tr>\n<th>Task</th>\n<th>API</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Create/change basket, order, payment, shipment</td>\n<td>Object model <code>Bitrix\\Sale\\*</code> (validates, saves collections, fires events, writes history)</td>\n</tr>\n<tr>\n<td>Lists, reports, aggregates</td>\n<td>ORM <code>Bitrix\\Sale\\Internals\\*Table</code> (<code>OrderTable</code>, <code>BasketTable</code>, <code>PaymentTable</code>, <code>ShipmentTable</code>) — <strong>read-only for order data</strong></td>\n</tr>\n<tr>\n<td>Settings/dictionaries via code</td>\n<td>Profile ORM: <code>PersonTypeTable</code>, <code>OrderPropsTable</code>, <code>StatusTable</code> (+<code>StatusLangTable</code>), <code>OrderPropsGroupTable</code> — writes allowed</td>\n</tr>\n<tr>\n<td>Pick a configured service</td>\n<td>Managers: <code>PaySystem\\Manager</code>, <code>Delivery\\Services\\Manager</code>, <code>Cashbox\\Manager</code>/<code>CheckManager</code>, <code>Services\\Company\\Manager</code>, <code>DiscountCouponsManager</code></td>\n</tr>\n<tr>\n<td>Operations with no full D7 replacement</td>\n<td>Legacy <code>CSale*</code>: <code>CSaleOrder::CanUser*()</code> (rights), <code>CSaleOrderChange</code> (history read), <code>CSaleDiscount::Add/Update</code> (cart rules), <code>CSaleOrderUserProps</code> (buyer profiles), <code>CSaleUserAccount</code> (account balance), <code>CSaleOrderTax</code> (tax rows)</td>\n</tr>\n</tbody>\n</table>\n<p>Never change an order via <code>OrderTable::update()</code> or create payments/shipments as raw ORM rows — collections, recalcs, events, and history desync. Never <code>Order::load()</code> in a loop for a list — use <code>OrderTable::getList()</code> / <code>Order::getList()</code>. Don't mix legacy <code>CSale*</code> writes with a loaded <code>Order</code> object in memory.</p>\n<h2>FUSER (Cart Owner)</h2>\n<p>Anonymous and authorized carts are keyed by <strong>FUSER</strong> (<code>Bitrix\\Sale\\Fuser</code>), not <code>USER_ID</code>.</p>\n<pre><code>$fuserId = Fuser::getId();               // creates if missing\n$fuserId = Fuser::getId(true);           // skip create → null if none\n$fuserId = Fuser::getIdByUserId($userId); // false if cannot resolve/create\n</code></pre>\n<p><code>USER_ID</code> (site account, required on saved order) and <code>FUSER_ID</code> (basket owner) are different — don't substitute one for the other.</p>\n<h2>Basket</h2>\n<pre><code>&lt;?php declare(strict_types=1);\n\nuse Bitrix\\Catalog\\Product\\Basket as CatalogBasket;\nuse Bitrix\\Sale\\Basket;\nuse Bitrix\\Sale\\Basket\\RefreshFactory;\nuse Bitrix\\Sale\\Fuser;\n\n$basket = Basket::loadItemsForFUser(Fuser::getId(), $siteId); // only rows with ORDER_ID = null\n\n// Preferred for catalog products: sets module, provider, and product data itself\n$r = CatalogBasket::addProductToBasket($basket, ['PRODUCT_ID' =&gt; $productId, 'QUANTITY' =&gt; 1], ['SITE_ID' =&gt; $siteId]);\n// merges into an existing row by default; pass ['USE_MERGE' =&gt; 'N'] as 4th arg for a separate row\n\n// Manual alternative:\n$item = $basket-&gt;createItem('catalog', $productId);\n$item-&gt;setFields(['QUANTITY' =&gt; 1, 'PRODUCT_PROVIDER_CLASS' =&gt; CatalogBasket::getDefaultProviderName()]);\n$basket-&gt;refresh(RefreshFactory::createSingle($item-&gt;getBasketCode())); // provider fills PRICE/CURRENCY/NAME/VAT/weight\n\n$result = $basket-&gt;save();                 // only for a basket NOT bound to an order\n</code></pre>\n<ul>\n<li>Don't set <code>PRICE</code>/<code>CURRENCY</code> for catalog products — the provider does. Own pricing: <code>CUSTOM_PRICE =&gt; 'Y'</code> + <code>PRICE</code> + <code>CURRENCY</code>.</li>\n<li>With SKUs put the <strong>offer ID</strong> in <code>PRODUCT_ID</code>, never the parent. Verify the element is a product (<code>Bitrix\\Catalog\\ProductTable</code>) before adding.</li>\n<li>Basket of a saved order: get via <code>$order-&gt;getBasket()</code>, save via <code>Order::save()</code> — never <code>loadItemsForFUser()</code> / <code>$basket-&gt;save()</code> for it.</li>\n<li>Before order creation: <code>$basket-&gt;refresh()</code> (<code>refreshData()</code> is deprecated), then <code>$basket-&gt;getOrderableItems()</code> — separate basket with only purchasable, non-delayed items.</li>\n<li>Item properties: <code>$item-&gt;getPropertyCollection()-&gt;createItem()</code> / <code>redefine()</code>. Prices: <code>getPrice()</code>, <code>getBasePrice()</code>, <code>getPriceWithVat()</code>, <code>getDiscountPrice()</code>.</li>\n<li>Pre-order discounts preview: <code>Discount::buildFromBasket($basket, new Discount\\Context\\Fuser($basket-&gt;getFUserId()))</code> → <code>calculate()</code> → <code>$basket-&gt;applyDiscount($data['BASKET_ITEMS'])</code>. Never for an order-bound basket.</li>\n</ul>\n<h2>Order Create (Pipeline)</h2>\n<p>Order of operations matters: basket → order → person type → basket in → properties → shipment → delivery calc → payment → <code>doFinalAction(true)</code> → sync payment SUM → re-check restrictions → <code>save()</code>.</p>\n<pre><code>&lt;?php declare(strict_types=1);\n\nuse Bitrix\\Sale\\Delivery\\Services\\Manager as DeliveryManager;\nuse Bitrix\\Sale\\Order;\nuse Bitrix\\Sale\\PaySystem\\Manager as PaySystemManager;\nuse Bitrix\\Sale\\Services\\Base\\RestrictionManager;\n\n$order = Order::create($siteId, $userId); // currency: site's, else base\n$order-&gt;setPersonTypeId($personTypeId);   // BEFORE getPropertyCollection(); not validated vs site\n$order-&gt;setBasket($orderableBasket);      // new (unsaved) order only\n\n// Properties (set depends on person type)\n$prop = $order-&gt;getPropertyCollection()-&gt;getItemByOrderPropertyCode('PHONE');\n$prop?-&gt;setValue($phone);                 // each setValue returns Result\n\n// Shipment: create user shipment, bind basket items, pick allowed delivery\n$shipment = $order-&gt;getShipmentCollection()-&gt;createItem(); // system shipment exists already — never assign it a service\nforeach ($order-&gt;getBasket() as $basketItem) {\n    $shipmentItem = $shipment-&gt;getShipmentItemCollection()-&gt;createItem($basketItem);\n    $shipmentItem-&gt;setQuantity($basketItem-&gt;getQuantity());\n}\n$deliveries = DeliveryManager::getRestrictedObjectsList($shipment);\n$shipment-&gt;setDeliveryService($deliveries[$deliveryId] ?? throw new \\RuntimeException('delivery unavailable'));\n$order-&gt;getShipmentCollection()-&gt;calculateDelivery();\n\n// Payment: create, preliminary SUM, pick allowed pay system\n$payment = $order-&gt;getPaymentCollection()-&gt;createItem();\n$payment-&gt;setField('SUM', $order-&gt;getPrice());\n$allowed = PaySystemManager::getListWithRestrictions($payment, RestrictionManager::MODE_CLIENT);\nisset($allowed[$paySystemId]) or throw new \\RuntimeException('pay system unavailable');\n$payment-&gt;setPaySystemService(PaySystemManager::getObjectById($paySystemId));\n\n$order-&gt;doFinalAction(true);              // discounts, taxes, totals — check Result\n$payment-&gt;setField('SUM', $order-&gt;getPrice()); // sync after final calc\n// re-check getRestrictedObjectsList / getListWithRestrictions here — totals may change availability\n\n$saveResult = $order-&gt;save();             // check isSuccess() AND getWarningMessages()\n$orderId = $saveResult-&gt;getId();\n</code></pre>\n<p>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 (<code>XML_ID</code> is not unique-constrained). Load later: <code>Order::load($id)</code>, <code>Order::loadByAccountNumber($number)</code>, <code>Order::loadByFilter([...])</code>; lock while editing with <code>Order::lock()/isLocked()/unlock()</code>.</p>\n<h2>Order Update</h2>\n<p>Work on one loaded object, save once. After a change decide what to rerun:</p>\n<table>\n<thead>\n<tr>\n<th>Change</th>\n<th>calculateDelivery</th>\n<th>doFinalAction(true)</th>\n<th>sync unpaid payments SUM</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Status, cancel, mark, comment, tracking, allow-delivery</td>\n<td>–</td>\n<td>–</td>\n<td>–</td>\n</tr>\n<tr>\n<td>Location/address in restrictions</td>\n<td>yes</td>\n<td>yes</td>\n<td>if price changed</td>\n</tr>\n<tr>\n<td>Basket items/quantity; delivery service/cost; shipment removal</td>\n<td>yes</td>\n<td>yes</td>\n<td>if price changed</td>\n</tr>\n<tr>\n<td>Coupon/discount/tax data</td>\n<td>if delivery affected</td>\n<td>yes</td>\n<td>if price changed</td>\n</tr>\n</tbody>\n</table>\n<ul>\n<li>Quantity down: reduce <code>ShipmentItem::setQuantity()</code> <strong>first</strong>, then <code>BasketItem::setField('QUANTITY')</code>; up: basket first, then shipment. Then <code>refresh</code> the item, recalc, save.</li>\n<li>Cancel via <code>setField('CANCELED', 'Y')</code> (+ <code>REASON_CANCELED</code>); blocked while a paid payment or shipped shipment exists. <code>Order::delete()</code> is a service-only hard delete — never use for customer refusal.</li>\n<li><code>PERSON_TYPE_ID</code> change is a migration (property values are not remapped). <code>CURRENCY</code>/<code>USER_ID</code> are not changeable via <code>setField()</code>. Don't write <code>SUM_PAID</code>/<code>PAYED</code> directly.</li>\n</ul>\n<h2>Order Properties</h2>\n<p>Setting (<code>OrderPropsTable</code>, bound to a person type; <code>ENTITY_TYPE</code> ORDER/SHIPMENT) vs value in an order (<code>PropertyValueCollection</code>). Create settings via <code>OrderPropsGroupTable::add()</code> + <code>OrderPropsTable::add()</code> in migrations, never during checkout.</p>\n<ul>\n<li>Find values: <code>getItemByOrderPropertyCode()</code> (first match), <code>getItemByOrderPropertyId()</code>, by role: <code>getDeliveryLocation()</code>, groups via <code>getGroups()</code>.</li>\n<li><code>LOCATION</code> takes the internal location code, not a name. <code>ENUM</code> takes variant <code>VALUE</code> (options via <code>$propertyValue-&gt;getPropertyObject()-&gt;getOptions()</code>); <code>MULTIPLE=Y</code> takes an array. Files/forms: <code>PropertyValueCollection::setValuesFromPost($_POST, $_FILES)</code> + <code>verify()</code>.</li>\n<li>Required check before save: iterate collection, <code>isRequired()</code> + <code>checkRequiredValue()</code>.</li>\n<li>Values save with <code>Order::save()</code> only; never write <code>OrderPropsValueTable</code> directly.</li>\n</ul>\n<h2>Statuses, Permissions</h2>\n<ul>\n<li>Order: <code>STATUS_ID</code>, initial <code>N</code>, final <code>F</code>, class <code>Bitrix\\Sale\\OrderStatus</code>. Shipment: own <code>STATUS_ID</code>, <code>DN</code>→<code>DF</code>, class <code>DeliveryStatus</code>. Dictionary <code>StatusTable</code> (<code>TYPE_ORDER</code>/<code>TYPE_SHIPMENT</code>) + <code>StatusLangTable</code> names.</li>\n<li>Allowed transitions for a user: <code>OrderStatus::getAllowedUserStatuses($userId, $currentStatusId)</code>; operations per status: <code>getStatusesUserCanDoOperations()</code>, <code>canGroupDoOperations()</code> (operations: <code>view</code>, <code>update</code>, <code>delete</code>, <code>cancel</code>, <code>mark</code>, <code>payment</code>, <code>delivery</code>, <code>deduction</code>, <code>from</code>, <code>to</code>).</li>\n<li><strong>Object API does not check rights.</strong> Before acting on a user request check the concrete order via legacy <code>CSaleOrder</code>: <code>CanUserViewOrder()</code>, <code>CanUserUpdateOrder()</code> (pass <code>0, $groups, $siteId</code> for create), <code>CanUserCancelOrder()</code>, <code>CanUserChangeOrderStatus()</code>, <code>CanUserChangeOrderFlag($id, 'PERM_PAYMENT'|'PERM_DELIVERY'|'PERM_DEDUCTION', $groups)</code>, <code>CanUserDeleteOrder()</code>. Check view rights <strong>before</strong> <code>Order::load()</code>.</li>\n<li>Module levels: <code>D</code> denied, <code>P</code> company binding, <code>U</code> order processing (still needs site + status-task grants), <code>W</code> full.</li>\n<li>History: written by <code>OrderHistory</code> on save; read via legacy <code>CSaleOrderChange::GetList()</code> (<code>@TYPE =&gt; ['ORDER_STATUS_CHANGED', ...]</code>).</li>\n</ul>\n<h2>Events</h2>\n<p>Register via <code>EventManager</code> in <code>init.php</code>. Key ones: <code>OnSaleOrderBeforeSaved</code> (may modify/deny), <code>OnSaleOrderSaved</code> (<code>IS_NEW</code>, <code>IS_CHANGED</code>; result ignored), deferred after save: <code>OnSaleStatusOrderChange</code> (<code>VALUE</code>/<code>OLD_VALUE</code>), <code>OnSaleOrderPaid</code>, <code>OnSaleOrderCanceled</code>, <code>OnSaleStatusShipmentChange</code>, <code>OnShipmentDeducted</code>, <code>OnShipmentAllowDelivery</code>, <code>OnShipmentTrackingNumberChange</code>; per-entity <code>On[Before]Sale{BasketItem,Payment,Shipment,ShipmentItem,PropertyValue}SetField</code> and <code>OnSale*EntitySaved</code>; basket: <code>OnSaleBasketItemBeforeSaved/Saved</code>, <code>OnSaleBasketItemRefreshData</code>; final calc: <code>On{Before,After}SaleOrderFinalAction</code>.</p>\n<p><strong>Never call <code>$order-&gt;save()</code> from <code>OnSaleOrderSaved</code></strong> — recursion. Mutate in <code>OnSaleOrderBeforeSaved</code> instead, or queue a job that reloads the order. <code>OnBefore*</code> handlers returning <code>EventResult::ERROR</code> surface as <code>setField()</code>/<code>save()</code> errors.</p>\n<h2>Payments</h2>\n<ul>\n<li>Create via <code>getPaymentCollection()-&gt;createItem($service)</code>; several payments per order = split/partial pay. Available: <code>PaySystem\\Manager::getListWithRestrictions($payment, MODE_CLIENT|MODE_MANAGER)</code> (or <code>getListWithRestrictionsByOrder()</code> pre-payment).</li>\n<li>Run: <code>$payment-&gt;getPaySystem()-&gt;initiatePay($payment, $request, BaseServiceHandler::STRING)</code> → <code>ServiceResult</code> (<code>getTemplate()</code>, <code>getPaymentUrl()</code>, QR). Manual confirm: <code>$payment-&gt;setPaid('Y')</code>; refund: <code>$payment-&gt;setReturn(Payment::RETURN_PS|RETURN_INNER|RETURN_NONE)</code>, partial via <code>Service::refund($payment, $sum)</code> (handler must implement <code>IRefund</code>). Recurring: <code>IRecurring</code>, <code>isRecurring()/repeatRecurrent()</code>.</li>\n<li>Internal account pay system: <code>PaySystem\\Manager::getInnerPaySystemId()</code>, <code>Payment::isInner()</code>. Balance itself: legacy <code>CSaleUserAccount::GetByUserID()</code> / <code>UpdateAccount($userId, $delta, ...)</code> — <strong>pass the delta, not the new total</strong>; journal read via <code>Internals\\UserTransactTable</code>. Buyer aggregates: <code>Bitrix\\Sale\\BuyerStatistic</code> (per user+site+currency).</li>\n<li>Custom handlers: <code>/local/php_interface/include/sale_payment/&lt;code&gt;/</code> (<code>handler.php</code> extending <code>PaySystem\\ServiceHandler</code>, <code>.description.php</code>, <code>template/</code>). Legacy <code>/bitrix/modules/sale/payment/</code> unsupported since sale <strong>22.200.0</strong>. Callback entry: <code>/bitrix/tools/sale_ps_result.php</code> (verify signature/sum/currency; handle repeated notifications idempotently). Custom restrictions: extend <code>Services\\Base\\Restriction</code>, register on <code>onSalePaySystemRestrictionsClassNamesBuildList</code>.</li>\n</ul>\n<h2>Delivery and Shipments</h2>\n<ul>\n<li>Available services for a shipment: <code>Delivery\\Services\\Manager::getRestrictedObjectsList($shipment)</code> or <code>getRestrictedList($shipment, Restrictions\\Manager::MODE_CLIENT)</code>. Single service object: <code>getObjectById()</code> — never trust a raw request ID without the restricted list.</li>\n<li>Cost: <code>ShipmentCollection::calculateDelivery()</code> (all non-system shipments; skips <code>CUSTOM_PRICE_DELIVERY='Y'</code>) or <code>Manager::calculateDeliveryPrice($shipment, $deliveryId, $extraServices)</code> → <code>CalculationResult</code> (price, period).</li>\n<li>The collection always holds a <strong>system shipment</strong> (<code>isSystem()</code>) with undistributed quantity — never assign it a service or edit it. Partial/split shipments: distribute quantities; guard with <code>getBasketItemDistributedQuantity()</code>.</li>\n<li>State: <code>allowDelivery()</code>/<code>disallowDelivery()</code>, deduct via <code>setField('DEDUCTED', 'Y')</code>, <code>TRACKING_NUMBER</code>, <code>setStoreId()</code> for pickup. Custom handler: extend <code>Delivery\\Services\\Base</code> (<code>calculateConcrete()</code>, <code>getConfigStructure()</code>), register on <code>onSaleDeliveryHandlersClassNamesBuildList</code>, add via <code>Manager::add()</code>; restrictions on <code>onSaleDeliveryRestrictionsClassNamesBuildList</code>; extra services in <code>Delivery\\ExtraServices\\*</code> + <code>Shipment::setExtraServices()</code>.</li>\n</ul>\n<h2>Discounts and Coupons</h2>\n<ul>\n<li>Cart rules are created via legacy <code>CSaleDiscount::Add()/Update()</code> (<code>CONDITIONS</code>/<code>ACTIONS</code> trees, <code>PRIORITY</code>+<code>SORT</code>, <code>LAST_DISCOUNT</code>) — no full D7 replacement; delete via <code>Internals\\DiscountTable::delete()</code>. Never compute discounts by hand or write final prices.</li>\n<li>Calculation: standalone basket → <code>Discount::buildFromBasket()</code> + <code>calculate()</code> + <code>applyDiscount()</code>; saved order → <code>Order::doFinalAction(true)</code> (never <code>buildFromBasket()</code> on an order basket).</li>\n<li>Coupons: <code>DiscountCouponsManager::init(MODE_CLIENT|MODE_MANAGER|MODE_ORDER [, userId/orderId])</code> → <code>add($code)</code>. <strong><code>add() === true</code> does not mean the discount applied</strong> — recalc, then <code>get(true, ['COUPON' =&gt; $code], true, true)</code> and check <code>STATUS === STATUS_APPLYED</code>. Coupon rows: <code>Internals\\DiscountCouponTable</code> (<code>TYPE_ONE_ORDER</code>, <code>TYPE_MULTI_ORDER</code> + <code>MAX_USE</code>).</li>\n<li>Applied result: <code>$order-&gt;getDiscount()-&gt;getApplyResult()</code>; saved orders: <code>OrderDiscount::loadResultFromDb($orderId)</code>, rows in <code>Internals\\OrderRulesTable</code>.</li>\n</ul>\n<h2>Reservation and Deduction</h2>\n<ul>\n<li>Reserve a shipment: <code>Shipment::tryReserve()</code> / <code>tryUnreserve()</code>; full-reserve check <code>isReserved()</code>. Per-item store rows: <code>BasketItem::getReserveQuantityCollection()</code> (<code>create()</code> → <code>setStoreId()</code> <strong>then</strong> <code>setQuantity()</code>). Always finish with <code>Order::save()</code> — never edit <code>RESERVED*</code> table fields.</li>\n<li>Deduct (write-off) = <code>Shipment::setField('DEDUCTED', 'Y')</code>; catalog provider updates stock (<code>StoreProductTable.AMOUNT/QUANTITY_RESERVED</code>) on save. Set the store first when inventory management is on.</li>\n<li>Auto-reserve config: <code>Sale\\Configuration::getProductReservationCondition()</code> → <code>ReserveCondition::ON_CREATE|ON_PAY|ON_FULL_PAY|ON_ALLOW_DELIVERY|ON_SHIP</code>; TTL <code>getProductReserveClearPeriod()</code>; stale reserves cleaned by <code>Helpers\\ReservedProductCleaner</code>. Available qty: <code>Reservation\\BasketReservationService::getAvailableCountForBasketItem()/ForOrder()</code>.</li>\n</ul>\n<h2>Reports, Archive, Performance</h2>\n<ul>\n<li>Lists/aggregates: ORM with explicit <code>select</code>, batch related tables by <code>ORDER_ID</code> array (no N+1); order-level flags <code>PAYED</code>/<code>DEDUCTED</code> avoid loading collections. Mass updates: pick IDs in chunks, then load/change/save each order.</li>\n<li>Archived orders disappear from active tables — read them via <code>Bitrix\\Sale\\Archive\\Manager::getList()/getById()</code>; <code>returnArchivedOrder()</code> returns a <strong>read-only</strong> object (don't save it as active). Combine active + archive explicitly in reports.</li>\n<li>One <code>doFinalAction(true)</code> and one <code>save()</code> per logical operation; check <code>Result::isSuccess()</code> <strong>and</strong> <code>getWarningMessages()</code> (warnings can hide sub-object failures — reload and verify critical state).</li>\n</ul>\n<h2>Module REST / Controllers</h2>\n<p><code>sale</code> enables <code>controllers.restIntegration</code>. Prefer thin Engine controllers + services reusing sale entities (<code>bitrix-controllers</code>); standard public checkout is <code>bitrix:sale.order.ajax</code> — customize business logic via the object model, not by patching component internals.</p>\n<h2>Checklist</h2>\n<ul>\n<li><input disabled=\"disabled\" type=\"checkbox\"> <code>sale</code> (+ <code>catalog</code>) included; API level chosen per task (object model / ORM read / manager / legacy).</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Cart keyed by <code>Fuser</code>; catalog lines via <code>addProductToBasket</code> or provider class + <code>refresh</code>.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Order pipeline: person type → basket → props → shipment+delivery calc → payment → <code>doFinalAction(true)</code> → SUM sync → restriction re-check → <code>save()</code>.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Services chosen from restricted lists, never by raw ID from request.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Rights checked (<code>CSaleOrder::CanUser*</code>, <code>getAllowedUserStatuses</code>) before user-driven load/changes.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Cancel via <code>CANCELED='Y'</code>, not <code>Order::delete()</code>; no direct ORM writes to order tables.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Coupon applied status verified (<code>STATUS_APPLYED</code>), not just <code>add()</code>.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> All <code>Result</code>s checked incl. warnings; no <code>save()</code> from <code>OnSaleOrderSaved</code>.</li>\n<li><input disabled=\"disabled\" type=\"checkbox\"> Business logic in services; components/controllers stay thin.</li>\n</ul>\n<h2>Related skills</h2>\n<p><code>bitrix-catalog</code>, <code>bitrix-iblocks</code>, <code>bitrix-result-and-errors</code>, <code>bitrix-events</code>, <code>bitrix-controllers</code>, <code>bitrix-service-locator</code>.</p>\n","files":[{"path":"SKILL.md","sizeBytes":18217,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-08-28T17:04:07.407658Z","sha256":"BC579DDAA3BDDE92D0D2B859D765DE58B253E97B805C802216865565ED24C83D","sizeBytes":7464},"review":null,"source":{"repositoryUrl":"https://github.com/bxmaximum/bitrix-framework-skills","path":"skills/bitrix-sale","license":null,"commit":"66c40e0ac8bdb3a3b68c3e53745b006659341594","subtreeSha":"F71A9B68712D824AE776DFE425CA4312680721ED8923BA9A8859D1C7F090B64B","lastSyncedAt":"2026-09-27T19:34:22.479278Z"},"reviewedAt":"2026-08-28T17:08:01.694176Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/bxmaximum/bitrix-framework-skills/tree/main/skills/bitrix-sale"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install bxmaximum-bitrix-framework-skills@llmmart"},{"target":"git","command":"git clone https://github.com/bxmaximum/bitrix-framework-skills.git"}]}