Skip to content

VAT Rate Management

Flow ID: AD-50 | Module(s): eshop, Order/Vat domain | Complexity: Medium Last Updated: 2026-04-28

Business Context

VAT rate management provides CRUD operations for the shop's tax rate catalog. Each product row stores a foreign-key-less vat_id referencing a row in shop_product_vats; that row's value is multiplied into list prices, cart totals, and captured verbatim into every order line at checkout. Greece typically uses 24% (standard), 13% (reduced), 6.5/6% (super-reduced) and 0% (exempt), which matches the four canonical IDs baked into the bridge table migration (1=>0%, 2=>13%, 3=>24%, 4=>6%). The default seed only ships 24.0 and 13.0.

Two parallel CRUD surfaces exist:

  • The legacy admin page at /eshop/vats_admin.htm, backed by a tiny Adv_vats_admin controller with form-validation-lite and a single delete guard.
  • The modern REST API under /rest/order/vat, backed by src/Domains/Order/Vat/ and src/Rest/Order/Vat, which is missing some protections the legacy path enforces (no cache invalidation). Validator rules for create/update were added in GH #7; RBAC for write endpoints was added in GH #10; the delete guard was added in commit 7049a4555. All three now match or exceed legacy behaviour.

VAT rates flow through a lazy singleton (AdvVatForOrder) that is a pass-through by default — nothing manipulates rates per-country out of the box. The shop_prices_view database view joins shop_product_vats directly on INNER JOIN, so any orphaned product silently disappears from listings. Historical VAT is preserved per order line in shop_order_basket.product_vat, so rate changes never mutate past orders.


API Reference

REST Endpoints (Modern Layer)

MethodPathActionAuth
GET/rest/order/vatList VAT rates (paginated, filterable)Guest
GET/rest/order/vat/{id}Get single VAT rate by IDGuest
GET/rest/order/vat/itemGet single VAT rate by filterGuest
POST/rest/order/vatCreate new VAT rateJWT — AUTH_ROLE_ADMIN or AUTH_ROLE_PRODUCTS
POST/rest/order/vat/{id}Update existing VAT rateJWT — AUTH_ROLE_ADMIN or AUTH_ROLE_PRODUCTS
DELETE/rest/order/vat/{id}Delete VAT rateJWT — AUTH_ROLE_ADMIN or AUTH_ROLE_PRODUCTS

