Orange Collar Media
Orange Collar Media
DOCS
OCMLABS/MODULE-EXTRA-FEES · ARCHITECTURE

How Extra Fees is built - total collectors, tax engine integration, schema, and APIs

MAGENTO 2.4.7 - 2.4.8 PHP 8.3+ LUMA + HYVÄ COMPOSER

Module OCMLabs_ExtraFees, namespace OCMLabs\ExtraFees, Composer package ocmlabs/module-extra-fees (plus the ocmlabs/module-extra-fees-commerce Adobe Commerce licensing metapackage). Declarative schema only - no install/upgrade scripts - and a clean Setup/Uninstall.php for --remove-data uninstalls.

The two-collector totals design

Quote totals are collected by two collectors registered in etc/sales.xml, split around core tax on purpose so fee tax is computed by Magento’s tax engine rather than by the module:

Model\Total\Quote\ExtraFees (code ocmlabs_extra_fees, sort_order 425 - after discount at 300/400, before core tax at 450):

  1. Clears any fee state left from a prior collect pass, then exits early if the module is disabled, the cart is empty, or the quote is multishipping (unsupported; a warning is logged).
  2. Loads active fees scoped to the current store (FeeRepository::getActiveList($storeId)), drops fees outside their schedule window (Model\FeeSchedule), drops optional fees the shopper has not selected (Model\SelectedFeeIds), and evaluates conditions on the rest (Model\ConditionEvaluator).
  3. Calculates each triggered fee (Model\FeeCalculator), sums them into the ocmlabs_extra_fees bucket in both currencies, and resolves each fee’s per-store display label.
  4. For taxable fees with an effective tax class (per-fee tax_class_id, else the configured default), registers each fee as a quote-level associated taxable via CommonTaxCollector - marked price-includes-tax when the tax-inclusive setting applies. It never writes to the tax bucket itself.

Model\Total\Quote\ExtraFeesTax (code ocmlabs_extra_fee_tax, sort_order 475 - after core tax at 450): reads back the tax core computed for each fee taxable from Total::getExtraTaxableDetails(), and persists the per-fee amount, tax, and percent as a JSON detail blob on the quote address (billing address for virtual quotes). Its fetch() returns one total segment per fee, which is what renders the individual fee lines in cart and checkout summaries. The code is deliberately not ocmlabs_extra_fees_tax: the framework derives a <code>_amount data key that must never collide with a persisted quote_address column.

Condition evaluation

Model\ConditionEvaluator handles two condition kinds. Product/SKU conditions are evaluated per cart item: == is an any-item match (“contains” for multiselect values), != is a none-match, and the four numeric operators are scalar-only comparisons that never match multiselect/array values. Because quote items only carry a whitelist of ~29 attributes, the evaluator reloads each unique product through ProductRepositoryInterface (request-scoped cache) so custom EAV attributes are available; configurable products are checked on both parent and child.

Cart-level pseudo-attributes (cart.subtotal, cart.qty, shipping country/region/postcode, customer group, shipping method, payment method) resolve one value from the quote. cart.subtotal is read in base currency from the in-flight Total being collected - not from a possibly stale Quote::getSubtotal(), which is not repopulated until every address finishes collecting. Shipping- and payment-dependent codes resolve to empty until the data exists on the quote, and an empty value fails the condition under every operator - that is what keeps such fees inert on virtual carts and early checkout steps.

Model\FeeCalculator returns both currencies in one DTO: fixed amounts convert base to display via PriceCurrencyInterface::convertAndRound(); percentage amounts are computed per currency from that currency’s own row totals, then clamped by the base-currency min/max caps. Model\FeeSchedule evaluates the active window as store-local wall-clock time with inclusive bounds, following core’s scheduled special price pattern.

