Source
This page is generated from docs/domain-glossary.md at the pinned revision ea6b031ccb33. 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 (spike C, T-044). - 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(T-011), 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 (T-020).- 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 (T-021): 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. 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.- 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-modeldoesn't model the percentage yet (BT-94/101/138/143), so fixtures with a discount/charge (T-022) simply omitbaseAmountrather than half-model the pair —baseAmountwas informational, not required by any base BR-* rule. - "Gutschrift" ≠ credit note (P-04, T-034). In UStG terms it is a self-billed invoice (389). Use "Rechnungskorrektur" for 381 in anything a human reads; keep code 381 in XML.
Medusa v2 (einvoice-medusa, T-070/T-071/T-072/T-073/T-074, W10)
Per plan-v0.1 §4.6's own warning, 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 }. Matches plan-v0.1's own expectation exactly. 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 — T-071's own 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 plugin matching plan-v0.1's own named integration target 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 plan-v0.1 wants T-072 to read),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 T-072, 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 T-075's quickstart doc: 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 T-076 ("Публикация в npm"), not previously an explicit finding.
T-071 (subscribers, idempotency, W10)
- 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 one T-070'sresolution-mode: "import"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.CommerceLine.netPriceis always ex-tax (buildInvoicecomputes tax itself fromTaxContext), so a tax-inclusive Medusa line needs the rate backed out ofunit_pricebefore mapping — skipping this would double an already-included tax on top ofbuildInvoice's own calculation for any merchant with tax-inclusive pricing turned on (Medusa's default for storefronts in several regions), not a rare edge case.- 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 ownEinvoiceCounter(T-071) needs 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 real MikroORM unique-constraint violation is detectable by
error.name === "UniqueConstraintViolationException"without adding@mikro-orm/coreas a new dependency — confirmed from its compiledexceptions.js: every exception in that file's hierarchy setsthis.name = this.constructor.namein a sharedDriverExceptionbase constructor. This plugin's own idempotency guard (EinvoiceDocument's(type, idempotency_key)unique index) relies on catching exactly this, duck-typed, rather than a "check, then insert" that would race under concurrent duplicate event delivery. - 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 T-071's 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, to keepSequentialNumberer's gap-free guarantee under 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 T-070's own 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 (T-071).- 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) — conflicts with
selectProfile's own heuristic ("presence ofbuyerReferencesignals a B2G buyer"), since always supplying one would make every order resolve toXRECHNUNGregardless ofdefaultProfile. Resolved by keeping two distinct values: the raw B2G signal (resolveB2gBuyerReference, only a realcustomer.metadata.buyer_reference/Leitweg-ID) is whatselectProfileis called with; the mappedCommerceInvoiceInput.references.buyerReferencealways gets a value — the order's owndisplay_idwhen there's no real B2G reference — to satisfy BR-DE-15 without corrupting profile selection. - 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 this task'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.
T-072 (@webbers/invoices-medusa integration, W10)
@webbers/[email protected]'s ownpackage.jsondeclares two more broken export subpaths, the same class of gap T-072's own research 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 (T-070/T-071, 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 this task's own 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 (T-070/T-071 flagged this as the reason no ordering guarantee exists between this plugin's own subscriber and
@webbers/invoices-medusa's; T-072 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 this task's 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 this repo's own T-071 entry 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 task'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 viaembedInvoiceInPdfA3(T-030) using 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 T-072 introduces —embedInvoiceInPdfA3's own doc comment already scopes this out ("does not attempt to fix a PDF that isn't already PDF/A-eligible … that repair step (Ghostscript, D-20's "Path 2") is a documented follow-up, not implemented here") — 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 T-076 (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.
T-073 (standalone mode's own PDF source, W10)
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 (T-072), not a new sequencing invented for this task.@normwerk/einvoice-pdfa's ownrenderInvoicePdf(T-030 continuation) was test-only until this task — noexportsentry beyond".", andindex.tsnever re-exported it, so nothing outside the package's own test suite could reach it. Re-exported here (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 T-071/T-072 both already logged, 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 T-070/T-071/T-072's own harnesses were — confirmed by actually running it fresh for this task, 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, D-20 "Path 2", first hit for real by T-072 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 T-072 hit. This confirms the gap is a property ofembedInvoiceInPdfA3itself, not something specific to Webbers' PDF generator — relevant for T-076's own documentation of 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 T-072 made tocredit-note-on-payment-refunded.ts(originally a T-071 bug, found and fixed under T-072 since it was directly related), confirming that fix holds under this task's own restructuring of 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) — T-071's original standalone behavior, unchanged.
T-074 (File Module storage, admin widget, Store API, W10)
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 (T-072's own finding that 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 in T-072'sfetchWebbersPdfBytes, 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 (T-071/T-072: 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 — same root cause T-072's own finding #16 already named (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) — T-074 hit the identical class of problem for the same reason (addingxml_file_id/pdf_file_id, droppingxml/pdf) 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 — T-072's own justification, 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 in T-072, 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". - No
defineLinkwas added betweenorderandEinvoiceDocument, despite@webbers/invoices-medusa's own realinvoice_orderlink (T-072) being the obvious analogy — a deliberate scope decision, not an oversight:EinvoiceDocument.order_id(a plain field since T-071) already answers every query this plugin needs, and Webbers needs a link only because their ownInvoicemodel carries no order-identifying field at all (confirmed by reading it directly — no redundant mechanism to choose between, unlike here).
T-076 (npm publish prep, W11 — partial, publish itself not yet done)
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 T-070 (https://github.com/normwerk/eInvoice, wrong casing, nodirectory) was a real, confirmed 404 at the time — the GitHub repo didn't exist yet. Caught by T-076 actually fetching it rather than treating it as a plausible placeholder — a concrete instance of the project's own "verify URLs before committing" rule catching a gap from before that rule was consistently applied. Resolved 2026-09-15 (P-09): the real repository isgithub.com/normwerk/einvoice(lowercase — GitHub URLs are case-insensitive, but everypackage.json's ownrepositoryfield is kept matching it exactly so the npm card and the Medusa catalog listing don't disagree in spelling); confirmed reachable viagit ls-remote, not a public API fetch, since it's currently private (a plainGET /repos/normwerk/einvoicestill 404s — GitHub's API can't distinguish "private" from "doesn't exist" for an unauthenticated caller). Made public is a deliberate, separate action on release day. - 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, T-070) but was missing the third until this task 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: plan-v0.1's own "иконка" (icon) as a T-076 metadata deliverable does not correspond to any confirmed real mechanism — not invented here, flagged instead.