@hartl-services/medusa-base 2.11.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.medusa/server/src/admin/index.js +39 -39
  2. package/.medusa/server/src/admin/index.mjs +39 -39
  3. package/.medusa/server/src/api/admin/tax-compliance/customer-addresses/[address_id]/tax-identification/route.js +15 -0
  4. package/.medusa/server/src/api/check-customer-address-tax-identification.js +93 -0
  5. package/.medusa/server/src/api/customer-address-identification-response.js +14 -0
  6. package/.medusa/server/src/api/middlewares.js +55 -1
  7. package/.medusa/server/src/api/store/carts/[id]/tax-identification/route.js +179 -0
  8. package/.medusa/server/src/api/store/customers/me/addresses/[address_id]/tax-identification/route.js +12 -0
  9. package/.medusa/server/src/api/store/customers/me/tax-identifications/route.js +14 -0
  10. package/.medusa/server/src/api/store/tax-identification-policy/route.js +21 -0
  11. package/.medusa/server/src/api/tax-identification-message.js +9 -0
  12. package/.medusa/server/src/api/to-response-amount.js +19 -0
  13. package/.medusa/server/src/jobs/tax-compliance-state-sync.js +26 -0
  14. package/.medusa/server/src/modules/tax-compliance/domain/validation-policy.js +48 -0
  15. package/.medusa/server/src/modules/tax-compliance/index.js +15 -0
  16. package/.medusa/server/src/modules/tax-compliance/migrations/Migration20260814120459.js +18 -0
  17. package/.medusa/server/src/modules/tax-compliance/migrations/Migration20260814121724.js +14 -0
  18. package/.medusa/server/src/modules/tax-compliance/migrations/Migration20260814153410.js +14 -0
  19. package/.medusa/server/src/modules/tax-compliance/migrations/Migration20260929091620.js +27 -0
  20. package/.medusa/server/src/modules/tax-compliance/migrations/Migration20261011090147.js +16 -0
  21. package/.medusa/server/src/modules/tax-compliance/models/tax-compliance-state.js +29 -0
  22. package/.medusa/server/src/modules/tax-compliance/models/tax-compliance-validation.js +74 -0
  23. package/.medusa/server/src/modules/tax-compliance/runtime-settings.js +43 -0
  24. package/.medusa/server/src/modules/tax-compliance/service.js +693 -0
  25. package/.medusa/server/src/providers/tax-compliance/index.js +11 -0
  26. package/.medusa/server/src/providers/tax-compliance/service.js +179 -0
  27. package/.medusa/server/src/public/constants.js +4 -2
  28. package/.medusa/server/src/public/index.js +6 -2
  29. package/.medusa/server/src/public/tax-compliance.js +22 -0
  30. package/.medusa/server/src/shared/lib/index.js +2 -1
  31. package/.medusa/server/src/shared/lib/optional-registration.js +34 -0
  32. package/.medusa/server/src/shop-config/build-food-shop-config.js +6 -9
  33. package/.medusa/server/src/subscribers/tax-compliance-plugin-features-updated.js +25 -0
  34. package/.medusa/server/src/tax-compliance/complete-cart-hooks.js +26 -0
  35. package/.medusa/server/src/tax-compliance/integrations/finanzonline/client.js +222 -0
  36. package/.medusa/server/src/tax-compliance/integrations/finanzonline/validator.js +50 -0
  37. package/.medusa/server/src/tax-compliance/integrations/registry.js +26 -0
  38. package/.medusa/server/src/tax-compliance/integrations/validator.js +19 -0
  39. package/.medusa/server/src/tax-compliance/lib/order-tax-context.js +100 -0
  40. package/.medusa/server/src/tax-compliance/lib/tax-compliance-state.js +59 -0
  41. package/.medusa/server/src/tax-compliance/lib/tax-runtime-settings.js +79 -0
  42. package/.medusa/server/src/tax-compliance/plugin-features.js +73 -0
  43. package/.medusa/server/src/tax-compliance/resolve-services.js +7 -0
  44. package/.medusa/server/src/tax-compliance/types/tax-compliance.js +6 -0
  45. package/.medusa/server/src/workflows/documents/issue-invoice-after-shipment.js +25 -30
  46. package/.medusa/server/src/workflows/documents/issue-packing-list.js +3 -14
  47. package/.medusa/server/src/workflows/hooks/cart-tax-context.js +78 -0
  48. package/.medusa/server/src/workflows/hooks/order-tax-context.js +7 -0
  49. package/.medusa/server/src/workflows/hooks/shipping-tax-apportionment.js +145 -0
  50. package/.medusa/server/src/workflows/tax-compliance/apportion-order-shipping-tax.js +72 -0
  51. package/.medusa/server/src/workflows/tax-compliance/domain/requires-frozen-tax-evidence.js +27 -0
  52. package/.medusa/server/src/workflows/tax-compliance/freeze-order-evidence.js +125 -0
  53. package/.medusa/server/src/workflows/tax-compliance/submit-customer-address-tax-identification.js +18 -0
  54. package/.medusa/server/src/workflows/tax-compliance/submit-tax-identification.js +18 -0
  55. package/README.md +344 -22
  56. package/dist/public/constants.d.ts +2 -0
  57. package/dist/public/constants.js +3 -1
  58. package/dist/public/index.d.ts +2 -1
  59. package/dist/public/index.js +5 -1
  60. package/dist/public/tax-compliance.d.ts +30 -0
  61. package/dist/public/tax-compliance.js +21 -0
  62. package/dist/shared/lib/index.d.ts +1 -0
  63. package/dist/shared/lib/index.js +1 -0
  64. package/dist/shared/lib/optional-registration.d.ts +18 -0
  65. package/dist/shared/lib/optional-registration.js +33 -0
  66. package/dist/shop-config/build-food-shop-config.d.ts +2 -2
  67. package/dist/shop-config/build-food-shop-config.js +5 -8
  68. package/package.json +3 -2
