Skip to content

v4.0.0-alpha.1

Pre-release
Pre-release

Choose a tag to compare

@justpilot justpilot released this 25 Oct 19:05
· 1 commit to main since this release
fcd4ff6

First pre-release of 4.0, a modernization of the SDK on PHP 8.5. The public API may still change before 4.0.0 — feedback via issues is very welcome.

Highlights

  • PHP 8.5 minimum; ext-bcmath required.
  • PSR-18/PSR-17 transport: bring any HTTP client (Symfony, Guzzle, …); Symfony is no longer a runtime dependency.
  • Exact money: amounts are Money (on BcMath\Number) instead of float.
  • Immutable write models with named arguments and with*() withers, Clear::Field to empty a field; typed list filters and OrderBy.
  • Nested, lazy sub-resources: $billomat->invoices->comments->list($invoiceId), uniform list()/listPage()/iterateAll().
  • Resilience and observability: retries for 429 (honoring Retry-After) and, on GET, for 502/503/504 and network errors, PSR-3 logging and PSR-14 events, error classification (isTransient(), mayHaveBeenProcessed()).
  • Webhook parsing (src/Webhook/), getOrFail(), pdf()/pdfContent(), forward-compatible models ($extra keeps unknown fields).
  • Models and options are generated from the API spec; checked by PHPStan level max (bleeding edge, no baseline), mutation testing and recorded contract fixtures from the Billomat sandbox.
  • English documentation at https://billomat-php.justpilot.io

Install

composer require justpilot/billomat-php-api:^4.0@alpha

Upgrading from 3.x

Follow UPGRADE-4.0.md. A shipped Rector set handles the mechanical changes:

vendor/bin/rector process src tests --config vendor/justpilot/billomat-php-api/rector/upgrade-4.0.php --dry-run

Full changelog

