Notes / April 10, 2026

Extending MoptAvalara6 for RDF and entity use codes (Shopware 6)

How we extended MediaOpt’s Avalara plugin with a companion plugin, composer patches, retail delivery fee lines, and AvaTax entity use code sync.

A software-engineering note from the Ellevet Shopware 6 build.

Shopware’s Avalara integration from MediaOpt (MoptAvalara6) handles most cart and order tax flows out of the box. For Ellevet we needed two compliance-oriented extensions that the stock plugin did not fully cover:

  1. Retail Delivery Fee (RDF) — states that require an RDF line on the AvaTax GetTax request so Avalara can return the correct fee/treatment.
  2. Entity use codes — customer-level tax exemption / use codes that Avalara expects on the transaction model, with admin-managed options synced from the AvaTax API.

Rather than fork the vendor plugin in a private repo, we shipped a companion static plugin AvalaraEnhanced and applied cweagans/composer-patches to store.shopware.com/moptavalara6 so upgrades stay traceable and diffs stay small.


1. Entity use codes (tax exemption on the customer)

Problem

Avalara distinguishes exemptions and special tax handling with entity use codes. Merchants need B2B customers to pick a valid code (or none), and that value must flow into CreateTransactionModel / tax requests—not only appear as a cosmetic custom field.

What we built

Customer custom field

On install/activate, AvalaraEnhanced upserts a custom field set avalara_tax_exemption_fieldset on customer with a SELECT field mopt_avalara_entity_use_code (labels under “Tax Settings” in the account area). The field starts with a single “No exemption” option.

Patching the MediaOpt plugin

A composer patch extends TransactionModelFactory::build() to accept an optional $entityUseCode and assign it to the Avalara model when non-empty. GetTax.php and CheckoutSubscriber.php read the customer’s custom fields and pass the trimmed code into build().

So the data path is: Customer entity → GetTax / checkout subscriber → TransactionModelFactory → Avalara entityUseCode.

Syncing codes from AvaTax

EntityUseCodeSyncService calls the official client’s listEntityUseCodes(), builds a Shopware-friendly options array (value + translated labels), then:

  • Persists options to system config key MoptAvalara6.config.entityUseCodes (so MediaOpt’s own config UI can reuse them where applicable).
  • Updates the custom field definition’s config.options via custom_field_set.repository so the storefront/admin select always matches the company’s Avalara account.

An admin-only API route POST /api/_action/avalara-enhanced/fetch-entity-use-codes (AvalaraEnhancedApiController) wires this up: it reuses MoptAvalara6 credentials (AvalaraSDKAdapter + Form::ACCOUNT_NUMBER_FIELD / license / live flag) from either the request payload (plugin settings form) or SystemConfigService, then runs the sync for the requested sales channel.

Why this design

  • Single source of truth: codes come from Avalara, not hand-maintained JSON.
  • Sales-channel aware: sync writes config per channel when needed.
  • Minimal intrusion: only a few vendor touch points are patched; the rest lives in AvalaraEnhanced.

2. Retail delivery fee (RDF) on the GetTax payload

Problem

In certain ship-to states, Avalara expects a dedicated RDF line on the tax request (with specific tax code and item code conventions) so the platform can calculate retail delivery fees correctly. The stock plugin did not add that line in our scenarios.

What we built

RdfLineHelper (our plugin)

AvalaraEnhanced\Service\RdfLineHelper centralizes eligibility and line construction:

  • Eligibility: ship-to state present; RDF enabled in our plugin config; state appears in a configurable comma-separated allow list; cart has at least one non-discount line (via LineFactory::isDiscount()).
  • State resolution: supports 2-letter codes, Shopware-style region strings, and a small full state name → abbreviation map for robustness.
  • Line shape (when eligible): LineItemModel with number from MediaOpt’s Form::RDF_LINE_NUMBER, itemCode like RDF-MISC-CO / RDF-MISC-MN, amount 0, quantity 1, description “Retail Delivery Fee”, taxCode OF400000, and addresses set so origin/destination match the rest of the transaction.

Config lives under AvalaraEnhanced.config.rdfEnabled and AvalaraEnhanced.config.rdfStates, read per sales channel the same way MediaOpt reads plugin config.

Patching MediaOpt’s line factory

Composer patches extend AbstractTransactionModelFactory::getLineModels() to accept $shipToStateCode and $addresses, then after product and shipping lines call:

$rdfLine = \AvalaraEnhanced\Service\RdfLineHelper::buildRdfLineIfEligible(
    $shipToStateCode,
    $lineItems,
    $addresses,
    $this->getPluginConfig(Form::RDF_ENABLED),
    $this->getPluginConfig(Form::RDF_STATES)
);

Additional patches (see ellevet-shopware/composer.json under extra.patches) wire RDF through transaction factory, GetTax response handling, OverwritePriceProcessor for a visible cart line when Avalara returns RDF tax, Form.php / config.xml for constants and admin fields, and a sync entities button in the MediaOpt config card.

Observability

RdfLoggerSubscriber and plugin boot() inject Monolog (monolog.logger.avalara when present) into RdfLineHelper so support can trace “why didn’t RDF fire?” (disabled, wrong channel, state list, no taxable lines, etc.) without guessing.


3. Order lifecycle: cancel and refund hooks

Tax engines care about document lifecycle, not only the first GetTax. Our OrderChangesSubscriber listens to OrderEvents::ORDER_WRITTEN_EVENT, watches for stateId changes on the order, and when Shopware transitions into configured cancel or refund states, calls the MediaOpt AvalaraSDKAdapter pipeline (CancelOrder / RefundOrder style operations—exact service names align with MoptAvalara6 internals).

Configuration

  • AvalaraEnhanced.config.orderCancel and AvalaraEnhanced.config.orderRefund let us override or complement MediaOpt’s defaults per sales channel—useful when the merchant’s finance team uses non-stock state machine customizations.

Operational detail

  • Log failures when the order cannot be reloaded (deleted environments) so support does not assume Avalara received a void silently.

4. Engineering takeaways

Theme What you can say in an interview
Vendor boundaries Extended a commercial Shopware plugin via a separate package + documented composer patches instead of unmaintainable vendor edits.
Tax domain Implemented state-specific RDF handling and Avalara entity use codes end-to-end (payload, config, admin sync).
Shopware DAL Used custom_field_set.repository and system config to keep UI options aligned with AvaTax listEntityUseCodes.
Operations Structured logging around RDF eligibility to shorten production debugging.

5. References in the repo

  • Plugin: ellevet-shopware/custom/static-plugins/AvalaraEnhanced/
  • Patches manifest: ellevet-shopware/composer.jsonextra.patchesstore.shopware.com/moptavalara6
  • RDF logic: AvalaraEnhanced/src/Service/RdfLineHelper.php
  • Entity use sync: AvalaraEnhanced/src/Service/EntityUseCodeSyncService.php
  • API trigger: AvalaraEnhanced/src/Controller/Api/AvalaraEnhancedApiController.php
  • Customer field install: AvalaraEnhanced/src/AvalaraEnhanced.php