package/README.md CHANGED
@@ -6,8 +6,10 @@ Admin hub under **Settings → Hartl Services Plugins** with one page per
6
6
  plugin, a Store endpoint for storefronts and helpers that plugins use to
7
7
  enforce their toggles and read their settings. Since 1.0.0 it also contains
8
8
  the shop's transactional E-Mail, order Documents (invoices, cancellations,
9
- packing lists) and S3 file storage, and the turnkey `medusa-config.ts` preset
10
- (see "E-Mail", "Dokumente", "Dateispeicher (S3)" and "Shop-Config-Preset").
9
+ packing lists) and S3 file storage, and the turnkey `medusa-config.ts` preset,
10
+ and since 3.0.0 the EU tax compliance (UID check, Tax provider) (see "E-Mail",
11
+ "Dokumente", "Steuer-Compliance", "Dateispeicher (S3)" and
12
+ "Shop-Config-Preset").
11
13
 
12
14
  ## What it does
13
15
 
@@ -54,6 +56,10 @@ packing lists) and S3 file storage, and the turnkey `medusa-config.ts` preset
54
56
  "E-Mail"), issues invoices, cancellation documents and packing lists as
55
57
  private PDFs (module `documents`, see "Dokumente") and stores files in
56
58
  S3-compatible storage behind the `/files` gateway (see "Dateispeicher (S3)").
59
+ - Checks B2B customers' VAT IDs (FinanzOnline), provides the Medusa Tax
60
+ provider `tp_tax_compliance` for reverse-charge and apportioned shipping tax
61
+ and freezes the tax evidence of an order at checkout (module
62
+ `tax_compliance`, see "Steuer-Compliance").
57
63
  - Declares which login methods customers may use (module `customer_auth`:
58
64
  e-mail + password and a one-time code by e-mail), provides the `email-otp`
59
65
  auth provider and the code request route (see "Kunden-Login").
@@ -116,6 +122,17 @@ exports from the matching `@hartl-services/medusa-base` subpath. The
116
122
  `documentsEnabled` option is gone; Documents is always installed, and the
117
123
  e-mail module follows the Documents `invoices` toggle.
118
124
 
125
+ Upgrading to 3.0.0 (Steuer-Compliance merged): remove
126
+ `@hartl-services/medusa-tax-compliance` from `package.json` and from the
127
+ `plugins` list and change the Tax provider resolve path to
128
+ `@hartl-services/medusa-base/providers/tax-compliance` (the provider id
129
+ `tp_tax_compliance`, the module key `tax_compliance`, its tables and
130
+ migrations are unchanged, so no data migration is needed). Plugins that
131
+ reached `tax_compliance` by container key keep working; the typed helper is
132
+ `listCustomerAddressTaxIdentifications` (see "Steuer-Compliance"). The other
133
+ Hartl Services plugins need the versions released with 3.0.0 (their peer
134
+ range is `^3`).
135
+
119
136
  Peer dependencies are the host's Medusa 2.19 packages (`@medusajs/framework`,
120
137
  `@medusajs/medusa`, `@medusajs/admin-sdk`, `@medusajs/js-sdk`, `@medusajs/ui`, `@medusajs/icons`)
121
138
  plus React, React Router and TanStack Query in the versions the Medusa 2.19
@@ -927,7 +944,7 @@ class MyModuleService extends MedusaService({ /* … */ }) {
927
944
 
928
945
  **Ordering.** Ascending `order`, then module key, then workflow id. In use:
929
946
  Affiliate 100 (`affiliate-validate-complete-cart`,
930
- `finalize-affiliate-order-attribution`), tax compliance 200
947
+ `finalize-affiliate-order-attribution`), the base's Steuer-Compliance 200
931
948
  (`freeze-tax-compliance-order-evidence`, `orderCreated` only).
932
949
 
933
950
  **Rollback semantics.**
@@ -2453,13 +2470,9 @@ separate documents package since 1.0.0.
2453
2470
 
2454
2471
  ### Configuration
2455
2472
 
2456
- `@hartl-services/medusa-tax-compliance` is a required *registered plugin*,
2457
- intentionally not a package dependency of the base: install it and list it in
2458
- `medusa-config.ts` yourself. The invoice workflow resolves the
2459
- `tax_compliance` module with no fallback; a shop that does not register Tax
2460
- Compliance fails at invoice issuance time (the first `shipment.created`
2461
- event) with an `UNEXPECTED_STATE` error naming the missing plugin, not at
2462
- startup.
2473
+ The invoice workflow reads the order's frozen B2B evidence from the base's own
2474
+ `tax_compliance` module (see "Steuer-Compliance"); register the Tax provider
2475
+ `@hartl-services/medusa-base/providers/tax-compliance` as shown there.
2463
2476
 