Added

  • PSR-18 transport. The SDK works with any PSR-18 client (Symfony, Guzzle, …) and PSR-17 factories, found via php-http/discovery or passed to BillomatClient. Without a client, Symfony's Psr18Client is used with BillomatConfig::$timeout when symfony/http-client is installed.
  • Automatic retries via BillomatConfig::$retry (RetryPolicy, default: 2 retries, 500 ms base delay, 10 s cap). 429 responses are retried for every method after the wait Billomat announces (Retry-After or X-Rate-Limit-Reset); GET requests are also retried on 502/503/504 and network errors with exponential backoff and jitter. RetryPolicy::none() turns retries off.
  • RateLimitException (429) and TransportException (no response, wraps the PSR-18 exception), both below BillomatException.
  • Error classification: BillomatException::isTransient() (429, 502, 503, 504 and network errors) and mayHaveBeenProcessed() (false for 4xx, true for 5xx and network errors), so callers no longer inspect status codes or getPrevious() to decide whether to retry later or to check before resending a write request.
  • HttpException::getRetryAfter(): seconds announced by Billomat, set for 429 (Retry-After or X-Rate-Limit-Reset) and for 502/503/504 (Retry-After).
  • WriteOptions interface with toArray(), implemented by all *CreateOptions/*UpdateOptions and the other write options.
  • Clear::Field empties a field in *UpdateOptions and SettingsUpdateOptions: null still means "not sent, unchanged"; Clear::Field is sent as JSON null, which makes Billomat clear the field.
  • with*() methods on every options class return a copy with one field changed (PHP 8.5 clone with properties), e.g. $options->withNote('…'); withItem() on document create options appends a position.
  • Typed list filters for the main resources: ClientFilter, SupplierFilter, ArticleFilter, UserFilter, InvoiceFilter, OfferFilter, ConfirmationFilter, CreditNoteFilter, DeliveryNoteFilter, LetterFilter, ReminderFilter, IncomingFilter, RecurringFilter and TemplateFilter, generated from the Billomat docs. They're immutable (named arguments plus with*()), use enums for status and payment types, DateTimeImmutable for from/to and lists for multi-value filters (sent comma-separated). list(), listPage() and iterateAll() accept them next to the plain arrays, which keep working. extra passes further query parameters.
  • OrderBy builds order_by: OrderBy::desc('date')->thenAsc('invoice_number') gives date+DESC,invoice_number+ASC.
  • Sorting and pagination for sub-resources. Items, comments, tags, reminder items, recurring e-mail receivers and property definitions take a SubResourceFilter (orderBy, page, perPage, extra) next to plain arrays, e.g. $billomat->invoices->items->list($id, new SubResourceFilter(orderBy: OrderBy::asc('position'))). Payments and property values get typed filters generated from the Billomat docs: InvoicePaymentFilter, CreditNotePaymentFilter, IncomingPaymentFilter (from, to, type as list<PaymentType>, userId) and ClientPropertyValueFilter, ArticlePropertyValueFilter, SupplierPropertyValueFilter, IncomingPropertyValueFilter; a filter of another resource throws an InvalidArgumentException. All of them have listPage() and iterateAll() now (Billomat paginates these endpoints like the top-level lists, checked in the sandbox).
  • Money value object (Justpilot\Billomat\Model\Money) for exact amounts, based on BcMath\Number: Money::of('19.99'), sum(), add(), subtract(), multiply(), round(), comparisons, isZero()/isPositive()/isNegative(), format(2, ',', '.') and toString() in Billomat's canonical form. Requires ext-bcmath.
  • Webhook helpers in Justpilot\Billomat\Webhook: WebhookEvent (all 83 events, with resource(), action(), envelopeKey() and modelClass()), WebhookHeaders::fromServerRequest()/fromArray() for the X-Billomat-Webhook-* headers (request ID as int), and WebhookPayload::fromJson()/fromArray(), which unwraps the REST-style envelope and hydrates the matching model via model(). They work on stored requests as well. Errors throw InvalidWebhookException.
  • PSR-3 logging and PSR-14 events: BillomatClient (and create()) take an optional logger and events dispatcher. The SDK logs successful responses (debug), retries (warning) and final failures (notice for 4xx, error for 5xx and network errors) with method, path, attempt, status and duration, never headers, query or body. It dispatches RequestStarted, ResponseReceived, RetryScheduled and RequestFailed (Justpilot\Billomat\Event). New runtime dependencies: psr/log and psr/event-dispatcher (interfaces only).
  • Unknown fields are kept: every generated read model has $extra with the fields Billomat returns that the model doesn't map (yet), raw as in the response. toArray() includes them, so new Billomat fields no longer get lost. Mapped fields win over extra entries with the same key.
  • getOrFail() on every resource and sub-resource with get(): returns the model or throws NotFoundException instead of returning null.
  • 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).
  • User::$rights (array<string, AccessLevel>): the access level per area (articles, banking, …) Billomat returns with every user; before, it ended up raw in User::$extra. New enum AccessLevel (READ, UPDATE, DELETE) with label() and allows() (DELETE allows UPDATE and READ). The generator has a map field type for such objects (area → enum value); empty and unknown values are dropped.
  • 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.
  • Rector upgrade set rector/upgrade-4.0.php for the mechanical parts of the 3.x → 4.0 upgrade: flat sub-resource properties to nested ones ($billomat->invoiceComments → $billomat->invoices->comments), listBy{Parent}() → list(), removed sub-resource, e-mail option and enum classes to their 4.0 replacements, OfferStatus::ACCEPTED/REJECTED → WON/LOST, getItems() → items, putEmptyResponse() → putVoid(). The rules live under the Justpilot\Billomat\Rector namespace; rector/rector is only needed to run them (see UPGRADE-4.0.md).
  • InvoiceItemUpdateOptions, OfferItemUpdateOptions, CreditNoteItemUpdateOptions, ConfirmationItemUpdateOptions, DeliveryNoteItemUpdateOptions and RecurringItemUpdateOptions for partial item updates: every field is optional (X|Clear|null, with with*()), only set fields are sent. Billomat keeps the other fields of the item (checked in the sandbox).

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.

  • The PHPStan baseline is empty and phpstan-baseline.neon is gone (composer analyse:baseline too): the findings were fixed in the code. Filter and query parameters are typed array<string, scalar|array<scalar|null>|null>, list responses are normalized in one place (AbstractApi), and ListCaster::toNestedList() reads the taxes/*-items wrappers of the generated models. Behaviour is unchanged, except that a response body that is a JSON list instead of an object now throws UnexpectedResponseException.

  • BC break: amounts are Money instead of float in all read models and write options (totalGross, totalNet, *Unreduced, totalReduction, openAmount, paidAmount, discountAmount, unitPrice, salesPrice–salesPrice5, purchasePrice, payment amount, chargeAmount, total, revenue*/costs*, finapiTransactionAmount, the amount of tax rows and InvoiceGroup totals). Payloads send amounts as decimal strings, toArray() exports them as strings. Quantities, tax and discount rates and exchange rates (quote) stay float.

  • BC break: ext-bcmath is required.

  • BC break: tax rows are TaxSummary objects. taxes on invoices, offers, credit notes, confirmations, delivery notes and recurrings is a list<TaxSummary> (name, rate, amount as Money) instead of a list of arrays; toArray() exports the rows as before, with the amount as a string.

  • BC break: OfferStatus::ACCEPTED/REJECTED are now WON/LOST. Billomat sets won and lost offers to WON and LOST (checked in the sandbox), so win()/lose() used to leave $offer->status at null, and OfferFilter(status: [OfferStatus::ACCEPTED]) sent a value Billomat doesn't know.

  • BC break: more fields are enums instead of strings. Client::$taxRule (TaxRule), $netGross (NetGross) and the five *Type fields discountRateType, discountDaysType, dueDaysType, reminderDueDaysType, offerValidityDaysType (ValueType), in the model and in ClientCreateOptions/ClientUpdateOptions; *PropertyValue::$type (PropertyType), InvoiceGroup::$status (InvoiceStatus), FreeText::$type (new FreeTextType) and EmailTemplate::$type (new EmailTemplateType). Unknown values hydrate to null.

  • BC break: InvoicePaymentType, InvoicePdfType and InvoiceItemType are now PaymentType, PdfType and ItemType (same namespace Justpilot\Billomat\Model\Enum, same cases). They were always used for credit notes, incomings, offers, letters, reminders and all document items as well.

  • BC break: ArticleType is merged into ItemType. Both had the cases PRODUCT and SERVICE; Article::$type and the type of ArticleCreateOptions/ArticleUpdateOptions are ?ItemType now.

  • BC break: payment types are list<PaymentType> instead of a comma-separated string: Invoice::$paymentTypes, Recurring::$paymentTypes, Client::$defaultPaymentTypes and the matching create/update options. Payloads and toArray() still send the comma-separated string; an empty list is not sent, Clear::Field empties the field. Unknown values are dropped when hydrating.

  • BC break: every enum has the same fromApi(?string): ?self (trait Model\Enum\FromApi): null, "" and unknown values give null. Enums that had no fromApi() (NetGross, TaxRule, ValueType, SupplyDateType, RecurringAction, RecurringCycle, the *CommentActionKey enums, …) get one; a few returned a case for "" before.

  • BC break: RecurringEmailReceiver::$type is ?RecurringEmailReceiverType and RecurringEmailReceiverType::fromApi() returns null for unknown values instead of silently falling back to TO (a typo like bc used to turn a BCC receiver into a visible To receiver). Lower-case values (bcc) are still accepted.

  • BC break: Role::$permissions is array<string, AccessLevel> instead of array<string, ?string>. Areas without access ("") and unknown values are left out instead of being null. Role::toArray() exports the permissions as top-level fields like Billomat ('articles' => 'DELETE') instead of under permissions. Role::AREAS lists the known areas.

  • BC break: InvoiceMailOptions::$paperWeight is ?int (80 or 90) instead of ?string, like letterPaperWeight on recurrings.

  • BC break: pdf($id, $type, rawPdf: true) is now pdfContent($id, $type). On invoices, offers, confirmations, credit notes, delivery notes, letters and reminders, pdf() always returns the *Pdf model (metadata plus base64) and pdfContent() the binary PDF as string, so callers no longer narrow InvoicePdf|string. The bool $rawPdf flag is gone. The Rector set rewrites pdf(…, rawPdf: true) to pdfContent(…) (PdfContentRector).

  • BC break: pdfContent() checks the body. It throws UnexpectedResponseException when Billomat answers 2xx with an empty body or something that does not start with %PDF-, instead of returning it as a PDF. Custom APIs can use the new AbstractApi::getPdf().

  • BC break: PHP 8.5 is now the minimum version ("php": "^8.5"). CI runs on PHP 8.5 (required) and 8.6 (experimental); Rector uses the PHP 8.5 rule set.

  • Read-only API methods are marked #[\NoDiscard]: get(), getOrFail(), getMyself(), list(), listPage(), listGrouped(), iterateAll(), cloud(), pdf(), pdfContent(), thumb(), avatar() and SearchApi::query(). PHP 8.5 emits a warning when their result is ignored; cast to (void) to discard it on purpose. Write methods (create(), update(), delete(), …) are not marked.

  • BC break: BillomatConfig validates its input. The Billomat ID must be a valid subdomain (letters, digits, inner hyphens, at most 63 characters), and the base URI is parsed with PHP 8.5's Uri\Rfc3986\Uri and must be absolute. Otherwise the constructor throws an InvalidArgumentException, instead of sending requests to a wrong host.

  • BC break: symfony/http-client is no longer a dependency. Install a PSR-18 client and PSR-17 factories (e.g. symfony/http-client + nyholm/psr7, or guzzlehttp/guzzle). BillomatClient::__construct() and create() take a Psr\Http\Client\ClientInterface instead of a Symfony HttpClientInterface; wrap Symfony clients in Psr18Client.

  • BC break: BillomatHttpClientInterface::request() returns a PSR-7 ResponseInterface and throws the SDK exceptions itself. Custom AbstractApi subclasses no longer catch Symfony exceptions; read the body with (string) $response->getBody().

  • BC break: a 2xx response whose body is not a JSON object throws UnexpectedResponseException instead of JsonException.

  • BillomatClient creates its APIs lazily: the 30 resource properties are PHP 8.4 property hooks (public private(set)), so an API object is only built on first access and reused afterwards. BillomatClient is no longer a readonly class; the properties can't be assigned from outside (they couldn't before either).

  • BC break: sub-resources hang off their parent resource. $billomat->invoiceComments becomes $billomat->invoices->comments, $billomat->clientTags becomes $billomat->clients->tags, and so on for all items, payments, comments, tags, properties, property values and recurring e-mail receivers (see the table in UPGRADE-4.0.md). They are created on first access.

  • BC break: one generic class per kind of sub-resource. CommentsApi, TagsApi, ItemsApi, PaymentsApi, PropertiesApi and PropertyValuesApi replace the 39 per-resource classes (InvoiceCommentsApi, ClientTagsApi, …); they are typed with @template, so PHPStan and IDEs know the model of $billomat->invoices->comments->get(). ReminderItemsApi and RecurringEmailReceiversApi stay as classes. create()/update() throw an InvalidArgumentException when passed the options of another resource (e.g. OfferCommentCreateOptions to $billomat->invoices->comments), as do comment filters with another resource's *CommentActionKey.

  • BC break: sub-resources take the parent ID as the first argument of create(). Payments, comments, tags, property values and recurring e-mail receivers work like items already did: $billomat->invoices->payments->create($invoiceId, new InvoicePaymentCreateOptions(amount: …)) instead of create(new InvoicePaymentCreateOptions(invoiceId: $invoiceId, amount: …)). The parent ID fields (invoiceId, clientId, recurringId, …) and their with*() methods are removed from the *PaymentCreateOptions, *CommentCreateOptions, *TagCreateOptions, *PropertyValueCreateOptions and RecurringEmailReceiverCreateOptions; the SDK adds the field to the payload. create() now mirrors list($parentId), and one options object can be reused for several parents. The Rector set moves the ID when the options are built inside the create() call (ParentIdToCreateArgumentRector).

  • BC break: listBy{Parent}() is now list() on comments, tags, items, recurring e-mail receivers and contacts (ContactsApi::listByClient() → list()). list() on payments and property values takes the parent ID as optional first argument: list(?int $parentId = null, array $filters = []), likewise listPage() and iterateAll() on property values.

  • BC break: methods that always returned true now return void: delete(), complete(), cancel(), uncancel(), clear(), undo(), win(), lose(), email(), mail(), encash(), upload() and uploadSignature(). Errors were and are reported as exceptions.

  • BC break: options are immutable. All *CreateOptions/*UpdateOptions, EmailOptions, InvoiceMailOptions, InboxDocumentCreateOptions and SettingsUpdateOptions are final readonly classes; every field is a constructor parameter with a default, so they are built with named arguments (new ClientCreateOptions(name: 'ACME')) instead of assigning properties. Required fields (e.g. clientId on InvoiceCreateOptions) stay the first positional parameters.

  • BC break: positions go into the constructor. addItem() and getItems() on InvoiceCreateOptions, OfferCreateOptions, ConfirmationCreateOptions, CreditNoteCreateOptions, DeliveryNoteCreateOptions and RecurringCreateOptions are replaced by the items: parameter, the $items property and withItem().

  • BC break: one EmailOptions class replaces InvoiceEmailOptions, OfferEmailOptions, ConfirmationEmailOptions, CreditNoteEmailOptions, DeliveryNoteEmailOptions, ReminderEmailOptions and LetterEmailOptions, which had identical fields.

  • BC break: ClientCreateOptions::$dunningRun defaults to null and is only sent when set, so Billomat's account default applies. Before, dunning_run=0 was always sent.

  • BC break: read-model IDs are int instead of ?int on every model of an addressable resource (documents, clients, suppliers, articles, contacts, items, payments, comments, tags, properties, property values, settings models, User, Role, InboxDocument, …; IncomingCategory::$id is a non-null string). A create → sub-resource flow needs no null check any more. fromArray() throws UnexpectedResponseException (naming model and field) when Billomat sends no usable id instead of hydrating null. unitPrice on document items and amount on payments are required the same way (they were Money with a silent Money::zero() fallback). The ID stays ?int on *TagCloudEntry, Country, Currency, Activity and SearchResult.

  • BC break: read-model constructors. Only those required fields have no default and come first (InvoiceItem(id:, unitPrice:, …), InvoicePayment(id:, amount:, …)); every other parameter now defaults to null (or 0/''/false for the non-nullable ones). Use named arguments.

  • BC break: ItemsApi::update() takes *ItemUpdateOptions instead of *ItemCreateOptions, so an item update no longer has to repeat quantity and unitPrice. Passing create options throws InvalidArgumentException.

  • WebhookPayload::model() throws InvalidWebhookException (with the UnexpectedResponseException as previous) when the record lacks a required field such as id.

  • Codegen: 'model' => ['required' => true] now means "Billomat always sends this field": non-nullable type without default, fromArray() throws via ScalarCaster::required() when it is missing. It no longer just removes the constructor default; nonNull amounts (Money::zero() is no constant expression) are rejected in favour of required.

  • Sub-resource get() throws UnexpectedResponseException when the response lacks the expected envelope; some classes returned null before.

  • Documentation: the document pages no longer claim that delete() only works on drafts. Billomat deletes documents in any status via the API (checked in the sandbox for completed, cancelled, won and lost documents); the docs recommend cancel() for completed documents because of bookkeeping rules.

  • Array query parameters go out as key%5B%5D=v (PSR-7 URIs encode the brackets per RFC 3986, which servers decode to the same key[]).

  • composer generate fails when a spec field has a fixed set of values but its definition maps it to no enum ('enum' => 'X', or 'enum' => false to keep the string on purpose).

  • Mutation testing covers the generated code on purpose: reflection-based tests check every read model's fromArray()/toArray() round trip and every write option's payload against the Billomat-Feld in its docblock. Covered MSI rises from 77 % to 97 % (generated code 73 % → 99.7 %), minMsi/minCoveredMsi from 60 to 95. PublicVisibility/ProtectedVisibility are disabled (about 1,500 mutants that never survived). CI mutates only the changed lines on pull requests (--git-diff-lines, 90 %) and all code on pushes to main/4.x.

  • Documentation website at billomat-php.justpilot.io, built with VitePress from docs/ (local search, edit links, sidebar from the page titles) and deployed to GitHub Pages by .github/workflows/docs.yml; links to files outside docs/ point to GitHub.

  • README is an English landing page (≈100 lines: dynamic badges, install, quickstart, features, documentation map, supported versions); README.en.md is gone, the resource table moved to docs/resources/README.md with the nested 4.x access paths.

  • CONTRIBUTING.md, SECURITY.md, the pull request template and the issue templates are in English and describe the 4.x workflow (PRs against 4.x, generated code, PSR-only dependencies, supported versions 4.x/3.x). Packagist keywords drop symfony/http-client and add psr-3, psr-14, money and e-invoice.

  • The examples in examples/ follow the 4.x API and are checked in CI: PHPStan (level max), PHP-CS-Fixer and Rector cover them like src/ and tests/. A shared examples/bootstrap.php reads the environment variables and builds the client; the scripts use getOrFail(), Money, OrderBy and the enums, and examples/README.md is in English.

Removed

  • BC break: everything marked @deprecated since 3.1 is gone.
    • DunningLevelsApi, $billomat->dunningLevels and the DunningLevel model (/dunning-levels answers with 404). Use $billomat->reminderTexts for reminder levels.
    • AbstractApi::putEmptyResponse() (use putVoid()).
    • The read-model properties Billomat no longer returns: Article::$unit/$purchasePriceCurrencyCode/$categoryId, Client::$customerportalUrl, Contact::$note, *Property::$position, TaxRate::$accountId, ReminderText::$dueDays, Country::$nameDe/$eu, User::$phone/$mobile/$fax, Invoice::$customerportalUrl, CreditNote::$customerportalUrl, Offer/Confirmation::$discountRate/$discountDate/$discountDays/$discountAmount/$customerportalUrl, Letter::$letterNumber/$number/$numberPre/$numberLength/$note/$customerportalUrl, Reminder::$reminderNumber/$number/$numberPre/$numberLength/$totalNet/$quote, Recurring::$numberPre, Incoming::$supplyDate/$dueDays/$paidAt/$intro, DeliveryNote::$totalGross/$totalNet/$netGross/$reduction/$totalGrossUnreduced/$totalNetUnreduced/$currencyCode/$quote/$customerportalUrl, $created on InvoiceItem/OfferItem/CreditNoteItem/ConfirmationItem, ReminderItem::$totalGross/$totalNet/$created and DeliveryNoteItem::$unitPrice/$taxName/$taxRate/$reduction/$totalGross/$totalNet/$totalGrossUnreduced/$totalNetUnreduced/$created. They also disappear from the models' constructors and toArray(). Fields that are writable stay in the *CreateOptions/*UpdateOptions.
  • AbstractApi::mapHttpException() and HttpException::fromResponse() (Symfony-specific; the mapping now lives in BillomatHttpClient).
  • BC break: status methods for endpoints Billomat doesn't have. OffersApi::undo() and ConfirmationsApi::undo() are gone (PUT …/undo answers 404); use uncancel() or unclear(). DeliveryNotesApi and LettersApi lose cancel(), clear() and undo(): Billomat answers cancel with 400 ("cancellation not supported") and clear/undo with 404, so these methods could never succeed.