Skip to content

Commit 7b8d5e9

Browse files
committed
feat: convert TaxRates to service contracts + add EU VAT sample data
Component/TaxRates.php — replace CsvImportHandler with service contracts: - Remove CsvImportHandler + DriverInterface filesystem round-trip - Inject TaxRateRepositoryInterface, TaxRateInterfaceFactory, SearchCriteriaBuilder, and RegionFactory - Each CSV row persisted via taxRateRepository->save() with idempotent skip-existing check via getList() filtered by code - Region codes ("CA", "NY", …) resolved to integer IDs via RegionFactory::create()->loadByCode($code, $countryId)->getId(); wildcard "*" and "0" map to region_id = 0 (all regions) - Remove now-unused <type name="TaxRates"> driver wiring from di.xml Test/stubs.php: - Add all setters to TaxRateInterface stub - Add TaxRateInterfaceFactory, Region, and RegionFactory stubs Test/Unit/Component/TaxRatesTest.php — rewrite with 7 tests: - Introduce makeSut(existingCount, regionMock) factory that creates fresh TaxRateRepositoryInterface and RegionFactory mocks per call, avoiding PHPUnit FIFO stub-stacking (root cause of 2 prior test failures) - Covers: alias, header-only data, create new rate, field setters, skip existing, exception handling, named region code lookup Samples: - Fix header column order in taxrates.csv and multi_example_taxrates.csv (tax_postcode and rate were swapped) - Add eu_taxrates.csv: all 27 EU member states, every rate tier (standard, reduced ×2, super-reduced, zero, parking) sourced from europa.eu/youreurope (checked 2025-10-30); Greece mapped to ISO code GR (EU uses EL) - Add eu_taxrates.csv as second source in Samples/master.yaml All 178 tests pass in standalone (phpunit.phar) and Docker.
1 parent 50737e7 commit 7b8d5e9

File tree

8 files changed

+420
-124
lines changed

8 files changed

+420
-124
lines changed

Component/TaxRates.php

Lines changed: 100 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
use CtiDigital\Configurator\Api\FileComponentInterface;
77
use CtiDigital\Configurator\Api\LoggerInterface;
8-
use CtiDigital\Configurator\Exception\ComponentException;
9-
use Magento\Framework\Exception\LocalizedException;
10-
use Magento\Framework\Filesystem\DriverInterface;
11-
use Magento\TaxImportExport\Model\Rate\CsvImportHandler;
8+
use Magento\Directory\Model\RegionFactory;
9+
use Magento\Framework\Api\SearchCriteriaBuilder;
10+
use Magento\Tax\Api\Data\TaxRateInterfaceFactory;
11+
use Magento\Tax\Api\TaxRateRepositoryInterface;
1212