2464
2477
  ```ts
2465
2478
  export default defineConfig({
@@ -2468,10 +2481,7 @@ export default defineConfig({
2468
2481
  resolve: "@medusajs/medusa/tax",
2469
2482
  options: {
2470
2483
  providers: [
2471
- {
2472
- resolve:
2473
- "@hartl-services/medusa-tax-compliance/providers/tax-compliance",
2474
- },
2484
+ { resolve: "@hartl-services/medusa-base/providers/tax-compliance" },
2475
2485
  ],
2476
2486
  },
2477
2487
  },
@@ -2490,10 +2500,6 @@ export default defineConfig({
2490
2500
  },
2491
2501
  },
2492
2502
  },
2493
- {
2494
- resolve: "@hartl-services/medusa-tax-compliance",
2495
- options: {},
2496
- },
2497
2503
  ],
2498
2504
  });
2499
2505
  ```
@@ -2608,7 +2614,8 @@ medusa-config fallback: what is stored in the Admin is what is printed.
2608
2614
 
2609
2615
  Requirements/couplings:
2610
2616
 
2611
- - A registered Tax Compliance module (`tax_compliance`) as shown above.
2617
+ - The Tax provider `tp_tax_compliance` of the base's Steuer-Compliance on the
2618
+ regions, so orders carry UID-based exemptions.
2612
2619
  - A Chromium/Chrome executable and a Medusa File provider that supports
2613
2620
  private files (see "Development" below for the sandbox/non-root note).
2614
2621
  Documents stores PDFs through `Modules.FILE` and streams them only through
@@ -3078,6 +3085,322 @@ const response = await sdk.client.fetch<ListAdminOrderPackingListsResponse>(
3078
3085
  );
