From db04aad16a87e60d0940b0cfa7709251f556fbd3 Mon Sep 17 00:00:00 2001 From: Dimitri Dovgan Date: Sun, 25 Oct 2026 19:06:30 +0100 Subject: [PATCH] test: add recorded contract fixtures to catch API/model drift Add tools/record-fixtures.php (composer fixtures:record), which records read-only GET responses from the Billomat sandbox, anonymizes personal data with deterministic placeholders and stores them under tests/Fixtures/Contract/. ContractFixturesTest feeds every fixture through the real *Api method and fails on unknown fields or values lost during hydration (e.g. unknown enum values). Drift found and fixed: Client/Supplier now map the client-property-values and supplier-property-values Billomat returns, Role maps created/updated. User.rights stays a documented known drift. --- CHANGELOG.md | 2 + composer.json | 9 +- docs/testing.md | 38 ++ src/Model/Client.php | 23 +- src/Model/Role.php | 11 + src/Model/Supplier.php | 24 +- tests/Api/LookupTrioApiTest.php | 13 + tests/Api/SuppliersApiTest.php | 2 + tests/Contract/ContractFixturesTest.php | 376 +++++++++++++++ .../Fixtures/Contract/activity-feed/list.json | 33 ++ .../Contract/article-properties/get.json | 10 + .../Contract/article-properties/list.json | 25 + .../article-property-values/list.json | 7 + tests/Fixtures/Contract/articles/list.json | 7 + .../Contract/client-properties/get.json | 10 + .../Contract/client-properties/list.json | 15 + .../Contract/client-property-values/list.json | 7 + tests/Fixtures/Contract/client-tags/list.json | 7 + tests/Fixtures/Contract/clients/get.json | 65 +++ tests/Fixtures/Contract/clients/list.json | 198 ++++++++ .../Contract/confirmation-comments/get.json | 15 + .../Contract/confirmation-comments/list.json | 20 + .../Contract/confirmation-items/get.json | 23 + .../Contract/confirmation-items/list.json | 28 ++ .../Contract/confirmation-tags/list.json | 7 + .../Fixtures/Contract/confirmations/get.json | 47 ++ .../Fixtures/Contract/confirmations/list.json | 52 ++ tests/Fixtures/Contract/contacts/list.json | 7 + tests/Fixtures/Contract/countries/list.json | 21 + .../Fixtures/Contract/country-taxes/list.json | 7 + .../Contract/credit-note-payments/list.json | 7 + .../Fixtures/Contract/credit-notes/list.json | 7 + tests/Fixtures/Contract/currencies/list.json | 24 + .../Contract/delivery-note-comments/get.json | 15 + .../Contract/delivery-note-comments/list.json | 20 + .../Contract/delivery-note-items/get.json | 14 + .../Contract/delivery-note-items/list.json | 19 + .../Contract/delivery-note-tags/list.json | 7 + .../Fixtures/Contract/delivery-notes/get.json | 25 + .../Contract/delivery-notes/list.json | 30 ++ .../Contract/email-templates/get.json | 13 + .../Contract/email-templates/list.json | 42 ++ tests/Fixtures/Contract/free-texts/get.json | 14 + tests/Fixtures/Contract/free-texts/list.json | 45 ++ .../Contract/inbox-documents/list.json | 7 + .../Contract/incoming-categories/get.json | 7 + .../Contract/incoming-categories/list.json | 24 + .../Contract/incoming-payments/list.json | 7 + .../Contract/incoming-properties/list.json | 7 + .../incoming-property-values/list.json | 7 + tests/Fixtures/Contract/incomings/list.json | 7 + .../Contract/invoice-comments/get.json | 15 + .../Contract/invoice-comments/list.json | 48 ++ .../Fixtures/Contract/invoice-items/get.json | 23 + .../Fixtures/Contract/invoice-items/list.json | 28 ++ .../Contract/invoice-payments/get.json | 20 + .../Contract/invoice-payments/list.json | 63 +++ .../Fixtures/Contract/invoice-tags/list.json | 7 + tests/Fixtures/Contract/invoices/get.json | 63 +++ tests/Fixtures/Contract/invoices/list.json | 192 ++++++++ tests/Fixtures/Contract/letters/list.json | 7 + tests/Fixtures/Contract/offers/list.json | 7 + tests/Fixtures/Contract/recurrings/list.json | 7 + .../Contract/reminder-texts/list.json | 7 + tests/Fixtures/Contract/reminders/list.json | 7 + tests/Fixtures/Contract/roles/get.json | 28 ++ tests/Fixtures/Contract/roles/list.json | 87 ++++ tests/Fixtures/Contract/settings/get.json | 74 +++ .../Contract/supplier-properties/list.json | 7 + .../supplier-property-values/list.json | 7 + .../Fixtures/Contract/supplier-tags/list.json | 7 + tests/Fixtures/Contract/suppliers/get.json | 41 ++ tests/Fixtures/Contract/suppliers/list.json | 87 ++++ tests/Fixtures/Contract/taxes/get.json | 12 + tests/Fixtures/Contract/taxes/list.json | 39 ++ tests/Fixtures/Contract/templates/get.json | 13 + tests/Fixtures/Contract/templates/list.json | 42 ++ tests/Fixtures/Contract/units/list.json | 7 + .../Contract/user-properties/list.json | 7 + tests/Fixtures/Contract/users/get.json | 38 ++ tests/Fixtures/Contract/users/list.json | 43 ++ tools/codegen/definitions/clients.php | 8 + tools/codegen/definitions/suppliers.php | 8 + tools/record-fixtures.php | 450 ++++++++++++++++++ 84 files changed, 2949 insertions(+), 7 deletions(-) create mode 100644 tests/Contract/ContractFixturesTest.php create mode 100644 tests/Fixtures/Contract/activity-feed/list.json create mode 100644 tests/Fixtures/Contract/article-properties/get.json create mode 100644 tests/Fixtures/Contract/article-properties/list.json create mode 100644 tests/Fixtures/Contract/article-property-values/list.json create mode 100644 tests/Fixtures/Contract/articles/list.json create mode 100644 tests/Fixtures/Contract/client-properties/get.json create mode 100644 tests/Fixtures/Contract/client-properties/list.json create mode 100644 tests/Fixtures/Contract/client-property-values/list.json create mode 100644 tests/Fixtures/Contract/client-tags/list.json create mode 100644 tests/Fixtures/Contract/clients/get.json create mode 100644 tests/Fixtures/Contract/clients/list.json create mode 100644 tests/Fixtures/Contract/confirmation-comments/get.json create mode 100644 tests/Fixtures/Contract/confirmation-comments/list.json create mode 100644 tests/Fixtures/Contract/confirmation-items/get.json create mode 100644 tests/Fixtures/Contract/confirmation-items/list.json create mode 100644 tests/Fixtures/Contract/confirmation-tags/list.json create mode 100644 tests/Fixtures/Contract/confirmations/get.json create mode 100644 tests/Fixtures/Contract/confirmations/list.json create mode 100644 tests/Fixtures/Contract/contacts/list.json create mode 100644 tests/Fixtures/Contract/countries/list.json create mode 100644 tests/Fixtures/Contract/country-taxes/list.json create mode 100644 tests/Fixtures/Contract/credit-note-payments/list.json create mode 100644 tests/Fixtures/Contract/credit-notes/list.json create mode 100644 tests/Fixtures/Contract/currencies/list.json create mode 100644 tests/Fixtures/Contract/delivery-note-comments/get.json create mode 100644 tests/Fixtures/Contract/delivery-note-comments/list.json create mode 100644 tests/Fixtures/Contract/delivery-note-items/get.json create mode 100644 tests/Fixtures/Contract/delivery-note-items/list.json create mode 100644 tests/Fixtures/Contract/delivery-note-tags/list.json create mode 100644 tests/Fixtures/Contract/delivery-notes/get.json create mode 100644 tests/Fixtures/Contract/delivery-notes/list.json create mode 100644 tests/Fixtures/Contract/email-templates/get.json create mode 100644 tests/Fixtures/Contract/email-templates/list.json create mode 100644 tests/Fixtures/Contract/free-texts/get.json create mode 100644 tests/Fixtures/Contract/free-texts/list.json create mode 100644 tests/Fixtures/Contract/inbox-documents/list.json create mode 100644 tests/Fixtures/Contract/incoming-categories/get.json create mode 100644 tests/Fixtures/Contract/incoming-categories/list.json create mode 100644 tests/Fixtures/Contract/incoming-payments/list.json create mode 100644 tests/Fixtures/Contract/incoming-properties/list.json create mode 100644 tests/Fixtures/Contract/incoming-property-values/list.json create mode 100644 tests/Fixtures/Contract/incomings/list.json create mode 100644 tests/Fixtures/Contract/invoice-comments/get.json create mode 100644 tests/Fixtures/Contract/invoice-comments/list.json create mode 100644 tests/Fixtures/Contract/invoice-items/get.json create mode 100644 tests/Fixtures/Contract/invoice-items/list.json create mode 100644 tests/Fixtures/Contract/invoice-payments/get.json create mode 100644 tests/Fixtures/Contract/invoice-payments/list.json create mode 100644 tests/Fixtures/Contract/invoice-tags/list.json create mode 100644 tests/Fixtures/Contract/invoices/get.json create mode 100644 tests/Fixtures/Contract/invoices/list.json create mode 100644 tests/Fixtures/Contract/letters/list.json create mode 100644 tests/Fixtures/Contract/offers/list.json create mode 100644 tests/Fixtures/Contract/recurrings/list.json create mode 100644 tests/Fixtures/Contract/reminder-texts/list.json create mode 100644 tests/Fixtures/Contract/reminders/list.json create mode 100644 tests/Fixtures/Contract/roles/get.json create mode 100644 tests/Fixtures/Contract/roles/list.json create mode 100644 tests/Fixtures/Contract/settings/get.json create mode 100644 tests/Fixtures/Contract/supplier-properties/list.json create mode 100644 tests/Fixtures/Contract/supplier-property-values/list.json create mode 100644 tests/Fixtures/Contract/supplier-tags/list.json create mode 100644 tests/Fixtures/Contract/suppliers/get.json create mode 100644 tests/Fixtures/Contract/suppliers/list.json create mode 100644 tests/Fixtures/Contract/taxes/get.json create mode 100644 tests/Fixtures/Contract/taxes/list.json create mode 100644 tests/Fixtures/Contract/templates/get.json create mode 100644 tests/Fixtures/Contract/templates/list.json create mode 100644 tests/Fixtures/Contract/units/list.json create mode 100644 tests/Fixtures/Contract/user-properties/list.json create mode 100644 tests/Fixtures/Contract/users/get.json create mode 100644 tests/Fixtures/Contract/users/list.json create mode 100644 tools/record-fixtures.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dad1c2..4dcab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for a step-by-step migration guide. - `BillomatConfig::$language` (and `BillomatClient::create(language: …)`) sets the `Accept-Language` header, e.g. `'en-gb'`; default stays `'de-de'`, `null` omits the header. - Exception messages include the method, the path and Billomat's error text, e.g. `Billomat responded with HTTP 422 to POST /clients. Name is missing`. - `OffersApi::uncancel()`/`unclear()`, `ConfirmationsApi::uncancel()`/`unclear()` and `RemindersApi::uncancel()` for the `/uncancel` and `/unclear` endpoints (checked in the sandbox). +- **Contract fixtures:** anonymized sandbox responses under `tests/Fixtures/Contract/` and `ContractFixturesTest` catch drift between the Billomat API and the models in CI without credentials; `composer fixtures:record` re-records them (read-only GETs, see `docs/testing.md`). +- `Client::$propertyValues`, `Supplier::$propertyValues` (the `client-property-values`/`supplier-property-values` Billomat returns with every client and supplier) and `Role::$created`/`$updated`, found by the contract fixtures. ### Changed - Development: PHPStan runs with the bleeding-edge ruleset and without `ignoreErrors` for the tests; the tests capture the outgoing `MockHttpClient` request as a typed `CapturedRequest` (method, URL, query, headers, decoded JSON body) instead of nested `mixed` arrays. No change to the public API. diff --git a/composer.json b/composer.json index cfde59d..648da51 100644 --- a/composer.json +++ b/composer.json @@ -117,7 +117,11 @@ "extract:spec": "php tools/extract-billomat-spec.php", "audit:spec": "php tools/audit-spec-vs-code.php", "generate": "php tools/generate.php", - "generate:check": "php tools/generate.php --check" + "generate:check": "php tools/generate.php --check", + "fixtures:record": [ + "Composer\\Config::disableProcessTimeout", + "php tools/record-fixtures.php" + ] }, "scripts-descriptions": { "test": "PHPUnit-Tests (Unit only, schnell)", @@ -135,6 +139,7 @@ "extract:spec": "Billomat-API-Doku (HTML-Spiegel) in docs/spec/billomat.json überführen", "audit:spec": "Vergleicht docs/spec/billomat.json gegen src/ und schreibt docs/audit.md", "generate": "Erzeugt Models und Options aus docs/spec/billomat.json + tools/codegen/definitions/", - "generate:check": "Prüft, ob die generierten Dateien unter src/ aktuell sind" + "generate:check": "Prüft, ob die generierten Dateien unter src/ aktuell sind", + "fixtures:record": "Nimmt anonymisierte Contract-Fixtures per GET gegen die Billomat-Sandbox auf (benötigt .env.test.local)" } } diff --git a/docs/testing.md b/docs/testing.md index 0acbb0a..557d594 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -130,6 +130,44 @@ final class MyIntegrationTest extends AbstractBillomatIntegrationTestCase - **No production keys.** Use a separate sandbox account only; see [SECURITY.md](../SECURITY.md). - **Keep the `markTestSkipped()` branch working.** A test that is meaningless without credentials must skip gracefully, otherwise the suite breaks elsewhere. +## Contract fixtures + +`tests/Contract/ContractFixturesTest.php` (part of the unit suite, no network) checks the models against recorded, anonymized responses from the Billomat sandbox in `tests/Fixtures/Contract//.json`. Every fixture goes through the real `*Api` method (`MockHttpClient` + `Psr18Client`), and the test fails when + +- hydration fails or returns fewer records than the response contains, +- the response has a field the model doesn't know (a non-empty `$extra` on generated models, also in nested models such as positions; for handwritten models, a field `toArray()` doesn't export), +- a non-empty value gets lost while hydrating, e.g. an enum value the SDK doesn't know, an unparsable date or an unknown entry in `payment_types`. + +The failure names the resource and the field, e.g. `invoices/get: 'Invoice.new_field' => 'unbekanntes Feld'`. Fix real drift in the codegen definition (`tools/codegen/definitions/`) and run `composer generate`. Differences that are accepted on purpose go into `KNOWN_DRIFT` in the test with a reason; an entry that no longer occurs fails the test, so the list doesn't go stale. + +### Re-recording + +```bash +composer fixtures:record # needs BILLOMAT_ID and BILLOMAT_API_KEY in .env.test.local +composer fixtures:record -- --cache=/tmp/billomat # keep raw responses outside the repo and reuse them on the next run +``` + +`tools/record-fixtures.php` only sends GET requests: one list per resource (`per_page=3`) and, if the sandbox has data, one single-object GET of the first entry, plus the sub-resources (items, comments, tags, email receivers, contacts) of that entry. A full run takes about 75 requests and stops at 150, well below the sandbox limit of 1000 requests per 15 minutes. It never creates, updates or deletes anything and skips `/clients/myself` and the account endpoint. Resources without data in the sandbox get only a `list.json`; the test picks up new fixtures automatically, a new resource needs an entry in `ContractFixturesTest::calls()`. + +With `--cache`, a cached response is used instead of a request, so you can tune the anonymization without hitting the API again. The cache holds unanonymized data: keep it outside the repository and delete it afterwards. + +### Anonymization + +Before writing, the recorder replaces personal data with deterministic placeholders in the same format (the same original value always gets the same placeholder, so re-recording gives stable diffs): + +- names, first/last names, company names, account owners → `Name 1`, `Vorname1`, `Nachname1`, `Firma 1 GmbH` +- e-mail addresses (also inside other strings and comma-separated lists) → `kontakt1@example.com`, senders → `Firma 1 GmbH ` +- phone, fax and mobile numbers → `+49 30 0000001` +- street, zip, city, state and the composed `address` → `Musterstraße 1`, `10001`, `Musterstadt 1` +- IBAN (also inside other strings), BIC, bank name, account and bank numbers, creditor ID → `DE00000000000000000001`, `TESTDEFFXXX`, `Testbank 1` +- tax and VAT numbers → `000/000/00001`, `DE000000001` +- URLs → `https://www.example.com/1` +- free texts (`note`, `intro`, `description`, `comment`, `title`, `label`, `subject`, `text`, settings like `invoice_intro`, …) → `Text 1` +- file names and Base64 file contents → `dokument-1.pdf` and a tiny placeholder PDF +- the Billomat ID anywhere in a string → `mycompany` + +Types, formats, enum values, amounts, dates, IDs, numbering and the presence of fields stay unchanged; empty strings stay empty. Before committing new fixtures, check them for anything that still looks personal, e.g. `grep -rn '@' tests/Fixtures/Contract | grep -v example.com`, a grep for your Billomat ID, and a look at phone-like numbers. Add new field names to `FIELD_KINDS`/`FIELD_SUFFIX_KINDS` in the recorder when Billomat adds personal fields. + ## Coverage ```bash diff --git a/src/Model/Client.php b/src/Model/Client.php index a130c84..a0727da 100644 --- a/src/Model/Client.php +++ b/src/Model/Client.php @@ -90,11 +90,13 @@ 'facturx_identifier' => true, 'seller_identifier' => true, 'dig_exclude' => true, + 'client-property-values' => true, ]; /** - * @param list $defaultPaymentTypes - * @param array $extra + * @param list $defaultPaymentTypes + * @param list $propertyValues + * @param array $extra */ public function __construct( /** Interne Billomat-ID des Kunden. */ @@ -290,6 +292,12 @@ public function __construct( public ?string $sellerIdentifier = null, /** Von Billomat geliefert, aber nicht dokumentiert. */ public ?bool $digExclude = null, + /** + * Attributwerte des Kunden. + * + * Von Billomat geliefert, aber nicht dokumentiert (leer: ""). + */ + public array $propertyValues = [], /** * Felder, die Billomat liefert, das SDK aber (noch) nicht abbildet – roh, wie in der Response. * @@ -367,6 +375,7 @@ public static function fromArray(array $data): self facturxIdentifier: ScalarCaster::toStringOrNull($data['facturx_identifier'] ?? null), sellerIdentifier: ScalarCaster::toStringOrNull($data['seller_identifier'] ?? null), digExclude: ScalarCaster::toBoolOrNull($data['dig_exclude'] ?? null), + propertyValues: ListCaster::toNestedList($data['client-property-values'] ?? null, 'client-property-value', 'id', ClientPropertyValue::fromArray(...)), extra: array_diff_key($data, self::FIELDS), ); } @@ -380,7 +389,7 @@ public static function fromArray(array $data): self */ public function toArray(): array { - return [ + $data = [ ...$this->extra, 'id' => $this->id, 'name' => $this->name, @@ -443,5 +452,13 @@ public function toArray(): array 'seller_identifier' => $this->sellerIdentifier, 'dig_exclude' => $this->digExclude, ]; + + if ([] !== $this->propertyValues) { + $data['client-property-values'] = [ + 'client-property-value' => array_map(static fn (ClientPropertyValue $item): array => $item->toArray(), $this->propertyValues), + ]; + } + + return $data; } } diff --git a/src/Model/Role.php b/src/Model/Role.php index 07a4fed..79b90a4 100644 --- a/src/Model/Role.php +++ b/src/Model/Role.php @@ -4,8 +4,11 @@ namespace Justpilot\Billomat\Model; +use DateTimeImmutable; use Justpilot\Billomat\Internal\ScalarCaster; +use const DATE_ATOM; + /** * Mitarbeiter-Rolle samt zugeordneter Zugriffsrechte. * @@ -26,6 +29,10 @@ public function __construct( public ?int $id, public ?string $name = null, public array $permissions = [], + /** Erstellungszeitpunkt der Rolle. */ + public ?DateTimeImmutable $created = null, + /** Zeitpunkt der letzten Änderung. */ + public ?DateTimeImmutable $updated = null, ) { } @@ -59,6 +66,8 @@ public static function fromArray(array $data): self id: ScalarCaster::toIntOrNull($data['id'] ?? null), name: ScalarCaster::toStringOrNull($data['name'] ?? null), permissions: $permissions, + created: ScalarCaster::toDateTimeOrNull($data['created'] ?? null), + updated: ScalarCaster::toDateTimeOrNull($data['updated'] ?? null), ); } @@ -71,6 +80,8 @@ public function toArray(): array 'id' => $this->id, 'name' => $this->name, 'permissions' => $this->permissions, + 'created' => $this->created?->format(DATE_ATOM), + 'updated' => $this->updated?->format(DATE_ATOM), ]; } } diff --git a/src/Model/Supplier.php b/src/Model/Supplier.php index 31fdfdb..48bcaf4 100644 --- a/src/Model/Supplier.php +++ b/src/Model/Supplier.php @@ -5,6 +5,7 @@ namespace Justpilot\Billomat\Model; use DateTimeImmutable; +use Justpilot\Billomat\Internal\ListCaster; use Justpilot\Billomat\Internal\ScalarCaster; use const DATE_ATOM; @@ -62,9 +63,13 @@ 'creditor_identifier' => true, 'costs_gross' => true, 'costs_net' => true, + 'supplier-property-values' => true, ]; - /** @param array $extra */ + /** + * @param list $propertyValues + * @param array $extra + */ public function __construct( /** Interne Billomat-ID des Lieferanten. */ public ?int $id, @@ -158,6 +163,12 @@ public function __construct( public ?Money $costsGross = null, /** Netto-Kosten (read-only Auswertungsfeld). */ public ?Money $costsNet = null, + /** + * Attributwerte des Lieferanten. + * + * Von Billomat geliefert, aber nicht dokumentiert (leer: ""). + */ + public array $propertyValues = [], /** * Felder, die Billomat liefert, das SDK aber (noch) nicht abbildet – roh, wie in der Response. * @@ -211,6 +222,7 @@ public static function fromArray(array $data): self creditorIdentifier: ScalarCaster::toStringOrNull($data['creditor_identifier'] ?? null), costsGross: ScalarCaster::toMoneyOrNull($data['costs_gross'] ?? null), costsNet: ScalarCaster::toMoneyOrNull($data['costs_net'] ?? null), + propertyValues: ListCaster::toNestedList($data['supplier-property-values'] ?? null, 'supplier-property-value', 'id', SupplierPropertyValue::fromArray(...)), extra: array_diff_key($data, self::FIELDS), ); } @@ -224,7 +236,7 @@ public static function fromArray(array $data): self */ public function toArray(): array { - return [ + $data = [ ...$this->extra, 'id' => $this->id, 'created' => $this->created?->format(DATE_ATOM), @@ -263,5 +275,13 @@ public function toArray(): array 'costs_gross' => $this->costsGross?->toString(), 'costs_net' => $this->costsNet?->toString(), ]; + + if ([] !== $this->propertyValues) { + $data['supplier-property-values'] = [ + 'supplier-property-value' => array_map(static fn (SupplierPropertyValue $item): array => $item->toArray(), $this->propertyValues), + ]; + } + + return $data; } } diff --git a/tests/Api/LookupTrioApiTest.php b/tests/Api/LookupTrioApiTest.php index 94bb75e..17a9d8c 100644 --- a/tests/Api/LookupTrioApiTest.php +++ b/tests/Api/LookupTrioApiTest.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpClient\Psr18Client; use Symfony\Component\HttpClient\Response\MockResponse; +use const DATE_ATOM; use const JSON_THROW_ON_ERROR; #[CoversClass(IncomingCategoriesApi::class)] @@ -166,6 +167,8 @@ public function itGetsSingleRole(): void 'role' => [ 'id' => 42, 'name' => 'Azubi', + 'created' => '2013-09-27T10:49:44+02:00', + 'updated' => '2014-01-02T08:00:00+01:00', 'articles' => 'READ', 'offers' => '', ], @@ -179,6 +182,16 @@ public function itGetsSingleRole(): void self::assertSame(42, $role->id); self::assertSame('READ', $role->permissions['articles']); self::assertNull($role->permissions['offers']); + self::assertArrayNotHasKey('created', $role->permissions); + self::assertSame('2013-09-27T10:49:44+02:00', $role->created?->format(DATE_ATOM)); + self::assertSame('2014-01-02T08:00:00+01:00', $role->updated?->format(DATE_ATOM)); + self::assertSame([ + 'id' => 42, + 'name' => 'Azubi', + 'permissions' => ['articles' => 'READ', 'offers' => null], + 'created' => '2013-09-27T10:49:44+02:00', + 'updated' => '2014-01-02T08:00:00+01:00', + ], $role->toArray()); } #[Test] diff --git a/tests/Api/SuppliersApiTest.php b/tests/Api/SuppliersApiTest.php index c3e3f71..bdd50c0 100644 --- a/tests/Api/SuppliersApiTest.php +++ b/tests/Api/SuppliersApiTest.php @@ -10,6 +10,7 @@ use Justpilot\Billomat\Config\BillomatConfig; use Justpilot\Billomat\Config\RetryPolicy; use Justpilot\Billomat\Http\BillomatHttpClient; +use Justpilot\Billomat\Internal\ListCaster; use Justpilot\Billomat\Internal\ScalarCaster; use Justpilot\Billomat\Model\Supplier; use Justpilot\Billomat\Tests\Support\CapturedRequest; @@ -30,6 +31,7 @@ #[UsesClass(BillomatConfig::class)] #[UsesClass(RetryPolicy::class)] #[UsesClass(BillomatHttpClient::class)] +#[UsesClass(ListCaster::class)] #[UsesClass(ScalarCaster::class)] final class SuppliersApiTest extends TestCase { diff --git a/tests/Contract/ContractFixturesTest.php b/tests/Contract/ContractFixturesTest.php new file mode 100644 index 0000000..54653b4 --- /dev/null +++ b/tests/Contract/ContractFixturesTest.php @@ -0,0 +1,376 @@ + Begründung. + * + * Ein Eintrag, der nicht mehr auftritt, lässt den Test fehlschlagen – dann bitte entfernen. + * + * @var array + */ + private const array KNOWN_DRIFT = [ + // Map Bereich → READ/UPDATE/DELETE wie bei Role::$permissions; der Generator kennt noch keinen Map-Typ. + 'User.rights' => 'Rechte des Benutzers, landen vorerst in User::$extra', + ]; + + #[Test] + #[DataProvider('fixtures')] + public function fixtureMatchesModel(string $resource, string $kind): void + { + $unexpected = array_filter( + $this->problems($resource, $kind), + static fn (string $path): bool => !\array_key_exists(self::withoutIndex($path), self::KNOWN_DRIFT), + ARRAY_FILTER_USE_KEY, + ); + + self::assertSame([], $unexpected, "{$resource}/{$kind}: Drift zwischen API und Model"); + } + + #[Test] + public function knownDriftStillOccurs(): void + { + $seen = []; + foreach (self::fixtures() as [$resource, $kind]) { + foreach (array_keys($this->problems($resource, $kind)) as $path) { + $seen[self::withoutIndex($path)] = true; + } + } + + $stale = array_values(array_diff(array_keys(self::KNOWN_DRIFT), array_keys($seen))); + + self::assertSame([], $stale, 'KNOWN_DRIFT-Einträge treten nicht mehr auf und können entfernt werden'); + } + + /** + * Hydriert eine Fixture über die echte API-Methode und sammelt alle Abweichungen. + * + * @return array Model-Pfad.Feld => Problem + */ + private function problems(string $resource, string $kind): array + { + $calls = $this->calls()[$resource] ?? null; + $call = null === $calls ? null : ('list' === $kind ? $calls['list'] : $calls['get']); + self::assertNotNull($calls, "{$resource}: keine API-Methode zugeordnet, bitte in ContractFixturesTest::calls() ergänzen"); + self::assertNotNull($call, "{$resource}/{$kind}: keine API-Methode zugeordnet, bitte in ContractFixturesTest::calls() ergänzen"); + + $body = (string) file_get_contents(self::FIXTURE_DIR."/{$resource}/{$kind}.json"); + $raw = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($raw); + + $mock = new MockHttpClient(new MockResponse($body, ['http_code' => 200])); + $billomat = new BillomatClient(new BillomatConfig(billomatId: 'mycompany', apiKey: 'secret-key'), new Psr18Client($mock)); + $result = $call($billomat); + + $records = $this->records($raw, $resource, $calls['key'], $kind); + $models = 'list' === $kind ? $result : [$result]; + self::assertIsArray($models); + self::assertCount(\count($records), $models, "{$resource}/{$kind}: Anzahl der Datensätze"); + + $problems = []; + foreach (array_values($models) as $i => $model) { + self::assertIsObject($model, "{$resource}/{$kind}: Datensatz {$i} nicht hydriert"); + $record = $records[$i]; + + foreach (self::unknownFields($model, $record) as $path => $keys) { + foreach ($keys as $key) { + $problems["{$path}.{$key}"] = 'unbekanntes Feld'; + } + } + + $class = new ReflectionObject($model)->getShortName(); + foreach ($this->lostValues($model, $record) as $field => $value) { + $problems["{$class}.{$field}"] = "Wert ging beim Hydrieren verloren: \"{$value}\""; + } + } + + return $problems; + } + + /** + * `Invoice.items[0].foo` → `Invoice.items.foo` für den Abgleich mit KNOWN_DRIFT. + */ + private static function withoutIndex(string $path): string + { + return (string) preg_replace('/\[\d+\]/', '', $path); + } + + /** + * @return Generator + */ + public static function fixtures(): Generator + { + $files = glob(self::FIXTURE_DIR.'/*/*.json'); + self::assertIsArray($files); + sort($files); + + foreach ($files as $file) { + $resource = basename(\dirname($file)); + $kind = basename($file, '.json'); + + yield "{$resource}/{$kind}" => [$resource, $kind]; + } + } + + /** + * Ressource (Pfad ohne Slash) → Envelope-Key eines Datensatzes und die SDK-Aufrufe. + * + * @return array + */ + private function calls(): array + { + return [ + 'clients' => ['key' => 'client', 'list' => static fn (BillomatClient $b): array => $b->clients->list(), 'get' => static fn (BillomatClient $b): ?object => $b->clients->get(1)], + 'contacts' => ['key' => 'contact', 'list' => static fn (BillomatClient $b): array => $b->contacts->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->contacts->get(1)], + 'client-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->clients->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->clients->tags->get(1)], + 'client-properties' => ['key' => 'client-property', 'list' => static fn (BillomatClient $b): array => $b->clients->properties->list(), 'get' => static fn (BillomatClient $b): ?object => $b->clients->properties->get(1)], + 'client-property-values' => ['key' => 'client-property-value', 'list' => static fn (BillomatClient $b): array => $b->clients->propertyValues->list(), 'get' => static fn (BillomatClient $b): ?object => $b->clients->propertyValues->get(1)], + 'suppliers' => ['key' => 'supplier', 'list' => static fn (BillomatClient $b): array => $b->suppliers->list(), 'get' => static fn (BillomatClient $b): ?object => $b->suppliers->get(1)], + 'supplier-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->suppliers->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->suppliers->tags->get(1)], + 'supplier-properties' => ['key' => 'supplier-property', 'list' => static fn (BillomatClient $b): array => $b->suppliers->properties->list(), 'get' => static fn (BillomatClient $b): ?object => $b->suppliers->properties->get(1)], + 'supplier-property-values' => ['key' => 'supplier-property-value', 'list' => static fn (BillomatClient $b): array => $b->suppliers->propertyValues->list(), 'get' => static fn (BillomatClient $b): ?object => $b->suppliers->propertyValues->get(1)], + 'articles' => ['key' => 'article', 'list' => static fn (BillomatClient $b): array => $b->articles->list(), 'get' => static fn (BillomatClient $b): ?object => $b->articles->get(1)], + 'article-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->articles->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->articles->tags->get(1)], + 'article-properties' => ['key' => 'article-property', 'list' => static fn (BillomatClient $b): array => $b->articles->properties->list(), 'get' => static fn (BillomatClient $b): ?object => $b->articles->properties->get(1)], + 'article-property-values' => ['key' => 'article-property-value', 'list' => static fn (BillomatClient $b): array => $b->articles->propertyValues->list(), 'get' => static fn (BillomatClient $b): ?object => $b->articles->propertyValues->get(1)], + 'units' => ['key' => 'unit', 'list' => static fn (BillomatClient $b): array => $b->units->list(), 'get' => static fn (BillomatClient $b): ?object => $b->units->get(1)], + 'taxes' => ['key' => 'tax', 'list' => static fn (BillomatClient $b): array => $b->taxes->list(), 'get' => static fn (BillomatClient $b): ?object => $b->taxes->get(1)], + 'invoices' => ['key' => 'invoice', 'list' => static fn (BillomatClient $b): array => $b->invoices->list(), 'get' => static fn (BillomatClient $b): ?object => $b->invoices->get(1)], + 'invoice-items' => ['key' => 'invoice-item', 'list' => static fn (BillomatClient $b): array => $b->invoices->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->invoices->items->get(1)], + 'invoice-payments' => ['key' => 'invoice-payment', 'list' => static fn (BillomatClient $b): array => $b->invoices->payments->list(), 'get' => static fn (BillomatClient $b): ?object => $b->invoices->payments->get(1)], + 'invoice-comments' => ['key' => 'invoice-comment', 'list' => static fn (BillomatClient $b): array => $b->invoices->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->invoices->comments->get(1)], + 'invoice-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->invoices->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->invoices->tags->get(1)], + 'offers' => ['key' => 'offer', 'list' => static fn (BillomatClient $b): array => $b->offers->list(), 'get' => static fn (BillomatClient $b): ?object => $b->offers->get(1)], + 'offer-items' => ['key' => 'offer-item', 'list' => static fn (BillomatClient $b): array => $b->offers->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->offers->items->get(1)], + 'offer-comments' => ['key' => 'offer-comment', 'list' => static fn (BillomatClient $b): array => $b->offers->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->offers->comments->get(1)], + 'offer-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->offers->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->offers->tags->get(1)], + 'confirmations' => ['key' => 'confirmation', 'list' => static fn (BillomatClient $b): array => $b->confirmations->list(), 'get' => static fn (BillomatClient $b): ?object => $b->confirmations->get(1)], + 'confirmation-items' => ['key' => 'confirmation-item', 'list' => static fn (BillomatClient $b): array => $b->confirmations->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->confirmations->items->get(1)], + 'confirmation-comments' => ['key' => 'confirmation-comment', 'list' => static fn (BillomatClient $b): array => $b->confirmations->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->confirmations->comments->get(1)], + 'confirmation-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->confirmations->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->confirmations->tags->get(1)], + 'credit-notes' => ['key' => 'credit-note', 'list' => static fn (BillomatClient $b): array => $b->creditNotes->list(), 'get' => static fn (BillomatClient $b): ?object => $b->creditNotes->get(1)], + 'credit-note-items' => ['key' => 'credit-note-item', 'list' => static fn (BillomatClient $b): array => $b->creditNotes->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->creditNotes->items->get(1)], + 'credit-note-payments' => ['key' => 'credit-note-payment', 'list' => static fn (BillomatClient $b): array => $b->creditNotes->payments->list(), 'get' => static fn (BillomatClient $b): ?object => $b->creditNotes->payments->get(1)], + 'credit-note-comments' => ['key' => 'credit-note-comment', 'list' => static fn (BillomatClient $b): array => $b->creditNotes->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->creditNotes->comments->get(1)], + 'credit-note-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->creditNotes->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->creditNotes->tags->get(1)], + 'delivery-notes' => ['key' => 'delivery-note', 'list' => static fn (BillomatClient $b): array => $b->deliveryNotes->list(), 'get' => static fn (BillomatClient $b): ?object => $b->deliveryNotes->get(1)], + 'delivery-note-items' => ['key' => 'delivery-note-item', 'list' => static fn (BillomatClient $b): array => $b->deliveryNotes->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->deliveryNotes->items->get(1)], + 'delivery-note-comments' => ['key' => 'delivery-note-comment', 'list' => static fn (BillomatClient $b): array => $b->deliveryNotes->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->deliveryNotes->comments->get(1)], + 'delivery-note-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->deliveryNotes->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->deliveryNotes->tags->get(1)], + 'reminders' => ['key' => 'reminder', 'list' => static fn (BillomatClient $b): array => $b->reminders->list(), 'get' => static fn (BillomatClient $b): ?object => $b->reminders->get(1)], + 'reminder-items' => ['key' => 'reminder-item', 'list' => static fn (BillomatClient $b): array => $b->reminders->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->reminders->items->get(1)], + 'reminder-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->reminders->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->reminders->tags->get(1)], + 'letters' => ['key' => 'letter', 'list' => static fn (BillomatClient $b): array => $b->letters->list(), 'get' => static fn (BillomatClient $b): ?object => $b->letters->get(1)], + 'letter-comments' => ['key' => 'letter-comment', 'list' => static fn (BillomatClient $b): array => $b->letters->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->letters->comments->get(1)], + 'letter-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->letters->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->letters->tags->get(1)], + 'recurrings' => ['key' => 'recurring', 'list' => static fn (BillomatClient $b): array => $b->recurrings->list(), 'get' => static fn (BillomatClient $b): ?object => $b->recurrings->get(1)], + 'recurring-items' => ['key' => 'recurring-item', 'list' => static fn (BillomatClient $b): array => $b->recurrings->items->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->recurrings->items->get(1)], + 'recurring-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->recurrings->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->recurrings->tags->get(1)], + 'recurring-email-receivers' => ['key' => 'recurring-email-receiver', 'list' => static fn (BillomatClient $b): array => $b->recurrings->emailReceivers->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->recurrings->emailReceivers->get(1)], + 'incomings' => ['key' => 'incoming', 'list' => static fn (BillomatClient $b): array => $b->incomings->list(), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->get(1)], + 'incoming-payments' => ['key' => 'incoming-payment', 'list' => static fn (BillomatClient $b): array => $b->incomings->payments->list(), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->payments->get(1)], + 'incoming-comments' => ['key' => 'incoming-comment', 'list' => static fn (BillomatClient $b): array => $b->incomings->comments->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->comments->get(1)], + 'incoming-tags' => ['key' => 'tag', 'list' => static fn (BillomatClient $b): array => $b->incomings->tags->list(1), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->tags->get(1)], + 'incoming-properties' => ['key' => 'incoming-property', 'list' => static fn (BillomatClient $b): array => $b->incomings->properties->list(), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->properties->get(1)], + 'incoming-property-values' => ['key' => 'incoming-property-value', 'list' => static fn (BillomatClient $b): array => $b->incomings->propertyValues->list(), 'get' => static fn (BillomatClient $b): ?object => $b->incomings->propertyValues->get(1)], + 'incoming-categories' => ['key' => 'incoming-category', 'list' => static fn (BillomatClient $b): array => $b->incomingCategories->list(), 'get' => static fn (BillomatClient $b): ?object => $b->incomingCategories->get('accounting_costs')], + 'inbox-documents' => ['key' => 'inbox-document', 'list' => static fn (BillomatClient $b): array => $b->inboxDocuments->list(), 'get' => static fn (BillomatClient $b): ?object => $b->inboxDocuments->get(1)], + 'templates' => ['key' => 'template', 'list' => static fn (BillomatClient $b): array => $b->templates->list(), 'get' => static fn (BillomatClient $b): ?object => $b->templates->get(1)], + 'free-texts' => ['key' => 'free-text', 'list' => static fn (BillomatClient $b): array => $b->freeTexts->list(), 'get' => static fn (BillomatClient $b): ?object => $b->freeTexts->get(1)], + 'email-templates' => ['key' => 'email-template', 'list' => static fn (BillomatClient $b): array => $b->emailTemplates->list(), 'get' => static fn (BillomatClient $b): ?object => $b->emailTemplates->get(1)], + 'reminder-texts' => ['key' => 'reminder-text', 'list' => static fn (BillomatClient $b): array => $b->reminderTexts->list(), 'get' => static fn (BillomatClient $b): ?object => $b->reminderTexts->get(1)], + 'country-taxes' => ['key' => 'country-tax', 'list' => static fn (BillomatClient $b): array => $b->countryTaxes->list(), 'get' => static fn (BillomatClient $b): ?object => $b->countryTaxes->get(1)], + 'countries' => ['key' => 'country', 'list' => static fn (BillomatClient $b): array => $b->countries->list(), 'get' => null], + 'currencies' => ['key' => 'currency', 'list' => static fn (BillomatClient $b): array => $b->currencies->list(), 'get' => null], + 'users' => ['key' => 'user', 'list' => static fn (BillomatClient $b): array => $b->users->list(), 'get' => static fn (BillomatClient $b): ?object => $b->users->get(1)], + 'user-properties' => ['key' => 'user-property', 'list' => static fn (BillomatClient $b): array => $b->users->properties->list(), 'get' => static fn (BillomatClient $b): ?object => $b->users->properties->get(1)], + 'roles' => ['key' => 'role', 'list' => static fn (BillomatClient $b): array => $b->roles->list(), 'get' => static fn (BillomatClient $b): ?object => $b->roles->get(1)], + 'activity-feed' => ['key' => 'activity', 'list' => static fn (BillomatClient $b): array => $b->activities->list(), 'get' => null], + 'settings' => ['key' => 'settings', 'list' => null, 'get' => static fn (BillomatClient $b): object => $b->settings->get()], + ]; + } + + /** + * Rohdatensätze aus dem Envelope, in derselben Reihenfolge wie die hydrierten Models. + * + * @param array $raw + * + * @return list> + */ + private function records(array $raw, string $resource, string $key, string $kind): array + { + if ('get' === $kind) { + $record = $raw[$key] ?? null; + self::assertIsArray($record, "{$resource}/get: Envelope-Key {$key} fehlt"); + + return [$record]; + } + + $wrapper = $raw[$resource] ?? null; + self::assertIsArray($wrapper, "{$resource}/list: Envelope-Key {$resource} fehlt"); + $inner = $wrapper[$key] ?? []; + self::assertIsArray($inner); + + $records = array_is_list($inner) ? $inner : [$inner]; + + return array_values(array_filter($records, is_array(...))); + } + + /** + * Unbekannte Felder pro Model-Pfad: `$extra` der generierten Models (rekursiv) + * bzw. für handgeschriebene Models ohne `$extra` die Felder, die `toArray()` nicht kennt. + * + * @param array $record + * + * @return array> + */ + private static function unknownFields(object $model, array $record, ?string $path = null): array + { + $path ??= new ReflectionObject($model)->getShortName(); + $reflection = new ReflectionObject($model); + + if (!$reflection->hasProperty('extra')) { + $unknown = array_keys(array_diff_key($record, self::wireView($model))); + + return [] === $unknown ? [] : [$path => array_map(strval(...), $unknown)]; + } + + $result = []; + $extra = $reflection->getProperty('extra')->getValue($model); + if (\is_array($extra) && [] !== $extra) { + $result[$path] = array_map(strval(...), array_keys($extra)); + } + + foreach (self::publicProperties($model) as $property) { + $value = $property->getValue($model); + $children = \is_object($value) ? [$value] : (\is_array($value) ? $value : []); + + foreach ($children as $i => $child) { + if (\is_object($child) && new ReflectionObject($child)->hasProperty('extra')) { + $childPath = \is_array($value) ? "{$path}.{$property->getName()}[{$i}]" : "{$path}.{$property->getName()}"; + $result += self::unknownFields($child, [], $childPath); + } + } + } + + return $result; + } + + /** + * Nicht-leere Rohwerte, die nach der Hydration `null` sind (unbekannter Enum-Wert, + * nicht parsebares Datum, …) oder bei Listenfeldern (z. B. `payment_types`) Einträge verloren haben. + * + * @param array $record + * + * @return array + */ + private function lostValues(object $model, array $record): array + { + $view = self::wireView($model); + $lost = []; + + foreach ($record as $field => $value) { + if (!\is_string($value) || '' === trim($value) || !\array_key_exists($field, $view)) { + continue; + } + + $hydrated = $view[$field]; + if (null === $hydrated) { + $lost[(string) $field] = $value; + } elseif (\is_string($hydrated) && str_contains($value, ',') && \count(explode(',', $value)) !== \count(explode(',', $hydrated))) { + $lost[(string) $field] = $value; + } + } + + return $lost; + } + + /** + * Model als Billomat-Feldnamen; ohne `toArray()` aus den öffentlichen Properties abgeleitet. + * + * @return array + */ + private static function wireView(object $model): array + { + // Handgeschriebene Models, die Feldgruppen als Map bündeln, wieder auf Billomat-Felder auffächern. + if ($model instanceof Role) { + $view = $model->toArray(); + unset($view['permissions']); + + return [...$view, ...$model->permissions]; + } + + if (method_exists($model, 'toArray')) { + $view = $model->toArray(); + self::assertIsArray($view); + + return $view; + } + + $view = []; + foreach (self::publicProperties($model) as $property) { + $value = $property->getValue($model); + $field = strtolower((string) preg_replace('/[A-Z]/', '_$0', $property->getName())); + $view[$field] = $value instanceof BackedEnum ? $value->value : $value; + } + + if ($model instanceof Settings) { + unset($view['price_groups']); + foreach ($model->priceGroups as $n => $label) { + $view["price_group{$n}"] = $label; + } + } + + return $view; + } + + /** + * @return list + */ + private static function publicProperties(object $model): array + { + return array_values(array_filter( + new ReflectionObject($model)->getProperties(ReflectionProperty::IS_PUBLIC), + static fn (ReflectionProperty $p): bool => !$p->isStatic(), + )); + } +} diff --git a/tests/Fixtures/Contract/activity-feed/list.json b/tests/Fixtures/Contract/activity-feed/list.json new file mode 100644 index 0000000..edd92be --- /dev/null +++ b/tests/Fixtures/Contract/activity-feed/list.json @@ -0,0 +1,33 @@ +{ + "activity-feed": { + "activity": [ + { + "resource": "invoices", + "id": "22849134", + "date": "2026-10-25T18:47:31+01:00", + "title": "Text 44", + "text": "Text 45", + "user_id": "136536" + }, + { + "resource": "invoices", + "id": "22849134", + "date": "2026-10-25T18:47:30+01:00", + "title": "Text 44", + "text": "Text 46", + "user_id": "136536" + }, + { + "resource": "invoices", + "id": "22849134", + "date": "2026-10-25T18:47:29+01:00", + "title": "Text 44", + "text": "Text 47", + "user_id": "136536" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "3" + } +} diff --git a/tests/Fixtures/Contract/article-properties/get.json b/tests/Fixtures/Contract/article-properties/get.json new file mode 100644 index 0000000..1d40c3d --- /dev/null +++ b/tests/Fixtures/Contract/article-properties/get.json @@ -0,0 +1,10 @@ +{ + "article-property": { + "id": "6031", + "name": "Name 5", + "default_value": "", + "is_nvl": "", + "type": "TEXTFIELD", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/article-properties/list.json b/tests/Fixtures/Contract/article-properties/list.json new file mode 100644 index 0000000..43f8a38 --- /dev/null +++ b/tests/Fixtures/Contract/article-properties/list.json @@ -0,0 +1,25 @@ +{ + "article-properties": { + "article-property": [ + { + "id": "6031", + "name": "Name 5", + "default_value": "", + "is_nvl": "", + "type": "TEXTFIELD", + "customfield": "" + }, + { + "id": "6033", + "name": "Name 6", + "default_value": "", + "is_nvl": "", + "type": "TEXTFIELD", + "customfield": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "2" + } +} diff --git a/tests/Fixtures/Contract/article-property-values/list.json b/tests/Fixtures/Contract/article-property-values/list.json new file mode 100644 index 0000000..6763836 --- /dev/null +++ b/tests/Fixtures/Contract/article-property-values/list.json @@ -0,0 +1,7 @@ +{ + "article-property-values": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/articles/list.json b/tests/Fixtures/Contract/articles/list.json new file mode 100644 index 0000000..c1ad4ec --- /dev/null +++ b/tests/Fixtures/Contract/articles/list.json @@ -0,0 +1,7 @@ +{ + "articles": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/client-properties/get.json b/tests/Fixtures/Contract/client-properties/get.json new file mode 100644 index 0000000..7cf8730 --- /dev/null +++ b/tests/Fixtures/Contract/client-properties/get.json @@ -0,0 +1,10 @@ +{ + "client-property": { + "id": "19196", + "name": "Name 2", + "default_value": "", + "is_nvl": "", + "type": "TEXTFIELD", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/client-properties/list.json b/tests/Fixtures/Contract/client-properties/list.json new file mode 100644 index 0000000..3d69d82 --- /dev/null +++ b/tests/Fixtures/Contract/client-properties/list.json @@ -0,0 +1,15 @@ +{ + "client-properties": { + "client-property": { + "id": "19196", + "name": "Name 2", + "default_value": "", + "is_nvl": "", + "type": "TEXTFIELD", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/client-property-values/list.json b/tests/Fixtures/Contract/client-property-values/list.json new file mode 100644 index 0000000..63e9533 --- /dev/null +++ b/tests/Fixtures/Contract/client-property-values/list.json @@ -0,0 +1,7 @@ +{ + "client-property-values": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/client-tags/list.json b/tests/Fixtures/Contract/client-tags/list.json new file mode 100644 index 0000000..478a12c --- /dev/null +++ b/tests/Fixtures/Contract/client-tags/list.json @@ -0,0 +1,7 @@ +{ + "client-tags": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/clients/get.json b/tests/Fixtures/Contract/clients/get.json new file mode 100644 index 0000000..a56f8dd --- /dev/null +++ b/tests/Fixtures/Contract/clients/get.json @@ -0,0 +1,65 @@ +{ + "client": { + "id": "9340866", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2026-10-25T17:29:56+01:00", + "archived": "0", + "dig_exclude": "0", + "facturx_identifier": "", + "client_number": "KD1", + "number": "1", + "number_pre": "KD", + "number_length": "0", + "name": "Name 1", + "salutation": "", + "first_name": "Vorname1", + "last_name": "Nachname1", + "street": "Musterstraße 1", + "zip": "", + "city": "Musterstadt 1", + "state": "", + "country_code": "DE", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "phone": "", + "fax": "", + "mobile": "", + "email": "", + "www": "", + "tax_number": "", + "vat_number": "DE000000001", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "enable_customerportal": "0", + "seller_identifier": "", + "default_payment_types": "", + "sepa_mandate": "", + "sepa_mandate_date": "", + "locale": "", + "tax_rule": "COUNTRY", + "net_gross": "SETTINGS", + "price_group": "0", + "debitor_account_number": "", + "reduction": "", + "discount_rate_type": "SETTINGS", + "discount_rate": "", + "discount_days_type": "SETTINGS", + "discount_days": "7", + "due_days_type": "SETTINGS", + "due_days": "0", + "reminder_due_days_type": "SETTINGS", + "reminder_due_days": "7", + "offer_validity_days_type": "SETTINGS", + "offer_validity_days": "28", + "dunning_run": "1", + "note": "Text 1", + "revenue_gross": "24653.07", + "revenue_net": "20716.83", + "customfield": "", + "client-property-values": "" + } +} diff --git a/tests/Fixtures/Contract/clients/list.json b/tests/Fixtures/Contract/clients/list.json new file mode 100644 index 0000000..e68cbcd --- /dev/null +++ b/tests/Fixtures/Contract/clients/list.json @@ -0,0 +1,198 @@ +{ + "clients": { + "client": [ + { + "id": "9340866", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2026-10-25T17:29:56+01:00", + "archived": "0", + "dig_exclude": "0", + "facturx_identifier": "", + "client_number": "KD1", + "number": "1", + "number_pre": "KD", + "number_length": "0", + "name": "Name 1", + "salutation": "", + "first_name": "Vorname1", + "last_name": "Nachname1", + "street": "Musterstraße 1", + "zip": "", + "city": "Musterstadt 1", + "state": "", + "country_code": "DE", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "phone": "", + "fax": "", + "mobile": "", + "email": "", + "www": "", + "tax_number": "", + "vat_number": "DE000000001", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "enable_customerportal": "0", + "seller_identifier": "", + "default_payment_types": "", + "sepa_mandate": "", + "sepa_mandate_date": "", + "locale": "", + "tax_rule": "COUNTRY", + "net_gross": "SETTINGS", + "price_group": "0", + "debitor_account_number": "", + "reduction": "", + "discount_rate_type": "SETTINGS", + "discount_rate": "", + "discount_days_type": "SETTINGS", + "discount_days": "7", + "due_days_type": "SETTINGS", + "due_days": "0", + "reminder_due_days_type": "SETTINGS", + "reminder_due_days": "7", + "offer_validity_days_type": "SETTINGS", + "offer_validity_days": "28", + "dunning_run": "1", + "note": "Text 1", + "revenue_gross": "24653.07", + "revenue_net": "20716.83", + "customfield": "", + "client-property-values": "" + }, + { + "id": "9347135", + "created": "2025-12-31T14:41:56+01:00", + "updated": "2025-12-31T14:41:56+01:00", + "archived": "0", + "dig_exclude": "0", + "facturx_identifier": "", + "client_number": "PAT44473", + "number": "44473", + "number_pre": "PAT", + "number_length": "5", + "name": "", + "salutation": "Herr", + "first_name": "Vorname2", + "last_name": "Nachname2", + "street": "Musterstraße 2", + "zip": "10001", + "city": "Musterstadt 2", + "state": "", + "country_code": "DE", + "address": "Firma 2 GmbH\nMusterstraße 2\n10002 Musterstadt 2", + "phone": "+49 30 0000001", + "fax": "", + "mobile": "", + "email": "kontakt1@example.com", + "www": "", + "tax_number": "", + "vat_number": "", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "enable_customerportal": "0", + "seller_identifier": "", + "default_payment_types": "", + "sepa_mandate": "", + "sepa_mandate_date": "", + "locale": "", + "tax_rule": "COUNTRY", + "net_gross": "SETTINGS", + "price_group": "0", + "debitor_account_number": "44473", + "reduction": "", + "discount_rate_type": "SETTINGS", + "discount_rate": "", + "discount_days_type": "SETTINGS", + "discount_days": "7", + "due_days_type": "SETTINGS", + "due_days": "0", + "reminder_due_days_type": "SETTINGS", + "reminder_due_days": "7", + "offer_validity_days_type": "SETTINGS", + "offer_validity_days": "28", + "dunning_run": "1", + "note": "", + "revenue_gross": "0", + "revenue_net": "0", + "customfield": "", + "client-property-values": "" + }, + { + "id": "9347139", + "created": "2025-12-31T14:48:31+01:00", + "updated": "2025-12-31T14:48:31+01:00", + "archived": "0", + "dig_exclude": "0", + "facturx_identifier": "", + "client_number": "PAT44473", + "number": "44473", + "number_pre": "PAT", + "number_length": "5", + "name": "", + "salutation": "Frau", + "first_name": "Vorname3", + "last_name": "Nachname2", + "street": "Musterstraße 3", + "zip": "10002", + "city": "Musterstadt 3", + "state": "", + "country_code": "DE", + "address": "Firma 3 GmbH\nMusterstraße 3\n10003 Musterstadt 3", + "phone": "+49 30 0000002", + "fax": "", + "mobile": "", + "email": "kontakt2@example.com", + "www": "", + "tax_number": "", + "vat_number": "", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "enable_customerportal": "0", + "seller_identifier": "", + "default_payment_types": "", + "sepa_mandate": "", + "sepa_mandate_date": "", + "locale": "", + "tax_rule": "COUNTRY", + "net_gross": "SETTINGS", + "price_group": "0", + "debitor_account_number": "44473", + "reduction": "", + "discount_rate_type": "SETTINGS", + "discount_rate": "", + "discount_days_type": "SETTINGS", + "discount_days": "7", + "due_days_type": "SETTINGS", + "due_days": "0", + "reminder_due_days_type": "SETTINGS", + "reminder_due_days": "7", + "offer_validity_days_type": "SETTINGS", + "offer_validity_days": "28", + "dunning_run": "1", + "note": "", + "revenue_gross": "353", + "revenue_net": "296.64", + "customfield": "", + "client-property-values": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "12574" + } +} diff --git a/tests/Fixtures/Contract/confirmation-comments/get.json b/tests/Fixtures/Contract/confirmation-comments/get.json new file mode 100644 index 0000000..6e99529 --- /dev/null +++ b/tests/Fixtures/Contract/confirmation-comments/get.json @@ -0,0 +1,15 @@ +{ + "confirmation-comment": { + "id": "1516010", + "created": "2026-10-25T14:50:00+01:00", + "comment": "Text 20", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "confirmation_id": "550206", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/confirmation-comments/list.json b/tests/Fixtures/Contract/confirmation-comments/list.json new file mode 100644 index 0000000..a8cfeb5 --- /dev/null +++ b/tests/Fixtures/Contract/confirmation-comments/list.json @@ -0,0 +1,20 @@ +{ + "confirmation-comments": { + "confirmation-comment": { + "id": "1516010", + "created": "2026-10-25T14:50:00+01:00", + "comment": "Text 20", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "confirmation_id": "550206", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/confirmation-items/get.json b/tests/Fixtures/Contract/confirmation-items/get.json new file mode 100644 index 0000000..3d0bdce --- /dev/null +++ b/tests/Fixtures/Contract/confirmation-items/get.json @@ -0,0 +1,23 @@ +{ + "confirmation-item": { + "id": "2235610", + "article_id": "", + "confirmation_id": "550206", + "position": "1", + "unit": "Stück", + "quantity": "1", + "type": "", + "unit_price": "81.39", + "tax_name": "USt", + "tax_rate": "19", + "title": "Text 19", + "description": "", + "total_net": "68.395", + "total_gross": "81.39", + "reduction": "", + "total_net_unreduced": "68.395", + "total_gross_unreduced": "81.39", + "tax_changed_manually": "0", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/confirmation-items/list.json b/tests/Fixtures/Contract/confirmation-items/list.json new file mode 100644 index 0000000..971efc2 --- /dev/null +++ b/tests/Fixtures/Contract/confirmation-items/list.json @@ -0,0 +1,28 @@ +{ + "confirmation-items": { + "confirmation-item": { + "id": "2235610", + "article_id": "", + "confirmation_id": "550206", + "position": "1", + "unit": "Stück", + "quantity": "1", + "type": "", + "unit_price": "81.39", + "tax_name": "USt", + "tax_rate": "19", + "title": "Text 19", + "description": "", + "total_net": "68.395", + "total_gross": "81.39", + "reduction": "", + "total_net_unreduced": "68.395", + "total_gross_unreduced": "81.39", + "tax_changed_manually": "0", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/confirmation-tags/list.json b/tests/Fixtures/Contract/confirmation-tags/list.json new file mode 100644 index 0000000..2edb140 --- /dev/null +++ b/tests/Fixtures/Contract/confirmation-tags/list.json @@ -0,0 +1,7 @@ +{ + "confirmation-tags": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/confirmations/get.json b/tests/Fixtures/Contract/confirmations/get.json new file mode 100644 index 0000000..aee075f --- /dev/null +++ b/tests/Fixtures/Contract/confirmations/get.json @@ -0,0 +1,47 @@ +{ + "confirmation": { + "id": "550206", + "created": "2026-10-25T14:50:00+01:00", + "updated": "2026-10-25T14:50:00+01:00", + "client_id": "9340866", + "contact_id": "", + "confirmation_number": "", + "number": "", + "number_pre": "AB", + "number_length": "0", + "title": "Text 16", + "date": "2026-10-25", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "status": "DRAFT", + "label": "", + "intro": "Text 17", + "note": "Text 18", + "total_net": "68.39", + "total_gross": "81.39", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "68.4", + "total_gross_unreduced": "81.39", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "13", + "amount_plain": "13", + "amount_rounded": "13", + "amount_net": "68.395", + "amount_net_plain": "68.395", + "amount_net_rounded": "68.4", + "amount_gross": "81.39", + "amount_gross_plain": "81.39", + "amount_gross_rounded": "81.39" + } + }, + "offer_id": "", + "template_id": "971612", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/confirmations/list.json b/tests/Fixtures/Contract/confirmations/list.json new file mode 100644 index 0000000..3f439fb --- /dev/null +++ b/tests/Fixtures/Contract/confirmations/list.json @@ -0,0 +1,52 @@ +{ + "confirmations": { + "confirmation": { + "id": "550206", + "created": "2026-10-25T14:50:00+01:00", + "updated": "2026-10-25T14:50:00+01:00", + "client_id": "9340866", + "contact_id": "", + "confirmation_number": "", + "number": "", + "number_pre": "AB", + "number_length": "0", + "title": "Text 16", + "date": "2026-10-25", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "status": "DRAFT", + "label": "", + "intro": "Text 17", + "note": "Text 18", + "total_net": "68.39", + "total_gross": "81.39", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "68.4", + "total_gross_unreduced": "81.39", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "13", + "amount_plain": "13", + "amount_rounded": "13", + "amount_net": "68.395", + "amount_net_plain": "68.395", + "amount_net_rounded": "68.4", + "amount_gross": "81.39", + "amount_gross_plain": "81.39", + "amount_gross_rounded": "81.39" + } + }, + "offer_id": "", + "template_id": "971612", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/contacts/list.json b/tests/Fixtures/Contract/contacts/list.json new file mode 100644 index 0000000..0cf3b4c --- /dev/null +++ b/tests/Fixtures/Contract/contacts/list.json @@ -0,0 +1,7 @@ +{ + "contacts": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/countries/list.json b/tests/Fixtures/Contract/countries/list.json new file mode 100644 index 0000000..034f482 --- /dev/null +++ b/tests/Fixtures/Contract/countries/list.json @@ -0,0 +1,21 @@ +{ + "countries": { + "country": [ + { + "name": "Name 9", + "code": "AF", + "id": "1" + }, + { + "name": "Name 10", + "code": "EG", + "id": "2" + }, + { + "name": "Name 11", + "code": "AL", + "id": "3" + } + ] + } +} diff --git a/tests/Fixtures/Contract/country-taxes/list.json b/tests/Fixtures/Contract/country-taxes/list.json new file mode 100644 index 0000000..45db104 --- /dev/null +++ b/tests/Fixtures/Contract/country-taxes/list.json @@ -0,0 +1,7 @@ +{ + "country-taxes": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/credit-note-payments/list.json b/tests/Fixtures/Contract/credit-note-payments/list.json new file mode 100644 index 0000000..b7a1da9 --- /dev/null +++ b/tests/Fixtures/Contract/credit-note-payments/list.json @@ -0,0 +1,7 @@ +{ + "credit-note-payments": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/credit-notes/list.json b/tests/Fixtures/Contract/credit-notes/list.json new file mode 100644 index 0000000..1ed6f38 --- /dev/null +++ b/tests/Fixtures/Contract/credit-notes/list.json @@ -0,0 +1,7 @@ +{ + "credit-notes": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/currencies/list.json b/tests/Fixtures/Contract/currencies/list.json new file mode 100644 index 0000000..23ecdea --- /dev/null +++ b/tests/Fixtures/Contract/currencies/list.json @@ -0,0 +1,24 @@ +{ + "currencies": { + "currency": [ + { + "id": "1", + "code": "EUR", + "symbol": "€", + "name": "Name 12" + }, + { + "id": "2", + "code": "USD", + "symbol": "$", + "name": "Name 13" + }, + { + "id": "3", + "code": "CHF", + "symbol": "CHF", + "name": "Name 14" + } + ] + } +} diff --git a/tests/Fixtures/Contract/delivery-note-comments/get.json b/tests/Fixtures/Contract/delivery-note-comments/get.json new file mode 100644 index 0000000..0e454be --- /dev/null +++ b/tests/Fixtures/Contract/delivery-note-comments/get.json @@ -0,0 +1,15 @@ +{ + "delivery-note-comment": { + "id": "1976646", + "created": "2026-10-20T20:37:38+02:00", + "comment": "Text 25", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "delivery_note_id": "889201", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/delivery-note-comments/list.json b/tests/Fixtures/Contract/delivery-note-comments/list.json new file mode 100644 index 0000000..710c819 --- /dev/null +++ b/tests/Fixtures/Contract/delivery-note-comments/list.json @@ -0,0 +1,20 @@ +{ + "delivery-note-comments": { + "delivery-note-comment": { + "id": "1976646", + "created": "2026-10-20T20:37:38+02:00", + "comment": "Text 25", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "delivery_note_id": "889201", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/delivery-note-items/get.json b/tests/Fixtures/Contract/delivery-note-items/get.json new file mode 100644 index 0000000..a11e0a0 --- /dev/null +++ b/tests/Fixtures/Contract/delivery-note-items/get.json @@ -0,0 +1,14 @@ +{ + "delivery-note-item": { + "id": "3490922", + "article_id": "", + "delivery_note_id": "889201", + "position": "1", + "unit": "Stück", + "quantity": "5", + "title": "Text 24", + "description": "", + "tax_changed_manually": "0", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/delivery-note-items/list.json b/tests/Fixtures/Contract/delivery-note-items/list.json new file mode 100644 index 0000000..2b9c7a0 --- /dev/null +++ b/tests/Fixtures/Contract/delivery-note-items/list.json @@ -0,0 +1,19 @@ +{ + "delivery-note-items": { + "delivery-note-item": { + "id": "3490922", + "article_id": "", + "delivery_note_id": "889201", + "position": "1", + "unit": "Stück", + "quantity": "5", + "title": "Text 24", + "description": "", + "tax_changed_manually": "0", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/delivery-note-tags/list.json b/tests/Fixtures/Contract/delivery-note-tags/list.json new file mode 100644 index 0000000..b7cbb07 --- /dev/null +++ b/tests/Fixtures/Contract/delivery-note-tags/list.json @@ -0,0 +1,7 @@ +{ + "delivery-note-tags": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/delivery-notes/get.json b/tests/Fixtures/Contract/delivery-notes/get.json new file mode 100644 index 0000000..6d4c2ab --- /dev/null +++ b/tests/Fixtures/Contract/delivery-notes/get.json @@ -0,0 +1,25 @@ +{ + "delivery-note": { + "id": "889201", + "created": "2026-10-20T20:37:38+02:00", + "updated": "2026-10-20T20:37:38+02:00", + "client_id": "9340866", + "contact_id": "", + "delivery_note_number": "", + "number": "", + "number_pre": "LS", + "number_length": "0", + "title": "Text 21", + "date": "", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "status": "DRAFT", + "label": "", + "intro": "Text 22", + "note": "Text 23", + "offer_id": "", + "confirmation_id": "", + "invoice_id": "", + "template_id": "971613", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/delivery-notes/list.json b/tests/Fixtures/Contract/delivery-notes/list.json new file mode 100644 index 0000000..34a2e3b --- /dev/null +++ b/tests/Fixtures/Contract/delivery-notes/list.json @@ -0,0 +1,30 @@ +{ + "delivery-notes": { + "delivery-note": { + "id": "889201", + "created": "2026-10-20T20:37:38+02:00", + "updated": "2026-10-20T20:37:38+02:00", + "client_id": "9340866", + "contact_id": "", + "delivery_note_number": "", + "number": "", + "number_pre": "LS", + "number_length": "0", + "title": "Text 21", + "date": "", + "address": "Firma 1 GmbH\nMusterstraße 1\n10001 Musterstadt 1", + "status": "DRAFT", + "label": "", + "intro": "Text 22", + "note": "Text 23", + "offer_id": "", + "confirmation_id": "", + "invoice_id": "", + "template_id": "971613", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/email-templates/get.json b/tests/Fixtures/Contract/email-templates/get.json new file mode 100644 index 0000000..3981606 --- /dev/null +++ b/tests/Fixtures/Contract/email-templates/get.json @@ -0,0 +1,13 @@ +{ + "email-template": { + "id": "768701", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 8", + "type": "INVOICES", + "subject": "Text 38", + "text": "Text 39", + "bcc": "kontakt3@example.com", + "is_default": "1" + } +} diff --git a/tests/Fixtures/Contract/email-templates/list.json b/tests/Fixtures/Contract/email-templates/list.json new file mode 100644 index 0000000..3808c51 --- /dev/null +++ b/tests/Fixtures/Contract/email-templates/list.json @@ -0,0 +1,42 @@ +{ + "email-templates": { + "email-template": [ + { + "id": "768701", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 8", + "type": "INVOICES", + "subject": "Text 38", + "text": "Text 39", + "bcc": "kontakt3@example.com", + "is_default": "1" + }, + { + "id": "768702", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 8", + "type": "OFFERS", + "subject": "Text 40", + "text": "Text 41", + "bcc": "kontakt3@example.com", + "is_default": "1" + }, + { + "id": "768703", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 8", + "type": "CONFIRMATIONS", + "subject": "Text 42", + "text": "Text 43", + "bcc": "kontakt3@example.com", + "is_default": "1" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "8" + } +} diff --git a/tests/Fixtures/Contract/free-texts/get.json b/tests/Fixtures/Contract/free-texts/get.json new file mode 100644 index 0000000..1ea5c25 --- /dev/null +++ b/tests/Fixtures/Contract/free-texts/get.json @@ -0,0 +1,14 @@ +{ + "free-text": { + "id": "489335", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "type": "INVOICE", + "name": "Name 8", + "title": "", + "label": "", + "intro": "Text 32", + "note": "Text 33", + "is_default": "1" + } +} diff --git a/tests/Fixtures/Contract/free-texts/list.json b/tests/Fixtures/Contract/free-texts/list.json new file mode 100644 index 0000000..39a0f8f --- /dev/null +++ b/tests/Fixtures/Contract/free-texts/list.json @@ -0,0 +1,45 @@ +{ + "free-texts": { + "free-text": [ + { + "id": "489335", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "type": "INVOICE", + "name": "Name 8", + "title": "", + "label": "", + "intro": "Text 32", + "note": "Text 33", + "is_default": "1" + }, + { + "id": "489336", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "type": "OFFER", + "name": "Name 8", + "title": "", + "label": "", + "intro": "Text 34", + "note": "Text 35", + "is_default": "1" + }, + { + "id": "489337", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "type": "CREDIT_NOTE", + "name": "Name 8", + "title": "", + "label": "", + "intro": "Text 36", + "note": "Text 37", + "is_default": "1" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "7" + } +} diff --git a/tests/Fixtures/Contract/inbox-documents/list.json b/tests/Fixtures/Contract/inbox-documents/list.json new file mode 100644 index 0000000..d3e6c05 --- /dev/null +++ b/tests/Fixtures/Contract/inbox-documents/list.json @@ -0,0 +1,7 @@ +{ + "inbox-documents": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/incoming-categories/get.json b/tests/Fixtures/Contract/incoming-categories/get.json new file mode 100644 index 0000000..ed59832 --- /dev/null +++ b/tests/Fixtures/Contract/incoming-categories/get.json @@ -0,0 +1,7 @@ +{ + "incoming-category": { + "id": "accommodation_costs_employee", + "title": "Text 26", + "description": "Text 27" + } +} diff --git a/tests/Fixtures/Contract/incoming-categories/list.json b/tests/Fixtures/Contract/incoming-categories/list.json new file mode 100644 index 0000000..45a5b73 --- /dev/null +++ b/tests/Fixtures/Contract/incoming-categories/list.json @@ -0,0 +1,24 @@ +{ + "incoming-categories": { + "incoming-category": [ + { + "id": "accommodation_costs_employee", + "title": "Text 26", + "description": "Text 27" + }, + { + "id": "accommodation_costs_owner", + "title": "Text 28", + "description": "Text 29" + }, + { + "id": "accounting_costs", + "title": "Text 30", + "description": "Text 31" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "84" + } +} diff --git a/tests/Fixtures/Contract/incoming-payments/list.json b/tests/Fixtures/Contract/incoming-payments/list.json new file mode 100644 index 0000000..1e4c1d9 --- /dev/null +++ b/tests/Fixtures/Contract/incoming-payments/list.json @@ -0,0 +1,7 @@ +{ + "incoming-payments": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/incoming-properties/list.json b/tests/Fixtures/Contract/incoming-properties/list.json new file mode 100644 index 0000000..02efe56 --- /dev/null +++ b/tests/Fixtures/Contract/incoming-properties/list.json @@ -0,0 +1,7 @@ +{ + "incoming-properties": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/incoming-property-values/list.json b/tests/Fixtures/Contract/incoming-property-values/list.json new file mode 100644 index 0000000..3b77182 --- /dev/null +++ b/tests/Fixtures/Contract/incoming-property-values/list.json @@ -0,0 +1,7 @@ +{ + "incoming-property-values": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/incomings/list.json b/tests/Fixtures/Contract/incomings/list.json new file mode 100644 index 0000000..c6812eb --- /dev/null +++ b/tests/Fixtures/Contract/incomings/list.json @@ -0,0 +1,7 @@ +{ + "incomings": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/invoice-comments/get.json b/tests/Fixtures/Contract/invoice-comments/get.json new file mode 100644 index 0000000..91a59b1 --- /dev/null +++ b/tests/Fixtures/Contract/invoice-comments/get.json @@ -0,0 +1,15 @@ +{ + "invoice-comment": { + "id": "92445240", + "created": "2025-12-31T14:48:32+01:00", + "comment": "Text 13", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "invoice_id": "22808888", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/invoice-comments/list.json b/tests/Fixtures/Contract/invoice-comments/list.json new file mode 100644 index 0000000..90a03de --- /dev/null +++ b/tests/Fixtures/Contract/invoice-comments/list.json @@ -0,0 +1,48 @@ +{ + "invoice-comments": { + "invoice-comment": [ + { + "id": "92445240", + "created": "2025-12-31T14:48:32+01:00", + "comment": "Text 13", + "actionkey": "CREATE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "invoice_id": "22808888", + "customfield": "" + }, + { + "id": "92445241", + "created": "2025-12-31T14:48:33+01:00", + "comment": "Text 14", + "actionkey": "STATUS", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "invoice_id": "22808888", + "customfield": "" + }, + { + "id": "92445411", + "created": "2025-12-31T15:00:33+01:00", + "comment": "Text 15", + "actionkey": "CREATE_INVOICE", + "public": "0", + "by_client": "0", + "user_id": "136536", + "email_id": "", + "client_id": "", + "invoice_id": "22808888", + "customfield": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "4" + } +} diff --git a/tests/Fixtures/Contract/invoice-items/get.json b/tests/Fixtures/Contract/invoice-items/get.json new file mode 100644 index 0000000..d1e7246 --- /dev/null +++ b/tests/Fixtures/Contract/invoice-items/get.json @@ -0,0 +1,23 @@ +{ + "invoice-item": { + "id": "59565129", + "article_id": "", + "invoice_id": "22808888", + "position": "1", + "unit": "", + "quantity": "1", + "type": "SERVICE", + "unit_price": "40", + "tax_name": "USt", + "tax_rate": "19", + "title": "Text 11", + "description": "Text 12", + "total_net": "33.6134", + "total_gross": "40", + "reduction": "", + "total_net_unreduced": "33.6134", + "total_gross_unreduced": "40", + "tax_changed_manually": "0", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/invoice-items/list.json b/tests/Fixtures/Contract/invoice-items/list.json new file mode 100644 index 0000000..fcf38ea --- /dev/null +++ b/tests/Fixtures/Contract/invoice-items/list.json @@ -0,0 +1,28 @@ +{ + "invoice-items": { + "invoice-item": { + "id": "59565129", + "article_id": "", + "invoice_id": "22808888", + "position": "1", + "unit": "", + "quantity": "1", + "type": "SERVICE", + "unit_price": "40", + "tax_name": "USt", + "tax_rate": "19", + "title": "Text 11", + "description": "Text 12", + "total_net": "33.6134", + "total_gross": "40", + "reduction": "", + "total_net_unreduced": "33.6134", + "total_gross_unreduced": "40", + "tax_changed_manually": "0", + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tests/Fixtures/Contract/invoice-payments/get.json b/tests/Fixtures/Contract/invoice-payments/get.json new file mode 100644 index 0000000..c22b09f --- /dev/null +++ b/tests/Fixtures/Contract/invoice-payments/get.json @@ -0,0 +1,20 @@ +{ + "invoice-payment": { + "id": "16378259", + "created": "2025-12-31T15:00:34+01:00", + "invoice_id": "22808964", + "user_id": "136536", + "date": "2025-12-31", + "amount": "-40", + "comment": "", + "transaction_purpose": "", + "currency_code": "EUR", + "quote": "1", + "payment_group": "455c7515-c131-44ee-9eed-6b3c29d77afd", + "finapi_transaction_id": "", + "finapi_transaction_amount": "", + "type": "", + "document_title": "Korrekturrechnung zur Rechnung RE352500001", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/invoice-payments/list.json b/tests/Fixtures/Contract/invoice-payments/list.json new file mode 100644 index 0000000..1942e6a --- /dev/null +++ b/tests/Fixtures/Contract/invoice-payments/list.json @@ -0,0 +1,63 @@ +{ + "invoice-payments": { + "invoice-payment": [ + { + "id": "16378259", + "created": "2025-12-31T15:00:34+01:00", + "invoice_id": "22808964", + "user_id": "136536", + "date": "2025-12-31", + "amount": "-40", + "comment": "", + "transaction_purpose": "", + "currency_code": "EUR", + "quote": "1", + "payment_group": "455c7515-c131-44ee-9eed-6b3c29d77afd", + "finapi_transaction_id": "", + "finapi_transaction_amount": "", + "type": "", + "document_title": "Korrekturrechnung zur Rechnung RE352500001", + "customfield": "" + }, + { + "id": "16378260", + "created": "2025-12-31T15:00:35+01:00", + "invoice_id": "22808888", + "user_id": "136536", + "date": "2025-12-31", + "amount": "40", + "comment": "", + "transaction_purpose": "", + "currency_code": "EUR", + "quote": "1", + "payment_group": "64192d73-210c-47ae-8064-42e0e50afb86", + "finapi_transaction_id": "", + "finapi_transaction_amount": "", + "type": "INVOICE_CORRECTION", + "document_title": "Rechnung RE352500001", + "customfield": "" + }, + { + "id": "16378309", + "created": "2025-12-31T15:04:26+01:00", + "invoice_id": "22808947", + "user_id": "136536", + "date": "2025-12-31", + "amount": "50", + "comment": "", + "transaction_purpose": "", + "currency_code": "EUR", + "quote": "1", + "payment_group": "2df0a18c-f2db-4ec9-9c9a-6c961cbe76fa", + "finapi_transaction_id": "", + "finapi_transaction_amount": "", + "type": "CASH", + "document_title": "Rechnung RE362500002", + "customfield": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "156" + } +} diff --git a/tests/Fixtures/Contract/invoice-tags/list.json b/tests/Fixtures/Contract/invoice-tags/list.json new file mode 100644 index 0000000..e93574d --- /dev/null +++ b/tests/Fixtures/Contract/invoice-tags/list.json @@ -0,0 +1,7 @@ +{ + "invoice-tags": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/invoices/get.json b/tests/Fixtures/Contract/invoices/get.json new file mode 100644 index 0000000..a662650 --- /dev/null +++ b/tests/Fixtures/Contract/invoices/get.json @@ -0,0 +1,63 @@ +{ + "invoice": { + "id": "22808888", + "created": "2025-12-31T14:48:32+01:00", + "updated": "2025-12-31T15:00:35+01:00", + "client_id": "9347139", + "contact_id": "", + "invoice_number": "RE352500001", + "number": "1", + "number_pre": "RE3525", + "number_length": "5", + "title": "Text 2", + "date": "2025-12-31", + "supply_date": "2026-01-16", + "supply_date_type": "SUPPLY_DATE", + "due_date": "2026-01-01", + "due_days": "1", + "address": "Firma 6 GmbH\nMusterstraße 6\n10006 Musterstadt 6", + "status": "PAID", + "label": "", + "intro": "Text 3", + "note": "Text 4", + "total_net": "33.61", + "total_gross": "40", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "33.61", + "total_gross_unreduced": "40", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "discount_rate": "", + "discount_date": "", + "discount_days": "7", + "discount_amount": "0", + "paid_amount": "40", + "open_amount": "0", + "payment_types": "", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "6.39", + "amount_plain": "6.39", + "amount_rounded": "6.39", + "amount_net": "33.6134", + "amount_net_plain": "33.6134", + "amount_net_rounded": "33.61", + "amount_gross": "40", + "amount_gross_plain": "40", + "amount_gross_rounded": "40" + } + }, + "invoice_id": "", + "offer_id": "", + "confirmation_id": "", + "recurring_id": "", + "dig_proceeded": "0", + "template_id": "971608", + "customfield": "", + "facturx_reference": "" + } +} diff --git a/tests/Fixtures/Contract/invoices/list.json b/tests/Fixtures/Contract/invoices/list.json new file mode 100644 index 0000000..5bdf446 --- /dev/null +++ b/tests/Fixtures/Contract/invoices/list.json @@ -0,0 +1,192 @@ +{ + "invoices": { + "invoice": [ + { + "id": "22808888", + "created": "2025-12-31T14:48:32+01:00", + "updated": "2025-12-31T15:00:35+01:00", + "client_id": "9347139", + "contact_id": "", + "invoice_number": "RE352500001", + "number": "1", + "number_pre": "RE3525", + "number_length": "5", + "title": "Text 2", + "date": "2025-12-31", + "supply_date": "2026-01-16", + "supply_date_type": "SUPPLY_DATE", + "due_date": "2026-01-01", + "due_days": "1", + "address": "Firma 6 GmbH\nMusterstraße 6\n10006 Musterstadt 6", + "status": "PAID", + "label": "", + "intro": "Text 3", + "note": "Text 4", + "total_net": "33.61", + "total_gross": "40", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "33.61", + "total_gross_unreduced": "40", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "discount_rate": "", + "discount_date": "", + "discount_days": "7", + "discount_amount": "0", + "paid_amount": "40", + "open_amount": "0", + "payment_types": "", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "6.39", + "amount_plain": "6.39", + "amount_rounded": "6.39", + "amount_net": "33.6134", + "amount_net_plain": "33.6134", + "amount_net_rounded": "33.61", + "amount_gross": "40", + "amount_gross_plain": "40", + "amount_gross_rounded": "40" + } + }, + "invoice_id": "", + "offer_id": "", + "confirmation_id": "", + "recurring_id": "", + "dig_proceeded": "0", + "template_id": "971608", + "customfield": "", + "facturx_reference": "" + }, + { + "id": "22808947", + "created": "2025-12-31T15:00:07+01:00", + "updated": "2025-12-31T15:12:02+01:00", + "client_id": "9347139", + "contact_id": "", + "invoice_number": "RE362500002", + "number": "2", + "number_pre": "RE3625", + "number_length": "5", + "title": "Text 5", + "date": "2025-12-31", + "supply_date": "2026-01-09", + "supply_date_type": "SUPPLY_DATE", + "due_date": "2026-01-01", + "due_days": "1", + "address": "Firma 6 GmbH\nMusterstraße 6\n10006 Musterstadt 6", + "status": "PAID", + "label": "", + "intro": "Text 6", + "note": "Text 4", + "total_net": "42.02", + "total_gross": "50", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "42.02", + "total_gross_unreduced": "50", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "discount_rate": "", + "discount_date": "", + "discount_days": "7", + "discount_amount": "0", + "paid_amount": "50", + "open_amount": "0", + "payment_types": "", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "7.98", + "amount_plain": "7.98", + "amount_rounded": "7.98", + "amount_net": "42.0168", + "amount_net_plain": "42.0168", + "amount_net_rounded": "42.02", + "amount_gross": "50", + "amount_gross_plain": "50", + "amount_gross_rounded": "50" + } + }, + "invoice_id": "", + "offer_id": "", + "confirmation_id": "", + "recurring_id": "", + "dig_proceeded": "0", + "template_id": "971861", + "customfield": "", + "facturx_reference": "" + }, + { + "id": "22808964", + "created": "2025-12-31T15:00:33+01:00", + "updated": "2025-12-31T15:06:46+01:00", + "client_id": "9347139", + "contact_id": "", + "invoice_number": "RE3525000003", + "number": "3", + "number_pre": "RE3525", + "number_length": "6", + "title": "Text 7", + "date": "2025-12-31", + "supply_date": "", + "supply_date_type": "", + "due_date": "2025-12-31", + "due_days": "0", + "address": "Firma 6 GmbH\nMusterstraße 6\n10006 Musterstadt 6", + "status": "PAID", + "label": "Text 8", + "intro": "Text 9", + "note": "Text 10", + "total_net": "-33.61", + "total_gross": "-40", + "reduction": "", + "total_reduction": "0", + "total_net_unreduced": "-33.61", + "total_gross_unreduced": "-40", + "currency_code": "EUR", + "quote": "1", + "net_gross": "GROSS", + "discount_rate": "", + "discount_date": "", + "discount_days": "7", + "discount_amount": "0", + "paid_amount": "-40", + "open_amount": "0", + "payment_types": "", + "taxes": { + "tax": { + "name": "Name 7", + "rate": "19", + "amount": "-6.39", + "amount_plain": "-6.39", + "amount_rounded": "-6.39", + "amount_net": "-33.6134", + "amount_net_plain": "-33.6134", + "amount_net_rounded": "-33.61", + "amount_gross": "-40", + "amount_gross_plain": "-40", + "amount_gross_rounded": "-40" + } + }, + "invoice_id": "22808888", + "offer_id": "", + "confirmation_id": "", + "recurring_id": "", + "dig_proceeded": "0", + "template_id": "971608", + "customfield": "", + "facturx_reference": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "780" + } +} diff --git a/tests/Fixtures/Contract/letters/list.json b/tests/Fixtures/Contract/letters/list.json new file mode 100644 index 0000000..25c3cf6 --- /dev/null +++ b/tests/Fixtures/Contract/letters/list.json @@ -0,0 +1,7 @@ +{ + "letters": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/offers/list.json b/tests/Fixtures/Contract/offers/list.json new file mode 100644 index 0000000..ffe209c --- /dev/null +++ b/tests/Fixtures/Contract/offers/list.json @@ -0,0 +1,7 @@ +{ + "offers": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/recurrings/list.json b/tests/Fixtures/Contract/recurrings/list.json new file mode 100644 index 0000000..25df266 --- /dev/null +++ b/tests/Fixtures/Contract/recurrings/list.json @@ -0,0 +1,7 @@ +{ + "recurrings": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/reminder-texts/list.json b/tests/Fixtures/Contract/reminder-texts/list.json new file mode 100644 index 0000000..74ea310 --- /dev/null +++ b/tests/Fixtures/Contract/reminder-texts/list.json @@ -0,0 +1,7 @@ +{ + "reminder-texts": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/reminders/list.json b/tests/Fixtures/Contract/reminders/list.json new file mode 100644 index 0000000..09f7bf4 --- /dev/null +++ b/tests/Fixtures/Contract/reminders/list.json @@ -0,0 +1,7 @@ +{ + "reminders": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/roles/get.json b/tests/Fixtures/Contract/roles/get.json new file mode 100644 index 0000000..b960f1e --- /dev/null +++ b/tests/Fixtures/Contract/roles/get.json @@ -0,0 +1,28 @@ +{ + "role": { + "id": "1", + "created": "2013-09-27T10:49:44+02:00", + "updated": "2013-09-27T10:49:44+02:00", + "name": "Name 15", + "articles": "DELETE", + "clients": "DELETE", + "suppliers": "DELETE", + "invoices": "DELETE", + "offers": "DELETE", + "reminders": "DELETE", + "incomings": "DELETE", + "credit_notes": "DELETE", + "confirmations": "DELETE", + "delivery_notes": "DELETE", + "letters": "DELETE", + "banking": "UPDATE", + "net_income_statement": "DELETE", + "accounting_export": "UPDATE", + "settings_my_account": "UPDATE", + "settings_documents": "UPDATE", + "settings_configuration": "UPDATE", + "settings_administration": "UPDATE", + "settings_addons": "UPDATE", + "settings_my_addons": "UPDATE" + } +} diff --git a/tests/Fixtures/Contract/roles/list.json b/tests/Fixtures/Contract/roles/list.json new file mode 100644 index 0000000..467b06c --- /dev/null +++ b/tests/Fixtures/Contract/roles/list.json @@ -0,0 +1,87 @@ +{ + "roles": { + "role": [ + { + "id": "1", + "created": "2013-09-27T10:49:44+02:00", + "updated": "2013-09-27T10:49:44+02:00", + "name": "Name 15", + "articles": "DELETE", + "clients": "DELETE", + "suppliers": "DELETE", + "invoices": "DELETE", + "offers": "DELETE", + "reminders": "DELETE", + "incomings": "DELETE", + "credit_notes": "DELETE", + "confirmations": "DELETE", + "delivery_notes": "DELETE", + "letters": "DELETE", + "banking": "UPDATE", + "net_income_statement": "DELETE", + "accounting_export": "UPDATE", + "settings_my_account": "UPDATE", + "settings_documents": "UPDATE", + "settings_configuration": "UPDATE", + "settings_administration": "UPDATE", + "settings_addons": "UPDATE", + "settings_my_addons": "UPDATE" + }, + { + "id": "2", + "created": "2013-09-27T10:49:44+02:00", + "updated": "2013-09-27T10:49:44+02:00", + "name": "Name 16", + "articles": "DELETE", + "clients": "DELETE", + "suppliers": "DELETE", + "invoices": "DELETE", + "offers": "DELETE", + "reminders": "DELETE", + "incomings": "DELETE", + "credit_notes": "DELETE", + "confirmations": "DELETE", + "delivery_notes": "DELETE", + "letters": "DELETE", + "banking": "", + "net_income_statement": "", + "accounting_export": "", + "settings_my_account": "", + "settings_documents": "UPDATE", + "settings_configuration": "UPDATE", + "settings_administration": "", + "settings_addons": "", + "settings_my_addons": "UPDATE" + }, + { + "id": "4", + "created": "2013-09-27T10:49:44+02:00", + "updated": "2018-01-15T06:11:27+01:00", + "name": "Name 17", + "articles": "READ", + "clients": "READ", + "suppliers": "READ", + "invoices": "READ", + "offers": "READ", + "reminders": "READ", + "incomings": "READ", + "credit_notes": "READ", + "confirmations": "READ", + "delivery_notes": "READ", + "letters": "", + "banking": "", + "net_income_statement": "DELETE", + "accounting_export": "UPDATE", + "settings_my_account": "", + "settings_documents": "", + "settings_configuration": "UPDATE", + "settings_administration": "", + "settings_addons": "UPDATE", + "settings_my_addons": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "3" + } +} diff --git a/tests/Fixtures/Contract/settings/get.json b/tests/Fixtures/Contract/settings/get.json new file mode 100644 index 0000000..44883ae --- /dev/null +++ b/tests/Fixtures/Contract/settings/get.json @@ -0,0 +1,74 @@ +{ + "settings": { + "created": "2025-12-24T22:59:57+01:00", + "updated": "2026-10-25T18:56:56+01:00", + "bgcolor": "999999", + "color1": "336600", + "color2": "99CC00", + "color3": "E6EFC2", + "currency_code": "EUR", + "locale": "de_DE", + "net_gross": "GROSS", + "sepa_creditor_id": "", + "number_range_mode": "IGNORE_PREFIX", + "article_number_pre": "ART", + "article_number_length": "0", + "article_number_next": "225", + "price_group2": "Preisgruppe 2", + "price_group3": "Preisgruppe 3", + "price_group4": "Preisgruppe 4", + "price_group5": "Preisgruppe 5", + "client_number_pre": "PAT", + "client_number_length": "6", + "client_number_next": "8260", + "invoice_number_pre": "RE", + "invoice_number_length": "5", + "invoice_number_next": "474", + "invoice_label": "", + "invoice_intro": "Text 32", + "invoice_note": "Text 33", + "invoice_filename": "", + "due_days": "0", + "discount_rate": "0", + "discount_days": "7", + "offer_number_pre": "AN", + "offer_number_length": "6", + "offer_number_next": "7", + "offer_label": "", + "offer_intro": "Text 34", + "offer_note": "Text 35", + "offer_filename": "", + "offer_validity_days": "28", + "confirmation_number_pre": "AB", + "confirmation_number_length": "0", + "confirmation_number_next": "4", + "confirmation_label": "", + "confirmation_intro": "Text 17", + "confirmation_note": "Text 18", + "confirmation_filename": "", + "credit_note_number_pre": "GS", + "credit_note_number_length": "0", + "credit_note_number_next": "2", + "credit_note_label": "", + "credit_note_intro": "Text 36", + "credit_note_note": "Text 37", + "credit_note_filename": "", + "delivery_note_number_pre": "LS", + "delivery_note_number_length": "0", + "delivery_note_number_next": "3", + "delivery_note_label": "", + "delivery_note_intro": "Text 22", + "delivery_note_note": "Text 23", + "delivery_note_filename": "", + "reminder_filename": "", + "reminder_due_days": "7", + "letter_label": "", + "letter_intro": "Text 48", + "letter_filename": "", + "template_engine": "DEFAULT", + "print_version": "0", + "default_email_sender": "Firma 1 GmbH ", + "bcc_addresses": "", + "taxation": "TARGET" + } +} diff --git a/tests/Fixtures/Contract/supplier-properties/list.json b/tests/Fixtures/Contract/supplier-properties/list.json new file mode 100644 index 0000000..f773d11 --- /dev/null +++ b/tests/Fixtures/Contract/supplier-properties/list.json @@ -0,0 +1,7 @@ +{ + "supplier-properties": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/supplier-property-values/list.json b/tests/Fixtures/Contract/supplier-property-values/list.json new file mode 100644 index 0000000..5e20c06 --- /dev/null +++ b/tests/Fixtures/Contract/supplier-property-values/list.json @@ -0,0 +1,7 @@ +{ + "supplier-property-values": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/supplier-tags/list.json b/tests/Fixtures/Contract/supplier-tags/list.json new file mode 100644 index 0000000..dc44262 --- /dev/null +++ b/tests/Fixtures/Contract/supplier-tags/list.json @@ -0,0 +1,7 @@ +{ + "supplier-tags": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/suppliers/get.json b/tests/Fixtures/Contract/suppliers/get.json new file mode 100644 index 0000000..5719509 --- /dev/null +++ b/tests/Fixtures/Contract/suppliers/get.json @@ -0,0 +1,41 @@ +{ + "supplier": { + "id": "388763", + "created": "2026-06-02T15:37:57+02:00", + "updated": "2026-10-25T17:31:00+01:00", + "name": "Name 3", + "salutation": "", + "first_name": "", + "last_name": "", + "street": "", + "zip": "", + "city": "", + "state": "", + "country_code": "DE", + "is_eu_country": "1", + "address": "Firma 4 GmbH\nMusterstraße 4\n10004 Musterstadt 4", + "phone": "", + "fax": "", + "mobile": "", + "email": "", + "www": "", + "tax_number": "", + "vat_number": "", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "locale": "", + "note": "", + "client_number": "", + "creditor_account_number": "", + "creditor_identifier": "", + "costs_gross": "0", + "costs_net": "0", + "customfield": "", + "supplier-property-values": "" + } +} diff --git a/tests/Fixtures/Contract/suppliers/list.json b/tests/Fixtures/Contract/suppliers/list.json new file mode 100644 index 0000000..47d2cf9 --- /dev/null +++ b/tests/Fixtures/Contract/suppliers/list.json @@ -0,0 +1,87 @@ +{ + "suppliers": { + "supplier": [ + { + "id": "388763", + "created": "2026-06-02T15:37:57+02:00", + "updated": "2026-10-25T17:31:00+01:00", + "name": "Name 3", + "salutation": "", + "first_name": "", + "last_name": "", + "street": "", + "zip": "", + "city": "", + "state": "", + "country_code": "DE", + "is_eu_country": "1", + "address": "Firma 4 GmbH\nMusterstraße 4\n10004 Musterstadt 4", + "phone": "", + "fax": "", + "mobile": "", + "email": "", + "www": "", + "tax_number": "", + "vat_number": "", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "locale": "", + "note": "", + "client_number": "", + "creditor_account_number": "", + "creditor_identifier": "", + "costs_gross": "0", + "costs_net": "0", + "customfield": "", + "supplier-property-values": "" + }, + { + "id": "392588", + "created": "2026-10-25T13:35:56+01:00", + "updated": "2026-10-25T13:35:56+01:00", + "name": "Name 4", + "salutation": "", + "first_name": "", + "last_name": "", + "street": "", + "zip": "", + "city": "", + "state": "", + "country_code": "DE", + "is_eu_country": "1", + "address": "Firma 5 GmbH\nMusterstraße 5\n10005 Musterstadt 5", + "phone": "", + "fax": "", + "mobile": "", + "email": "", + "www": "", + "tax_number": "", + "vat_number": "", + "bank_account_owner": "", + "bank_number": "", + "bank_name": "", + "bank_account_number": "", + "bank_swift": "", + "bank_iban": "", + "currency_code": "EUR", + "locale": "", + "note": "", + "client_number": "", + "creditor_account_number": "", + "creditor_identifier": "", + "costs_gross": "0", + "costs_net": "0", + "customfield": "", + "supplier-property-values": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "2" + } +} diff --git a/tests/Fixtures/Contract/taxes/get.json b/tests/Fixtures/Contract/taxes/get.json new file mode 100644 index 0000000..147ef56 --- /dev/null +++ b/tests/Fixtures/Contract/taxes/get.json @@ -0,0 +1,12 @@ +{ + "tax": { + "id": "271923", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 7", + "rate": "0", + "is_default": "0", + "can_delete": "0", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/taxes/list.json b/tests/Fixtures/Contract/taxes/list.json new file mode 100644 index 0000000..4f590bd --- /dev/null +++ b/tests/Fixtures/Contract/taxes/list.json @@ -0,0 +1,39 @@ +{ + "taxes": { + "tax": [ + { + "id": "271923", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 7", + "rate": "0", + "is_default": "0", + "can_delete": "0", + "customfield": "" + }, + { + "id": "271924", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 7", + "rate": "7", + "is_default": "0", + "can_delete": "1", + "customfield": "" + }, + { + "id": "271925", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-24T22:59:58+01:00", + "name": "Name 7", + "rate": "19", + "is_default": "1", + "can_delete": "1", + "customfield": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "3" + } +} diff --git a/tests/Fixtures/Contract/templates/get.json b/tests/Fixtures/Contract/templates/get.json new file mode 100644 index 0000000..8023f23 --- /dev/null +++ b/tests/Fixtures/Contract/templates/get.json @@ -0,0 +1,13 @@ +{ + "template": { + "id": "971608", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-29T10:43:03+01:00", + "type": "INVOICE", + "template_type": "DEFINED", + "name": "Name 8", + "is_background_available": "0", + "is_default": "1", + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/templates/list.json b/tests/Fixtures/Contract/templates/list.json new file mode 100644 index 0000000..b62007b --- /dev/null +++ b/tests/Fixtures/Contract/templates/list.json @@ -0,0 +1,42 @@ +{ + "templates": { + "template": [ + { + "id": "971608", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-29T10:43:03+01:00", + "type": "INVOICE", + "template_type": "DEFINED", + "name": "Name 8", + "is_background_available": "0", + "is_default": "1", + "customfield": "" + }, + { + "id": "971609", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-29T10:43:03+01:00", + "type": "OFFER", + "template_type": "DEFINED", + "name": "Name 8", + "is_background_available": "0", + "is_default": "1", + "customfield": "" + }, + { + "id": "971610", + "created": "2025-12-24T22:59:58+01:00", + "updated": "2025-12-29T10:43:04+01:00", + "type": "REMINDER", + "template_type": "DEFINED", + "name": "Name 8", + "is_background_available": "0", + "is_default": "1", + "customfield": "" + } + ], + "@page": "1", + "@per_page": "3", + "@total": "11" + } +} diff --git a/tests/Fixtures/Contract/units/list.json b/tests/Fixtures/Contract/units/list.json new file mode 100644 index 0000000..ab414f9 --- /dev/null +++ b/tests/Fixtures/Contract/units/list.json @@ -0,0 +1,7 @@ +{ + "units": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/user-properties/list.json b/tests/Fixtures/Contract/user-properties/list.json new file mode 100644 index 0000000..77d6421 --- /dev/null +++ b/tests/Fixtures/Contract/user-properties/list.json @@ -0,0 +1,7 @@ +{ + "user-properties": { + "@page": "1", + "@per_page": "3", + "@total": "0" + } +} diff --git a/tests/Fixtures/Contract/users/get.json b/tests/Fixtures/Contract/users/get.json new file mode 100644 index 0000000..f5f21cc --- /dev/null +++ b/tests/Fixtures/Contract/users/get.json @@ -0,0 +1,38 @@ +{ + "user": { + "id": "136536", + "created": "2025-12-24T22:59:57+01:00", + "updated": "2026-10-25T13:43:59+01:00", + "salutation": "MR", + "first_name": "Vorname4", + "last_name": "Nachname3", + "email": "kontakt4@example.com", + "locale": "de_DE", + "timezone": "Europe/Berlin", + "is_account_owner": "1", + "role_id": "1", + "rights": { + "articles": "DELETE", + "clients": "DELETE", + "suppliers": "DELETE", + "invoices": "DELETE", + "offers": "DELETE", + "reminders": "DELETE", + "incomings": "DELETE", + "credit_notes": "DELETE", + "confirmations": "DELETE", + "delivery_notes": "DELETE", + "letters": "DELETE", + "banking": "UPDATE", + "net_income_statement": "DELETE", + "accounting_export": "UPDATE", + "settings_my_account": "UPDATE", + "settings_documents": "UPDATE", + "settings_configuration": "UPDATE", + "settings_administration": "UPDATE", + "settings_addons": "UPDATE", + "settings_my_addons": "UPDATE" + }, + "customfield": "" + } +} diff --git a/tests/Fixtures/Contract/users/list.json b/tests/Fixtures/Contract/users/list.json new file mode 100644 index 0000000..971551d --- /dev/null +++ b/tests/Fixtures/Contract/users/list.json @@ -0,0 +1,43 @@ +{ + "users": { + "user": { + "id": "136536", + "created": "2025-12-24T22:59:57+01:00", + "updated": "2026-10-25T13:43:59+01:00", + "salutation": "MR", + "first_name": "Vorname4", + "last_name": "Nachname3", + "email": "kontakt4@example.com", + "locale": "de_DE", + "timezone": "Europe/Berlin", + "is_account_owner": "1", + "role_id": "1", + "rights": { + "articles": "DELETE", + "clients": "DELETE", + "suppliers": "DELETE", + "invoices": "DELETE", + "offers": "DELETE", + "reminders": "DELETE", + "incomings": "DELETE", + "credit_notes": "DELETE", + "confirmations": "DELETE", + "delivery_notes": "DELETE", + "letters": "DELETE", + "banking": "UPDATE", + "net_income_statement": "DELETE", + "accounting_export": "UPDATE", + "settings_my_account": "UPDATE", + "settings_documents": "UPDATE", + "settings_configuration": "UPDATE", + "settings_administration": "UPDATE", + "settings_addons": "UPDATE", + "settings_my_addons": "UPDATE" + }, + "customfield": "" + }, + "@page": "1", + "@per_page": "3", + "@total": "1" + } +} diff --git a/tools/codegen/definitions/clients.php b/tools/codegen/definitions/clients.php index ab0bc6e..a15870c 100644 --- a/tools/codegen/definitions/clients.php +++ b/tools/codegen/definitions/clients.php @@ -173,5 +173,13 @@ 'facturx_identifier' => ['type' => 'string', 'readOnly' => true, 'doc' => 'Kennung für E-Rechnungen (ZUGFeRD/Factur-X).', 'details' => ['Von Billomat geliefert, aber nicht dokumentiert.']], 'seller_identifier' => ['type' => 'string', 'readOnly' => true, 'doc' => 'Verkäufer-Kennung für E-Rechnungen (ZUGFeRD/Factur-X).', 'details' => ['Von Billomat geliefert, aber nicht dokumentiert.']], 'dig_exclude' => ['type' => 'bool', 'readOnly' => true, 'doc' => 'Von Billomat geliefert, aber nicht dokumentiert.'], + 'client-property-values' => [ + 'type' => 'list', + 'readOnly' => true, + 'list' => ['class' => 'ClientPropertyValue', 'item' => 'client-property-value'], + 'model' => ['property' => 'propertyValues'], + 'doc' => 'Attributwerte des Kunden.', + 'details' => ['Von Billomat geliefert, aber nicht dokumentiert (leer: "").'], + ], ], ]; diff --git a/tools/codegen/definitions/suppliers.php b/tools/codegen/definitions/suppliers.php index fa4d8e0..bd8d513 100644 --- a/tools/codegen/definitions/suppliers.php +++ b/tools/codegen/definitions/suppliers.php @@ -103,5 +103,13 @@ 'creditor_identifier' => ['doc' => 'SEPA-Gläubiger-ID.'], 'costs_gross' => ['type' => 'float', 'readOnly' => true, 'doc' => 'Brutto-Kosten (read-only Auswertungsfeld).'], 'costs_net' => ['type' => 'float', 'readOnly' => true, 'doc' => 'Netto-Kosten (read-only Auswertungsfeld).'], + 'supplier-property-values' => [ + 'type' => 'list', + 'readOnly' => true, + 'list' => ['class' => 'SupplierPropertyValue', 'item' => 'supplier-property-value'], + 'model' => ['property' => 'propertyValues'], + 'doc' => 'Attributwerte des Lieferanten.', + 'details' => ['Von Billomat geliefert, aber nicht dokumentiert (leer: "").'], + ], ], ]; diff --git a/tools/record-fixtures.php b/tools/record-fixtures.php new file mode 100644 index 0000000..fe1b547 --- /dev/null +++ b/tools/record-fixtures.php @@ -0,0 +1,450 @@ +/.json ab. + * tests/Contract/ContractFixturesTest.php prüft damit ohne Netz, ob die Models + * jedes Feld der echten API kennen. + * + * Zugangsdaten kommen aus .env.test.local (BILLOMAT_ID, BILLOMAT_API_KEY, optional + * BILLOMAT_APP_ID/BILLOMAT_APP_SECRET) und werden nie ausgegeben. + * + * Anonymisiert werden Namen, Firmen, E-Mails, Telefon-/Fax-/Mobilnummern, + * Anschriften, Bankdaten, Steuer-/USt-IDs, URLs, Freitexte und die Billomat-ID. + * Typen, Formate, Enum-Werte, Beträge, Datumsangaben, IDs und die Anwesenheit + * der Felder bleiben unverändert; leere Strings bleiben leer. + */ + +namespace Justpilot\Billomat\Tools\Fixtures; + +use Justpilot\Billomat\Config\BillomatConfig; +use Justpilot\Billomat\Config\RetryPolicy; +use Justpilot\Billomat\Exception\BillomatException; +use Justpilot\Billomat\Http\BillomatHttpClient; +use RuntimeException; +use Symfony\Component\Dotenv\Dotenv; +use Symfony\Component\HttpClient\Psr18Client; + +use const JSON_PRETTY_PRINT; +use const JSON_THROW_ON_ERROR; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +const PROJECT_ROOT = __DIR__.'/..'; +const FIXTURE_DIR = PROJECT_ROOT.'/tests/Fixtures/Contract'; + +// Obergrenze pro Lauf; das Sandbox-Limit liegt bei 1000 Requests / 15 Minuten. +const MAX_REQUESTS = 150; + +// Einträge pro Listen-Request: klein halten, aber genug für Varianten. +const PER_PAGE = 3; + +/* + * Ressourcen mit eigenem Endpunkt. `get` = Einzelabruf des ersten Listeneintrags, + * `children` = Sub-Ressourcen, die über `{parent}_id` am ersten Eintrag hängen. + * `list: false` = Endpunkt liefert ein einzelnes Objekt (Settings). + */ +const RESOURCES = [ + 'clients' => ['key' => 'client', 'get' => true, 'children' => ['contacts' => 'contact', 'client-tags' => 'tag']], + 'client-properties' => ['key' => 'client-property', 'get' => true], + 'client-property-values' => ['key' => 'client-property-value', 'get' => true], + 'suppliers' => ['key' => 'supplier', 'get' => true, 'children' => ['supplier-tags' => 'tag']], + 'supplier-properties' => ['key' => 'supplier-property', 'get' => true], + 'supplier-property-values' => ['key' => 'supplier-property-value', 'get' => true], + 'articles' => ['key' => 'article', 'get' => true, 'children' => ['article-tags' => 'tag']], + 'article-properties' => ['key' => 'article-property', 'get' => true], + 'article-property-values' => ['key' => 'article-property-value', 'get' => true], + 'units' => ['key' => 'unit', 'get' => true], + 'taxes' => ['key' => 'tax', 'get' => true], + 'invoices' => ['key' => 'invoice', 'get' => true, 'children' => ['invoice-items' => 'invoice-item', 'invoice-comments' => 'invoice-comment', 'invoice-tags' => 'tag']], + 'invoice-payments' => ['key' => 'invoice-payment', 'get' => true], + 'offers' => ['key' => 'offer', 'get' => true, 'children' => ['offer-items' => 'offer-item', 'offer-comments' => 'offer-comment', 'offer-tags' => 'tag']], + 'confirmations' => ['key' => 'confirmation', 'get' => true, 'children' => ['confirmation-items' => 'confirmation-item', 'confirmation-comments' => 'confirmation-comment', 'confirmation-tags' => 'tag']], + 'credit-notes' => ['key' => 'credit-note', 'get' => true, 'children' => ['credit-note-items' => 'credit-note-item', 'credit-note-comments' => 'credit-note-comment', 'credit-note-tags' => 'tag']], + 'credit-note-payments' => ['key' => 'credit-note-payment', 'get' => true], + 'delivery-notes' => ['key' => 'delivery-note', 'get' => true, 'children' => ['delivery-note-items' => 'delivery-note-item', 'delivery-note-comments' => 'delivery-note-comment', 'delivery-note-tags' => 'tag']], + 'reminders' => ['key' => 'reminder', 'get' => true, 'children' => ['reminder-items' => 'reminder-item', 'reminder-tags' => 'tag']], + 'letters' => ['key' => 'letter', 'get' => true, 'children' => ['letter-comments' => 'letter-comment', 'letter-tags' => 'tag']], + 'recurrings' => ['key' => 'recurring', 'get' => true, 'children' => ['recurring-items' => 'recurring-item', 'recurring-tags' => 'tag', 'recurring-email-receivers' => 'recurring-email-receiver']], + 'incomings' => ['key' => 'incoming', 'get' => true, 'children' => ['incoming-comments' => 'incoming-comment', 'incoming-tags' => 'tag']], + 'incoming-payments' => ['key' => 'incoming-payment', 'get' => true], + 'incoming-properties' => ['key' => 'incoming-property', 'get' => true], + 'incoming-property-values' => ['key' => 'incoming-property-value', 'get' => true], + 'incoming-categories' => ['key' => 'incoming-category', 'get' => true], + 'inbox-documents' => ['key' => 'inbox-document', 'get' => true], + 'templates' => ['key' => 'template', 'get' => true], + 'free-texts' => ['key' => 'free-text', 'get' => true], + 'email-templates' => ['key' => 'email-template', 'get' => true], + 'reminder-texts' => ['key' => 'reminder-text', 'get' => true], + 'country-taxes' => ['key' => 'country-tax', 'get' => true], + 'countries' => ['key' => 'country', 'get' => false], + 'currencies' => ['key' => 'currency', 'get' => false], + 'users' => ['key' => 'user', 'get' => true], + 'user-properties' => ['key' => 'user-property', 'get' => true], + 'roles' => ['key' => 'role', 'get' => true], + 'activity-feed' => ['key' => 'activity', 'get' => false], + 'settings' => ['key' => 'settings', 'list' => false], +]; + +/* + * Feldnamen → Art des Ersatzwerts. Gilt auf jeder Ebene (auch in Positionen, + * Steuerzeilen und Einstellungen); nicht aufgeführte Strings durchlaufen nur + * den allgemeinen Scan nach E-Mails, IBANs, URLs und der Billomat-ID. + */ +const FIELD_KINDS = [ + 'name' => 'name', 'company' => 'company', 'first_name' => 'first_name', 'last_name' => 'last_name', + 'firstname' => 'first_name', 'lastname' => 'last_name', 'bank_account_owner' => 'name', + 'email' => 'email', 'bcc_addresses' => 'email', 'sender' => 'email', 'sender_email' => 'email', + 'reply_to' => 'email', 'email_bcc' => 'email', 'email_sender' => 'email', 'bcc' => 'email', + 'phone' => 'phone', 'fax' => 'phone', 'mobile' => 'phone', + 'street' => 'street', 'zip' => 'zip', 'city' => 'city', 'state' => 'state', 'address' => 'address', + 'www' => 'url', 'url' => 'url', 'website' => 'url', 'avatar' => 'url', 'public_url' => 'url', + 'bank_name' => 'bank_name', 'bank_iban' => 'iban', 'iban' => 'iban', 'bank_swift' => 'bic', 'bic' => 'bic', 'swift' => 'bic', + 'bank_account_number' => 'digits', 'bank_number' => 'digits', 'creditor_identifier' => 'creditor_id', 'sepa_mandate' => 'text', + 'tax_number' => 'tax_number', 'vat_number' => 'vat_number', + 'note' => 'text', 'notes' => 'text', 'intro' => 'text', 'description' => 'text', 'comment' => 'text', 'text' => 'text', + 'subject' => 'text', 'body' => 'text', 'title' => 'text', 'label' => 'text', 'message' => 'text', 'signature' => 'text', + 'customfield' => 'text', 'value' => 'text', 'reference' => 'text', 'filename' => 'filename', 'file_name' => 'filename', + 'base64file' => 'base64', 'file' => 'base64', 'pdf' => 'base64', 'document' => 'base64', +]; + +/** Wie FIELD_KINDS, aber nach Namensendung (z. B. `offer_intro`, `default_email_sender`). */ +const FIELD_SUFFIX_KINDS = [ + '_sender' => 'sender', '_email' => 'email', '_intro' => 'text', '_note' => 'text', '_text' => 'text', + '_subject' => 'text', '_signature' => 'text', '_phone' => 'phone', '_fax' => 'phone', +]; + +/** Platzhalter-PDF für Base64-Felder (Inhalt der Sandbox-Dokumente geht nie ins Repo). */ +const FAKE_PDF = "%PDF-1.4\n% anonymisiert\n%%EOF\n"; + +try { + main($argv); +} catch (RuntimeException $e) { + fwrite(STDERR, 'Fehler: '.$e->getMessage()."\n"); + exit(1); +} + +/** + * @param list $argv + */ +function main(array $argv): void +{ + require PROJECT_ROOT.'/vendor/autoload.php'; + + $cacheDir = null; + foreach (\array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--cache=')) { + $cacheDir = rtrim(substr($arg, 8), '/'); + } else { + throw new RuntimeException("Unbekannte Option {$arg}"); + } + } + + $dotenv = new Dotenv(); + $dotenv->usePutenv(); + $dotenv->load(PROJECT_ROOT.'/.env.test'); + if (is_file(PROJECT_ROOT.'/.env.test.local')) { + $dotenv->overload(PROJECT_ROOT.'/.env.test.local'); + } + + $billomatId = (string) getenv('BILLOMAT_ID'); + $apiKey = (string) getenv('BILLOMAT_API_KEY'); + if ('' === $billomatId || '' === $apiKey) { + throw new RuntimeException('BILLOMAT_ID und BILLOMAT_API_KEY fehlen (.env.test.local).'); + } + + $config = new BillomatConfig( + billomatId: $billomatId, + apiKey: $apiKey, + appId: ((string) getenv('BILLOMAT_APP_ID')) ?: null, + appSecret: ((string) getenv('BILLOMAT_APP_SECRET')) ?: null, + timeout: 30.0, + retry: new RetryPolicy(maxRetries: 0), + ); + $recorder = new Recorder(new BillomatHttpClient(new Psr18Client(), $config), $cacheDir); + $anonymizer = new Anonymizer($billomatId); + + foreach (RESOURCES as $resource => $definition) { + if (false === ($definition['list'] ?? true)) { + $body = $recorder->fetch("/{$resource}"); + if (null !== $body) { + write($resource, 'get', $anonymizer->anonymize($body)); + } + + continue; + } + + $list = $recorder->fetch("/{$resource}", ['per_page' => PER_PAGE]); + if (null === $list) { + continue; + } + + write($resource, 'list', $anonymizer->anonymize($list)); + $id = firstId($list, $resource, $definition['key']); + if (null === $id) { + echo " {$resource}: keine Daten, kein Einzelabruf\n"; + + continue; + } + + if ($definition['get']) { + $single = $recorder->fetch("/{$resource}/{$id}"); + if (null !== $single) { + write($resource, 'get', $anonymizer->anonymize($single)); + } + } + + $parentKey = str_replace('-', '_', $definition['key']).'_id'; + foreach ($definition['children'] ?? [] as $child => $childKey) { + $childList = $recorder->fetch("/{$child}", [$parentKey => $id, 'per_page' => PER_PAGE]); + if (null === $childList) { + continue; + } + + write($child, 'list', $anonymizer->anonymize($childList)); + $childId = firstId($childList, $child, $childKey); + if (null !== $childId && 'tag' !== $childKey) { + $childSingle = $recorder->fetch("/{$child}/{$childId}"); + if (null !== $childSingle) { + write($child, 'get', $anonymizer->anonymize($childSingle)); + } + } + } + } + + echo "Fertig: {$recorder->requests} Requests.\n"; +} + +/** + * ID des ersten Eintrags einer Liste (`{ "clients": { "client": [...] | {...} } }`). + * + * @param array $body + */ +function firstId(array $body, string $listKey, string $key): ?string +{ + $inner = $body[$listKey][$key] ?? null; + if (!\is_array($inner) || [] === $inner) { + return null; + } + + $first = array_is_list($inner) ? $inner[0] : $inner; + $id = \is_array($first) ? ($first['id'] ?? null) : null; + + return \is_string($id) || \is_int($id) ? (string) $id : null; +} + +/** + * @param array $body + */ +function write(string $resource, string $kind, array $body): void +{ + $dir = FIXTURE_DIR.'/'.$resource; + if (!is_dir($dir) && !mkdir($dir, 0o777, true)) { + throw new RuntimeException("Kann {$dir} nicht anlegen"); + } + + $json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + file_put_contents("{$dir}/{$kind}.json", $json."\n"); + echo " {$resource}/{$kind}.json\n"; +} + +/** + * Führt die GETs aus, zählt sie und speichert Roh-Antworten optional zwischen. + */ +final class Recorder +{ + public int $requests = 0; + + public function __construct( + private readonly BillomatHttpClient $http, + private readonly ?string $cacheDir, + ) { + } + + /** + * @param array $query + * + * @return array|null null bei Fehlern (404, fehlende Berechtigung, …) oder leerem Body + */ + public function fetch(string $path, array $query = []): ?array + { + $cacheFile = null; + if (null !== $this->cacheDir) { + $cacheFile = $this->cacheDir.'/'.trim(str_replace('/', '_', $path), '_').([] === $query ? '' : '_'.md5(serialize($query))).'.json'; + if (is_file($cacheFile)) { + return self::decode((string) file_get_contents($cacheFile)); + } + } + + if ($this->requests >= MAX_REQUESTS) { + throw new RuntimeException('Request-Budget von '.MAX_REQUESTS.' erschöpft'); + } + + ++$this->requests; + + try { + $body = (string) $this->http->request('GET', $path, $query)->getBody(); + } catch (BillomatException $e) { + // Nur Klasse und Status ausgeben; die Message kann Kundendaten enthalten. + echo " {$path}: übersprungen (".new \ReflectionClass($e)->getShortName().")\n"; + + return null; + } + + if (null !== $cacheFile) { + if (!is_dir($this->cacheDir) && !mkdir($this->cacheDir, 0o700, true)) { + throw new RuntimeException("Kann {$this->cacheDir} nicht anlegen"); + } + file_put_contents($cacheFile, $body); + } + + return self::decode($body); + } + + /** + * @return array|null + */ + private static function decode(string $body): ?array + { + if ('' === trim($body)) { + return null; + } + + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + return \is_array($data) ? $data : null; + } +} + +/** + * Ersetzt personenbezogene Werte deterministisch durch Platzhalter gleichen Formats. + * + * Gleicher Originalwert → gleicher Ersatz (pro Art), damit Bezüge innerhalb der + * Fixtures erhalten bleiben und ein erneuter Lauf stabile Diffs liefert. + */ +final class Anonymizer +{ + /** @var array> */ + private array $seen = []; + + public function __construct(private readonly string $billomatId) + { + } + + /** + * @param array $data + * + * @return array + */ + public function anonymize(array $data): array + { + $result = []; + foreach ($data as $key => $value) { + $result[$key] = match (true) { + \is_array($value) => $this->anonymize($value), + \is_string($value) => $this->value((string) $key, $value), + default => $value, + }; + } + + return $result; + } + + private function value(string $key, string $value): string + { + if ('' === trim($value)) { + return $value; + } + + $kind = FIELD_KINDS[$key] ?? self::kindBySuffix($key); + // Numerische Freitext-Werte (z. B. Checkbox-Attribute "1") tragen keine Personendaten. + if (null !== $kind && !('text' === $kind && is_numeric($value))) { + return $this->fake($kind, $value); + } + + return $this->scan($value); + } + + /** + * Einstellungen tragen viele Varianten (`invoice_intro`, `default_email_sender`, …). + */ + private static function kindBySuffix(string $key): ?string + { + foreach (FIELD_SUFFIX_KINDS as $suffix => $kind) { + if (str_ends_with($key, $suffix)) { + return $kind; + } + } + + return null; + } + + /** + * Allgemeiner Scan für Felder ohne feste Regel. + */ + private function scan(string $value): string + { + $value = (string) preg_replace_callback( + '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', + fn (array $m): string => $this->fake('email', $m[0]), + $value, + ); + $value = (string) preg_replace_callback( + '/\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/', + fn (array $m): string => $this->fake('iban', $m[0]), + $value, + ); + $value = (string) preg_replace_callback( + '#https?://[^\s"<>]+#i', + fn (array $m): string => $this->fake('url', $m[0]), + $value, + ); + + return str_ireplace($this->billomatId, 'mycompany', $value); + } + + private function fake(string $kind, string $original): string + { + if ('email' === $kind && str_contains($original, ',')) { + return implode(',', array_map(fn (string $part): string => '' === trim($part) ? $part : $this->fake('email', trim($part)), explode(',', $original))); + } + + $n = $this->seen[$kind][$original] ??= (string) (\count($this->seen[$kind] ?? []) + 1); + $i = (int) $n; + + return match ($kind) { + 'name' => "Name {$n}", + 'company' => "Firma {$n} GmbH", + 'first_name' => "Vorname{$n}", + 'last_name' => "Nachname{$n}", + 'email' => "kontakt{$n}@example.com", + 'sender' => "Firma {$n} GmbH ", + 'phone' => \sprintf('+49 30 %07d', $i), + 'street' => "Musterstraße {$n}", + 'zip' => \sprintf('%05d', 10000 + $i), + 'city' => "Musterstadt {$n}", + 'state' => "Bundesland {$n}", + 'address' => "Firma {$n} GmbH\nMusterstraße {$n}\n".\sprintf('%05d', 10000 + $i)." Musterstadt {$n}", + 'url' => "https://www.example.com/{$n}", + 'bank_name' => "Testbank {$n}", + 'iban' => \sprintf('DE00%018d', $i), + 'bic' => 'TESTDEFFXXX', + 'digits' => \sprintf('%0'.max(1, \strlen(preg_replace('/\D/', '', $original) ?? '')).'d', $i), + 'creditor_id' => \sprintf('DE00ZZZ%011d', $i), + 'tax_number' => \sprintf('000/000/%05d', $i), + 'vat_number' => \sprintf('DE%09d', $i), + 'filename' => "dokument-{$n}".(preg_match('/\.[a-z0-9]{2,4}$/i', $original, $m) ? strtolower($m[0]) : ''), + 'base64' => base64_encode(FAKE_PDF), + default => "Text {$n}", + }; + } +}