Routes registered in application/config/rest_routes.php lines 701-706 (read) and 1349-1354 (write), each with a locale-prefixed variant. Write endpoints require AUTH_ROLE_ADMIN or AUTH_ROLE_PRODUCTS via application/config/rest_policies.php (GH #10); read endpoints are public (guest).

Legacy Admin Routes

RouteControllerMethodHTTPDescription
eshop/vats_adminAdv_vats_adminindex()GETList all VAT rates
eshop/vats_admin/addAdv_vats_adminadd()GET/POSTCreate VAT rate
eshop/vats_admin/edit/{id}Adv_vats_adminedit($id)GET/POSTEdit VAT rate
eshop/vats_admin/delete/{id}Adv_vats_admindelete($id)GETDelete VAT rate

Defined in application/config/routes.php:328-331 (default + locale-prefixed).

Required roles (controller constructor, ecommercen/eshop/controllers/Adv_vats_admin.php:22-28): AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS.

Admin menu roles (application/config/admin_menu.php:519-525): AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN — the menu omits AUTH_ROLE_PRODUCTS, so a PRODUCTS-role user cannot see the link but can still reach the URL directly (security-through-obscurity mismatch).


Code Flow

Legacy Admin CRUD

Adv_vats_admin::add() / edit($id)
  |
  +--> POST submit -> validation()
  |                     \-- set_rules('vat_value', ..., 'trim|numeric')
  |                         (no required, no range, no uniqueness)
  |
  +--> Build data array ['value' => $this->input->post('vat_value')]
  +--> $this->vats_model->addRecord($data)    or  editRecord($id, $data)
  +--> afterAdd($id) | afterEdit($id)   [empty stub in base]
  +--> redirect('eshop/vats_admin')
Adv_vats_admin::delete($id)
  |
  +--> $this->vats_model->deleteRecord($id)
  |      |
  |      +--> canDelete($id) -> SELECT count(*) FROM shop_product WHERE vat_id = ?
  |      +--> if zero references: DELETE FROM shop_product_vats WHERE id = ?
  |      \-- else: return false (silent)
  |
  +--> afterDelete($id)   [empty stub in base]
  +--> redirect

The afterAdd() / afterEdit() / afterDelete() hooks at ecommercen/eshop/controllers/Adv_vats_admin.php:134-147 are empty stubs, intended to be overridden in client application/modules/eshop/controllers/Vats_admin.php. The base does not clear pscache, write an audit trail, or re-parse product prices — stale cached rates persist until TTL expiry.

Modern REST Write Flow

POST /rest/order/vat  or  POST /rest/order/vat/{id}
  |
  +--> Rest\Order\Controllers\Vat::store() / update($id)
  +--> HandlesWriteActions::doStore() / doUpdate($id)
  |      |
  |      +--> WriteData::from(request()) -- single ?float $value field
  |      +--> Validator::validateForCreate($data) / validateForUpdate($data, $id)
  |      |    validateForCreate: value required; range [0,100]; max 1 decimal place;
  |      |    |                  no duplicate via WriteRepository::existsWithValue($value)
  |      |    validateForUpdate: value optional; if present, same range + precision;
  |      |                       duplicate check excludes current row:
  |      |                       WriteRepository::existsWithValue($value, (int)$id)
  |      +--> WriteService::create() / update($id)
  |           \-- WriteRepository->insert/update into shop_product_vats
  |
  +--> 200 + VatResource::toArray()  => ['id' => int, 'value' => (float)$value]
DELETE /rest/order/vat/{id}
  |
  +--> Rest\Order\Controllers\Vat::destroy($id)
  +--> HandlesWriteActions::doDestroy($id)
  |      |
  |      +--> service->get($id)  [existence check]
  |      |      \-- 404 if not found
  |      |
  |      +--> WriteService::delete($id)
  |      |      |
  |      |      +--> Validator::validateForDelete($id)
  |      |      |      \-- WriteRepository::hasReferences($id)
  |      |      |          SELECT 1 FROM shop_product WHERE vat_id = ? LIMIT 1
  |      |      |          if any row: throw ValidationException ['id' => 'Cannot delete VAT rate: referenced by existing products']
  |      |      |
  |      |      \-- WriteRepository::delete($id)  [only reached if no references]
  |      |
  |      +--> 200 {'success': true, 'message': 'Entity deleted successfully'}
  |      \-- on ValidationException: 409 {'success': false, 'errors': {'id': '...'}}

src/Domains/Order/Vat/WriteService.php:47-51 (7049a4555):

php
public function delete(int|string $id): bool
{
    $this->validator->validateForDelete($id);
    return $this->writeRepository->delete($id);
}

The guard calls Validator::validateForDelete() which delegates to WriteRepository::hasReferences() (src/Domains/Order/Vat/Repository/WriteRepository.php). If any product references the VAT, ValidationException is thrown and caught by HandlesWriteActions::doDestroy() (src/Rest/Support/Controllers/HandlesWriteActions.php) as HTTP 409 Conflict. This matches the legacy canDelete() behaviour.


Data Model

shop_product_vats Table

Defined exclusively in database/initial/initial.sql:1877-1881. No Phinx migration has ever altered it.

ColumnTypeNullableDescription
idINT(11) AUTO_INCREMENT PKNoSurrogate key
valueDECIMAL(11,1) NOT NULLNoVAT rate percentage

Constraints / notes:

  • No foreign key from shop_product.vat_idshop_product_vats.id (the relationship is purely logical).
  • No UNIQUE constraint on value — duplicate rates are permitted.
  • No description, label, country, or active flag.
  • No created_at / updated_at timestamps.
  • DECIMAL(11,1) allows one decimal digit only. You can store 24.0, 13.0, 6.5; you cannot store 23.99 (silently truncated to 24.0).
  • Default seed (database/seeders/data/global/shop_product_vats.json) inserts only 24.0 and 13.0.
  • Canonical ID convention from database/migrations/20240923141428_new_bridge_tables.php:46 comment: 1=>0%, 2=>13%, 3=>24%, 4=>6%.

Tables that Reference VAT

TableColumnTypePurpose
shop_productvat_id INT NOT NULLFK-less referenceProduct's assigned VAT rate (initial.sql:1524,1560)
shop_ordertotal_vat DECIMAL(11,2) NULLOrder-level VAT amount in currencyAggregate captured at checkout
shop_order_basketproduct_vat DECIMAL(11,2) NOT NULLPer-line snapshot of VAT rateHistorical preservation — subsequent rate changes do not alter past orders
couponsold_total_vat DECIMAL(11,2) NOT NULL DEFAULT 0.00Audit/rollbackCoupon side of VAT audit trail

Unrelated: skroutz_invoice_details.vat_number etc. is a customer ΑΦΜ/VIES tax identifier, not a reference to shop_product_vats.

shop_prices_view (only DB view in the system)

Defined at database/initial/initial.sql:2451-2467. Joins shop_productshop_product_vats via INNER JOIN and computes original_price / final_price with CASE branches for VAT-inclusive and discount variants.

Fragility: The INNER JOIN means any product whose vat_id row is deleted from shop_product_vats silently disappears from every query that goes through the view — including storefront listings. The view also uses the raw shop_product_vats.value directly and does not route through AdvVatForOrder, so it always returns Greek-mainland inclusive pricing regardless of country (consistent with the default enableOrderVatManipulation=false but diverges if the flag is ever flipped).


Domain Layer

Modern Domain (src/Domains/Order/Vat/)

Note the context: Order, not Product — even though functionally VAT is a product taxonomy, the domain is filed under the Order bounded context.

FileResponsibility
src/Domains/Order/Vat/Repository/Entity.php@property int $id, @property float $value; no TABLE constant
src/Domains/Order/Vat/Repository/Repository.phpRead repo, protected string $table = 'shop_product_vats';
src/Domains/Order/Vat/Repository/RepositoryConfigurator.phpgetRelations(): array { return []; } — no joins
src/Domains/Order/Vat/Repository/WriteRepository.phpWrite repo, extends BaseWriteRepository, PK id; adds hasReferences(int $id): bool and existsWithValue(float $value, ?int $excludeId = null): bool
src/Domains/Order/Vat/Service.phpRead service: all(), item(), get(); implements ReadService, RendersPagination
src/Domains/Order/Vat/WriteService.phpcreate(), update(int|string $id, array $data), delete(int|string $id) — each calls validator before repository. update() passes $id through to validateForUpdate() so the duplicate check can exclude the record being edited (GH #7). delete() calls validateForDelete($id) (added in 7049a4555).
src/Domains/Order/Vat/WriteData.phpDTO with single public readonly ?float $value; OpenAPI schema name Vat, required ['value']
src/Domains/Order/Vat/Validator.phpvalidateForCreate(WriteData $data): requires non-null value, enforces range [0, 100], rejects more than 1 decimal place, blocks duplicate via existsWithValue(). validateForUpdate(WriteData $data, int|string|null $id): same checks when value is present, duplicate check excludes the current record. validateForDelete(int|string $id) (added in 7049a4555): rejects deletion if hasReferences() returns true. (GH #7)
src/Domains/Order/Vat/ListRequest.phpAllowed filters: id, value (Exact). Allowed sorts: id, value. No relation sorts.

REST Layer (src/Rest/Order/)

FileResponsibility
src/Rest/Order/Controllers/Vat.php148 lines; extends HandlesRestfulActions + HandlesWriteActions; full OpenAPI annotations; tags Rest/Order/Vat; schemas Vat, VatResource, VatCollection
src/Rest/Order/Resources/Vat/Resource.php['id' => int, 'value' => (float)$resource->value]
src/Rest/Order/Resources/Vat/Collection.phpCollects Resource::class

Constructor (src/Rest/Order/Controllers/Vat.php:27-36) injects: ReadService, $resourceClass, $collectionClass, $listRequestClass, WriteService.

DI Registration

  • src/Domains/Order/container.php:18-24 — Domain services
  • src/Rest/Order/container.php:22-27 — Controller + resources

Legacy Layer (ecommercen/)

FileLinesResponsibility
ecommercen/eshop/controllers/Adv_vats_admin.php148Admin CRUD controller, extends Admin_c
application/modules/eshop/controllers/Vats_admin.phpEmpty client subclass override point
ecommercen/eshop/models/Adv_vats_model.php152Legacy model, extends Adv_base_model
application/modules/eshop/models/Vats_model.phpEmpty client subclass override point
application/views/admin/vats/list.phpListing view
application/views/admin/vats/create.phpCreate form
application/views/admin/vats/update.phpEdit form

Notable model methods:

  • deleteRecord($vatId) (ecommercen/eshop/models/Adv_vats_model.php:129-139): calls canDelete() and only deletes if it returns true.
  • canDelete($vatID) (lines 147-151): SELECT count(*) FROM shop_product WHERE vat_id = ? — returns true only if zero rows. Only checks shop_product; does not check shop_order_basket (different column anyway) or soft-deleted products.

VAT Calculation Pipeline

AdvVatForOrder Singleton

Defined in ecommercen/eshop/libraries/AdvVatForOrder.php (76 lines). Empty client subclass override at application/modules/eshop/libraries/VatForOrder.php.

Constructor reads config_item('enableOrderVatManipulation') from application/config/app.php:524, which defaults to false. When the flag is false — which is the platform-default state — AdvVatForOrder is a pure pass-through: whatever rate is stored in shop_product_vats is returned verbatim by vat().

Properties:

php
bool                   $enableVatCompute;
?OrderChargeAreaType   $chargeAreaType;
?OrderDeliverAreaType  $deliverAreaType;
bool                   $isInvoice;

Area Enums

OrderDeliverAreaType: Greece=1, GreeceReduced=2, EuropeUnion=3, NotEuropeUnion=4

OrderChargeAreaType: Greece=1, GreeceReduced=2, EuropeVIES=3, EuropeNoVIES=4, ThirdCountries=5

setDeliverAreaType($country, $greekArea = '') (lines 25-37)

if ($country == 'GR' && in_array($greekArea, ['not yet implemented', 'reduced invoice area']))
    -> GreeceReduced
elseif ($country == 'GR')
    -> Greece
elseif (isEuCountry($country))
    -> EuropeUnion
else
    -> NotEuropeUnion

vat(float $vatValue, bool $isDigital = false)

if ($enableVatCompute === false)  return $vatValue;   // default path
return $isInvoice ? $this->invoiceVat() : $this->receiptVat();
  • invoiceVat() — marked //TODO: not yet implemented; returns $vatValue unchanged
  • receiptVat() — returns 0 if $deliverAreaType === NotEuropeUnion, else $vatValue
  • $isDigital flag is set by callers but never consumed by vat() / invoiceVat() / receiptVat()
  • $chargeAreaType property is set by setChargeAreaType() (which does branch on $isVIES to pick EuropeVIES vs EuropeNoVIES) but the property is never read anywhere — vat(), invoiceVat(), and receiptVat() only consult $deliverAreaType

Dead Code Finding

No production code path passes a non-empty $greekArea argument to setDeliverAreaType(). A codebase-wide search shows only tests/Legacy/Eshop/AdvVatForOrderTest.php supplies 'reduced invoice area' or 'not yet implemented'. The Greek-islands reduced-VAT branch is unreachable from production. Combined with the empty invoiceVat() stub and the ignored $isDigital/$chargeAreaType/$isVIES inputs, a significant portion of AdvVatForOrder is cosmetic.

How VAT Reaches a Product Price

Central entry at ecommercen/eshop/models/Adv_product_parser_model.php:112-127:

php
$vatForOrder   = VatForOrder::getInstance();
$productVats   = getResultObjectAsIndexedArray(
                     $this->pscache->model('vats_model', 'getRecords', [], $this->pscacheTtl)
                 );
foreach ($this->products as &$product) {
    $product->vat_value = $vatForOrder->vat($productVats[$product->vat_id]->value);
    ...
    $this->setPrices($product, $vatForOrder);
}

setPrices() (lines 464-479) applies VAT via applyVatWithoutFormat() in ecommercen/helpers/shopmodule_helper.php:269-272:

php
return round(($amount + (($vat / 100) * $amount)) * $qty, 2);

A second cached site exists at ecommercen/eshop/models/Adv_new_product_prices_model.php:67.

Capturing VAT into Orders

At ecommercen/eshop/models/Adv_order_model.php:478-489:

php
$cart[$rowId]['vat_rate_captured'] = $vatForOrder->vat($prItem->vat_value);

Persisted via ecommercen/helpers/eshop_helper.php:102-125 into shop_order_basket.product_vat (DECIMAL(11,2)). This is the historical preservation mechanism — once an order is placed, subsequent changes to shop_product_vats rows do not retroactively alter past order lines.


Configuration

Feature Flag: enableOrderVatManipulation

  • File: application/config/app.php:524
  • Default: false
  • When false (default): AdvVatForOrder::vat() returns the raw shop_product_vats.value unchanged. No country logic, no invoice branching, no digital-goods branching.
  • When true: control enters invoiceVat() / receiptVat(), but invoiceVat() is a //TODO: not yet implemented pass-through and receiptVat() only zeroes VAT for NotEuropeUnion deliveries. Setting it to true without additional work changes very little behavior.

Hard-coded Marketplace Shipping VAT

application/config/app.php:529 defines 'shipping_vat' => 24 inside publicMarketplaceSettings. This constant is totally independent of shop_product_vats. Public Marketplace feed generators read it directly. Admin changes to shop_product_vats do not propagate here.

Cache Behavior

  • ecommercen/eshop/models/Adv_product_parser_model.php:113-115 — caches vats_model.getRecords() via pscache with $this->pscacheTtl.
  • ecommercen/eshop/models/Adv_new_product_prices_model.php:67 — second cached site.
  • ecommercen/helpers/pscache_helper.php:66-82 — declares vats_model in the products clearCache group.
  • BUT: Adv_vats_admin never invokes clearCache('products'). The legacy admin writes to shop_product_vats and relies on the TTL to expire. During that TTL window, price listings still reflect the old rate.

Hidden Writers

Pharmacy ERP Side-Channel (Europharmacy)

ecommercen/helpers/farmakon_helper.php:100-122 — during Europharmacy ERP product sync, new VAT rows are auto-created when an inbound rate is not yet present:

php
$vatId = array_search($productVat, $vats);
if ($vatId === false) {
    $ci->db->insert('shop_product_vats', ['value' => $productVat]);
    $vatId = $ci->db->insert_id();
    $vats[$vatId] = $productVat;
}
  • Bypasses the modern Validator entirely — now that the Validator enforces required value, range, and duplicate checks (GH #7), this ERP side-channel is a live gap: new rates inserted by Europharmacy skip all those guards.
  • Strict-string match via strval() means typos (e.g. '24.00' vs '24.0') silently create duplicate rows.
  • No afterAdd hook, no cache clear.

Pharmacy Accounting String Match

ecommercen/helpers/farmakon_helper.php:283-318 — hard-coded string branches on '6.00', '13.00', and '24.00' for pharmacy accounting. Any rate other than those three (e.g. Greek-island reduced '17.00') silently fails to match and is skipped by this code path.


Client Extension Points

  • Override legacy admin controller: create application/modules/eshop/controllers/Vats_admin.php (empty client subclass scaffold already exists).
  • Override legacy model: create application/modules/eshop/models/Vats_model.php.
  • Override AdvVatForOrder: create application/modules/eshop/libraries/VatForOrder.php (empty client subclass scaffold already exists) and implement invoiceVat() / receiptVat() / the dead branches as needed.
  • REST extensions: in client repos, extend Custom\Domains\Order\Vat\* and register DI aliases under custom/Domains/container.php.
  • Hook into legacy CRUD: override afterAdd($id), afterEdit($id), afterDelete($id) in the client Vats_admin controller to invalidate caches, emit audit events, or recompute prices.

Business Rules

  1. Historical preservation: Orders store per-line shop_order_basket.product_vat at checkout, so rate changes never affect past orders. (ecommercen/helpers/eshop_helper.php:102-125)
  2. Legacy delete guard blocks deletion of a VAT rate while any row in shop_product references it. (ecommercen/eshop/models/Adv_vats_model.php:147-151)
  3. Greek compliance: VAT rates must align with Greek tax law tiers for proper invoicing. The canonical ID convention is 1=>0%, 2=>13%, 3=>24%, 4=>6%. (database/migrations/20240923141428_new_bridge_tables.php:46)
  4. Decimal precision is one digit: DECIMAL(11,1) forbids rates like 23.99. Half-percent rates such as 6.5 are valid.
  5. Cache staleness window: Legacy admin changes do not invalidate pscache; stale rates persist until TTL expiry. (ecommercen/helpers/pscache_helper.php:66-82)
  6. Pass-through VAT by default: enableOrderVatManipulation=false means AdvVatForOrder returns raw stored rates; country/area logic is dormant. (application/config/app.php:524)
  7. Marketplace shipping VAT is hard-coded: changes to shop_product_vats do not affect marketplace shipping VAT. (application/config/app.php:529)

Known Issues & Security Gaps

  1. Empty Validator on create/updateFixed (GH #7). src/Domains/Order/Vat/Validator.php now rejects null/missing value on create, enforces the range [0, 100], pins decimal precision to a single digit to match the DECIMAL(11,1) column, and blocks duplicate rates via WriteRepository::existsWithValue().
  2. Legacy form validation is too looseecommercen/eshop/controllers/Adv_vats_admin.php:129-132 uses only trim|numeric; there is no required, no range check, no uniqueness. Submitting an empty POST silently inserts/updates to 0.
  3. RBAC mismatch — controller allows AUTH_ROLE_PRODUCTS (ecommercen/eshop/controllers/Adv_vats_admin.php:22-28) but the admin menu hides the link from that role (application/config/admin_menu.php:519-525). Security through obscurity: a PRODUCTS user reaching the URL directly can still operate the CRUD.
  4. No foreign key constraintshop_product.vat_id has no DB-level FK. Out-of-band SQL, migrations, REST deletes, or the pharmacy auto-insert can orphan products with no database protection.
  5. shop_prices_view INNER JOIN — orphaned products silently vanish from storefront listings. (database/initial/initial.sql:2451-2467)
  6. Pharmacy ERP auto-insert is a hidden second writerecommercen/helpers/farmakon_helper.php:100-122 creates VAT rows outside the validator, with string-match-based dedup that is prone to typos.
  7. Dead code paths may mislead future devs — Greek-islands branch in AdvVatForOrder::setDeliverAreaType(), unused $isDigital, never-read $chargeAreaType property (set but ignored by vat()/invoiceVat()/receiptVat()), and unimplemented invoiceVat(). (ecommercen/eshop/libraries/AdvVatForOrder.php)
  8. invoiceVat() is //TODO: not yet implemented — invoice-specific VAT logic never landed. (ecommercen/eshop/libraries/AdvVatForOrder.php)
  9. Hard-coded marketplace shipping VATapplication/config/app.php:529 stores 'shipping_vat' => 24, divorced from shop_product_vats.
  10. Pharmacy accounting hard-codes VAT stringsecommercen/helpers/farmakon_helper.php:283-318 branches on literal '6.00' / '13.00' / '24.00'. Adding a new rate silently breaks pharmacy-accounting mapping.
  11. No REST RBACFixed (GH #10). application/config/rest_policies.php now scopes Vat::class writes to [AUTH_ROLE_ADMIN, AUTH_ROLE_PRODUCTS] (matching the legacy admin controller), while reads (index, show, item) remain public.
  12. Cache not invalidated on admin writeAdv_vats_admin never calls clearCache('products'), so storefront prices remain stale until the pscache TTL expires.

Tests

FileCovers
tests/Integration/Domains/Order/Vat/RepositoryTest.phpRead repository queries
tests/Integration/Domains/Order/Vat/ServiceTest.phpall(), item(), get(), plus create_persists_vat, update_modifies_value, delete_removes_vat, and round_trip. Seeds IDs 99001 (0%), 99002 (13%), 99003 (24%).
tests/Unit/Domains/Order/Vat/ServiceTest.phpUnit-level read service coverage
tests/Unit/Domains/Order/Vat/WriteRepositoryTest.phphasReferences(): returns true when products reference the VAT, false when none (7049a4555). existsWithValue(): returns true on match, false when absent, excludes $excludeId row on update (GH #7).
tests/Unit/Domains/Order/Vat/ValidatorTest.phpvalidateForDelete(): passes with no refs, throws ValidationException with 'id' key when refs exist (7049a4555). validateForCreate(): rejects null value, out-of-range values, >1 decimal place, duplicate values (GH #7). validateForUpdate(): allows null value, rejects range/precision/duplicate violations, excludes self from duplicate check (GH #7).
tests/Unit/Domains/Order/Vat/WriteServiceTest.phpdelete(): validator called before repo, repo skipped when validator rejects, returns true on success (7049a4555). update(): passes $id to validateForUpdate() (GH #7).
tests/Legacy/Eshop/AdvVatForOrderTest.phpOnly production-side caller of the dead Greek-islands branch