Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
- **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<Invoice>` can be passed where a `Page<object>` is expected.

### Fixed

- **Fields Billomat sends that the models didn't map** (found with new sandbox fixtures): `Article::$propertyValues` and `Incoming::$propertyValues` (embedded `article-property-values`/`incoming-property-values`, like `Client`/`Supplier`), `CountryTax::$created`, `$updated` and `$customfield`. Until now they ended up in `$extra`.

### Changed

- **`InvoiceMailOptions` is `MailOptions`**, since every document type takes the same Pixelletter options. The Rector set renames it.
Expand Down
3 changes: 3 additions & 0 deletions docs/resources/articles.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Mirrors `ArticleCreateOptions`, but `title` is no longer required and every fiel
| `costCenter` | `?string` |
| `purchasePriceNetGross` | `?NetGross` (whether `purchasePrice` is net or gross) |
| `customfield` | `?string` (free-form extra field, see [Custom meta attributes](../concepts/custom-meta-attributes.md)) |
| `propertyValues` | `list<ArticlePropertyValue>` (custom property values, embedded by Billomat in `get()` and `list()`) |
| `extra` | `array<string, mixed>` (unmapped fields, see [Models](../concepts/models.md)) |

## Article tags
Expand Down Expand Up @@ -302,6 +303,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/articles.create.json [POST /articles]
<<< @/../tests/Fixtures/Contract/articles/get.json [GET /articles/{id} (response)]
<<< @/../tests/Fixtures/Contract/articles/list.json [GET /articles (response)]
:::
<!-- json-examples:end -->

Expand Down
1 change: 1 addition & 0 deletions docs/resources/client-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/client-tags.create.json [POST /client-tags]
<<< @/../tests/Fixtures/Contract/client-tags/list.json [GET /client-tags (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/contacts.create.json [POST /contacts]
<<< @/../tests/Fixtures/Contract/contacts/get.json [GET /contacts/{id} (response)]
<<< @/../tests/Fixtures/Contract/contacts/list.json [GET /contacts (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/credit-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/credit-notes.create.json [POST /credit-notes]
<<< @/../tests/Fixtures/Contract/credit-notes/get.json [GET /credit-notes/{id} (response)]
<<< @/../tests/Fixtures/Contract/credit-notes/list.json [GET /credit-notes (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/inbox-documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/inbox-documents.create.json [POST /inbox-documents]
<<< @/../tests/Fixtures/Contract/inbox-documents/get.json [GET /inbox-documents/{id} (response)]
<<< @/../tests/Fixtures/Contract/inbox-documents/list.json [GET /inbox-documents (response)]
:::
<!-- json-examples:end -->

Expand Down
3 changes: 3 additions & 0 deletions docs/resources/incomings.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ Constructor: `new IncomingPropertyValueCreateOptions(int $incomingPropertyId, mi
| `pageCount` | `?int` |
| `hasAttachment` | `?bool` |
| `customfield` | `?string` (free-form extra field, see [Custom meta attributes](../concepts/custom-meta-attributes.md)) |
| `propertyValues` | `list<IncomingPropertyValue>` (custom property values, embedded by Billomat in `get()` and `list()`) |
| `extra` | `array<string, mixed>` (unmapped fields, see [Models](../concepts/models.md)) |

`paidAmount` and `openAmount` are calculated server-side from the payments. Amounts are [`Money`](../concepts/money.md) values.
Expand Down Expand Up @@ -435,6 +436,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/incomings.create.json [POST /incomings]
<<< @/../tests/Fixtures/Contract/incomings/get.json [GET /incomings/{id} (response)]
<<< @/../tests/Fixtures/Contract/incomings/list.json [GET /incomings (response)]
:::
<!-- json-examples:end -->

Expand Down
1 change: 1 addition & 0 deletions docs/resources/invoice-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/invoice-tags.create.json [POST /invoice-tags]
<<< @/../tests/Fixtures/Contract/invoice-tags/list.json [GET /invoice-tags (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/letters.create.json [POST /letters]
<<< @/../tests/Fixtures/Contract/letters/get.json [GET /letters/{id} (response)]
<<< @/../tests/Fixtures/Contract/letters/list.json [GET /letters (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/offers.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/offers.create.json [POST /offers]
<<< @/../tests/Fixtures/Contract/offers/get.json [GET /offers/{id} (response)]
<<< @/../tests/Fixtures/Contract/offers/list.json [GET /offers (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/recurrings.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/recurrings.create.json [POST /recurrings]
<<< @/../tests/Fixtures/Contract/recurrings/get.json [GET /recurrings/{id} (response)]
<<< @/../tests/Fixtures/Contract/recurrings/list.json [GET /recurrings (response)]
:::
<!-- json-examples:end -->

Expand Down
2 changes: 2 additions & 0 deletions docs/resources/reminders.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ Billomat's API documentation only shows XML. These are the JSON bodies the SDK s

::: code-group
<<< @/snippets/json/reminders.create.json [POST /reminders]
<<< @/../tests/Fixtures/Contract/reminders/get.json [GET /reminders/{id} (response)]
<<< @/../tests/Fixtures/Contract/reminders/list.json [GET /reminders (response)]
:::
<!-- json-examples:end -->

Expand Down
11 changes: 11 additions & 0 deletions docs/resources/settings-reminder-levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ Like `get()`, but throws `NotFoundException` instead of returning `null`.
| `customfield` | `customfield` | `?string` | Free extra field, see [Custom meta attributes](../concepts/custom-meta-attributes.md) |
| `extra` | — | `array<string,mixed>` | Fields the SDK doesn't map yet, raw (see [Read models](../concepts/models.md)) |

<!-- json-examples:start (generated by tools/generate-json-examples.php, run `composer docs:json`) -->
## 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/reminder-texts/get.json [GET /reminder-texts/{id} (response)]
<<< @/../tests/Fixtures/Contract/reminder-texts/list.json [GET /reminder-texts (response)]
:::
<!-- json-examples:end -->

## Pitfalls

- **`sort` instead of `sorting`.** Billomat returns the order as `sorting` (formerly `sort`); the SDK keeps exposing it as `ReminderText::$sort`, and `toArray()` writes it under the key `sort`.
Expand Down
14 changes: 14 additions & 0 deletions docs/resources/settings-tax-free-countries.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,23 @@ Like `get()`, but throws `NotFoundException` instead of returning `null`.
| Property | Billomat field | Type |
|---|---|---|
| `id` | `id` | `int` |
| `created` | `created` | `?DateTimeImmutable` |
| `updated` | `updated` | `?DateTimeImmutable` |
| `countryCode` | `country_code` | `?string` |
| `customfield` | `customfield` | `?string` |
| `extra` | — | `array<string,mixed>`: fields the SDK doesn't map yet, raw (see [Read models](../concepts/models.md)) |

<!-- json-examples:start (generated by tools/generate-json-examples.php, run `composer docs:json`) -->
## 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/country-taxes/get.json [GET /country-taxes/{id} (response)]
<<< @/../tests/Fixtures/Contract/country-taxes/list.json [GET /country-taxes (response)]
:::
<!-- json-examples:end -->

## Pitfalls

- **No tax rate in the model.** Unlike `/taxes` (see [taxes.md](taxes.md)), this endpoint only holds the list of country codes; the tax rate for these countries is implicitly 0 %.
Expand Down
11 changes: 11 additions & 0 deletions docs/resources/settings-units.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ Like `get()`, but throws `NotFoundException` instead of returning `null`.
| `customfield` | `customfield` | `?string` — free extra field, see [Custom meta attributes](../concepts/custom-meta-attributes.md) |
| `extra` | — | `array<string,mixed>`: fields the SDK doesn't map yet, raw (see [Read models](../concepts/models.md)) |

<!-- json-examples:start (generated by tools/generate-json-examples.php, run `composer docs:json`) -->
## 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/units/get.json [GET /units/{id} (response)]
<<< @/../tests/Fixtures/Contract/units/list.json [GET /units (response)]
:::
<!-- json-examples:end -->

## Pitfalls

- **Single-item list quirk.** With only one unit in the account Billomat returns an object instead of a list; `UnitsApi::list()` normalizes this.
Expand Down
24 changes: 22 additions & 2 deletions src/Model/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Justpilot\Billomat\Model;

use DateTimeImmutable;
use Justpilot\Billomat\Internal\ListCaster;
use Justpilot\Billomat\Internal\ScalarCaster;
use Justpilot\Billomat\Model\Enum\ItemType;
use Justpilot\Billomat\Model\Enum\NetGross;
Expand Down Expand Up @@ -52,9 +53,13 @@
'revenue_account_number' => true,
'cost_center' => true,
'purchase_price_net_gross' => true,
'article-property-values' => true,
];

/** @param array<string, mixed> $extra */
/**
* @param list<ArticlePropertyValue> $propertyValues
* @param array<string, mixed> $extra
*/
public function __construct(
/** Interne Billomat-ID des Artikels. */
public int $id,
Expand Down Expand Up @@ -130,6 +135,12 @@ public function __construct(
public ?string $costCenter = null,
/** Preisbasis des Einkaufspreises (netto oder brutto). */
public ?NetGross $purchasePriceNetGross = null,
/**
* Attributwerte des Artikels.
*
* 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.
*
Expand Down Expand Up @@ -172,6 +183,7 @@ public static function fromArray(array $data): self
revenueAccountNumber: ScalarCaster::toStringOrNull($data['revenue_account_number'] ?? null),
costCenter: ScalarCaster::toStringOrNull($data['cost_center'] ?? null),
purchasePriceNetGross: NetGross::tryFrom(ScalarCaster::toStringOrNull($data['purchase_price_net_gross'] ?? null) ?? ''),
propertyValues: ListCaster::toNestedList($data['article-property-values'] ?? null, 'article-property-value', 'id', ArticlePropertyValue::fromArray(...)),
extra: array_diff_key($data, self::FIELDS),
);
}
Expand All @@ -185,7 +197,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),
Expand Down Expand Up @@ -213,5 +225,13 @@ public function toArray(): array
'cost_center' => $this->costCenter,
'purchase_price_net_gross' => $this->purchasePriceNetGross?->value,
];

if ([] !== $this->propertyValues) {
$data['article-property-values'] = [
'article-property-value' => array_map(static fn (ArticlePropertyValue $item): array => $item->toArray(), $this->propertyValues),
];
}

return $data;
}
}
18 changes: 18 additions & 0 deletions src/Model/CountryTax.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Justpilot\Billomat\Model;

use DateTimeImmutable;
use Justpilot\Billomat\Internal\ScalarCaster;

use const DATE_ATOM;

/**
* Steuerfreies Land, für das Billomat keine Mehrwertsteuer berechnet.
*
Expand All @@ -22,19 +25,28 @@
/** Billomat-Felder, die das Model abbildet. */
private const array FIELDS = [
'id' => true,
'created' => true,
'updated' => true,
'country_code' => true,
'customfield' => true,
];

/** @param array<string, mixed> $extra */
public function __construct(
/** Interne Billomat-ID des Eintrags. */
public int $id,
/** Zeitpunkt der Erstellung. */
public ?DateTimeImmutable $created = null,
/** Zeitpunkt der letzten Änderung. */
public ?DateTimeImmutable $updated = null,
/**
* Ländercode nach ISO 3166 Alpha-2.
*
* Beispiel: "CH"
*/
public ?string $countryCode = null,
/** Freies Zusatzfeld (siehe Custom-Meta-Attribute). */
public ?string $customfield = null,
/**
* Felder, die Billomat liefert, das SDK aber (noch) nicht abbildet – roh, wie in der Response.
*
Expand All @@ -53,7 +65,10 @@ public static function fromArray(array $data): self
{
return new self(
id: ScalarCaster::required(ScalarCaster::toIntOrNull($data['id'] ?? null), self::class, 'id'),
created: ScalarCaster::toDateTimeOrNull($data['created'] ?? null),
updated: ScalarCaster::toDateTimeOrNull($data['updated'] ?? null),
countryCode: ScalarCaster::toStringOrNull($data['country_code'] ?? null),
customfield: ScalarCaster::toStringOrNull($data['customfield'] ?? null),
extra: array_diff_key($data, self::FIELDS),
);
}
Expand All @@ -70,7 +85,10 @@ public function toArray(): array
return [
...$this->extra,
'id' => $this->id,
'created' => $this->created?->format(DATE_ATOM),
'updated' => $this->updated?->format(DATE_ATOM),
'country_code' => $this->countryCode,
'customfield' => $this->customfield,
];
}
}
24 changes: 22 additions & 2 deletions src/Model/Incoming.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Justpilot\Billomat\Model;

use DateTimeImmutable;
use Justpilot\Billomat\Internal\ListCaster;
use Justpilot\Billomat\Internal\ScalarCaster;
use Justpilot\Billomat\Model\Enum\IncomingStatus;

Expand Down Expand Up @@ -47,9 +48,13 @@
'category' => true,
'page_count' => true,
'has_attachment' => true,
'incoming-property-values' => true,
];

/** @param array<string, mixed> $extra */
/**
* @param list<IncomingPropertyValue> $propertyValues
* @param array<string, mixed> $extra
*/
public function __construct(
/** Interne Billomat-ID der Eingangsrechnung. */
public int $id,
Expand Down Expand Up @@ -97,6 +102,12 @@ public function __construct(
public ?int $pageCount = null,
/** Ist ein Belegdokument angehängt? */
public ?bool $hasAttachment = null,
/**
* Attributwerte der Eingangsrechnung.
*
* 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.
*
Expand Down Expand Up @@ -137,6 +148,7 @@ public static function fromArray(array $data): self
category: ScalarCaster::toStringOrNull($data['category'] ?? null),
pageCount: ScalarCaster::toIntOrNull($data['page_count'] ?? null),
hasAttachment: ScalarCaster::toBoolOrNull($data['has_attachment'] ?? null),
propertyValues: ListCaster::toNestedList($data['incoming-property-values'] ?? null, 'incoming-property-value', 'id', IncomingPropertyValue::fromArray(...)),
extra: array_diff_key($data, self::FIELDS),
);
}
Expand All @@ -150,7 +162,7 @@ public static function fromArray(array $data): self
*/
public function toArray(): array
{
return [
$data = [
...$this->extra,
'id' => $this->id,
'supplier_id' => $this->supplierId,
Expand All @@ -176,5 +188,13 @@ public function toArray(): array
'page_count' => $this->pageCount,
'has_attachment' => $this->hasAttachment,
];

if ([] !== $this->propertyValues) {
$data['incoming-property-values'] = [
'incoming-property-value' => array_map(static fn (IncomingPropertyValue $item): array => $item->toArray(), $this->propertyValues),
];
}

return $data;
}
}
2 changes: 2 additions & 0 deletions tests/Api/ArticlesApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Justpilot\Billomat\Config\RetryPolicy;
use Justpilot\Billomat\Exception\HttpException;
use Justpilot\Billomat\Http\BillomatHttpClient;
use Justpilot\Billomat\Internal\ListCaster;
use Justpilot\Billomat\Internal\ScalarCaster;
use Justpilot\Billomat\Model\Article;
use Justpilot\Billomat\Model\Enum\ItemType;
Expand All @@ -30,6 +31,7 @@
#[CoversClass(ArticleCreateOptions::class)]
#[CoversClass(ArticleUpdateOptions::class)]
#[CoversClass(Article::class)]
#[UsesClass(ListCaster::class)]
#[UsesClass(BillomatConfig::class)]
#[UsesClass(RetryPolicy::class)]
#[UsesClass(HttpException::class)]
Expand Down
Loading
Loading