3079
3086
  ```
3080
3087
 
3088
+ ## Steuer-Compliance
3089
+
3090
+ There is no separate `@hartl-services/medusa-tax-compliance` package since
3091
+ 3.0.0: the EU tax compliance (module `tax_compliance`, the Tax Module provider
3092
+ `tp_tax_compliance`, the UID check routes, the order-evidence freeze and the
3093
+ apportioned shipping tax) is part of the base. The module owns the durable
3094
+ validation evidence; a Storefront never imports its implementation, only
3095
+ `@hartl-services/medusa-food-supplements-contracts`.
3096
+
3097
+ ### Configuration
3098
+
3099
+ Register the Tax Module provider in `medusa-config.ts` (the Shop-Config-Preset
3100
+ does this); the module itself boots with the base and has no options:
3101
+
3102
+ ```ts
3103
+ modules: [
3104
+ {
3105
+ resolve: "@medusajs/medusa/tax",
3106
+ options: {
3107
+ providers: [
3108
+ { resolve: "@hartl-services/medusa-base/providers/tax-compliance" },
3109
+ ],
3110
+ },
3111
+ },
3112
+ ],
3113
+ ```
3114
+
3115
+ The provider is configured without a secondary `id`, so Medusa registers its
3116
+ class identifier `tax_compliance` as exactly `tp_tax_compliance`; regions
3117
+ select it as their tax provider. `medusa db:migrate` creates the tables
3118
+ `tax_compliance_validation` and `tax_compliance_state` (unchanged names and
3119
+ migrations of the former package, so an upgraded shop needs no data
3120
+ migration).
3121
+
3122
+ The merchant's own country and VAT number come only from the Shop-Stammdaten
3123
+ (fields `country_code` and `vat_number`); there is no config fallback. They
3124
+ select the qualified VAT validator (currently only the Austrian FinanzOnline
3125
+ adapter). While either is missing, carts get ordinary B2C tax.
3126
+
3127
+ ### Runtime settings
3128
+
3129
+ Declared on the Steuer-Compliance descriptor and edited on its Admin page
3130
+ (Settings → Hartl Services Plugins → Steuer-Compliance):
3131
+
3132
+ | Key | Label | Type | Required |
3133
+ | ----------------------------- | -------------- | ------ | -------- |
3134
+ | `finanzonline_participant_id` | Teilnehmer-ID | string | yes |
3135
+ | `finanzonline_user_id` | Benutzer-ID | string | yes |
3136
+ | `finanzonline_password` | PIN | secret | yes |
3137
+
3138
+ All three sit in the group "FinanzOnline" ("Webservice-Benutzer für die
3139
+ qualifizierte UID-Abfrage.").
3140
+
3141
+ - Read per validation, only for an Austrian merchant with a real UID. There
3142
+ is no environment or medusa-config fallback; `FINANZONLINE_*` variables are
3143
+ ignored.
3144
+ - All three are needed as a set. While any is missing the plugin page shows
3145
+ "nicht konfiguriert", one warning is logged per process and not-configured
3146
+ state, and the qualified check answers `unavailable`
3147
+ (`missing_credentials`) without calling FinanzOnline.
3148
+ - A failing settings read is logged (`logger.error`, reported to Sentry by
3149
+ the base logger wrapper) and handled like missing credentials.
3150
+ - Merchant country and UID are read from the shop master data. On the cart
3151
+ tax path (tax-context hook) and in the Store route a failing read is logged
3152
+ and treated as "no merchant": ordinary B2C tax, and the route answers 500
3153
+ as before for a missing merchant profile. The cart tax path and the
3154
+ order-evidence freeze read the master data only for a cart that has a
3155
+ current UID validation record, so B2C carts never touch the settings and
3156
+ an outage does not log an error per tax calculation. The order-evidence
3157
+ freeze at checkout lets a read failure propagate, so a zero-rated order
3158
+ fails and an ordinarily taxed one continues.
3159
+ - Both are read at app level, never by the module service (a module does not
3160
+ reach into other modules): `src/tax-compliance/lib/tax-runtime-settings.ts`
3161
+ provides `resolveMerchantProfile(container, { failSafe })` and
3162
+ `resolveFinanzOnlineCredentials(container)`, called with the application
3163
+ container of the Store route, the validation and freeze workflow steps and
3164
+ the cart tax-context hook. The service takes the results as parameters:
3165
+ `validateCart(input, credentials)` (the step reads the credentials only
3166
+ when `requiresFinanzOnlineCredentials(input)` says so, and never passes
3167
+ them as workflow input), `getCurrentValidationId(cart, loadMerchant)` and
3168
+ `freezeOrderEvidence(orderId, cart, loadMerchant)` (the loader runs only
3169
+ after a current validation record was found) and
3170
+ `isValidationEligibleForTax(validationId, countryCode, merchant)`.
3171
+ - The cart tax-context hook puts the merchant profile it checked the
3172
+ evidence against into `additional_context.tax_compliance_merchant`, next to
3173
+ the validation ID. The tax provider runs in the Tax Module's isolated
3174
+ container and reads the merchant only from that context; without one it
3175
+ answers ordinary B2C rates. The order tax path needs no merchant: it checks
3176
+ the evidence frozen under the order ID.
3177
+
3178
+ ### Feature toggles
3179
+
3180
+ Steuer-Compliance declares its switch and three features under the plugin key
3181
+ `tax_compliance` (`TAX_COMPLIANCE_PLUGIN_KEY` / `TAX_COMPLIANCE_FEATURES` from
3182
+ the Contracts `/paths/tax-compliance` subpath). An Admin switches them under
3183
+ Settings → "Hartl Services Plugins"; Storefronts read the effective values
3184
+ from `GET /store/plugin-features/tax_compliance`. All features default to on.
3185
+ The plugin switch off counts as all three features off.
3186
+
3187
+ | Key | Label | Default | Off means |
3188
+ | ---------------- | ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3189
+ | `vat_validation` | UID-Prüfung erlauben | on | `POST /store/customers/me/addresses/:address_id/tax-identification`, `GET /store/customers/me/tax-identifications` and the Admin re-check answer 404; Affiliate partner registration takes VAT IDs unchecked. Also switches `b2b` off. The FinanzOnline settings are dimmed. |
3190
+ | `b2b` | B2B-Verkauf im Checkout | on | `POST /store/carts/:id/tax-identification` answers 404 (before body validation); carts get no reverse charge, even with evidence checked earlier today; completing an ordinarily taxed order freezes no evidence. Child of `vat_validation`. Storefronts keep the company name and hide the VAT id. |
3191
+ | `shipping_tax` | Versandsteuer-Compliance | on | Shipping is taxed at the shipping option's own rate (Medusa's default) instead of the net-value-weighted item rate, for carts and order recalculations. |
3192
+
3193
+ With `shipping_tax` on, a cart with items at several rates gets one shipping
3194
+ tax line "Anteilige Versandsteuer" (code `APPORTIONED_SHIPPING_TAX_CODE` from
3195
+ `/lib`) at the items' rates weighted by their net amount after discounts
3196
+ (computed like Medusa's line totals), which yields the same tax as splitting
3197
+ the shipping by that amount onto each rate. Medusa taxes a cart before it
3198
+ applies promotions and a code redeemed through `POST /store/carts/:id/promotions`
3199
+ recalculates no tax, so the cart's rate can lag behind its discounts; the
3200
+ `orderCreated` participant therefore sets the order's rate again from its
3201
+ final discounts (tax-inclusive shipping only, whose paid gross amount stays
3202
+ the same; on error the order keeps the cart's rate and a warning is logged).
3203
+ Medusa stores the rate with six decimals. The invoice (see "Dokumente")
3204
+ splits that line back onto the item rates by the same key.
3205
+
3206
+ `GET /store/tax-identification-policy` follows only the plugin switch.
3207
+ Evidence frozen under an order that was zero-rated keeps counting for that
3208
+ order whatever the toggles say now.
3209
+
3210
+ The tax hooks and the order-evidence freeze never read a toggle. The
3211
+ `tax_compliance_state` row mirrors the effective `b2b` and `shipping_tax`
3212
+ values; the `plugin-features.updated.v1` subscriber and the
3213
+ `tax-compliance-state-sync` job (every 5 minutes) write it. Before the first
3214
+ sync (no row) both count as on; when the row cannot be read both count as
3215
+ off (ordinary B2C rates, Medusa's shipping tax).
3216
+
3217
+ ### UID check in the checkout
3218
+
3219
+ Public Store request, response, schema, and path imports belong to
3220
+ `@hartl-services/medusa-food-supplements-contracts`:
3221
+
3222
+ ```ts
3223
+ import type { SubmitTaxIdentificationResponse } from "@hartl-services/medusa-food-supplements-contracts/types/tax-compliance-store";
3224
+ import { taxCompliancePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/tax-compliance";
3225
+
3226
+ const result = await sdk.client.fetch<SubmitTaxIdentificationResponse>(
3227
+ taxCompliancePaths.store.carts.taxIdentification(cartId),
3228
+ { method: "POST", body: {} },
3229
+ );
3230
+ ```
3231
+
3232
+ The module persists normalized cart validation evidence and selects qualified
3233
+ VAT validators by merchant country. Austrian merchants use the isolated
3234
+ FinanzOnline stage-2 adapter. Missing credentials, the development UID
3235
+ placeholder `ATU00000000`, technical/rate-limit responses, and unsupported
3236
+ merchant countries produce local `unavailable` evidence. Tax calculation
3237
+ interprets missing, stale, invalid, ineligible, and unavailable evidence as the
3238
+ normal B2C outcome. Cart tax calculation only accepts same-day `eligible`
3239
+ evidence whose current cart and merchant fingerprints still match, whose UID
3240
+ prefix matches the delivery country, and whose delivery country is
3241
+ cross-border. Once evidence is frozen, order tax calculation instead verifies
3242
+ the exact evidence-ID/order-ID binding and its frozen facts; it deliberately
3243
+ does not reopen current-cart or current-day eligibility. Eligible calculations
3244
+ receive explicit zero-percent item and shipping tax lines.
3245
+
3246
+ `ATU00000000` is a development-only Austrian UID placeholder: the registry
3247
+ treats it as missing (`merchant_uid_sentinel`, never sent to FinanzOnline)
3248
+ and Documents never prints it. Enter the real UID in the Shop-Stammdaten
3249
+ before issuing production invoices.
3250
+
3251
+ The Store route is:
3252
+
3253
+ ```text
3254
+ POST /store/carts/:id/tax-identification
3255
+ ```
3256
+
3257
+ The UID belongs to the cart's billing address: company plus
3258
+ `metadata.vat_number` (`TAX_IDENTIFICATION_METADATA_KEY`), set by the
3259
+ storefront like any other address field, for guests too. The cart evidence
3260
+ fingerprint reads the UID from the current billing address, so changing or
3261
+ removing it ends the exemption. Medusa itself recomputes taxes only when the
3262
+ shipping address changes, so a storefront calls this route (empty body) after
3263
+ the addresses changed and before it initiates payment.
3264
+
3265
+ It requires a publishable key scoped to the cart's sales channel. With a UID
3266
+ on a billing address outside the merchant country (Shop-Stammdaten) it
3267
+ performs one qualified validation; without one, or for a company in the
3268
+ merchant country, it answers `status: null` without any query and only
3269
+ recomputes when the cart still has a current check record (an earlier
3270
+ exemption falls away). It then forces tax-line recomputation, reconciles an
3271
+ existing payment collection with the new cart total, and returns only the
3272
+ public status, optional user message, and safe cart totals. The deprecated
3273
+ body (`company_name` and `vat_number` together) is still accepted: the route
3274
+ writes both into the billing address first. Provider facts, evidence IDs,
3275
+ credentials, sessions, and SOAP data are never part of the response. When the
3276
+ total changed, the reconciliation deletes the cart's payment sessions, so a
3277
+ storefront re-initiates payment after the call.
3278
+
3279
+ The adapter requires a dedicated FinanzOnline web-service user whose
3280
+ credentials are entered as runtime settings (see above). The PIN is stored
3281
+ encrypted and never sent to the Admin or a Storefront. SOAP clients are
3282
+ validation-scoped, scrub their node-soap transport state after every
3283
+ operation, and always attempt logout after a successful login. Every
3284
+ `unavailable` answer from the adapter logs one warning with the failing step
3285
+ (login or UID query) and FinanzOnline's return code and message, or the
3286
+ exception message (PIN masked), so a rejected web-service user shows up in
3287
+ the server log while the shopper only sees the generic message. Same-day
3288
+ provider facts are serialized and reused by normalized merchant UID, customer
3289
+ UID, and UTC date across carts and saved addresses. Every subject still
3290
+ receives its own evidence row and fingerprint; changed company or address
3291
+ submissions are re-evaluated locally against the structured provider facts
3292
+ without another remote request. Only records FinanzOnline actually answered
3293
+ for (provider facts, valid or not) are reused: an `unavailable` without an
3294
+ answer (missing credentials, the placeholder UID, an outage or login failure)
3295
+ is checked again on the next submission instead of blocking the UID for the
3296
+ rest of the day. Transaction-scoped PostgreSQL advisory locks cover both the
3297
+ normalized subject and the UID/day reuse key. Partial unique indexes permit
3298
+ only one current evidence row per cart and per saved address, while a
3299
+ separate lookup index supports cross-subject reuse.
3300
+
3301
+ A domestic company never qualifies for a tax-free intra-Community supply, so
3302
+ its UID is neither checked nor stored as evidence. Storefronts ask for the UID
3303
+ only outside the merchant country and read that country from:
3304
+
3305
+ ```text
3306
+ GET /store/tax-identification-policy → { merchant_country_code: "at" | null }
3307
+ ```
3308
+
3309
+ `null` while the master data name no country (or cannot be read): the
3310
+ storefront then asks for every country.
3311
+
3312
+ ```ts
3313
+ import type { GetTaxIdentificationPolicyResponse } from "@hartl-services/medusa-food-supplements-contracts/types/tax-compliance-store";
3314
+ import { taxCompliancePaths } from "@hartl-services/medusa-food-supplements-contracts/paths/tax-compliance";
3315
+
3316
+ const { merchant_country_code } = await sdk.client.fetch<GetTaxIdentificationPolicyResponse>(
3317
+ taxCompliancePaths.store.taxIdentificationPolicy,
3318
+ { method: "GET" },
3319
+ );
3320
+ ```
3321
+
3322
+ Login and UID query share a 10 s budget (WSDL downloads included) and logout
3323
+ gets 3 s, so the request stays below a storefront's 15 s checkout wait and the
3324
+ database transaction holding the UID lock never waits on a hanging
3325
+ FinanzOnline call; a timeout answers `unavailable` (`technical_error`).
3326
+
3327
+ ### Saved customer addresses
3328
+
3329
+ A saved customer address carries its UID in `metadata.vat_number`
3330
+ (`TAX_IDENTIFICATION_METADATA_KEY` in the Contracts). A logged-in customer
3331
+ checks it with
3332
+
3333
+ ```text
3334
+ POST /store/customers/me/addresses/:address_id/tax-identification
3335
+ GET /store/customers/me/tax-identifications
3336
+ ```
3337
+
3338
+ Company, UID and address are read from the stored address, never from the
3339
+ request; the address's own country stands in for the delivery country.
3340
+ `verified` (status `eligible` or `ineligible`) means the UID is real and
3341
+ matches company and address. The GET route only reports evidence whose
3342
+ fingerprint still matches the address as it is now: editing company, UID or
3343
+ address drops the status until the address is checked again. Metadata alone
3344
+ never proves a check. Both routes follow the `vat_validation` feature (404
3345
+ while off) and require a customer login.
3346
+
3347
+ Other plugins read the same current evidence, whatever its status (also
3348
+ `unavailable` and `invalid`), through
3349
+ `listCustomerAddressTaxIdentifications(container, customerId)` from
3350
+ `@hartl-services/medusa-base` (typed `CustomerAddressTaxIdentification` with
3351
+ the checked `billing_address`); the caller decides with
3352
+ `isPluginFeatureEnabledByKey(container, "tax_compliance", "vat_validation")`
3353
+ whether the check is on. The Affiliate partner registration uses it.
3354
+
3355
+ An Admin checks any customer's saved address again with
3356
+
3357
+ ```text
3358
+ POST /admin/tax-compliance/customer-addresses/:address_id/tax-identification
3359
+ ```
3360
+
3361
+ (admin login, `vat_validation` feature; answers like the Store route,
3362
+ `SubmitCustomerAddressTaxIdentificationResponse`, path
3363
+ `taxCompliancePaths.admin.customerAddressTaxIdentification(addressId)`). The
3364
+ Affiliate plugin uses it to re-check a partner application whose check was
3365
+ unavailable.
3366
+
3367
+ All three FinanzOnline settings are required as a set. Otherwise the service
3368
+ stores `unavailable` evidence and checkout continues with B2C tax; the Store
3369
+ response returns the checkout warning "Die UID-Prüfung ist derzeit nicht
3370
+ verfügbar. Bitte versuchen Sie es später erneut." Invalid or ineligible B2B
3371
+ facts likewise receive ordinary B2C tax, without a warning. The registry is
3372
+ country-based: it currently provides only the Austrian FinanzOnline adapter;
3373
+ an unsupported merchant country deliberately resolves to `unavailable`.
3374
+
3375
+ ### Frozen order evidence
3376
+
3377
+ Cart tax workflows pass only the opaque current evidence ID and the merchant
3378
+ profile (country and UID) to the provider. Order tax workflows use evidence
3379
+ frozen under the order ID by the workflow
3380
+ `freeze-tax-compliance-order-evidence`, which the base runs as an
3381
+ `orderCreated` participant with `order` 200 (see "Complete-cart hook
3382
+ composition"), after Affiliate's attribution (100) when Affiliate is
3383
+ installed. After the freeze the same workflow sets the order's apportioned
3384
+ shipping rate from its final discounts (see "Feature toggles" above).
3385
+ Compensation runs in reverse (tax, then Affiliate); if the freeze fails, the
3386
+ base cancels Affiliate's already finished attribution. Tax-only module lookup
3387
+ and context failures remain fail-closed to ordinary B2C. The freeze step
3388
+ decides itself whether a failure is fatal: an auxiliary freeze failure is
3389
+ logged and contained for an ordinarily taxed B2C cart, but a zero-rated cart
3390
+ cannot complete unless its evidence was durably frozen under the order ID.
3391
+ Zero-rated means the provider set a line to 0 % although its configured tax
3392
+ rate is not 0 % (UID-based exemption); a line that is 0 % because its
3393
+ configured tax rate is 0 % (e.g. the small-business exemption) is ordinary
3394
+ taxation and needs no evidence. Freeze-compensation persistence failures are
3395
+ logged and propagated so workflow cancellation stays failed instead of
3396
+ silently leaving a stale order binding.
3397
+
3398
+ Documents reads that frozen eligible B2B evidence when an invoice is issued,
3399
+ so the document snapshot contains the company, customer UID and exemption
3400
+ wording from the completed order (see "Dokumente"). A later cart change or
3401
+ validation cannot alter an already issued document; B2C, invalid, ineligible
3402
+ and unavailable evidence adds no tax-proof wording.
3403
+
3081
3404
  ## Dateispeicher (S3)
3082
3405
 
3083
3406
  The base provides a Medusa File Module provider for durable S3-compatible
@@ -3319,15 +3642,14 @@ loads the Sentry logger wrapper and with it `@sentry/node`; `/config` stays
3319
3642
  free of it.
3320
3643
 
3321
3644
  The preset registers the base (with its E-Mail, Documents and `/files`
3322
- options) plus five feature plugins, in this order: `medusa-base`,
3323
- `medusa-tax-compliance`, `medusa-free-shipping`, `medusa-batch-management`,
3645
+ options, and its Tax provider) plus four feature plugins, in this order:
3646
+ `medusa-base`, `medusa-free-shipping`, `medusa-batch-management`,
3324
3647
  `medusa-affiliate`, `medusa-payload-sync`.
3325
3648
 
3326
3649
  ### Installation in a shop
3327
3650
 
3328
3651
  ```bash
3329
3652
  pnpm add @hartl-services/medusa-base \
3330
- @hartl-services/medusa-tax-compliance \
3331
3653
  @hartl-services/medusa-free-shipping \
3332
3654
  @hartl-services/medusa-batch-management \
3333
3655
  @hartl-services/medusa-affiliate \
@@ -1,2 +1,4 @@
1
1
  /** Container key of the base plugin's module (`Module("plugin_features", …)`). */
2
2
  export declare const PLUGIN_FEATURES_MODULE: "plugin_features";
3
+ /** Container key of the base plugin's Steuer-Compliance module (`Module("tax_compliance", …)`). */
4
+ export declare const TAX_COMPLIANCE_MODULE: "tax_compliance";
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PLUGIN_FEATURES_MODULE = void 0;
3
+ exports.TAX_COMPLIANCE_MODULE = exports.PLUGIN_FEATURES_MODULE = void 0;
4
4
  /** Container key of the base plugin's module (`Module("plugin_features", …)`). */
5
5
  exports.PLUGIN_FEATURES_MODULE = "plugin_features";
6
+ /** Container key of the base plugin's Steuer-Compliance module (`Module("tax_compliance", …)`). */
7
+ exports.TAX_COMPLIANCE_MODULE = "tax_compliance";
@@ -1,4 +1,5 @@
1
- export { PLUGIN_FEATURES_MODULE } from "./constants";
1
+ export { PLUGIN_FEATURES_MODULE, TAX_COMPLIANCE_MODULE } from "./constants";
2
+ export { listCustomerAddressTaxIdentifications, TAX_COMPLIANCE_STATUSES, type AddressSnapshot, type CustomerAddressTaxIdentification, type TaxComplianceStatus, } from "./tax-compliance";
2
3
  export { definePluginFeatures } from "./descriptor";
3
4
  export { resolveEffectivePluginFeatures } from "./effective-features";
4
5
  export { isOwnPluginEvent, isPluginFeatureEnabled, isPluginFeatureEnabledByKey, pluginFeatureGate, requirePluginFeature, } from "./enforcement";
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.reportPluginActionProgress = exports.shopMasterDataDescriptor = exports.getShopMasterData = exports.getShopAppearance = exports.getPluginSettings = exports.PLUGIN_ICON_NAMES = exports.resolveBaseModule = exports.isBasePluginRegistered = exports.requirePluginFeature = exports.pluginFeatureGate = exports.isPluginFeatureEnabledByKey = exports.isPluginFeatureEnabled = exports.isOwnPluginEvent = exports.resolveEffectivePluginFeatures = exports.definePluginFeatures = exports.PLUGIN_FEATURES_MODULE = void 0;
3
+ exports.reportPluginActionProgress = exports.shopMasterDataDescriptor = exports.getShopMasterData = exports.getShopAppearance = exports.getPluginSettings = exports.PLUGIN_ICON_NAMES = exports.resolveBaseModule = exports.isBasePluginRegistered = exports.requirePluginFeature = exports.pluginFeatureGate = exports.isPluginFeatureEnabledByKey = exports.isPluginFeatureEnabled = exports.isOwnPluginEvent = exports.resolveEffectivePluginFeatures = exports.definePluginFeatures = exports.TAX_COMPLIANCE_STATUSES = exports.listCustomerAddressTaxIdentifications = exports.TAX_COMPLIANCE_MODULE = exports.PLUGIN_FEATURES_MODULE = void 0;
4
4
  var constants_1 = require("./constants");
5
5
  Object.defineProperty(exports, "PLUGIN_FEATURES_MODULE", { enumerable: true, get: function () { return constants_1.PLUGIN_FEATURES_MODULE; } });
6
+ Object.defineProperty(exports, "TAX_COMPLIANCE_MODULE", { enumerable: true, get: function () { return constants_1.TAX_COMPLIANCE_MODULE; } });
7
+ var tax_compliance_1 = require("./tax-compliance");
8
+ Object.defineProperty(exports, "listCustomerAddressTaxIdentifications", { enumerable: true, get: function () { return tax_compliance_1.listCustomerAddressTaxIdentifications; } });
9
+ Object.defineProperty(exports, "TAX_COMPLIANCE_STATUSES", { enumerable: true, get: function () { return tax_compliance_1.TAX_COMPLIANCE_STATUSES; } });
6
10
  var descriptor_1 = require("./descriptor");
7
11
  Object.defineProperty(exports, "definePluginFeatures", { enumerable: true, get: function () { return descriptor_1.definePluginFeatures; } });
8
12
  var effective_features_1 = require("./effective-features");
@@ -0,0 +1,30 @@
1
+ import type { MedusaContainer } from "@medusajs/framework/types";
2
+ export declare const TAX_COMPLIANCE_STATUSES: readonly ["eligible", "ineligible", "invalid", "unavailable"];
3
+ export type TaxComplianceStatus = (typeof TAX_COMPLIANCE_STATUSES)[number];
4
+ export type AddressSnapshot = {
5
+ address1: string;
6
+ address2?: string | null;
7
+ city: string;
8
+ countryCode: string;
9
+ postalCode: string;
10
+ };
11
+ /** Status of the current UID check of a saved customer address whose facts still match it. */
12
+ export type CustomerAddressTaxIdentification = {
13
+ customer_address_id: string;
14
+ status: TaxComplianceStatus;
15
+ customer_vat_number: string;
16
+ company_name: string;
17
+ validated_at: Date;
18
+ /** `eligible` or `ineligible`: the UID is real and matches company and address. */
19
+ verified: boolean;
20
+ /** The billing address the UID was checked against (`countryCode` lowercase, `address2` only when set). */
21
+ billing_address: AddressSnapshot;
22
+ };
23
+ /**
24
+ * The current UID check of each saved address of the customer that still
25
+ * matches the address as it is now, whatever its status (also `unavailable`
26
+ * and `invalid`). For other plugins, e.g. the Affiliate partner registration;
27
+ * the caller decides whether the `vat_validation` feature of `tax_compliance`
28
+ * is on (`isPluginFeatureEnabledByKey`).
29
+ */
30
+ export declare const listCustomerAddressTaxIdentifications: (container: MedusaContainer, customerId: string) => Promise<CustomerAddressTaxIdentification[]>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listCustomerAddressTaxIdentifications = exports.TAX_COMPLIANCE_STATUSES = void 0;
4
+ const constants_1 = require("./constants");
5
+ exports.TAX_COMPLIANCE_STATUSES = [
6
+ "eligible",
7
+ "ineligible",
8
+ "invalid",
9
+ "unavailable",
10
+ ];
11
+ /**
12
+ * The current UID check of each saved address of the customer that still
13
+ * matches the address as it is now, whatever its status (also `unavailable`
14
+ * and `invalid`). For other plugins, e.g. the Affiliate partner registration;
15
+ * the caller decides whether the `vat_validation` feature of `tax_compliance`
16
+ * is on (`isPluginFeatureEnabledByKey`).
17
+ */
18
+ const listCustomerAddressTaxIdentifications = (container, customerId) => container
19
+ .resolve(constants_1.TAX_COMPLIANCE_MODULE)
20
+ .listCustomerAddressIdentifications(customerId);
21
+ exports.listCustomerAddressTaxIdentifications = listCustomerAddressTaxIdentifications;
@@ -5,6 +5,7 @@ export * from "./errors";
5
5
  export * from "./hash";
6
6
  export * from "./logging";
7
7
  export * from "./money";
8
+ export * from "./optional-registration";
8
9
  export * from "./refreshing-snapshot";
9
10
  export * from "./shipping-tax";
10
11
  export * from "./text";
@@ -23,6 +23,7 @@ __exportStar(require("./errors"), exports);
23
23
  __exportStar(require("./hash"), exports);
24
24
  __exportStar(require("./logging"), exports);
25
25
  __exportStar(require("./money"), exports);
26
+ __exportStar(require("./optional-registration"), exports);
26
27
  __exportStar(require("./refreshing-snapshot"), exports);
27
28
  __exportStar(require("./shipping-tax"), exports);
28
29
  __exportStar(require("./text"), exports);
@@ -0,0 +1,18 @@
1
+ type ResolvingContainer = {
2
+ resolve<TValue = unknown>(name: string): TValue;
3
+ hasRegistration?: (name: string) => boolean;
4
+ };
5
+ /**
6
+ * Whether `error` is the container saying `name` is not registered: the
7
+ * awilix message Medusa throws ("Could not resolve 'name'. ...", only for the
8
+ * requested name itself, never a failing dependency further down the chain)
9
+ * or the older "Missing registration" wording.
10
+ */
11
+ export declare const isMissingRegistrationError: (error: unknown, name: string) => boolean;
12
+ /**
13
+ * Resolves an optional plugin's module service by its container key, or null
14
+ * while that plugin is not installed. A known-absent registration returns
15
+ * null without resolving; any other resolution failure is rethrown.
16
+ */
17
+ export declare const tryResolveOptional: <T>(container: ResolvingContainer, name: string) => T | null;
18
+ export {};
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.tryResolveOptional = exports.isMissingRegistrationError = void 0;
4
+ /**
5
+ * Whether `error` is the container saying `name` is not registered: the
6
+ * awilix message Medusa throws ("Could not resolve 'name'. ...", only for the
7
+ * requested name itself, never a failing dependency further down the chain)
8
+ * or the older "Missing registration" wording.
9
+ */
10
+ const isMissingRegistrationError = (error, name) => error instanceof Error &&
11
+ (error.message.includes("Missing registration") ||
12
+ error.message.startsWith(`Could not resolve '${name}'`));
13
+ exports.isMissingRegistrationError = isMissingRegistrationError;
14
+ /**
15
+ * Resolves an optional plugin's module service by its container key, or null
16
+ * while that plugin is not installed. A known-absent registration returns
17
+ * null without resolving; any other resolution failure is rethrown.
18
+ */
19
+ const tryResolveOptional = (container, name) => {
20
+ if (container.hasRegistration?.(name) === false) {
21
+ return null;
22
+ }
23
+ try {
24
+ return container.resolve(name);
25
+ }
26
+ catch (error) {
27
+ if ((0, exports.isMissingRegistrationError)(error, name)) {
28
+ return null;
29
+ }
30
+ throw error;
31
+ }
32
+ };
33
+ exports.tryResolveOptional = tryResolveOptional;
@@ -33,8 +33,8 @@ export type FoodShopConfigInput = {
33
33
  * Builds the plain `medusa-config.ts` input of a food shop: Medusa core
34
34
  * settings, the Redis event bus, the mail provider (set up in the Admin), S3 file storage, tax and
35
35
  * Stripe payment providers, and the Hartl Services plugins (the base plugin
36
- * with its E-Mail, Documents and file storage modules plus five feature
37
- * plugins). Deployment
36
+ * with its E-Mail, Documents, Steuer-Compliance and file storage modules plus
37
+ * four feature plugins). Deployment
38
38
  * values are read and validated from `environment`; a missing or invalid one
39
39
  * fails with its name.
40
40
  */