1313
class TaxRates implements FileComponentInterface
1414
{
@@ -17,38 +17,68 @@ class TaxRates implements FileComponentInterface
1717
protected string $description = 'Component to create Tax Rates';
1818

1919
public function __construct(
20-
protected readonly CsvImportHandler $csvImportHandler,
21-
private readonly LoggerInterface $log,
22-
private readonly DriverInterface $driver
20+
private readonly TaxRateRepositoryInterface $taxRateRepository,
21+
private readonly TaxRateInterfaceFactory $taxRateFactory,
22+
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
23+
private readonly RegionFactory $regionFactory,
24+
private readonly LoggerInterface $log
2325
) {}
2426

2527
/**
26-
* @throws LocalizedException
28+
* Process tax rate data from CSV rows.
29+
*
30+
* The first row must contain column headers. Supported columns:
31+
* code, tax_country_id, tax_region_id, tax_postcode, rate,
32+
* zip_is_range, zip_from, zip_to
33+
*
34+
* Rates are created when they do not already exist; rates matched by code
35+
* are skipped so repeated runs are idempotent.
2736
*/
2837
public function execute(mixed $data = null): void
2938
{
30-
try {
31-
// Sort data into order importExport requires
32-
$sortedData = $this->getSortedData($data);
33-
34-
// Generate sorted csv file
35-
$tmpFile = $this->getTmpFile($sortedData);
36-
37-
// Pass the temporary file name to the import handler
38-
$this->csvImportHandler->importFromCsvFile(['tmp_name' => $tmpFile]);
39-
40-
// Remove the temporary file
41-
$this->driver->deleteFile($tmpFile);
42-
43-
// We don't know how many were successfully imported
44-
// so we can't log the number of records imported, but we can log that the import was successful
45-
// we could diff the count of the state before and after but it would be expensive
46-
$this->log->logInfo(
47-
sprintf('Tax rates finished importing, check the rates in the admin panel.')
48-
);
49-
} catch (ComponentException $e) {
50-
$this->log->logError($e->getMessage());
39+
if (empty($data) || count($data) < 2) {
40+
$this->log->logError('Tax rates: no data found.');
41+
return;
5142
}
43+
44+
$headers = array_values($data[0]);
45+
unset($data[0]);
46+
47+
$created = 0;
48+
$skipped = 0;
49+
50+
foreach ($data as $row) {
51+
$rate = array_combine($headers, array_values($row));
52+
$code = (string) ($rate['code'] ?? '');
53+
54+
if ($code === '') {
55+
$this->log->logError('Tax rate skipped: missing code.');
56+
continue;
57+
}
58+
59+
try {
60+
if ($this->rateExists($code)) {
61+
$this->log->logComment(
62+
sprintf('Tax rate "%s" already exists, skipping.', $code),
63+
1
64+
);
65+
$skipped++;
66+
continue;
67+
}
68+
69+
$this->saveRate($rate);
70+
$this->log->logInfo(sprintf('Tax rate "%s" created.', $code), 1);
71+
$created++;
72+
} catch (\Exception $e) {
73+
$this->log->logError(
74+
sprintf('Tax rate "%s" failed: %s', $code, $e->getMessage())
75+
);
76+
}
77+
}
78+
79+
$this->log->logInfo(
80+
sprintf('Tax rates import complete: %d created, %d skipped.', $created, $skipped)
81+
);
5282
}
5383

5484
public function getAlias(): string
@@ -61,66 +91,59 @@ public function getDescription(): string
6191
return $this->description;
6292
}
6393

64-
protected function getSortedData(array $data): array
94+
/**
95+
* Return true if a tax rate with the given code already exists.
96+
*/
97+
private function rateExists(string $code): bool
6598
{
66-
$sortedData = [];
67-
68-
foreach ($data as $index => $rate) {
69-
if ($index === 0) {
70-
$sortedData[] = $rate;
71-
continue; // Skip the header row
72-
}
99+
$criteria = $this->searchCriteriaBuilder
100+
->addFilter('code', $code)
101+
->create();
73102

74-
$relativeData = array_combine($data[0], $rate);
75-
76-
// Reorder the data to match the expected format
77-
// Why this is a requirement is not clear
78-
$rateData = [
79-
$relativeData['code'],
80-
$relativeData['tax_country_id'],
81-
$relativeData['tax_region_id'],
82-
$relativeData['tax_postcode'],
83-
$relativeData['rate'],
84-
$relativeData['zip_is_range'],
85-
$relativeData['zip_from'],
86-
$relativeData['zip_to']
87-
];
88-
$sortedData[] = $rateData;
89-
}
90-
return $sortedData;
103+
return $this->taxRateRepository->getList($criteria)->getTotalCount() > 0;
91104
}
92105

93-
protected function getTmpFile(array $sortedData): string
106+
/**
107+
* Build and persist a single tax rate from an associative row array.
108+
*/
109+
private function saveRate(array $rate): void
94110
{
95-
// Define a temporary file name
96-
$tmpFile = sys_get_temp_dir() . '/tax_rates_' . uniqid() . '.csv';
97-
98-
// Write the CSV data to the temporary file
99-
$fileHandle = $this->driver->fileOpen($tmpFile, 'w');
100-
foreach ($sortedData as $line) {
101-
$this->driver->fileWrite($fileHandle, $this->formatCsvLine($line));
111+
$taxRate = $this->taxRateFactory->create();
112+
$taxRate->setCode((string) $rate['code']);
113+
$taxRate->setTaxCountryId((string) $rate['tax_country_id']);
114+
$taxRate->setTaxPostcode((string) ($rate['tax_postcode'] ?: '*'));
115+
$taxRate->setRate((float) $rate['rate']);
116+
$taxRate->setTaxRegionId(
117+
$this->resolveRegionId(
118+
(string) ($rate['tax_region_id'] ?? ''),
119+
(string) $rate['tax_country_id']
120+
)
121+
);
122+
123+
$zipIsRange = isset($rate['zip_is_range'])
124+
&& $rate['zip_is_range'] !== ''
125+
&& $rate['zip_is_range'] !== '0';
126+
127+
if ($zipIsRange) {
128+
$taxRate->setZipIsRange(1);
129+
$taxRate->setZipFrom((int) ($rate['zip_from'] ?? 0));
130+
$taxRate->setZipTo((int) ($rate['zip_to'] ?? 0));
102131
}
103-
// close stream
104-
$this->driver->fileClose($fileHandle);
105132

106-
// Return the path to the temporary file
107-
return $tmpFile;
133+
$this->taxRateRepository->save($taxRate);
108134
}
109135

110136
/**
111-
* Format an array of fields as an RFC 4180 CSV line.
112-
* Replicates fputcsv() with escape: '' (no legacy escape character).
137+
* Resolve a region code (e.g. "CA") or wildcard ("*", "0", "") to its
138+
* integer region_id. Returns 0 to indicate "all regions".
113139
*/
114-
private function formatCsvLine(array $fields): string
140+
private function resolveRegionId(string $regionCode, string $countryId): int
115141
{
116-
$csvFields = array_map(static function (mixed $field): string {
117-
$field = (string) $field;
118-
if (str_contains($field, ',') || str_contains($field, '"') || str_contains($field, "\n")) {
119-
return '"' . str_replace('"', '""', $field) . '"';
120-
}
121-
return $field;
122-
}, $fields);
142+
if ($regionCode === '' || $regionCode === '*' || $regionCode === '0') {
143+
return 0;
144+
}
123145

124-
return implode(',', $csvFields) . "\n";
146+
$region = $this->regionFactory->create()->loadByCode($regionCode, $countryId);
147+
return (int) $region->getId();
125148
}
126149
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
code,tax_country_id,tax_region_id,tax_postcode,rate,zip_is_range,zip_from,zip_to
2+
EU-AT-Standard,AT,*,*,20,,,
3+
EU-AT-Reduced-10,AT,*,*,10,,,
4+
EU-AT-Reduced-13,AT,*,*,13,,,
5+
EU-AT-Parking,AT,*,*,13,,,
6+
EU-BE-Standard,BE,*,*,21,,,
7+
EU-BE-Reduced-6,BE,*,*,6,,,
8+
EU-BE-Reduced-12,BE,*,*,12,,,
9+
EU-BE-Parking,BE,*,*,12,,,
10+
EU-BG-Standard,BG,*,*,20,,,
11+
EU-BG-Reduced,BG,*,*,9,,,
12+
EU-CY-Standard,CY,*,*,19,,,
13+
EU-CY-Reduced-5,CY,*,*,5,,,
14+
EU-CY-Reduced-9,CY,*,*,9,,,
15+
EU-CZ-Standard,CZ,*,*,21,,,
16+
EU-CZ-Reduced-12,CZ,*,*,12,,,
17+
EU-CZ-Zero,CZ,*,*,0,,,
18+
EU-DE-Standard,DE,*,*,19,,,
19+
EU-DE-Reduced,DE,*,*,7,,,
20+
EU-DK-Standard,DK,*,*,25,,,
21+
EU-DK-Zero,DK,*,*,0,,,
22+
EU-EE-Standard,EE,*,*,24,,,
23+
EU-EE-Reduced,EE,*,*,9,,,
24+
EU-ES-Standard,ES,*,*,21,,,
25+
EU-ES-Reduced,ES,*,*,10,,,
26+
EU-FI-Standard,FI,*,*,25.5,,,
27+
EU-FI-Reduced-10,FI,*,*,10,,,
28+
EU-FI-Reduced-14,FI,*,*,14,,,
29+
EU-FR-Standard,FR,*,*,20,,,
30+
EU-FR-Reduced-5.5,FR,*,*,5.5,,,
31+
EU-FR-Reduced-10,FR,*,*,10,,,
32+
EU-FR-Super-Reduced,FR,*,*,2.1,,,
33+
EU-GR-Standard,GR,*,*,24,,,
34+
EU-GR-Reduced-6,GR,*,*,6,,,
35+
EU-GR-Reduced-13,GR,*,*,13,,,
36+
EU-HR-Standard,HR,*,*,25,,,
37+
EU-HR-Reduced-5,HR,*,*,5,,,
38+
EU-HR-Reduced-13,HR,*,*,13,,,
39+
EU-HU-Standard,HU,*,*,27,,,
40+
EU-HU-Reduced-5,HU,*,*,5,,,
41+
EU-HU-Reduced-18,HU,*,*,18,,,
42+
EU-IE-Standard,IE,*,*,23,,,
43+
EU-IE-Reduced-9,IE,*,*,9,,,
44+
EU-IE-Reduced-13.5,IE,*,*,13.5,,,
45+
EU-IE-Super-Reduced,IE,*,*,4.8,,,
46+
EU-IT-Standard,IT,*,*,22,,,
47+
EU-IT-Reduced-5,IT,*,*,5,,,
48+
EU-IT-Reduced-10,IT,*,*,10,,,
49+
EU-IT-Super-Reduced,IT,*,*,4,,,
50+
EU-LT-Standard,LT,*,*,21,,,
51+
EU-LT-Reduced-5,LT,*,*,5,,,
52+
EU-LT-Reduced-9,LT,*,*,9,,,
53+
EU-LU-Standard,LU,*,*,17,,,
54+
EU-LU-Reduced,LU,*,*,8,,,
55+
EU-LU-Super-Reduced,LU,*,*,3,,,
56+
EU-LU-Parking,LU,*,*,14,,,
57+
EU-LV-Standard,LV,*,*,21,,,
58+
EU-LV-Reduced-5,LV,*,*,5,,,
59+
EU-LV-Reduced-12,LV,*,*,12,,,
60+
EU-MT-Standard,MT,*,*,18,,,
61+
EU-MT-Reduced-5,MT,*,*,5,,,
62+
EU-MT-Reduced-7,MT,*,*,7,,,
63+
EU-NL-Standard,NL,*,*,21,,,
64+
EU-NL-Reduced,NL,*,*,9,,,
65+
EU-PL-Standard,PL,*,*,23,,,
66+
EU-PL-Reduced-5,PL,*,*,5,,,
67+
EU-PL-Reduced-8,PL,*,*,8,,,
68+
EU-PT-Standard,PT,*,*,23,,,
69+
EU-PT-Reduced-6,PT,*,*,6,,,
70+
EU-PT-Reduced-13,PT,*,*,13,,,
71+
EU-PT-Parking,PT,*,*,13,,,
72+
EU-RO-Standard,RO,*,*,21,,,
73+
EU-RO-Reduced,RO,*,*,11,,,
74+
EU-SE-Standard,SE,*,*,25,,,
75+
EU-SE-Reduced-6,SE,*,*,6,,,
76+
EU-SE-Reduced-12,SE,*,*,12,,,
77+
EU-SI-Standard,SI,*,*,22,,,
78+
EU-SI-Reduced-5,SI,*,*,5,,,
79+
EU-SI-Reduced-9.5,SI,*,*,9.5,,,
80+
EU-SK-Standard,SK,*,*,23,,,
81+
EU-SK-Reduced-5,SK,*,*,5,,,
82+
EU-SK-Reduced-19,SK,*,*,19,,,

Samples/Components/TaxRates/multi_example_taxrates.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
code,tax_country_id,tax_region_id,rate,tax_postcode,zip_is_range,zip_from,zip_to
1+
code,tax_country_id,tax_region_id,tax_postcode,rate,zip_is_range,zip_from,zip_to
22
VAT Standard Rate,GB,*,*,20,,,
33
VAT Zero Rate,GB,*,*,0,,,
44
Czech Republic,CZ,*,*,20,,,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
code,tax_country_id,tax_region_id,rate,tax_postcode,zip_is_range,zip_from,zip_to
1+
code,tax_country_id,tax_region_id,tax_postcode,rate,zip_is_range,zip_from,zip_to
22
"US-CA-*-Rate 1","US","CA","*","8.2500","","",""
33
"US-NY-*-Rate 1","US","NY","*","8.3750","","",""

Samples/master.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ taxrates:
7171
method: code
7272
sources:
7373
- ../configurator/TaxRates/taxrates.csv
74+
- ../configurator/TaxRates/eu_taxrates.csv
7475
env:
7576
local:
7677
mode: maintain

0 commit comments

Comments
 (0)