Source
This page is generated from docs/domain-glossary.md at the pinned revision 5ab878035219. Edit it there, not here.
Domain glossary
Project-specific EN 16931 / XRechnung conventions and pitfalls that are easy to miss and not obvious from reading the spec alone. See AGENTS.md §2 for how this file is used. Back to docs/README.md.
- The XRechnung customization ID is a compound URN, not just a version number:
urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0. It names both the EN 16931 compliance level and the specific CIUS. Every scenario in the KoSIT validator configuration matches documents by this exact string (s:matchinscenarios.xml) — get it wrong and the validator silently runs the wrong scenario (or none) instead of failing loudly. - A KoSIT Schematron message's
leveldoes not map to pass/fail.BR-DE-TMP-32(missing delivery date / invoicing period) is emitted atlevel="information"on an otherwise fully compliant invoice — the top-levelrep:report/@validattribute (or the top-levelsummary/@statusin Mustang's report) is the actual verdict; counting "any message present" as failure produces false negatives. Verified against a real KoSIT Validator 1.6.3 run. - The KoSIT Validator writes its report next to the input file, not to stdout (
<input>-report.xmland.html, same directory) — a read-only mount of the input directory makes the validator fail to write the report, not just skip it silently. - Both the KoSIT Validator and Mustang exit non-zero for a rejected document, not only for a tool crash — treat "process exited with an error" and "document is invalid" as the same signal, but still parse the emitted report/JSON rather than trusting the exit code alone (a crash produces no report).
- veraPDF's JSON output nests
validationSummaryunderreport.batchSummary, not at the top level ofreport— easy to miswire when parsing the CLI's--format jsonoutput. - A summarized fetch of a BT reference page can simply be wrong, even when the page itself is real. While building
einvoice-model, a summarized read of a Peppol postal-address page claimed BT-40 = "Seller country subdivision" and BT-41 = "Seller country code" — both wrong. Our own vendored Schematron directly asserts BR-09: "The Seller postal address (BG-5) shall contain a Seller country code (BT-40)", and a raw (non-summarized) fetch of the same Peppol page confirmed BT-41 = "Seller contact point". Treat a summarized answer about a specific BT/BG number as a lead to verify via direct quote (grep the vendored artifact, or read the raw fetched text yourself), never as the citation itself —tools/codegen/model/generate.mjs's artifact cross-check exists specifically to catch this class of error before it reaches generated code. currencyIDmay only be set onram:TaxTotalAmount, never on any otherram:*Amountelement, under the XRechnung CII profile (CII-DT-031, inXRechnung-CII-validation.xsl, KoSIT/Apache-2.0) — base UN/CEFACT CII allows it everywhere, so this is an XRechnung-specific tightening, not obvious from the CII XSD alone. Found by running our own serializer output through the real KoSIT validator.- CII caps "Preceding Invoice reference" (BG-3) at one occurrence, even though the EN 16931 semantic model phrases the rule as "Each Preceding Invoice reference (BG-3) shall contain..." implying it can repeat.
HeaderTradeSettlementType'sInvoiceReferencedDocumentelement has nomaxOccurs="unbounded"in the CII D16B XSD — a genuine binding limitation of the CII syntax, not something the UBL binding necessarily shares. A credit note referencing multiple prior invoices needs a different mechanism (out of scope for v0.1's single-reference credit-note scenario). - The XRechnung CII profile requires far more than the base EN 16931 rule set: a seller contact with name, phone, and email (BG-6/BT-41/42/43); seller and buyer city/postcode, not just country (BT-37/38/52/53); payment instructions (BG-16) on every invoice, not just ones with a bank transfer; seller and buyer electronic addresses with an EAS scheme (BT-34/49,
@schemeIDfrom the CEF EAS code list,BR-CL-25); and a business process type (BT-23) — this last one and the electronic-address checks (PEPPOL-EN16931-R001/R010/R020) come from rules KoSIT bundles into the "EN16931 (CII)" Schematron step itself, not the XRechnung-specific layer, so they fire even outside the DE profile once validated through KoSIT's tooling. None of this is discoverable from the base ConnectingEurope EUPL-1.2 Schematron alone — found by serializing real fixtures and reading the KoSIT rejection. A buyer without an electronic address failsPEPPOL-EN16931-R010even once the emptyURIUniversalCommunicationis gone (checked with KoSIT on 2026-09-23), sobuildInvoicerefuses both parties without one (MissingElectronicAddressError) — the Medusa adapter's source is the order's email. BR-AE-02(reverse charge) needs identification on both parties, not just the seller: the seller's VAT-ID or tax registration and the buyer's VAT-ID or legal registration identifier. Easy to miss becauseBR-S-02-style rules for other categories only ever check the seller.BR-IC-02(intra-EU supply, category K) has the exact same both-parties trap asBR-AE-02above, not just the seller's VAT-ID: seller and buyer VAT-ID,flag="fatal"(verbatim-verified against the vendored Schematron).buildInvoiceguards it together with the neighbouringBR-IC-11/BR-IC-12(delivery date/country): a K-category document without the buyer's VAT-ID is refused (MissingBuyerVatIdError), not just one without a positive VIES check.BR-DE-16(seller VAT/tax identifier) is fatal;BR-DE-17(allowed XRechnung document-type codes) andBR-DE-26(BT-25preceding-invoice reference recommended on a corrected invoice, type 384) are onlyflag="warning"— don't "fix" a warning-only rule as if it were a rejection. This also means KoSIT's own top-levelvalidattribute is not the business verdict here: a document can bevalid="false"from aBR-DE-26warning alone while<rep:assessment><rep:accept>still accepts it — seekosit-report.ts'sacceptedfield rather than readingvalidalone for these two rules specifically.- An allowance/charge's base amount can't be set without a percentage.
PEPPOL-EN16931-R042: "Allowance/charge percentage MUST be provided when allowance/charge base amount is provided" — CII'sTradeAllowanceChargeTypeputsCalculationPercentright beforeBasisAmountin its sequence, and supplying one without the other is rejected.einvoice-modelcarries both (baseAmountandcalculationPercent: BT-93/94 for a document allowance, BT-100/101 for a charge, BT-137/138 and BT-142/143 on a line); set both or neither — thede-document-discountfixture sets both. - "Gutschrift" ≠ credit note. In UStG terms it is a self-billed invoice (389). Use "Rechnungskorrektur" for 381 in anything a human reads; keep code 381 in XML.
BR-CO-17andBR-S-09accept a group VAT that is off by less than 1. Their Schematron tests compare BT-117 with round(BT-116 × rate) within ±1 (a whole currency unit, not a cent). That is what lets an invoice for prices including VAT state the VAT taken out of the gross total, which differs from taxable × rate by a cent for about one gross total in six — confirmed against KoSIT with thecommerce-gross-pricesfixture (4.97 on 26.13 at 19%, where taxable × rate gives 4.96).- No validator asks for a street, and German law does. EN 16931 leaves every address line optional (BT-35/36 seller, BT-50/51 buyer, BT-75/76 deliver-to) and the XRechnung rules require only city and post code (BR-DE-3/4/8/9), so a street-less invoice is KoSIT-green — while §14 Abs. 4 Satz 1 Nr. 1 UStG needs the full address of both parties (and §33 UStDV still the seller's on a small-amount invoice). In CII the lines are
ram:LineOne/ram:LineTwo, betweenram:PostcodeCodeandram:CityNameinTradeAddressType's sequence. No rule in the vendored Schematron names BT-35/36/50/51/75/76; their names were taken from@e-invoice-eu/core's schema and confirmed on each element's docs.peppol.eu page. - A country code does not say where the EU VAT area ends. A shop sends the Canary Islands as
ES(postcodes 35/38), Northern Ireland asGB(BT…) and Heligoland asDE(27498). The EN 16931 country code list does haveXIfor Northern Ireland, but no shop uses it. The Commission's territorial-scope table (taxation-customs.ec.europa.eu/territorial-scope_en) has a "VAT rules apply" column: "no" for Heligoland, Büsingen, Ceuta, Melilla, Livigno, Mount Athos, the Canary Islands, Campione d'Italia, the Italian waters of Lake Lugano, the Åland Islands and the French outermost regions; Northern Ireland is "partly — goods only". Monaco is French territory for VAT. None of this is visible to a validator.
Medusa v2 (einvoice-medusa)
None of the following is taken from documentation by memory — every item was read directly from a real, freshly-created create-medusa-app@latest --plugin/full app (v2.19.0/2.21.0 at the time) or a real installed @medusajs/* package's compiled source.
- The real fulfillment-created event is
order.fulfillment_created(OrderWorkflowEvents.FULFILLMENT_CREATED,@medusajs/utils/dist/core-flows/events.js), payload{ order_id, fulfillment_id, no_notification }. There is no separate "shipment created" event at the order level for this purpose (that name,FulfillmentWorkflowEvents.SHIPMENT_CREATED="shipment.created", is a different, lower-level fulfillment-module event, not what a subscriber wanting "an order got a fulfillment" should use). - There is no
order.refund_createdevent. A refund is a payment-module concept in Medusa v2:PaymentEvents.REFUNDED="payment.refunded", payload only{ id }— the payment's id, not the order's. A subscriber must resolve the order from the payment id itself (e.g. via a remote query following the payment↔order module link) before it can build aCommerceInvoiceInputfor a credit note — the refund subscriber's design has to account for this, it cannot assume anorder_idarrives with the event the wayorder.fulfillment_createdprovides one. - A custom Medusa module's service constructor receives
(container, options),optionsbeing exactly whatmedusa-config.ts'splugins: [{ resolve, options }]declared for that plugin. Verified against a real compiled module provider (@medusajs/[email protected]'sLocalNotificationService,constructor({ logger }, options)), not the (accurate but non-concrete) prose in the plugin scaffold's ownsrc/modules/README.md. - A real, currently-published Medusa v2 invoicing plugin, the one this adapter once integrated with, exists and was inspected directly:
@webbers/[email protected](npm, MIT). It defines its owninvoicemodule (INVOICE_MODULE = "invoice") with a data model carryingdisplay_id(autoincrement — the human-readable invoice number the Webbers integration reads),resource_id,type("debit" | "credit" | "void"),pdf_url(nullable),parent_invoice(self-referential, links a credit invoice to what it corrects). It links its owninvoicemodule toordervia adefineLink(isList, tableinvoice_order) — one order can have many invoices (original + corrections). Its ownfulfillment-created-invoicesubscriber listens to the sameorder.fulfillment_createdevent and guards idempotency by querying that link for an existing invoice before creating one. @webbers/invoices-medusa's owncreateInvoiceWorkflowdefines nocreateHook()— Medusa v2 workflows can expose named extension points for other plugins to hook into, but this one doesn't, so there is no clean "Webbers finished creating its invoice" signal to subscribe to instead of the raw platform event. Open question for the Webbers integration, not resolved here: whether a second subscriber on the sameorder.fulfillment_createdevent (querying Webbers' owninvoice_orderlink, retrying briefly if not yet present) is safe against subscriber-ordering/timing, or whether some other mechanism is needed — genuinely unverified, flagged rather than guessed.create-medusa-app@latest(current, v2.21.0) scaffolds a Turborepo-style monorepo by default (apps/backend/, a rootturbo.json/pnpm-workspace.yaml), not the single-root layout older tutorials/screenshots show —medusa-config.tslives atapps/backend/medusa-config.ts, not the repo root. Matters for the Medusa quickstart: a "put this inmedusa-config.ts" instruction needs that path spelled out or a new developer will look in the wrong place.- A Medusa v2 plugin package needs its own, self-contained
tsconfig.json, not extending a shared base the way every other package in this monorepo does:target: "ES2021",module/moduleResolution: "Node16",emitDecoratorMetadata/experimentalDecorators: true, no"type": "module"inpackage.json(both the official scaffold and@webbers/invoices-medusaagree on this — plugins are CommonJS at the TS-compile level). A type-only import from one of this repo's own ESM packages (@normwerk/einvoice-commerce) into the plugin therefore needs an explicitwith { "resolution-mode": "import" }(TS 5.3+) — a real, load-bearing consequence of the CJS/ESM boundary between this package and the rest of the monorepo, not a style choice. medusa plugin:buildactually type-checks (not merely transpiles via SWC) — confirmed by deliberately introducing a type error and observing a realTS2322failure with exit code 1, not a silent pass. Its own build succeeding is real evidence, not merely "no crash."- Installing a private, unpublished
@normwerk/*package chain into another Medusa app for local development needs every package in the chain yalc-published, not just the plugin itself — a plugin's own compiledpackage.json(frommedusa plugin:build) cannot carry pnpm'sworkspace:*protocol (yalc publishsilently falls back to"*"for it), so a consuming app's package manager will try to fetch that dependency from the real npm registry and 404. This is not just a yalc quirk: it means@normwerk/einvoice-modeland@normwerk/einvoice-commercemust themselves be published to npm before@normwerk/einvoice-medusacan be a normally-installable plugin for anyone outside this repo — relevant to publishing on npm, not previously an explicit finding. - Cancelling an order refunds its captured payments without a
payment.refundedevent.cancelOrderWorkflowrefunds throughrefundCapturedPaymentsWorkflow, which emits nothing a refund subscriber hears; the only signal isorder.canceled(OrderWorkflowEvents.CANCELED, payload{ id }, the order's id). A credit note for a cancelled, already-invoiced order therefore needs its ownorder.canceledsubscriber — a refund subscriber alone silently leaves the invoice uncorrected. - Medusa refuses to cancel an order that still has an active fulfillment. Each fulfillment is cancelled first (
POST /admin/orders/:id/fulfillments/:fulfillment_id/cancel), then the order. An order invoiced onorder.fulfillment_createdis always in that state, so cancelling one is a two-step operation. - A refund's amount is
payment.refunds[].amount, gross. A partial refund is credited as one VAT-inclusive line (priceInclVat) over that sum, so the credit note totals exactly the refund. Converting it to a net amount first cannot always work: at 2-decimal precision about one gross sum in six has no net whose VAT adds back to it (0.03 at 19%). - Tax-inclusive prices in Medusa are per price preference, not per product. The region and the currency each get a price preference when the region is created (
POST /admin/price-preferencesfor the region then fails with "already exists"; update it instead). On a tax-inclusive lineunit_priceanddiscount_totalinclude tax anddiscount_subtotaldoes not; a tax-inclusive shipping method'stotalis what was charged. - A guest checkout's customer record has no name. Medusa creates it from the email alone — no
first_name,last_nameorcompany_name; the buyer's name lives only on the order's billing and shipping addresses. Reading names fromorder.customeralone names every guest after their email. - An order of services ships nothing and is still fulfilled. A product with no shipping profile whose variant has
manage_inventory: falsegetsrequires_shipping: falselines; a cart of only such lines completes without a shipping method, andPOST /admin/orders/:id/fulfillmentsaccepts it without ashipping_option_idand emitsorder.fulfillment_created(verified on 2.21.0). Such an order is invoiced when the merchant fulfills it, not before. - A tax region without a default rate charges no VAT, silently.
createTaxRegionsWorkflowaccepts a country withprovider_idonly; the system tax provider then charges 0 on every order shipped there. The e2e stand's own seed had exactly that until an invoice total was compared withorder.total.
Subscribers and idempotency
- A CommonJS-mode Medusa plugin cannot statically
importa value from one of this repo's own ESM packages (@normwerk/einvoice-commerce/@normwerk/einvoice-cii, both"type": "module"with norequireexport condition) — that compiles to arequire()that throwsERR_REQUIRE_ESMat runtime. This is a different problem from the oneresolution-mode: "import"(above) fixed: that attribute only ever changes how TypeScript resolves types, never how Node resolves a value at runtime. The real, necessary fix for a subscriber that needs to actually callbuildInvoice/selectProfile/SequentialNumberer/serializeCiiis a dynamicimport()inside the (already-async) subscriber function body — TypeScript compiles a dynamicimport()expression to a real ES dynamic import even under CommonJS output, which is the sanctioned interop path. Pure type-only usage elsewhere in the plugin (service.ts,numbering-store.ts, themapping/module) still uses the staticresolution-mode: "import"form — the two techniques solve different halves of the same CJS/ESM boundary and neither substitutes for the other. - A Medusa v2 order line item's own DML model (
OrderLineItem) has noquantityfield at all — confirmed directly from@medusajs/[email protected]'s compiled model source. Quantity lives on a separate, linkedOrderItemrow, reached in aquery.graphfields array asitems.detail(the exact path real admin routes use,api/admin/orders/query-config.js'sdefaultAdminRetrieveOrderFields). Naively readingitem.quantityoff a queried order line compiles and often "looks right" in a quick manual check (a wrongly-defaulted1for a single-item cart is easy to miss) — this is exactly the kind of "obviously simple" platform field this project's own rule (docs/domain-glossary.md's own earlier BT-40/ BT-41 entry) says to verify rather than assume, applied here to platform schema instead of an EN 16931 BT. OrderLineItem.is_tax_inclusiveis a real, per-line boolean — a store's prices can be tax-inclusive or tax-exclusive per line, not fixed store-wide. A tax-inclusive line is mapped toCommerceLine.priceInclVat, never tonetPrice— treating the gross price as net would add tax on top of tax already included, and backing the rate out line by line rounds each line on its own, so the invoice can total a cent less than was charged.- Race-safe per-series counters in real Medusa module services don't use
SELECTthenUPDATEfrom application code —display_id(Medusa's own order-numbering field) is a native PostgresSERIALcolumn (model.autoincrement(), confirmed in@medusajs/order's compiled model + migration), and the one real first-party precedent for an application-level concurrency-sensitive counter,@medusajs/promotion'sregisterUsage(compiled source), takes an explicitSELECT ... FOR UPDATErow lock inside a transaction (needed there because it also enforces a budget/usage-limit check, not a pure increment). This plugin's ownEinvoiceCounterneeds no limit check, so a single atomicINSERT ... ON CONFLICT (series) DO UPDATE SET value = value + 1 RETURNING valueis the simpler, still fully race-safe mechanism for that narrower case — reached via@InjectTransactionManager()+@MedusaContext()(the same real decorator pair@medusajs/order's own module service methods use throughout, confirmed by name in its compiled source) to get a transaction-bound knex instance (transactionManager.getTransactionContext() ?? transactionManager.getKnex()). - A unique-constraint violation does not reach a module service as MikroORM's own exception. Medusa's repository layer (
@medusajs/utils,dal/mikro-orm/db-error-mapper.js) catches MikroORM'sUniqueConstraintViolationException(or Postgres code23505) and rethrows aMedusaErrorof typeinvalid_datawith the message "… already exists." — itsnameis plain"Error". Corrected 2026-09-23: this entry used to say the violation is detectable byerror.name, and the idempotency guard relied on that, so its lost-race branch never ran.EinvoiceModuleService.recordDocumentIfAbsentnow re-reads the(type, idempotency_key)key after any failed insert instead: a document there means another delivery won the race; none means a real failure, rethrown unchanged. - Medusa's local event bus (the default, no Redis configured) never retries a subscriber that throws — confirmed from
@medusajs/[email protected]'s compiled source: it wraps every subscriber call in a try/catch that only logs the error, nothing more. The Redis event bus can retry (BullMQattempts), but its own default job options setattempts: 1— so even with Redis configured, a genuine automatic retry only happens if whoever emits the event explicitly opts in with a higherattemptsvalue. This matters for the subscribers' idempotency design: a "redelivered"order.fulfillment_created/payment.refundedin practice means a manual replay, not an automatic retry storm — informed how strict the idempotency check needs to be (checked up front, before allocating a document number, so a redelivery does not burn one in the realistic case) versus how strict it only needs to be as a backstop (the database-level unique constraint, for the truly-concurrent case the local event bus can't even produce). payment.refunded's payload-only-carries-{id}gap (flagged unresolved in the refund entry above) needs twoquery.graphcalls, not one, and the working filter shape is a nested object, not a dotted string. Aquery.graph({ entity: "order", filters: { "payment_collections.payments.id": paymentId } })— the same dot-notation path@medusajs/core-flows'refundCapturedPaymentsWorkflowreads in its ownfieldsarray — looks like the natural filter equivalent, but a real e2e run (Docker Postgres) produced an actual Postgres error:missing FROM-clause entry for table "payments". The reason:payment_collections .paymentsis a two-hop path across a module link (order↔payment_collection is a realdefineLink; payment_collection→payment is a plain FK inside the payment module alone) —query.graph's filter resolution only builds a SQL join for a path present infieldswithin one module's own schema, it does not reach across a module link that way, even though the identical path works fine as afieldsentry (reading, not filtering, apparently takes a different resolution path). The real, working fix: (1) querypaymentfor its ownpayment_collection_id(no link — a plain field), then (2) queryorderfiltered by the nested-object form{ payment_collections: { id: paymentCollectionId } }— one hop, and a nested object rather than a dotted string. This nested-object shape is what actually resolves a cross-module link filter. Neither of these two facts (two-hop paths need two queries; a cross-module link filter needs the nested-object form) is visible from reading@medusajs/core-flows' own source, which never filters by a linked field at all in any bundled workflow — found only by running the real query against a real instance and reading its actual SQL error.- The admin API's own
"*relation"wildcard-prefix field syntax (api/admin/orders/query-config.js) silently returns nothing when passed directly toquery.graph()from a subscriber — no error, the relation is just absent from the result, easy to mistake for "this order really has no customer/address" rather than a syntax problem. Confirmed against a real running instance:fields: ["*customer", "*shipping_address", "*items"]returned only the top-level scalar fields; the explicitrelation.fielddot-notation form ("customer.email","items.detail.quantity", …) returns the real data. The admin route's own resolution of"*relation"must go through a different layer (its own field-config expansion) that a directquery.graph()call from a subscriber doesn't get — this repo's first attempt atORDER_QUERY_FIELDScopied the admin route's own convention and had to be corrected once run for real. buildInvoice's XML output always targets the full XRechnung 3.0 CIUS, regardless of which profileselectProfileresolves (EN16931vsXRECHNUNGis only about which ZUGFeRD/PDF-embedding profile string to use —profile.ts's own doc comment already said this, but its practical consequence wasn't obvious until a real KoSIT run showed it): every invoice/credit-note this plugin builds is validated against the same Schematron rule set either way, including three fields nothing inbuildInvoiceitself enforces (it just passes each through unchanged if given):- BT-34/BT-49 (seller/buyer electronic address) need a scheme identifier (BR-62/BR-63) — real KoSIT rejection on a mapping that left both unset.
service.ts'sassertValidOptionsnow requiresseller.electronicAddress/electronicAddressScheme; the mapping (mapOrderToCommerceInvoiceInput) fills the buyer's from the order's own email with EAS scheme"EM"— the same convention every fixture in this repo already used for a plain email address. - BT-10 (buyer reference) is mandatory on every invoice, not just a B2G one (BR-DE-15) — so its presence, or its shape, can never be the B2G signal. The first fix kept a separate "raw" reference for
selectProfile; that still read a Leitweg-ID out of the shape of free text, and an ordinary reference such as2024-01has the same shape (refused for failing the check digits, or routed to XRechnung when they happened to add up). A Leitweg-ID is now its own declared field,references.leitwegId, fromcustomer.metadata.leitweg_id;buyerReferenceis always free text —customer.metadata.buyer_referenceor the order'sdisplay_id. - BG-16 (payment instructions) is mandatory on every invoice (BR-DE-1) — merchant-level bank details (which account the buyer should pay into), the same kind of static config as
selleritself, not derivable from an order. Added as a new requiredEinvoiceModuleOptions.paymentfield ({ means, iban?, terms? });service.ts'sassertValidOptionsrefuses to construct the module without it, the same way it already refused a seller missingvatIdentifier. All three were found by actually running the plugin's own output through the real KoSIT Validator inside the plugin's e2e proof (not anticipated from readingbuild-invoice.tsor the base Schematron alone) — each fix was verified by re-running the same validator until it returnedACCEPTABLE, not just by making the error message go away.
- BT-34/BT-49 (seller/buyer electronic address) need a scheme identifier (BR-62/BR-63) — real KoSIT rejection on a mapping that left both unset.
- A plan writes nothing — the log included.
planInvoiceForFulfillmentandplanCreditNotereturn a refusal as{ kind: "refused", error }and a block as{ kind: "blocked", block }; only the commit (issueInvoiceForFulfillment,issueCreditNote) records, announces and logs them. Easy to break by "just" adding alogger.warnor aclearRefusalto the decision: a plan run over a shop's past orders would then write to its log or its refusals. The commit reads the issue date back frominput.document.issueDate, so plan and commit cannot straddle midnight with two dates. - A core error's message reaches the merchant's log verbatim — so it never carries a value of the order.
recordRefusalOfErrorlogs the message of every error with a code (errorForLoginerrors.tsdoes the same for the other log lines). Until 2026-10-05, fourTaxRuleErrors named the buyer's VAT-ID and the special-territory refusal the buyer's post code, so every VIES outage wrote VAT-IDs to the log. Name the problem, the code and the amounts; the value is on the order. An error without a code (INTERNAL_ERROR) is logged by its class only: its message is someone else's — a merchant's VIES client, the database driver. - knex puts a failed query's values into the error message.
formatQuery(sql, bindings)is prepended to every driver error unlesscompileSqlOnError: false(knex/lib/execution/internal/query-executioner.js). Medusa'sdbErrorMapperrewrites only unique, not-null, foreign-key and invalid-field errors; anything else — a dropped connection mid-insert — carries theINSERT's values, which foreinvoice_documentincludetax_decisionsandvat_id_evidence. Another reason the plugin's log lines name such an error by its class only. - Both Medusa event buses log the whole error a subscriber throws —
[email protected]withlogger.error(err),[email protected]withlogger.warn(err). A failure after the document number is taken is rethrown after the plugin's own line naming the number: Medusa's line has the message, the plugin's the number no document carries.
@webbers/invoices-medusa integration
The integration was removed before 0.1.0: their PDF shows Medusa's totals, which differ from the e-invoice wherever the plugin corrects the VAT, and two documents for one supply are two invoices. The findings below describe their package and stay as reference; the plugin now only warns at startup when their module is registered (modules/einvoice/other-invoice-plugins.ts).
A module can see which plugins and modules the app registered: every module's container carries
configModuleandlogger(@medusajs/modules-sdkloadInternalModule), and Medusa merges each plugin's modules intoconfigModule.modulesbefore loading them (mergePluginModules,@medusajs/utils) — keyed by the module's service name (invoicefor Webbers),resolvepointing inside the plugin (@webbers/invoices-medusa/.medusa/server/src/modules/invoice). The module service is constructed withlocalContainer.cradle, soconfigModuleis readable in its constructor, at boot.@webbers/[email protected]'s ownpackage.jsondeclares two more broken export subpaths, the same class of gap first found for"./links"(that file simply doesn't exist in the published tarball):"./workflows"(.medusa/server/src/workflows/index.js) is ALSO missing — only the individual workflow files (create-invoice.js,create-credit-invoice.js, …) exist, no barrel. The wildcard"./*"subpath still resolves a direct file path (@webbers/invoices-medusa/workflows/create-invoice,@webbers/invoices-medusa/links/invoice-order) — confirmed by actually hittingCannot find module '.../workflows/index.js'on a real running instance when importing the documented"./workflows"path, not assumed from reading the export map alone.Dynamically
import()-ing another CommonJS package (not an ESM one) from this plugin double-wraps the default export — a genuinely different case from this plugin's own established "dynamic-import an ESM-only package from CJS" pattern (needed because the target is ESM-only). Here, both this plugin and@webbers/invoices-medusaare CommonJS; going through Node's ESMimport()loader to reach a CJS module still triggers CJS/ESM interop, which sets the synthetic namespace's.defaultto the wholemodule.exportsobject ({ __esModule: true, default: <real value> }), not tomodule.exports.defaultdirectly — so the real value ends up atmod.default.default, notmod.default. Confirmed empirically with a temporary debug route logging the actual awaited value during the Webbers e2e run (it printed{ default: { entryPoint: "invoice_order", ... } }), not assumed from Node's interop documentation. Real, load-bearing consequence:waitForWebbersInvoice's own link-loading code unwraps one extra level, defensively falling back to the un-nested shape too (integrations/webbers.ts).Medusa's local event bus really does start every subscriber of the same event without waiting for any of them (flagged above as the reason no ordering guarantee exists between this plugin's own subscriber and
@webbers/invoices-medusa's; the Webbers integration is where it was actually exercised for real) — confirmed by observing both subscribers' log lines interleave (Processing order.fulfillment_created which has 2 subscribers) and by the poll/wait mechanism (waitForWebbersInvoice) genuinely being necessary rather than decorative: a plain, un-delayed read of theirinvoice_orderlink immediately after the event fires reliably finds nothing yet.@webbers/[email protected]'s owncreateInvoiceWorkflowhas a real, reproducible bug: PDF generation (core/classes/pdf-generator/templates/invoice-content.ts) threwCannot read properties of undefined (reading 'toString')on every real order the e2e harness threw at it (a plaincreate-medusa-appdefault-seeded product, DE billing/shipping address, no discounts) — their own workflow's compensation logic then marks the invoicetype: "void"(a real, documented behavior: "Voided invoices … preserve their sequence number to avoid gaps", their own README) and soft-deletes theinvoice_orderlink row. Root-caused (not merely observed) by invoking theircreateInvoiceWorkflowdirectly from a debug route and reading the full stack trace their own subscriber'scatchblock discards (it only logserror.message): the throw site isinvoice.display_id.toString(), whereinvoice = order.invoices.find(i => i.type === "debit")from their ownquery.graphcall using a"invoices.*"wildcard field — the same class of wildcard-field gap the subscriber section above independently found (a"*relation"-style field silently failing to hydrate outside certain calling contexts). This is an external, confirmed defect in their published package, not caused by anything in this plugin —waitForWebbersInvoice's own timeout-and-throw behavior (WebbersInvoiceNotFoundError) is the correct, honest reaction to it (refuse to allocate a number of this plugin's own rather than assume their workflow succeeded). Proven for real regardless: a debit/credit invoice row andinvoice_orderlink were inserted directly (matching their exact real, migrated schema) with a real PDF uploaded through the real File Module, and this plugin's own subscriber picked both up correctly within its poll window, reused the realdisplay_idas its own document number, and embedded its XML into the real downloaded PDF — the part of the acceptance criterion actually owned by this plugin, proven against their real schema and File Module semantics, with their own workflow's bug clearly separated out as an external, reported limitation rather than something this plugin's own code either caused or silently worked around.pdf_urlon theirInvoicemodel is a File Module file id, not a URL (confirmed directly from their compiledupload-invoice-pdf-step.js:invoiceModule.updateInvoices({ id, pdf_url: file.id })) —fileModuleService.retrieveFile(id)returns{ id, url }, whereurlis itself a presigned download URL that still needs a realfetch()to get bytes (fetchWebbersPdfBytes,integrations/webbers.ts).A PDF produced by
pdfmakewith only the built-in standard fonts (their ownpdf-generator/index.js:pdfmake.addFonts({ Helvetica: { normal: "Helvetica", … } }), never a real embedded font file) is not PDF/A-3-eligible — real veraPDF output on a PDF embedded viaembedInvoiceInPdfA3using exactly this kind of base PDF:"The font program is not embedded" … "compliant": false(ISO 19005-3:2012 §6.2.11.4.1). This is not a new gap the Webbers integration introduces —embedInvoiceInPdfA3's own doc comment already scopes this out: it does not attempt to fix a PDF that isn't already PDF/A-eligible, and that repair step (re-embedding the fonts with Ghostscript) is a documented follow-up, not implemented. But it means, concretely, that@webbers/invoices-medusa's own default configuration (no custom embedded font supplied via itsheader/footer/addressInfooptions) produces PDFs this plugin's Webbers-integration mode cannot turn into a genuinely valid PDF/A-3 hybrid without that still-unimplemented repair step — real, useful context for documenting this integration mode's actual limits, rather than a silent assumption that "if their PDF exists, embedding it always yields a compliant PDF/A-3."@webbers/invoices-medusa's own module requires configuration (addressInfo.companyName/address/ cocNumber/vatNumber/iban/email, all mandatory per its own README) — registering it as a bare string plugin entry (plugins: ["@webbers/invoices-medusa"], nooptions) fails module loading outright (Invalid options: {"addressInfo":["Invalid input: expected object, received undefined"]}), confirmed by hitting this for real before supplying the options their README documents.
Standalone mode's own PDF source
options.standalone.basePdf(invoice)is called with the already-builtInvoice(@normwerk/einvoice-model), not the raw Medusa order — a deliberate design choice, not the only possible one: it means a real implementation can render exactly the BT-1 number/BT-22x totals/BG-23 VAT breakdown the XML will actually carry (the same objectserializeCiiitself serializes), rather than re-deriving them from the order independently and risking the PDF and XML disagreeing. The cost is that the hook necessarily runs after numbering is resolved andbuildInvoicehas already run — both subscribers call it in that order, mirrored from Webbers mode's own PDF-after-numbering sequencing, not a new sequencing invented for standalone mode.@normwerk/einvoice-pdfa's ownrenderInvoicePdfwas test-only until standalone mode needed it — noexportsentry beyond".", andindex.tsnever re-exported it, so nothing outside the package's own test suite could reach it. Re-exported (index.ts) because it is the one concrete, realstandalone.basePdfimplementation this plugin's own e2e proof needed, and — genuinely, not just for the test — it is exactly what a merchant with no PDF renderer of their own would reach for: a real, font-embedded, PDF/A-eligible base PDF, for free, instead of nothing.- The standalone e2e proof needed a real
npm installin the host app afteryalc add, notyalc addalone — a yalc-linked package's owndependencies(here,@normwerk/einvoice-pdfa's real dependency onpdf-lib/@pdf-lib/fontkit) are not copied into the host app'snode_modulesbyyalc additself; the dev server crashed with a realERR_MODULE_NOT_FOUNDforpdf-lib(imported from inside@normwerk/einvoice-pdfa/dist/index.js) until a plainnpm installin the host app's root resolved the linked package's own dependency tree — the same class of "yalc surfaces a packaging gap a normal npm install wouldn't" finding already logged above, this time in the harness itself rather than in a third-party package. [email protected]now scaffolds a turborepo-style monorepo (apps/backend, rootpackage.jsonwith aworkspacesfield), not a flat single-app directory the way the earlier harnesses were — confirmed by actually running it fresh for the standalone e2e run, not assumed unchanged from before. Functionally inert for this plugin (Node module resolution still finds a yalc-linked package hoisted to the workspace root'snode_modulesfromapps/backend), but real, since a future e2e run should expect this layout rather than be surprised by it.- The same already-documented
embedInvoiceInPdfA3limitation (non-embedded-font base PDF isn't PDF/A-eligible without the unimplemented Ghostscript repair step, first hit for real against Webbers' own default PDF) reproduces identically for a standalone-supplied PDF — proven, not assumed, by configuringstandalone.basePdfto return a plainpdf-libpage usingStandardFonts.Helvetica(no embedded font) and observing a real veraPDFFAIL … 6.2.11.4.1-1on the result, the same rule class the Webbers PDF hit. This confirms the gap is a property ofembedInvoiceInPdfA3itself, not something specific to Webbers' PDF generator — relevant for documenting this option's actual limits. - Two partial refunds on the same payment, through the standalone path, produced two distinct credit notes (
GS-2026-0001/GS-2026-0002, keyed byref_...id) — a direct re-exercise of the per-refund idempotency fix made tocredit-note-on-payment-refunded.tsduring the Webbers integration, confirming that fix holds after standalone mode restructured the same subscriber, not just under Webbers mode. - Full real e2e proof, both document types, both PDF outcomes: a
[email protected]instance (Docker Postgres) with@normwerk/einvoice-medusaconfigured with nointegrationat all andstandalone.basePdfset torenderInvoicePdf— a real order → fulfillment produced an invoice (RE-2026-0003) with a real embedded PDF/A-3, and two partial refunds on its payment each produced their own credit note (GS-2026-0001/GS-2026-0002) with their own embedded PDF/A-3; all four documents' XML passed the real KoSIT Validator ("Validation successful!", the same pre-known informational BR-DE-TMP-32 message) and both distinct PDFs (one invoice, one credit note) passed real veraPDF--flavour 3b. Separately,standalone.basePdfomitted entirely still produced a pure-XML document (pdf: NULLineinvoice_document) — the subscribers' original standalone behavior, unchanged.
File Module storage, admin widget, Store API
IFileModuleService.createFiles's real signature (@medusajs/types, confirmed against the actually installed2.19.0type declarations, not guessed):{ filename, mimeType, content: <base64 string>, access?: "public" | "private" }, defaulting to"private"whenaccessis omitted — the same default Webbers' own upload step already relied on (their PDF was readable viaretrieveFiledespite never settingaccessexplicitly).getAsBuffer/getDownloadStreamexist too but only@since 2.8.0— this plugin's own peerDependencies claim>=2.4.0, so file read-back still goes through the older, always-availableretrieveFile+fetchtwo-step (already established infetchWebbersPdfBytes, now shared asstorage.ts's ownfetchFileBytes) rather than the newer method.[email protected]'s scaffolded local File Module provider names a file id asprivate-<epoch-ms>-<filename>and serves it fromapps/backend/static/— confirmed by generating a real document and finding the exact file on disk with that name, not assumed from the module's own interface (which only promises an opaqueid: string).medusa db:generate <module-name>(notplugin:db:generate) is the real command for regenerating a plugin's own module migration from inside a full host app that already has the plugin installed — runningplugin:db:generatedirectly inside the plugin package's own directory (outside any host app) fails with a realCannot find module './service.js'(the package's own Node16-module-resolution TypeScript source assumes a build/host context this bare invocation doesn't provide). The already-established workflow (generate inside a real scratch app, copy the output file back into the plugin's ownsrc/.../migrations/) is the one that actually works, not a shortcut run from the package itself.- A second migration for an already-existing table is silently useless, again — the same root cause already hit during the Webbers integration (a
create table if not existsmigration for a table an earlier migration already created is a real no-op, so new columns never actually land). Addingxml_file_id/pdf_file_idand droppingxml/pdfhit the identical class of problem for the same reason and used the same fix: delete the old migration file entirely, commit the freshly generated one as the sole baseline (pre-release, no real deployment history to preserve — the same justification as before, not a new one). - Medusa's own real, compiled
GET /store/orders/:id(@medusajs/[email protected]) has no ownership check at all — its source literally reads// TODO: Do we want to apply some sort of authentication here?, confirmed by reading the compiled route file directly, not assumed from documentation. This plugin's own store e-invoice routes do not copy that gap:authenticate("customer", ["session", "bearer"])(the same middleware, same auth-type combination, Medusa's own core uses for every other customer-owned-order action,/store/orders/:id/transfer/*) plus an explicitcustomer_idmatch, confirmed for real: no token →401; a different, real registered customer →404; the actual owning customer →200with the correct file, KoSIT-valid. DetailWidgetProps<HttpTypes.AdminOrder>(@medusajs/types) is the real, documented prop type for an order-detail-zone admin widget — confirmed directly in the package's own.d.tsdoc comment/example, not inferred from a third-party plugin's compiled output alone (though@webbers/invoices-medusa's own real, published widget, inspected directly, independently confirms the{ data: order }shape and the adjacentorder.details.side.beforezone).- A plain same-origin
fetch(url, { credentials: "include" })from an admin widget authenticates via the dashboard's own session cookie, without needing@medusajs/js-sdk's configured client the way Webbers' own widget does it — confirmed by actually clicking a download link in a real logged-in admin session and observing a real200 OKonGET /admin/orders/:id/einvoice/:documentId/xmlin the browser's own network log, not assumed from the two approaches being "probably equivalent". - The order link is read-only:
defineLink({ linkable: order, field: "id" }, { ...einvoiceDocument.id, primaryKey: "order_id" }, { readOnly: true, isList: true })joins on the document's ownorder_id— no link table, no migration, nothing to keep in step (@medusajs/utilsdefineReadOnlyLink, the same code in 2.12.6 and 2.19). Medusa loads a plugin's links from<plugin>/linksnext to its compiled modules (@medusajs/medusaloaders/index.js).isListgoes in the options: the read-only link takes it from there or from the left side, not from the right. - Medusa's local event bus is per process: an event emitted in a
medusa execscript reaches only that process's subscribers. A test of what a subscriber in the app receives has to trigger it inside the app. - The store's settings page takes widgets too: zone
store.details.after(@medusajs/admin-shared2.19's zone list) — where the "E-Invoicing" support statement sits, visible before an order hits a limit. - The plugin cannot extend the core packages' error class. It compiles to CommonJS and loads the ESM-only packages with a dynamic
import(), so a class that exists only after that import cannot be a base class when a module loads, and the options are checked synchronously in the module's constructor. Its own errors (PluginError,src/errors.ts) restate the same shape —code,docsUrl— and the supported seller countries are restated inservice.ts; unit tests compare both with the core packages'. An error from either side is recognised by its shape (codeanddocsUrlboth strings), not byinstanceof.
An invoice per fulfillment
- A fulfillment's lines are readable from the order:
query.graph({ entity: "order", fields: ["fulfillments.id", "fulfillments.created_at", "fulfillments.canceled_at", "fulfillments.items.line_item_id", "fulfillments.items.quantity"] })— the paths Medusa's owncancelOrderFulfillmentWorkflowreads (@medusajs/core-flows2.19), confirmed on the stand (S18). A fulfillment item names its order line byline_item_id. - Cancelling a fulfillment gives its units back:
OrderItem.detail.fulfilled_quantitydrops, so a line's fulfilled count against itsdetail.quantitytells whether part of the order is still unshipped (confirmed on the stand). Medusa cancels only a fulfillment that has not shipped (shipped_at), and emitsorder.fulfillment_canceledwith{ order_id, fulfillment_id, no_notification }. - A line's totals are for the units the buyer kept.
getLineItemTotals(@medusajs/utils, identical in 2.12.6 and 2.19) computessubtotal,total,tax_totalanddiscount_subtotalonquantitylessreturn_received_quantityandreturn_dismissed_quantity(when the order detail carries them), so a per-unit amount is the line's total divided by those units — the same before and after a return. The lifecycle totals (fulfilled_total,return_received_total) are per unit of the full quantity, but only appear on 2.19 and later. - What was paid is on the payment collection:
payment_collections.captured_amountandrefunded_amount, readable through the order (confirmed on the stand, S18).pp_system_defaultauthorizes at checkout and captures only when asked.
npm publish preparation
pnpm pack/pnpm publishrewrite aworkspace:*dependency to the real resolved version; a plainnpm pack/npm publishdoes not — confirmed empirically (not assumed from either tool's docs) by actually packing@normwerk/einvoice-ciiboth ways and inspecting the packedpackage.json:npm packleft"@normwerk/einvoice-model": "workspace:*"completely unresolved (a broken dependency spec for anyone installing the published tarball), whilepnpm packcorrectly wrote the real version. Real, load-bearing consequence: publishing any of these packages for real must go throughpnpm publish/pnpm -r publish(orchangeset publish, which shells out to the package manager pnpm itself is configured for here), never a barenpm publishrun from inside a package directory.- An npm
filesarray entry can be a negation glob ("!dist/**/*.test.js"), andnpm pack/npm publishhonor it — confirmed empirically, not assumed from partial/ambiguous docs. This is what's needed at all: every one of this monorepo'stsc-built packages compiles its own*.test.tsfiles intodist/right alongside real source (no test/non-test distinction intsconfig.json's owninclude), so a plainfiles: ["dist"]publishes test code in every tarball unless something excludes it. - The same negation pattern does not work for
pnpm packoneinvoice-medusa's own.medusa/server/output — a real, confirmed inconsistency betweennpm's andpnpm's ownfiles-array negation handling for that specific nested, dot-prefixed directory shape (root cause not chased further, since a robust workaround existed). Fixed by not depending onfilesnegation for this at all: aprepacklifecycle script (tools/publish/strip-test-output.mjs, a plain recursive walk-and-delete, no new dependency) physically removes compiled*.test.*output from the build directory before every pack/ publish, regardless of which tool does the packing — confirmed working for all five packages viapnpm pack, including the one case the negation pattern alone didn't cover. - The
repositoryURL committed on@normwerk/einvoice-medusasince the plugin was scaffolded (https://github.com/eInvoice, nodirectory) was a real, confirmed 404 at the time — the GitHub repo didn't exist yet, unrelated to its casing (caught by actually fetching it during publish preparation, not treating it as a plausible placeholder — the project's own "verify URLs before committing" rule catching a gap from before it was consistently applied). A change on 2026-09-15 then "corrected" the casing to lowercasegithub.com/normwerk/einvoice— based ongit remote -vechoing back whatever casing was typed intogit remote add, not GitHub's own canonical casing, which that command has no way to reveal on its own. Settled 2026-09-16, empirically: a realgit pushto the lowercase remote succeeded but GitHub replied withremote: This repository moved. Please use the new location: [email protected]:Normwerk/eInvoice.git— GitHub's own redirect response is the actual source of truth here, not a guess either way. So at that moment canonical wasgithub.com/Normwerk/eInvoice(capital N, capital I): the original casing, and the 2026-09-15 "fix" was itself the bug. Then superseded the same day by a founder decision: one register for the name everywhere, all lowercase — not a correction of the finding above but a rename on top of it. Two of the four spellings can't be capitalised at all (npm package names, thenormwerk.devdomain), and Docker image names forbid uppercase, soghcr.io/Normwerk/…would not have built once validator images are pushed there. The organisation and repository were to be renamed tonormwerk/einvoice; GitHub treats those slugs case-insensitively, so the rename only changes display — old links, clones and redirects keep working and the old name is not released. Half done as of the rename push (commit14eb492, 2026-09-16): GitHub answered that push withremote: This repository moved. Please use the new location: [email protected]:Normwerk/einvoice.git— the repository is lowercaseeinvoicenow, the organisation was stillNormwerk. By 2026-09-23 the organisation was renamed too: GitHub's API returns its canonical login asnormwerk, so every committednormwerk/einvoiceURL is now the exact canonical spelling. Second half of the lesson, learned the same way:git ls-remoteis silent about redirects too — it returned refs against the lowercase remote with nomovedline, which reads exactly like confirmation and is not. Only a push says. Still not a public API fetch, since the repository is currently private (a plainGET /repos/normwerk/einvoicestill 404s — GitHub's API can't distinguish "private" from "doesn't exist" for an unauthenticated caller). Making it public is a deliberate, separate action on release day. Lesson, stated plainly:git remote -vtells you what string a human typed andgit ls-remotesucceeds through a redirect without mentioning it — neither one reveals GitHub's own canonical casing. Only a real push (and reading its response) or the GitHub API/UI settle that question. - Medusa's own real plugin-catalog listing keywords, confirmed against a live
registry.npmjs.orgsearch for currently-published packages (not just the docs' own prose):medusa-v2andmedusa-plugin-integrationare the two required keywords; a third, category-specific one is also real and in active use —medusa-plugin-otherfor a third-party integration that doesn't fit their other categories (analytics/auth/cms/notification/payment/search/shipping), confirmed present alongside the other two on several real, currently-listed npm packages (e.g.@jytextiles/medusa-plugin-etsy-sync,@alphabite/medusa-wishlist).einvoice-medusa's ownkeywordsalready had the first two (copied from the official plugin scaffold) but was missing the third until publish preparation added it. - The Medusa integrations page has no known, documented per-package icon field —
medusajs.com/ integrationsitself states the list "is curated from npm," and its own visible icons (Stripe, Mailchimp, etc.) are plausibly curated/assigned by Medusa's own team for well-known brands, not something apackage.jsonfield controls. Real, honest finding: a package icon as a publish-metadata deliverable does not correspond to any confirmed real mechanism — not invented here, flagged instead.
regimeOverride/supplyType wired through the adapter
OrderLineItem.requires_shippingis a real, first-class boolean on@medusajs/order's own line-item model — confirmed directly againstnode_modules/@medusajs/order/dist/types/line-item.d.ts(the compiled package this monorepo actually depends on, not documentation or assumption), the same tier asis_tax_inclusive/unit_price(already documented in the subscriber section above), not a wildcard relation path that section already warnsquery.graphcan silently drop. This is the one signalmapOrderToCommerceInvoiceInputderivessupplyTypefrom (false→ a service line): a production-grade verification would still confirm it against a real running instance the wayORDER_QUERY_FIELDSwas checked above, but the type-level check is strong enough evidence for a first-class model column, not a computed/admin-only field, to build on without that heavier step.- A Medusa tax line's
rateis a fact to pass on, never to interpret in the adapter. The adapter once snapped it to the nearer of Germany's 19% and 7%, so a line Medusa taxed at 0% (a tax region without a default rate) became a 7% line on the invoice. It now passes the summed rate on asCommerceLine.chargedVatRate, andresolveLineRateineinvoice-commercedecides: 19 or 7 on a domestic line, exactly the declaredossRateOverrideon an OSS line, refusal otherwise. - Before Medusa 2.19
query.graphcomputes shipping totals only whenshipping_methods.amountis infields. Without it every shipping method'stotal/subtotal— and the order'stotal— come back as if shipping were free: the invoice left shipping out, and the total check compared a total without shipping with a total without shipping and stayed silent (2.12.6 and 2.16.0 checked in a running stand, 2.18.0 by the end-to-end suite before and after the fix, 2026-09-24). 2.19 and later add the field themselves;ORDER_QUERY_FIELDSnow asks for it. - Medusa 2.12 cannot filter across a module link in
query.graph:filters: { payment_collections: { id } }onorderfails with "Trying to query by not existing property Order.payment_collections". Reading the linked field works on every release whose refund scenarios ran (2.12.6, 2.18.0 and later), so the refund subscriber readspayment_collection.order.idand then fetches the order by id. - On Medusa 2.16 and 2.17 the test store's products were charged no VAT, only its shipping — the order's
item_tax_totalis 0 and its lines have no tax lines, read straight from Medusa's own order detail workflow, so the plugin refuses every domestic order there (a line charged 0% is not invoiced at 19%). 2.12 and 2.18 tax the same products. Cause not pinned down; 2.16.0 changed tax handling around gift-card lines (#15545). The plugin therefore does not support 2.16 and 2.17, and refuses to start there (src/medusa-version.tsholds the supported releases, the peer ranges are tested against it). order.totalis net of credit lines, and every refund becomes one. Medusa subtractscredit_line_totalfrom the order's total (@medusajs/utilstotals/cart:total = subtotal + tax − discount − credit lines, credit lines taxed at 0), andrefundPaymentWorkflowrecords each refund as a credit line when nothing is owed to the buyer (@medusajs/[email protected]refund-payment). What the buyer was charged is thereforeorder.total + order.credit_line_total— stable across refunds;order.totalalone drops with each one.- Medusa never recomputes an existing order's tax lines on its own. Changing a tax region's rates, or an order's address (
updateOrderWorkflow: "doesn't recompute the order's tax lines or totals"), leaves the placed order as it was at checkout. OnlyupdateOrderTaxLinesWorkflowrecomputes them — no Admin API route runs it; order edits compute tax lines for the items and shipping methods they add, not for the rest. Run it for the lines that need it (shipping_method_ids,item_ids): without them it recomputes the whole order, and on 2.12.6 that added a second tax line to every item (a 19% line then read 38%) where 2.21.0 replaced them. - A shipping method's own totals are there only when the order's totals are.
decorateCartTotalswritestotal,subtotal,tax_total,discount_subtotalonto eachshipping_methods[]entry (Object.assign), which happens only when a top-level totals field such astotalis requested (OrderModuleService.shouldIncludeTotals). - A received return shrinks the order's totals. Once goods come back, Medusa computes each line's
total,subtotal,discount_subtotaland the order'stotalfor the units the buyer kept (a line all of whose units came back hastotal0), whiledetail.quantitystays the ordered quantity (@medusajs/utilstotals/line-item:currentQuantity = quantity − returned). The returned units' value isreturn_received_total— the full-quantity gross after discounts per unit, timesdetail.return_received_quantity— computed only when that quantity field is requested with a totals field. Valuing returns by it passed end to end on 2.19.0 and 2.21.x and failed on 2.12.6–2.18.0 (not pinned down further); the plugin values a returned unit at what its own invoice stated for the line instead. - Query a return's items through the return, not the order.
query.graphonorderwithreturns.items.received_quantityreturns the return items withoutreceived_quantity(onlyitem_id); onreturn, filtered byorder_id, it is there (both checked on 2.21.0). - A partially received return has no
received_at.confirmReturnReceiveWorkflowsetsreceived_at(and statusreceived) only when everything requested came back; otherwise statuspartially_received,received_atnull. - A Kleinunternehmer's order looks like a missing tax rate. Medusa charges no VAT in both cases — a tax line at rate 0, or none at all when the country has no tax region — so a per-line refusal ("0 % is not a German rate", "no rate") sends a Kleinunternehmer to their tax settings.
buildInvoicechecks the whole order first: nothing above 0 % on any line or on shipping isNO_VAT_CHARGED, which names both causes. Shipping's rate is read for this alone (CommerceShipping.chargedVatRate); the invoice's shipping rate still follows the lines. - A fulfillment item is an inventory item, not an order line. For a variant with stock managed, Medusa creates one fulfillment item per inventory item of the variant, with the line's units times its
required_quantity(prepareFulfillmentData,@medusajs/core-flows): a set of a table and four chairs ships as 1 + 4 units of oneline_item_id. The line's own units are in the order's record of the fulfillment — aFULFILL_ITEMaction,reference_idthe fulfillment's id,details.reference_idthe line (registerFulfillment,@medusajs/order), read through Query asorder_change.actions. - The EU's TEDB does not know Germany's 2020 rates. Asked for Germany on 2020-09-01 (
retrieveVatRates), it answers 19 %, and has no 16 % record between 2019 and 2021 at all. The periods come from the UStG itself: § 28 Abs. 1 and 2 still carry the 16 % and 5 % of 2020-07-01 to 2020-12-31 in the consolidated text. - gesetze-im-internet.de does not answer GitHub's runners. A connection to it from a hosted runner times out, on port 443 and on 80, while it answers elsewhere in a quarter of a second. The rate check reads the UStG from the federal legal information portal's API instead (
rechtsinformationen.bund.de, LegalDocML.de): the same § 28 word for word, and a § 12 without gesetze-im-internet.de's editorial note "Zur Anwendung vgl. § 28" — so the two sources' hashes of § 12 differ. - A credit note's BT-72 is its invoice's. A credit note corrects the supply its invoice was issued for, so it states that delivery date, read back from the invoice's CII, and that day's rates apply to it — not the day the credit note is issued.
- Medusa does not mark a replacement on the line. An exchange's new item is an
order_exchange_itemrow (item_idthe order line); a claim's replacement anorder_claim_itemrow withis_additional_item = true— the claimed item shares the table without the flag. Neitherorder_line_itemnororder_itemcarries an exchange or claim id, and the order has no relation to either: readorder_exchange/order_claimthrough Query byorder_id, withadditional_items.*. - An order edit may change a shipped line's price.
ITEM_UPDATEwithunit_price(item-update.js) only refuses a quantity below what was fulfilled.order-edit.confirmedcarries{ order_id, actions }; the confirmed edits are also readable asorder_changewithchange_type: "edit",status: "confirmed". - An exchange's or claim's new item is reserved only with an outbound shipping method. Confirming the request reserves stock for it only when the exchange or claim has one (
confirm-exchange-request.js,confirm-claim-request.js); without it, fulfilling the new item fails with "No stock reservation found". - Confirming an exchange cancels a payment that is only authorized. Capture the order's payment first if a refund is to follow (checked on 2.21.0, end-to-end scenario S22).
medusa execloads no entry points. It boots the app withskipLoadingEntryPoints: true(@medusajs/medusacommands/exec.js, 2.21): modules and their options, but no subscribers, jobs or routes — a script run this way cannot set off the plugin's own subscribers. The plugin's module still checks its options and the Medusa release when it loads, so on an unsupported release the dry run does not start.medusa exec <file> [args..]hands over positional words. A--fromwould be taken by Medusa's own option parser; the dry run readsfrom=YYYY-MM-DD-style words instead.- KoSIT validates many files in one run.
validator.jar … -o <dir> a.xml b.xmlwritesa-report.xml,b-report.xmlinto<dir>and starts its JVM once; it exits non-zero when it rejects any of them, the reports are still written. - Mustang names a rule inside its message.
--action validatereports<error>/<warning>/<notice>elements whose text carries[ID BR-CO-15]; a<notice>(BR-DE-TMP-32) fails nothing, as KoSIT's information level does not. Read the id, not the text: the text and thecriterionquote the document's structure, and a schema error's text can quote its values. - Medusa records every change to an order in
order_change, a fulfillment's too.change_typeisreturn_request,return_receive,exchange,claim,editorcredit_line;updateOrderWorkflowrecords a change of the order's metadata, email or addresses asupdate_order(2.21, not in theOrderChangeTypeenum); a fulfillment's change has no type and carriesFULFILL_ITEMactions. A returned item is aRECEIVE_RETURN_ITEM(orRECEIVE_DAMAGED_RETURN_ITEM) action naming the line item. - An order read later is not the order its invoice was issued from. The dry run over the end-to-end stand's orders disagreed with what the plugin had issued in exactly three ways, all changes after the fulfillment: a line whose every unit came back leaves nothing to check the invoice against (
CHARGED_TOTALS_MISSING); an exchange's or claim's outbound shipping joins the order's shipping methods, so the first invoice read later carries it; aregime_overridewithdrawn after the invoice refuses it (VAT_ID_UNVERIFIED). The dry run leaves such an invoice not assessed.