Order lifecycle integration

  • Observer\TransferFeesToOrder (sales_model_service_quote_submit_before) copies amounts, tax, and the detail JSON from quote address to order and populates order extension attributes.
  • Observer\PreserveSelectedFeesOnCartMerge (sales_quote_merge_after) carries a guest’s optional-fee selection onto the customer quote on login, since Quote::merge() copies only items and the coupon code.
  • Model\Total\Invoice\ExtraFees and Model\Total\Creditmemo\ExtraFees implement the invoice-once/refund-once rule using order-level invoiced/refunded tracker columns; refundable is always invoiced minus refunded, clamped at zero.
  • The admin orders grid column is a declarative entry on the Sales\...\Order\Grid virtualType’s columns argument, so core’s own grid refresh populates it under both synchronous and async (cron) grid indexing - no plugin on order save.
  • A shared Block\Sales\Order\Totals\ExtraFees renders totals rows across admin, account, guest, print, and email views; etc/pdf.xml registers the PDF total once for both invoice and credit memo PDFs.

Database schema

Own tables: ocmlabs_extra_fee (definitions incl. caps, tax class, optional flag, schedule), ocmlabs_extra_fee_condition (attribute/value/operator rows, CASCADE), ocmlabs_extra_fee_store (store linkage, store_id 0 = all), ocmlabs_extra_fee_label (per-store labels), ocmlabs_extra_fee_log (audit rows, no FK so entries survive fee deletion).

Columns added to core tables: quote.ocmlabs_selected_fee_ids; amount/base/tax/detail columns on quote_address and sales_order; invoiced and refunded tracker columns on sales_order; per-document amount and tax columns on sales_invoice and sales_creditmemo; sales_order_grid.ocmlabs_extra_fees_amount. Fee data is exposed as extension attributes on OrderInterface and quote AddressInterface.

REST API

Admin fee CRUD (token-authenticated, per-route ACL):

MethodRouteACL
GET/V1/ocmlabs-extra-fees/searchOCMLabs_ExtraFees::view
GET/V1/ocmlabs-extra-fees/:feeIdOCMLabs_ExtraFees::view
GET/V1/ocmlabs-extra-fees/:feeId/conditionsOCMLabs_ExtraFees::view
POST/V1/ocmlabs-extra-feesOCMLabs_ExtraFees::manage
DELETE/V1/ocmlabs-extra-fees/:feeIdOCMLabs_ExtraFees::delete

Delete requires the delete ACL specifically - a manage-only token cannot delete, matching the admin form. Store scoping, per-store labels, and condition writes are not settable over this API (they live behind saveWithConditions(), which is not exposed); a fee created here is seeded to All Store Views. Unknown body fields fail with HTTP 400 before any save - core ServiceInputProcessor behavior.

Customer and guest optional-fee selection endpoints (/V1/carts/mine/ocmlabs-extra-fees, /V1/guest-carts/:cartId/ocmlabs-extra-fees) are documented on Optional Fees.

GraphQL

Parsed only in the graphql area via soft module dependencies, so non-GraphQL installs are unaffected. All amounts are display-currency and tax-exclusive:

  • Cart.ocmlabs_extra_fees: [OcmLabsCartExtraFee!]! - applied fees (code, label, amount, base_amount, tax_amount, is_optional)
  • Cart.ocmlabs_available_optional_fees: [OcmLabsOptionalFee!]! - offerable optional fees (fee_id, label, amount, selected)
  • Mutation.ocmlabsSetSelectedFees(input: { cart_id, fee_ids }) - replaces the selection, returns the cart with totals recollected
  • CustomerOrder.ocmlabs_extra_fees: [OcmLabsOrderExtraFee!]! - fees on a placed order, labels snapshotted at purchase time

Theme integration

The cart and checkout fee lines are Knockout components extending Magento_Checkout/js/view/summary/abstract-total, iterating totals segments with the ocmlabs_extra_fee_ code prefix. Hyva’s cart page does not process Luma jsLayout, so the companion ocmlabs/module-extra-fees-hyva module supplies Alpine.js/Tailwind cart totals templates and a totals-sort plugin (store-view aware via getActiveList($storeId)). The checkout flow on Hyva stores uses the Luma/Knockout fallback; native Hyva Checkout (Magewire) is not supported.