diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 448b004..5b38364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,9 @@ jobs: - name: Generated code up to date run: composer generate:check + - name: JSON examples in the docs up to date + run: composer docs:json:check + - name: Composer audit run: composer audit diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 61c41b3..eec1c5e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,10 +2,10 @@ name: Docs on: pull_request: - paths: ['docs/**', '.github/workflows/docs.yml'] + paths: ['docs/**', 'tests/Fixtures/Contract/**', '.github/workflows/docs.yml'] push: branches: [4.x] - paths: ['docs/**', '.github/workflows/docs.yml'] + paths: ['docs/**', 'tests/Fixtures/Contract/**', '.github/workflows/docs.yml'] workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index 1852698..e354b7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,9 @@ composer mutate # Infection mutation testing (~3 min, all of src composer audit # Security audit of dependencies composer generate # Regenerate models/options from docs/spec/billomat.json + tools/codegen/definitions/ composer generate:check # Fail if generated files under src/ are out of date -composer ci # Full local CI: lint + analyse + refactor:dry + generate:check + test:all +composer docs:json # Regenerate the "JSON on the wire" sections of docs/resources/ (requests via the SDK, responses from tests/Fixtures/Contract) +composer docs:json:check # Fail if those JSON examples are out of date +composer ci # Full local CI: lint + analyse + refactor:dry + generate:check + docs:json:check + test:all ``` Single test file/method: @@ -44,7 +46,7 @@ CI runs via `.github/workflows/ci.yml` on PHP 8.5 (required) and 8.6 (experiment ### Documentation website -`docs/` is also a VitePress site (`docs/.vitepress/config.mts`, `docs/package.json`, `cd docs && npm ci && npm run build`), deployed by `.github/workflows/docs.yml` to https://billomat-php.justpilot.io (`docs/public/CNAME`). The sidebar is built from the first `# ` heading of each page; `docs/spec/`, `docs/audit.md` and `docs/roadmap-v4.md` are excluded. Relative links to files outside `docs/` are rewritten to GitHub. Dead links fail the build. +`docs/` is also a VitePress site (`docs/.vitepress/config.mts`, `docs/package.json`, `cd docs && npm ci && npm run build`), deployed by `.github/workflows/docs.yml` to https://billomat-php.justpilot.io (`docs/public/CNAME`). The sidebar is built from the first `# ` heading of each page; `docs/spec/`, `docs/audit.md` and `docs/roadmap-v4.md` are excluded. Relative links to files outside `docs/` are rewritten to GitHub. Dead links fail the build. The "JSON on the wire" sections between the `json-examples` markers are generated by `tools/generate-json-examples.php` (request bodies captured from the SDK into `docs/snippets/json/`, responses imported from the contract fixtures); never edit them by hand, add a request or page in the tool and run `composer docs:json`. ## Architecture diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa1e0b..dbac64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - **Missing actions:** `mail()` (Pixelletter) on offers, confirmations, credit notes, delivery notes, letters and reminders; `recurrings->stop()`; `recurrings->emailReceivers->update()` with `RecurringEmailReceiverUpdateOptions`; `contacts->avatar()` and `users->avatar()`. - **`listPage()` and `iterateAll()`** on `contacts` (with the client ID as the first argument), `taxes`, `templates` and `reminderTexts`. `countries->list()` and `currencies->list()` accept filters, e.g. `list(['code' => 'DE'])`. - **Backward compatibility promise:** [docs/concepts/backward-compatibility.md](docs/concepts/backward-compatibility.md) says what is covered from 4.0.0-beta.1 on. `AbstractApi`, `AbstractSubResourceApi`, the sub-resource API constructors and the `FromApi` trait are marked `@internal`. +- **JSON examples in the docs:** the resource pages have a "JSON on the wire" section with the request body the SDK sends (captured from the SDK, kept up to date by `composer docs:json:check` in CI) and real, anonymized sandbox responses from the contract fixtures. Billomat's own API docs only show XML. - `Page` is covariant in its item type, so a `Page` can be passed where a `Page` is expected. ### Changed diff --git a/composer.json b/composer.json index b627e28..94e7fac 100644 --- a/composer.json +++ b/composer.json @@ -115,12 +115,15 @@ "@analyse", "@refactor:dry", "@generate:check", + "@docs:json:check", "@test:all" ], "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", + "docs:json": "php tools/generate-json-examples.php", + "docs:json:check": "php tools/generate-json-examples.php --check", "fixtures:record": [ "Composer\\Config::disableProcessTimeout", "php tools/record-fixtures.php" @@ -143,6 +146,8 @@ "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", + "docs:json": "Schreibt die JSON-Beispiele der Ressourcen-Doku (docs/snippets/json/ und die Abschnitte „JSON on the wire“)", + "docs:json:check": "Prüft, ob die JSON-Beispiele der Doku aktuell sind", "fixtures:record": "Nimmt anonymisierte Contract-Fixtures per GET gegen die Billomat-Sandbox auf (benötigt .env.test.local)" } } diff --git a/docs/resources/activities.md b/docs/resources/activities.md index cbb313c..746613f 100644 --- a/docs/resources/activities.md +++ b/docs/resources/activities.md @@ -75,6 +75,16 @@ foreach ($billomat->activities->iterateAll() as $activity) { Helper `isSystemActivity(): bool` returns `true` when `userId` is `null`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/activity-feed/list.json [GET /activity-feed (response)] +::: + + ## Pitfalls - **`user_id` can be an empty string.** For system activities Billomat sends ``, which is `""` in JSON. `Activity::fromArray()` normalizes it to `null` via `ScalarCaster::toIntOrNull()`. diff --git a/docs/resources/articles.md b/docs/resources/articles.md index db3118f..2f27967 100644 --- a/docs/resources/articles.md +++ b/docs/resources/articles.md @@ -295,6 +295,16 @@ Both values are required and set via the constructor. - [`NetGross`](../../src/Model/Enum/NetGross.php): `NET`, `GROSS`, `SETTINGS` (`purchasePriceNetGross`). - [`PropertyType`](../../src/Model/Enum/PropertyType.php): `TEXTFIELD`, `TEXTAREA`, `CHECKBOX`, on the property definition ([`ArticleProperty`](../../src/Model/ArticleProperty.php)) and on `ArticlePropertyValue::$type`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/articles.create.json [POST /articles] +::: + + ## Pitfalls - **No `update()` for tags and property values.** Delete tags and create them again; for property values, another POST with the same `article_property_id` overwrites the existing entry. diff --git a/docs/resources/client-tags.md b/docs/resources/client-tags.md index a23142e..7978325 100644 --- a/docs/resources/client-tags.md +++ b/docs/resources/client-tags.md @@ -123,6 +123,16 @@ Constructor: `new ClientTagCreateOptions(string $name)`. The object is `final re None. `name` is free text. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/client-tags.create.json [POST /client-tags] +::: + + ## Pitfalls - **Two views, two models.** `list()` returns `ClientTag`, `cloud()` returns `ClientTagCloudEntry`. Mixing them up leads to "why doesn't this thing have a `clientId`?" moments. diff --git a/docs/resources/clients.md b/docs/resources/clients.md index 5ce6e0a..77a3ca5 100644 --- a/docs/resources/clients.md +++ b/docs/resources/clients.md @@ -386,6 +386,18 @@ The client ID isn't part of the options: pass it as the first argument of `creat - [`PaymentType`](../../src/Model/Enum/PaymentType.php): `INVOICE_CORRECTION`, `CREDIT_NOTE`, `BANK_CARD`, `BANK_TRANSFER`, `DEBIT`, `CASH`, `CHECK`, `PAYPAL`, `CREDIT_CARD`, `COUPON`, `MISC` (`defaultPaymentTypes`) - [`PropertyType`](../../src/Model/Enum/PropertyType.php): `TEXTFIELD`, `TEXTAREA`, `CHECKBOX` (`ClientPropertyValue::$type`) + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/clients.create.json [POST /clients] +<<< @/../tests/Fixtures/Contract/clients/get.json [GET /clients/{id} (response)] +<<< @/../tests/Fixtures/Contract/clients/list.json [GET /clients (response)] +::: + + ## Pitfalls - **E-invoicing (undocumented).** Billomat returns `facturx_identifier` and `seller_identifier` in API responses but doesn't describe them in the API docs. The SDK only reads them (types derived from observed values); writing isn't supported for lack of a specification. diff --git a/docs/resources/confirmations.md b/docs/resources/confirmations.md index 414683b..04156ba 100644 --- a/docs/resources/confirmations.md +++ b/docs/resources/confirmations.md @@ -427,6 +427,18 @@ Shared class for sending every document type by email (invoices, offers, confirm - [`PdfType`](../../src/Model/Enum/PdfType.php): `SIGNED` (`signed`), `PRINT` (`print`); the wire values are lowercase. - [`NetGross`](../../src/Model/Enum/NetGross.php): `NET`, `GROSS`, `SETTINGS`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/confirmations.create.json [POST /confirmations] +<<< @/../tests/Fixtures/Contract/confirmations/get.json [GET /confirmations/{id} (response)] +<<< @/../tests/Fixtures/Contract/confirmations/list.json [GET /confirmations (response)] +::: + + ## Pitfalls - **Simpler lifecycle than offers.** There is no `win()`/`lose()`: a confirmation is either marked as done (`clear()`, reverted by `unclear()`) or canceled (`cancel()`, reverted by `uncancel()`). Calling `win()` out of habit means looking for the wrong endpoint. diff --git a/docs/resources/contacts.md b/docs/resources/contacts.md index 0dfd79b..2dc77b8 100644 --- a/docs/resources/contacts.md +++ b/docs/resources/contacts.md @@ -164,6 +164,16 @@ Same fields as `ContactCreateOptions`, **without** `clientId`: Billomat doesn't None. All fields are strings, including `salutation` and `countryCode`. Billomat expects the latter as an ISO 3166-1 alpha-2 code (validated server-side). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/contacts.create.json [POST /contacts] +::: + + ## Pitfalls - **`list()` requires a filter.** Billomat's `GET /contacts` without `client_id` returns an error, not a global list. The SDK makes the client ID a required argument. diff --git a/docs/resources/countries.md b/docs/resources/countries.md index 190666b..17290a1 100644 --- a/docs/resources/countries.md +++ b/docs/resources/countries.md @@ -64,3 +64,13 @@ Lazy iteration over all pages. | `extra` | `array` | Fields the SDK doesn't map yet, raw (see [Read models](../concepts/models.md)) | `Country::fromArray($data)` hydrates from the wire format; `Country::toArray()` writes snake_case back. + + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/countries/list.json [GET /countries (response)] +::: + diff --git a/docs/resources/credit-notes.md b/docs/resources/credit-notes.md index 27b50e1..da690ae 100644 --- a/docs/resources/credit-notes.md +++ b/docs/resources/credit-notes.md @@ -508,6 +508,16 @@ Empty recipient arrays are left out of the payload so Billomat falls back to the - [`PaymentType`](../../src/Model/Enum/PaymentType.php): shared payment types (`BANK_TRANSFER`, `PAYPAL`, `CASH`, …). - [`PdfType`](../../src/Model/Enum/PdfType.php): `pdf()` accepts the same PDF variants as for invoices. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/credit-notes.create.json [POST /credit-notes] +::: + + ## Pitfalls - **`delete()` isn't limited to `DRAFT`.** Billomat's API deletes credit notes in any status. Once completed, prefer `cancel()`: the document is numbered, and deleting it leaves a gap in the number sequence (GoBD). diff --git a/docs/resources/currencies.md b/docs/resources/currencies.md index 5125564..c27c968 100644 --- a/docs/resources/currencies.md +++ b/docs/resources/currencies.md @@ -65,3 +65,13 @@ Lazy iteration over all pages. | `extra` | `array` | Fields the SDK doesn't map yet, raw (see [Read models](../concepts/models.md)) | `Currency::fromArray($data)` hydrates from the wire format; `Currency::toArray()` writes snake_case back. + + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/currencies/list.json [GET /currencies (response)] +::: + diff --git a/docs/resources/delivery-notes.md b/docs/resources/delivery-notes.md index c1d6254..c9c37fd 100644 --- a/docs/resources/delivery-notes.md +++ b/docs/resources/delivery-notes.md @@ -396,6 +396,18 @@ Shared class for sending every document type by email (invoices, offers, confirm - [`PdfType`](../../src/Model/Enum/PdfType.php): `SIGNED` (`signed`), `PRINT` (`print`); the wire values are lowercase. - [`NetGross`](../../src/Model/Enum/NetGross.php): `NET`, `GROSS`, `SETTINGS`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/delivery-notes.create.json [POST /delivery-notes] +<<< @/../tests/Fixtures/Contract/delivery-notes/get.json [GET /delivery-notes/{id} (response)] +<<< @/../tests/Fixtures/Contract/delivery-notes/list.json [GET /delivery-notes (response)] +::: + + ## Pitfalls - **A delivery note carries little monetary data.** Billomat returns no total or price fields in the read model, and the document creates no receivable. If you expect a payment, you want `$billomat->invoices` or `$billomat->incomings`. diff --git a/docs/resources/inbox-documents.md b/docs/resources/inbox-documents.md index 231006f..c8da6b8 100644 --- a/docs/resources/inbox-documents.md +++ b/docs/resources/inbox-documents.md @@ -119,6 +119,16 @@ Plus the helper `getBinary(): string`, which decodes `base64file` and returns th None. The resource has no status enum. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/inbox-documents.create.json [POST /inbox-documents] +::: + + ## Pitfalls - **E-invoicing (undocumented).** Billomat returns `xrechnung_extractions_id` (`InboxDocument::$xrechnungExtractionsId`) in API responses but doesn't describe it in the API docs. The SDK only reads it (type derived from observed values); writing isn't supported for lack of a specification. diff --git a/docs/resources/incoming-categories.md b/docs/resources/incoming-categories.md index 408f195..baa7dfe 100644 --- a/docs/resources/incoming-categories.md +++ b/docs/resources/incoming-categories.md @@ -50,6 +50,17 @@ Note: the ID is a **string** slug (e.g. `goods`, `services`), not a numeric key. | `description` | `description` | `?string` | | `extra` | — | `array` (unmapped fields, see [Models](../concepts/models.md)) | + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/incoming-categories/get.json [GET /incoming-categories/{id} (response)] +<<< @/../tests/Fixtures/Contract/incoming-categories/list.json [GET /incoming-categories (response)] +::: + + ## Pitfalls - **String ID.** Unlike most Billomat resources, categories are identified by a slug (`goods`, `services`, …) instead of an integer. diff --git a/docs/resources/incomings.md b/docs/resources/incomings.md index cafc5fa..58ec747 100644 --- a/docs/resources/incomings.md +++ b/docs/resources/incomings.md @@ -428,6 +428,16 @@ Constructor: `new IncomingPropertyValueCreateOptions(int $incomingPropertyId, mi - [`PaymentType`](../../src/Model/Enum/PaymentType.php): enum shared with invoices (`BANK_TRANSFER`, `CASH`, `PAYPAL`, …). - [`PropertyType`](../../src/Model/Enum/PropertyType.php): data type of the property definition (`IncomingProperty`) and of `IncomingPropertyValue::$type`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/incomings.create.json [POST /incomings] +::: + + ## Pitfalls - **`upload()` doesn't encode.** The string must already be Base64-encoded; passing `file_get_contents()` directly results in broken PDFs on the server. Always use `base64_encode(file_get_contents(...))`. The same applies to the `base64file` option. diff --git a/docs/resources/invoice-comments.md b/docs/resources/invoice-comments.md index 02bbaa7..71b8c4d 100644 --- a/docs/resources/invoice-comments.md +++ b/docs/resources/invoice-comments.md @@ -136,6 +136,18 @@ Constructor: `new InvoiceCommentCreateOptions(string $comment, …)`. The object Billomat doesn't document a closed list. If new values show up, the SDK parses `actionkey` to `null` and fills `actionkeyRaw` with the original string, so nothing is lost. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/invoice-comments.create.json [POST /invoice-comments] +<<< @/../tests/Fixtures/Contract/invoice-comments/get.json [GET /invoice-comments/{id} (response)] +<<< @/../tests/Fixtures/Contract/invoice-comments/list.json [GET /invoice-comments (response)] +::: + + ## Pitfalls - **`invoice_id` is required.** Without the filter Billomat responds with 400; `list()` sets it automatically. diff --git a/docs/resources/invoice-items.md b/docs/resources/invoice-items.md index 6978b38..3a80704 100644 --- a/docs/resources/invoice-items.md +++ b/docs/resources/invoice-items.md @@ -135,6 +135,18 @@ Same fields as `InvoiceItemCreateOptions`, but all of them optional: `new Invoic - [`ItemType`](../../src/Model/Enum/ItemType.php): `PRODUCT`, `SERVICE`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/invoice-items.create.json [POST /invoice-items] +<<< @/../tests/Fixtures/Contract/invoice-items/get.json [GET /invoice-items/{id} (response)] +<<< @/../tests/Fixtures/Contract/invoice-items/list.json [GET /invoice-items (response)] +::: + + ## Pitfalls - **Changing the tax rate manually.** If the item should differ from the client's/article's default tax rate, set `taxRate` **and** `taxChangedManually: true`. Without the flag, Billomat often ignores the manual `taxRate`. diff --git a/docs/resources/invoice-payments.md b/docs/resources/invoice-payments.md index 1bf1aba..f77c8a3 100644 --- a/docs/resources/invoice-payments.md +++ b/docs/resources/invoice-payments.md @@ -145,6 +145,18 @@ The invoice ID isn't part of the options; `create()` takes it as an argument. `t The same enum is used for the accepted payment types of documents (`paymentTypes`, `list`). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/invoice-payments.create.json [POST /invoice-payments] +<<< @/../tests/Fixtures/Contract/invoice-payments/get.json [GET /invoice-payments/{id} (response)] +<<< @/../tests/Fixtures/Contract/invoice-payments/list.json [GET /invoice-payments (response)] +::: + + ## Pitfalls - **No updates.** A payment can't be changed, only deleted and created again. diff --git a/docs/resources/invoice-tags.md b/docs/resources/invoice-tags.md index a719c77..a44146a 100644 --- a/docs/resources/invoice-tags.md +++ b/docs/resources/invoice-tags.md @@ -119,6 +119,16 @@ Constructor: `new InvoiceTagCreateOptions(string $name)`. The object is immutabl | `count` | `int` | | `extra` | `array` | + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/invoice-tags.create.json [POST /invoice-tags] +::: + + ## Pitfalls - **Two views, two models.** `list()` returns `InvoiceTag`, `cloud()` returns `InvoiceTagCloudEntry`. Mixing them up leads to "why doesn't this have an `invoiceId`?" moments. diff --git a/docs/resources/invoices.md b/docs/resources/invoices.md index aec9442..190045b 100644 --- a/docs/resources/invoices.md +++ b/docs/resources/invoices.md @@ -445,6 +445,18 @@ Payload for `mail()` (Pixelletter), shared by all document types (invoices, offe - [`NetGross`](../../src/Model/Enum/NetGross.php): `NET`, `GROSS`, `SETTINGS`. - [`SupplyDateType`](../../src/Model/Enum/SupplyDateType.php): `SUPPLY_DATE`, `DELIVERY_DATE`, `SUPPLY_TEXT`, `DELIVERY_TEXT`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/invoices.create.json [POST /invoices] +<<< @/../tests/Fixtures/Contract/invoices/get.json [GET /invoices/{id} (response)] +<<< @/../tests/Fixtures/Contract/invoices/list.json [GET /invoices (response)] +::: + + ## Pitfalls - **E-invoice (undocumented).** Billomat returns `facturx_reference` (`Invoice::$facturxReference`) in API responses but doesn't describe it in the API docs. The SDK only reads it (types derived from observed values); writing it is not supported because there is no specification. diff --git a/docs/resources/letters.md b/docs/resources/letters.md index 4b4ce1b..0d5c17e 100644 --- a/docs/resources/letters.md +++ b/docs/resources/letters.md @@ -342,6 +342,16 @@ Empty recipient arrays are left out, so Billomat falls back to the template. - [`LetterCommentActionKey`](../../src/Model/Enum/LetterCommentActionKey.php): `CREATE`, `EDIT`, `OPEN`, `COMPLETE`, `CANCEL`, `CLEAR`, `CHANGE_STATUS`, `EMAIL`, `MAIL`, `COMMENT`, `UPLOAD`. The additional `UPLOAD` key marks a letter PDF uploaded via `upload()`. - [`PdfType`](../../src/Model/Enum/PdfType.php): shared PDF variants for `pdf()`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/letters.create.json [POST /letters] +::: + + ## Pitfalls - **`delete()` isn't limited to `DRAFT`.** Billomat's API deletes letters in any status, also completed ones. diff --git a/docs/resources/offers.md b/docs/resources/offers.md index 95c21b0..924d3a9 100644 --- a/docs/resources/offers.md +++ b/docs/resources/offers.md @@ -476,6 +476,16 @@ Shared class for sending all document types by email (invoices, offers, confirma - [`PdfType`](../../src/Model/Enum/PdfType.php): `SIGNED` (`signed`), `PRINT` (`print`); the wire values are lowercase. - [`NetGross`](../../src/Model/Enum/NetGross.php): `NET`, `GROSS`, `SETTINGS`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/offers.create.json [POST /offers] +::: + + ## Pitfalls - **Status transitions are strict.** `win()` only works from `OPEN`, `clear()` only from `WON` (not from `OPEN`). Trying them in the wrong status gets you a 400 → `ValidationException`. Order: first `complete()`, then `win()`, then `clear()`. diff --git a/docs/resources/properties.md b/docs/resources/properties.md index d77c370..7f6ce04 100644 --- a/docs/resources/properties.md +++ b/docs/resources/properties.md @@ -155,6 +155,18 @@ All five are `final readonly` classes with the same structure: - [`PropertyType`](../../src/Model/Enum/PropertyType.php): `TEXTFIELD`, `TEXTAREA`, `CHECKBOX`. Wire values are upper case (`TEXTFIELD`, not `textfield`). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/client-properties.create.json [POST /client-properties] +<<< @/../tests/Fixtures/Contract/client-properties/get.json [GET /client-properties/{id} (response)] +<<< @/../tests/Fixtures/Contract/client-properties/list.json [GET /client-properties (response)] +::: + + ## Pitfalls - **Don't confuse definitions and values.** `$billomat->articles->properties` only defines the schema ("there is a field called *Lead time*"). To assign the value `5 business days` to a specific article, use `$billomat->articles->propertyValues`. diff --git a/docs/resources/recurrings.md b/docs/resources/recurrings.md index 2790dbe..89966c6 100644 --- a/docs/resources/recurrings.md +++ b/docs/resources/recurrings.md @@ -374,6 +374,16 @@ Both values are required constructor parameters: `type` (`RecurringEmailReceiver - [`RecurringEmailReceiverType`](../../src/Model/Enum/RecurringEmailReceiverType.php): `TO`, `CC`, `BCC` with the wire values `To`/`Cc`/`Bcc`, as Billomat returns them. `fromApi()` also accepts other casing (the Billomat docs use lowercase). - [`PaymentType`](../../src/Model/Enum/PaymentType.php), [`NetGross`](../../src/Model/Enum/NetGross.php), [`SupplyDateType`](../../src/Model/Enum/SupplyDateType.php), [`ItemType`](../../src/Model/Enum/ItemType.php): see [Invoices](invoices.md#enums). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/recurrings.create.json [POST /recurrings] +::: + + ## Pitfalls - **Don't change items via `update()`.** Billomat ignores embedded `recurring-items` on a PUT to `/recurrings/{id}`. Always use `$billomat->recurrings->items`. diff --git a/docs/resources/reminders.md b/docs/resources/reminders.md index 9dd7047..9b5edfd 100644 --- a/docs/resources/reminders.md +++ b/docs/resources/reminders.md @@ -338,6 +338,16 @@ There is **no** `ReminderItemCreateOptions` and no `ReminderItemUpdateOptions`. There is **no** comment action key enum for reminders: reminders simply have no `/reminder-comments` resource. Activity is tracked in the comments of the underlying invoice. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/reminders.create.json [POST /reminders] +::: + + ## Pitfalls - **`delete()` isn't limited to `DRAFT`.** Billomat's API deletes reminders in any status. Once completed, prefer `cancel()`: the document is numbered, and deleting it leaves a gap in the number sequence (GoBD). diff --git a/docs/resources/settings-email-templates.md b/docs/resources/settings-email-templates.md index 24c7c2a..67c6f5c 100644 --- a/docs/resources/settings-email-templates.md +++ b/docs/resources/settings-email-templates.md @@ -89,6 +89,17 @@ Like `get()`, but throws `NotFoundException` instead of returning `null`. Unknown values become `null`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/email-templates/get.json [GET /email-templates/{id} (response)] +<<< @/../tests/Fixtures/Contract/email-templates/list.json [GET /email-templates (response)] +::: + + ## Pitfalls - **`isDefault` is unique per type.** Each document type has exactly one default template; setting a new default replaces the old one on the server. diff --git a/docs/resources/settings-free-texts.md b/docs/resources/settings-free-texts.md index 404a1b0..86cc335 100644 --- a/docs/resources/settings-free-texts.md +++ b/docs/resources/settings-free-texts.md @@ -98,6 +98,17 @@ All text fields accept Billomat placeholders, e.g.: The full list depends on the document type and is documented in the Billomat UI under *Einstellungen → Vorlagen* (Settings → Templates). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/free-texts/get.json [GET /free-texts/{id} (response)] +<<< @/../tests/Fixtures/Contract/free-texts/list.json [GET /free-texts (response)] +::: + + ## Pitfalls - **`is_default` is exclusive per type.** Each document type has exactly one default free text; setting a new default replaces the old one on the server. diff --git a/docs/resources/settings-roles.md b/docs/resources/settings-roles.md index b9083e8..ec1b562 100644 --- a/docs/resources/settings-roles.md +++ b/docs/resources/settings-roles.md @@ -81,6 +81,17 @@ Each permission is a `Justpilot\Billomat\Model\Enum\AccessLevel`: They are in `Role::AREAS`. `fromArray()` also accepts any key starting with `settings_`, so new settings permissions end up in `permissions` automatically if Billomat adds them. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/roles/get.json [GET /roles/{id} (response)] +<<< @/../tests/Fixtures/Contract/roles/list.json [GET /roles (response)] +::: + + ## Pitfalls - **The master role can't be deleted.** The system role `Master` (usually `id=1`) can't be removed, not even in the UI. diff --git a/docs/resources/settings.md b/docs/resources/settings.md index 45c2af7..c527ddd 100644 --- a/docs/resources/settings.md +++ b/docs/resources/settings.md @@ -217,6 +217,17 @@ Each document type has `xLabel`, `xIntro`, `xNote`, `xFilename`: - [`TemplateEngine`](../../src/Model/Enum/TemplateEngine.php): `DEFAULT`. - [`ValueType`](../../src/Model/Enum/ValueType.php): `SETTINGS`, `ABSOLUTE`, `RELATIVE` — not used in settings directly, but a related value type for clients/invoices. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/settings.update.json [PUT /settings] +<<< @/../tests/Fixtures/Contract/settings/get.json [GET /settings (response)] +::: + + ## Pitfalls - **Read-only fields can't be set.** Fields like `*NumberNext` or `created`/`updated` can't be changed via PUT, so `SettingsUpdateOptions` doesn't offer them at all. diff --git a/docs/resources/suppliers.md b/docs/resources/suppliers.md index 10bc514..e78bd35 100644 --- a/docs/resources/suppliers.md +++ b/docs/resources/suppliers.md @@ -299,6 +299,18 @@ Both values are required and set via the constructor. Suppliers have no other resource-specific enums; all fields on `Supplier` itself are plain strings (`countryCode`, `currencyCode`, …). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/suppliers.create.json [POST /suppliers] +<<< @/../tests/Fixtures/Contract/suppliers/get.json [GET /suppliers/{id} (response)] +<<< @/../tests/Fixtures/Contract/suppliers/list.json [GET /suppliers (response)] +::: + + ## Pitfalls - **No `update()` for tags and property values.** Delete tags and create them again; for property values, another POST with the same `supplier_property_id` overwrites the existing entry. diff --git a/docs/resources/taxes.md b/docs/resources/taxes.md index 65cdfcf..6b9bb74 100644 --- a/docs/resources/taxes.md +++ b/docs/resources/taxes.md @@ -100,6 +100,18 @@ Constructor: `new TaxRateCreateOptions(string $name, float $rate, bool $isDefaul None. `name` is free text and `rate` a `float`. Tax rates are percentages, not amounts, so they stay `float` rather than `Money`. + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/taxes.create.json [POST /taxes] +<<< @/../tests/Fixtures/Contract/taxes/get.json [GET /taxes/{id} (response)] +<<< @/../tests/Fixtures/Contract/taxes/list.json [GET /taxes (response)] +::: + + ## Pitfalls - **`isDefault = true` is exclusive.** When you make a tax rate the default, the others lose the flag automatically on the server. diff --git a/docs/resources/templates.md b/docs/resources/templates.md index 70864c0..0c77ddd 100644 --- a/docs/resources/templates.md +++ b/docs/resources/templates.md @@ -158,6 +158,18 @@ Constructor: `new TemplateCreateOptions(TemplateDocumentType $type, …)` — `t - [`TemplateEngine`](../../src/Model/Enum/TemplateEngine.php): `DEFAULT` (Billomat currently has only one engine; used in [Settings](settings.md)). - [`TemplateThumbFormat`](../../src/Model/Enum/TemplateThumbFormat.php): `PNG`, `GIF`, `JPG` (values `png`, `gif`, `jpg`). + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/snippets/json/templates.create.json [POST /templates] +<<< @/../tests/Fixtures/Contract/templates/get.json [GET /templates/{id} (response)] +<<< @/../tests/Fixtures/Contract/templates/list.json [GET /templates (response)] +::: + + ## Pitfalls - **`format` + `base64file` only together.** To create an `UPLOADED` template, set both fields. Otherwise Billomat creates an empty DEFINED template. diff --git a/docs/resources/users.md b/docs/resources/users.md index 75aaac6..d8f3bd4 100644 --- a/docs/resources/users.md +++ b/docs/resources/users.md @@ -109,6 +109,17 @@ Returns the user's avatar as raw image bytes (usually PNG), not JSON. `$size` is file_put_contents('me.png', $billomat->users->avatar($billomat->users->getMyself()->id, 64)); ``` + +## JSON on the wire + +Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)). + +::: code-group +<<< @/../tests/Fixtures/Contract/users/get.json [GET /users/{id} (response)] +<<< @/../tests/Fixtures/Contract/users/list.json [GET /users (response)] +::: + + ## Pitfalls - **No write access via the API.** Billomat documents only read endpoints for `/users` (list, single, avatar, myself); there is no official `POST/PUT/DELETE /users`. User management — creating users, changing roles, passwords, deleting — happens only in the Billomat web UI. The SDK therefore deliberately has no `create`/`update`/`delete` for users. (Custom field *definitions* under `$billomat->users->properties` are writable.) diff --git a/docs/snippets/json/articles.create.json b/docs/snippets/json/articles.create.json new file mode 100644 index 0000000..f242aba --- /dev/null +++ b/docs/snippets/json/articles.create.json @@ -0,0 +1,8 @@ +{ + "article": { + "title": "Consulting hour", + "sales_price": "120", + "currency_code": "EUR", + "unit": "hours" + } +} diff --git a/docs/snippets/json/client-properties.create.json b/docs/snippets/json/client-properties.create.json new file mode 100644 index 0000000..4495b5e --- /dev/null +++ b/docs/snippets/json/client-properties.create.json @@ -0,0 +1,6 @@ +{ + "client-property": { + "name": "Industry", + "type": "TEXTFIELD" + } +} diff --git a/docs/snippets/json/client-tags.create.json b/docs/snippets/json/client-tags.create.json new file mode 100644 index 0000000..ee22c02 --- /dev/null +++ b/docs/snippets/json/client-tags.create.json @@ -0,0 +1,6 @@ +{ + "client-tag": { + "name": "VIP", + "client_id": 1001 + } +} diff --git a/docs/snippets/json/clients.create.json b/docs/snippets/json/clients.create.json new file mode 100644 index 0000000..51c6271 --- /dev/null +++ b/docs/snippets/json/clients.create.json @@ -0,0 +1,12 @@ +{ + "client": { + "name": "Example GmbH", + "street": "Musterstraße 1", + "zip": "10115", + "city": "Berlin", + "country_code": "DE", + "email": "billing@example.com", + "net_gross": "NET", + "due_days": 14 + } +} diff --git a/docs/snippets/json/confirmations.create.json b/docs/snippets/json/confirmations.create.json new file mode 100644 index 0000000..3c3b09c --- /dev/null +++ b/docs/snippets/json/confirmations.create.json @@ -0,0 +1,17 @@ +{ + "confirmation": { + "client_id": 1001, + "date": "2026-11-02", + "confirmation-items": { + "confirmation-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/contacts.create.json b/docs/snippets/json/contacts.create.json new file mode 100644 index 0000000..4d88bd9 --- /dev/null +++ b/docs/snippets/json/contacts.create.json @@ -0,0 +1,8 @@ +{ + "contact": { + "client_id": 1001, + "first_name": "Erika", + "last_name": "Mustermann", + "email": "erika@example.com" + } +} diff --git a/docs/snippets/json/credit-notes.create.json b/docs/snippets/json/credit-notes.create.json new file mode 100644 index 0000000..82d770f --- /dev/null +++ b/docs/snippets/json/credit-notes.create.json @@ -0,0 +1,18 @@ +{ + "credit-note": { + "client_id": 1001, + "date": "2026-11-02", + "title": "Refund for invoice RE-2026-0042", + "credit-note-items": { + "credit-note-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/delivery-notes.create.json b/docs/snippets/json/delivery-notes.create.json new file mode 100644 index 0000000..3a680e3 --- /dev/null +++ b/docs/snippets/json/delivery-notes.create.json @@ -0,0 +1,17 @@ +{ + "delivery-note": { + "client_id": 1001, + "date": "2026-11-02", + "delivery-note-items": { + "delivery-note-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/inbox-documents.create.json b/docs/snippets/json/inbox-documents.create.json new file mode 100644 index 0000000..982ba20 --- /dev/null +++ b/docs/snippets/json/inbox-documents.create.json @@ -0,0 +1,7 @@ +{ + "inbox-document": { + "filename": "receipt.pdf", + "mimetype": "application/pdf", + "base64file": "JVBERi0xLjQK…" + } +} diff --git a/docs/snippets/json/incomings.create.json b/docs/snippets/json/incomings.create.json new file mode 100644 index 0000000..0cc0eb7 --- /dev/null +++ b/docs/snippets/json/incomings.create.json @@ -0,0 +1,11 @@ +{ + "incoming": { + "supplier_id": 3001, + "number": "R-778", + "date": "2026-11-02", + "due_days": 30, + "total_gross": "119", + "total_net": "100", + "currency_code": "EUR" + } +} diff --git a/docs/snippets/json/invoice-comments.create.json b/docs/snippets/json/invoice-comments.create.json new file mode 100644 index 0000000..1d39661 --- /dev/null +++ b/docs/snippets/json/invoice-comments.create.json @@ -0,0 +1,6 @@ +{ + "invoice-comment": { + "comment": "Customer asked for a copy by post.", + "invoice_id": 2001 + } +} diff --git a/docs/snippets/json/invoice-items.create.json b/docs/snippets/json/invoice-items.create.json new file mode 100644 index 0000000..060b96f --- /dev/null +++ b/docs/snippets/json/invoice-items.create.json @@ -0,0 +1,9 @@ +{ + "invoice-item": { + "type": "SERVICE", + "title": "Travel expenses", + "quantity": 1, + "unit_price": "15", + "invoice_id": 2001 + } +} diff --git a/docs/snippets/json/invoice-payments.create.json b/docs/snippets/json/invoice-payments.create.json new file mode 100644 index 0000000..520b6aa --- /dev/null +++ b/docs/snippets/json/invoice-payments.create.json @@ -0,0 +1,9 @@ +{ + "invoice-payment": { + "amount": "118.76", + "date": "2026-11-02", + "type": "BANK_TRANSFER", + "mark_invoice_as_paid": 1, + "invoice_id": 2001 + } +} diff --git a/docs/snippets/json/invoice-tags.create.json b/docs/snippets/json/invoice-tags.create.json new file mode 100644 index 0000000..d713c3f --- /dev/null +++ b/docs/snippets/json/invoice-tags.create.json @@ -0,0 +1,6 @@ +{ + "invoice-tag": { + "name": "Q4", + "invoice_id": 2001 + } +} diff --git a/docs/snippets/json/invoices.create.json b/docs/snippets/json/invoices.create.json new file mode 100644 index 0000000..4db4ba7 --- /dev/null +++ b/docs/snippets/json/invoices.create.json @@ -0,0 +1,20 @@ +{ + "invoice": { + "client_id": 1001, + "date": "2026-11-02", + "due_days": 14, + "title": "Invoice for November", + "payment_types": "BANK_TRANSFER,PAYPAL", + "invoice-items": { + "invoice-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/letters.create.json b/docs/snippets/json/letters.create.json new file mode 100644 index 0000000..1952211 --- /dev/null +++ b/docs/snippets/json/letters.create.json @@ -0,0 +1,8 @@ +{ + "letter": { + "client_id": 1001, + "date": "2026-11-02", + "subject": "Change of bank details", + "intro": "Dear Sir or Madam, …" + } +} diff --git a/docs/snippets/json/offers.create.json b/docs/snippets/json/offers.create.json new file mode 100644 index 0000000..bd22d7d --- /dev/null +++ b/docs/snippets/json/offers.create.json @@ -0,0 +1,19 @@ +{ + "offer": { + "client_id": 1001, + "date": "2026-11-02", + "validity_days": 30, + "title": "Offer for the website relaunch", + "offer-items": { + "offer-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/recurrings.create.json b/docs/snippets/json/recurrings.create.json new file mode 100644 index 0000000..08bcbe8 --- /dev/null +++ b/docs/snippets/json/recurrings.create.json @@ -0,0 +1,21 @@ +{ + "recurring": { + "client_id": 1001, + "name": "Hosting", + "action": "EMAIL", + "cycle_number": 1, + "cycle": "MONTHLY", + "start_date": "2026-11-02", + "recurring-items": { + "recurring-item": [ + { + "title": "Consulting", + "quantity": 2, + "unit_price": "49.9", + "unit": "hours", + "tax_rate": 19 + } + ] + } + } +} diff --git a/docs/snippets/json/reminders.create.json b/docs/snippets/json/reminders.create.json new file mode 100644 index 0000000..8db09c6 --- /dev/null +++ b/docs/snippets/json/reminders.create.json @@ -0,0 +1,7 @@ +{ + "reminder": { + "invoice_id": 2001, + "date": "2026-11-02", + "due_days": 7 + } +} diff --git a/docs/snippets/json/settings.update.json b/docs/snippets/json/settings.update.json new file mode 100644 index 0000000..22ad3f0 --- /dev/null +++ b/docs/snippets/json/settings.update.json @@ -0,0 +1,6 @@ +{ + "settings": { + "invoice_number_pre": "RE-", + "due_days": 14 + } +} diff --git a/docs/snippets/json/suppliers.create.json b/docs/snippets/json/suppliers.create.json new file mode 100644 index 0000000..0802d35 --- /dev/null +++ b/docs/snippets/json/suppliers.create.json @@ -0,0 +1,7 @@ +{ + "supplier": { + "name": "Office Supplies AG", + "country_code": "DE", + "email": "invoices@supplier.example" + } +} diff --git a/docs/snippets/json/taxes.create.json b/docs/snippets/json/taxes.create.json new file mode 100644 index 0000000..f023bd9 --- /dev/null +++ b/docs/snippets/json/taxes.create.json @@ -0,0 +1,7 @@ +{ + "tax": { + "name": "VAT reduced", + "rate": 7, + "is_default": 0 + } +} diff --git a/docs/snippets/json/templates.create.json b/docs/snippets/json/templates.create.json new file mode 100644 index 0000000..6303447 --- /dev/null +++ b/docs/snippets/json/templates.create.json @@ -0,0 +1,6 @@ +{ + "template": { + "type": "INVOICE", + "name": "Invoice (English)" + } +} diff --git a/tools/generate-json-examples.php b/tools/generate-json-examples.php new file mode 100644 index 0000000..dbb30e3 --- /dev/null +++ b/tools/generate-json-examples.php @@ -0,0 +1,433 @@ +'; +const END = ''; + +require PROJECT_ROOT.'/vendor/autoload.php'; + +/** + * Request-Beispiele: Name der Snippet-Datei → SDK-Aufruf. + * + * @return array + */ +function requests(): array +{ + $date = new DateTimeImmutable('2026-11-02'); + $item = static fn (string $class): object => new $class( + quantity: 2.0, + unitPrice: Money::of('49.90'), + unit: 'hours', + taxRate: 19.0, + title: 'Consulting', + ); + + return [ + 'clients.create' => static fn (BillomatClient $b): object => $b->clients->create(new ClientCreateOptions( + name: 'Example GmbH', + street: 'Musterstraße 1', + zip: '10115', + city: 'Berlin', + countryCode: 'DE', + email: 'billing@example.com', + netGross: NetGross::NET, + dueDays: 14, + )), + 'contacts.create' => static fn (BillomatClient $b): object => $b->contacts->create(new ContactCreateOptions( + clientId: 1001, + firstName: 'Erika', + lastName: 'Mustermann', + email: 'erika@example.com', + )), + 'suppliers.create' => static fn (BillomatClient $b): object => $b->suppliers->create(new SupplierCreateOptions( + name: 'Office Supplies AG', + countryCode: 'DE', + email: 'invoices@supplier.example', + )), + 'articles.create' => static fn (BillomatClient $b): object => $b->articles->create(new ArticleCreateOptions( + title: 'Consulting hour', + salesPrice: Money::of('120.00'), + currencyCode: 'EUR', + unit: 'hours', + )), + 'client-tags.create' => static fn (BillomatClient $b): object => $b->clients->tags->create(1001, new ClientTagCreateOptions(name: 'VIP')), + 'client-properties.create' => static fn (BillomatClient $b): object => $b->clients->properties->create(new PropertyCreateOptions( + name: 'Industry', + type: PropertyType::TEXTFIELD, + )), + 'invoices.create' => static fn (BillomatClient $b): object => $b->invoices->create(new InvoiceCreateOptions( + clientId: 1001, + date: $date, + dueDays: 14, + title: 'Invoice for November', + paymentTypes: [PaymentType::BANK_TRANSFER, PaymentType::PAYPAL], + items: [$item(InvoiceItemCreateOptions::class)], + )), + 'invoice-items.create' => static fn (BillomatClient $b): object => $b->invoices->items->create(2001, new InvoiceItemCreateOptions( + quantity: 1.0, + unitPrice: Money::of('15.00'), + title: 'Travel expenses', + type: ItemType::SERVICE, + )), + 'invoice-payments.create' => static fn (BillomatClient $b): object => $b->invoices->payments->create(2001, new InvoicePaymentCreateOptions( + amount: Money::of('118.76'), + date: $date, + type: PaymentType::BANK_TRANSFER, + markInvoiceAsPaid: true, + )), + 'invoice-comments.create' => static fn (BillomatClient $b): object => $b->invoices->comments->create(2001, new InvoiceCommentCreateOptions(comment: 'Customer asked for a copy by post.')), + 'invoice-tags.create' => static fn (BillomatClient $b): object => $b->invoices->tags->create(2001, new InvoiceTagCreateOptions(name: 'Q4')), + 'offers.create' => static fn (BillomatClient $b): object => $b->offers->create(new OfferCreateOptions( + clientId: 1001, + date: $date, + validityDays: 30, + title: 'Offer for the website relaunch', + items: [$item(OfferItemCreateOptions::class)], + )), + 'confirmations.create' => static fn (BillomatClient $b): object => $b->confirmations->create(new ConfirmationCreateOptions( + clientId: 1001, + date: $date, + items: [$item(ConfirmationItemCreateOptions::class)], + )), + 'credit-notes.create' => static fn (BillomatClient $b): object => $b->creditNotes->create(new CreditNoteCreateOptions( + clientId: 1001, + date: $date, + title: 'Refund for invoice RE-2026-0042', + items: [$item(CreditNoteItemCreateOptions::class)], + )), + 'delivery-notes.create' => static fn (BillomatClient $b): object => $b->deliveryNotes->create(new DeliveryNoteCreateOptions( + clientId: 1001, + date: $date, + items: [$item(DeliveryNoteItemCreateOptions::class)], + )), + 'letters.create' => static fn (BillomatClient $b): object => $b->letters->create(new LetterCreateOptions( + clientId: 1001, + date: $date, + subject: 'Change of bank details', + intro: 'Dear Sir or Madam, …', + )), + 'reminders.create' => static fn (BillomatClient $b): object => $b->reminders->create(new ReminderCreateOptions( + invoiceId: 2001, + date: $date, + dueDays: 7, + )), + 'recurrings.create' => static fn (BillomatClient $b): object => $b->recurrings->create(new RecurringCreateOptions( + clientId: 1001, + name: 'Hosting', + action: RecurringAction::EMAIL, + cycleNumber: 1, + cycle: RecurringCycle::MONTHLY, + startDate: $date, + items: [$item(RecurringItemCreateOptions::class)], + )), + 'incomings.create' => static fn (BillomatClient $b): object => $b->incomings->create(new IncomingCreateOptions( + supplierId: 3001, + date: $date, + dueDays: 30, + number: 'R-778', + totalGross: Money::of('119.00'), + totalNet: Money::of('100.00'), + currencyCode: 'EUR', + )), + 'inbox-documents.create' => static fn (BillomatClient $b): object => $b->inboxDocuments->create(new InboxDocumentCreateOptions( + filename: 'receipt.pdf', + mimeType: 'application/pdf', + base64file: 'JVBERi0xLjQK…', + )), + 'taxes.create' => static fn (BillomatClient $b): object => $b->taxes->create(new TaxRateCreateOptions(name: 'VAT reduced', rate: 7.0)), + 'templates.create' => static fn (BillomatClient $b): object => $b->templates->create(new TemplateCreateOptions( + type: TemplateDocumentType::INVOICE, + name: 'Invoice (English)', + )), + 'settings.update' => static fn (BillomatClient $b): object => $b->settings->update(new SettingsUpdateOptions( + dueDays: 14, + invoiceNumberPre: 'RE-', + )), + ]; +} + +/** + * Doku-Seite → Tabs: `request` = Snippet aus {@see requests()}, sonst Fixture `/`. + */ +const PAGES = [ + 'clients' => ['clients.create', 'clients/get', 'clients/list'], + 'contacts' => ['contacts.create'], + 'suppliers' => ['suppliers.create', 'suppliers/get', 'suppliers/list'], + 'articles' => ['articles.create'], + 'client-tags' => ['client-tags.create'], + 'properties' => ['client-properties.create', 'client-properties/get', 'client-properties/list'], + 'invoices' => ['invoices.create', 'invoices/get', 'invoices/list'], + 'invoice-items' => ['invoice-items.create', 'invoice-items/get', 'invoice-items/list'], + 'invoice-payments' => ['invoice-payments.create', 'invoice-payments/get', 'invoice-payments/list'], + 'invoice-comments' => ['invoice-comments.create', 'invoice-comments/get', 'invoice-comments/list'], + 'invoice-tags' => ['invoice-tags.create'], + 'offers' => ['offers.create'], + 'confirmations' => ['confirmations.create', 'confirmations/get', 'confirmations/list'], + 'credit-notes' => ['credit-notes.create'], + 'delivery-notes' => ['delivery-notes.create', 'delivery-notes/get', 'delivery-notes/list'], + 'letters' => ['letters.create'], + 'reminders' => ['reminders.create'], + 'recurrings' => ['recurrings.create'], + 'incomings' => ['incomings.create'], + 'inbox-documents' => ['inbox-documents.create'], + 'taxes' => ['taxes.create', 'taxes/get', 'taxes/list'], + 'templates' => ['templates.create', 'templates/get', 'templates/list'], + 'settings' => ['settings.update', 'settings/get'], + 'settings-email-templates' => ['email-templates/get', 'email-templates/list'], + 'settings-free-texts' => ['free-texts/get', 'free-texts/list'], + 'settings-roles' => ['roles/get', 'roles/list'], + 'users' => ['users/get', 'users/list'], + 'incoming-categories' => ['incoming-categories/get', 'incoming-categories/list'], + 'countries' => ['countries/list'], + 'currencies' => ['currencies/list'], + 'activities' => ['activity-feed/list'], +]; + +/** + * Führt den SDK-Aufruf gegen einen Mock aus und liefert Methode, Pfad und Body des Requests. + * + * @param Closure(BillomatClient): mixed $call + * + * @return array{string, string, string} + */ +function capture(Closure $call): array +{ + $captured = null; + $mock = new MockHttpClient(static function (string $method, string $url, array $options) use (&$captured): MockResponse { + $path = substr((string) parse_url($url, PHP_URL_PATH), \strlen('/api')); + $body = $options['body'] ?? ''; + $captured = [$method, $path, \is_string($body) ? $body : '']; + + // Minimale Antwort, damit die Hydration des Rückgabewerts klappt. + $slug = explode('/', $path)[1] ?? ''; + $key = 'settings' === $slug ? 'settings' : (string) preg_replace(['/ies$/', '/(x)es$/', '/s$/'], ['y', '$1', ''], $slug); + + return new MockResponse((string) json_encode([$key => ['id' => '1', 'unit_price' => '0', 'amount' => '0', 'name' => 'x', 'filename' => 'x', 'mime_type' => 'x', 'filesize' => '1', 'created' => '2026-11-02T00:00:00+01:00']])); + }); + + $call(new BillomatClient(new BillomatConfig(billomatId: 'mycompany', apiKey: 'example'), new Psr18Client($mock))); + + if (null === $captured) { + throw new RuntimeException('Kein Request mitgeschnitten.'); + } + + return $captured; +} + +function prettyJson(string $json): string +{ + return json_encode(json_decode($json, true, flags: JSON_THROW_ON_ERROR), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)."\n"; +} + +/** + * Hat die Listen-Fixture mindestens einen Eintrag? + */ +function hasRows(string $file): bool +{ + $data = json_decode((string) file_get_contents($file), true, flags: JSON_THROW_ON_ERROR); + $envelope = \is_array($data) ? reset($data) : null; + if (!\is_array($envelope)) { + return false; + } + + return [] !== array_filter(array_keys($envelope), static fn (int|string $key): bool => !str_starts_with((string) $key, '@')); +} + +/** + * @param array $requestLabels Snippet-Name → [Methode, Pfad] + * @param list $tabs + */ +function section(array $requestLabels, array $tabs): ?string +{ + $lines = []; + foreach ($tabs as $tab) { + if (isset($requestLabels[$tab])) { + [$method, $path] = $requestLabels[$tab]; + $lines[] = "<<< @/snippets/json/{$tab}.json [{$method} {$path}]"; + + continue; + } + + [$resource, $kind] = explode('/', $tab); + $file = FIXTURE_DIR."/{$resource}/{$kind}.json"; + if (!is_file($file) || ('list' === $kind && !hasRows($file))) { + continue; + } + $label = 'get' === $kind ? ('settings' === $resource ? "GET /{$resource}" : "GET /{$resource}/{id}") : "GET /{$resource}"; + $lines[] = "<<< @/../tests/Fixtures/Contract/{$resource}/{$kind}.json [{$label} (response)]"; + } + + if ([] === $lines) { + return null; + } + + return implode("\n", [ + START, + '## JSON on the wire', + '', + "Billomat's API documentation only shows XML. These are the JSON bodies the SDK sends and receives: requests as the SDK serializes them, responses recorded in the Billomat sandbox and anonymized ([contract fixtures](../../tests/Fixtures/Contract)). Billomat sends every value as a string; the read models cast them (see [Read models](../concepts/models.md)).", + '', + '::: code-group', + ...$lines, + ':::', + END, + ]); +} + +function updatePage(string $markdown, ?string $section): string +{ + $start = strpos($markdown, START); + if (false !== $start) { + $end = strpos($markdown, END, $start); + if (false === $end) { + throw new RuntimeException('Endmarker fehlt.'); + } + $before = rtrim(substr($markdown, 0, $start))."\n\n"; + $after = ltrim(substr($markdown, $end + \strlen(END))); + + return null === $section ? rtrim($before)."\n".('' === $after ? '' : "\n".$after) : $before.$section."\n".('' === $after ? '' : "\n".$after); + } + + if (null === $section) { + return $markdown; + } + + $pitfalls = strpos($markdown, "\n## Pitfalls\n"); + if (false !== $pitfalls) { + return substr($markdown, 0, $pitfalls)."\n".$section."\n".substr($markdown, $pitfalls); + } + + return rtrim($markdown)."\n\n".$section."\n"; +} + +$check = \in_array('--check', $argv, true); +$outdated = []; +$written = []; + +$requestLabels = []; +foreach (requests() as $name => $call) { + [$method, $path, $body] = capture($call); + $requestLabels[$name] = [$method, $path]; + $file = SNIPPET_DIR."/{$name}.json"; + $json = prettyJson($body); + if (!is_file($file) || file_get_contents($file) !== $json) { + $outdated[] = $file; + if (!$check) { + if (!is_dir(SNIPPET_DIR)) { + mkdir(SNIPPET_DIR, 0o777, true); + } + file_put_contents($file, $json); + } + } + $written[] = basename($file); +} + +foreach (glob(SNIPPET_DIR.'/*.json') ?: [] as $file) { + if (!\in_array(basename($file), $written, true)) { + $outdated[] = $file; + if (!$check) { + unlink($file); + } + } +} + +foreach (PAGES as $page => $tabs) { + $file = PROJECT_ROOT."/docs/resources/{$page}.md"; + $markdown = (string) file_get_contents($file); + $updated = updatePage($markdown, section($requestLabels, $tabs)); + if ($updated !== $markdown) { + $outdated[] = $file; + if (!$check) { + file_put_contents($file, $updated); + } + } +} + +foreach ($outdated as $file) { + echo ($check ? 'veraltet: ' : 'geschrieben: ').substr($file, \strlen(PROJECT_ROOT) + 1)."\n"; +} + +if ($check && [] !== $outdated) { + echo "JSON-Beispiele sind veraltet, bitte `composer docs:json` ausführen.\n"; + exit(1); +} + +if ($check) { + echo "JSON-Beispiele sind aktuell.\n"; +}