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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
origin is compared against the configured base URL before the credential is
attached. Explicit custom `$baseUrl` values are unaffected.

### Changed

- **Breaking:** `currency` is required on a price row. `Price::fromArray()`
defaulted it to `USD`, which labelled a euro-denominated carbon price as
dollars. A `$0.00` Brent quote is obviously broken and a human catches it;
`78.40 USD` on an EUA contract is plausible, roughly 8% wrong, and flows into
a model undetected. The catalogue is not USD-only - the repo's own fixtures
carry `EU_CARBON_EUR`.

### Fixed

- Reject fabricated observation timestamps. `Price::fromArray()` parsed the
Expand All @@ -36,6 +45,14 @@
raises `ApiException`. Naive timestamps are read as UTC rather than as the
host's local timezone, and leap seconds are rejected rather than rolled
silently into the next minute.
- Read the descriptive fields instead of casting them. `currency`, `unit`,
`name`, `source`, `type` and `formatted` were unchecked `(string)` casts, so
`currency: ["EUR"]` became the literal `'Array'` plus a PHP
"Array to string conversion" warning, `true` became `'1'`, and the ISO
numeric currency `978` became `'978'`. A present-but-non-string value now
raises `ApiException` naming the field and its actual type; absent and null
still mean "not provided". Surrounding whitespace on `currency` is
normalized so `$price->currency === 'EUR'` behaves.
- Reject malformed price rows instead of reporting them as `$0.00`. A row
without a usable code or a numeric price, an unparseable timestamp, and a
missing or non-array `prices` field now raise `ApiException` across
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ the caller requires a predictable single `Price` result.
`source`, `change24h`, `name`, `unit`, `type`, and `formatted` when supplied by
the API.

`code`, `price` and `currency` are required; a row missing any of them raises
`ApiException` rather than being defaulted. `currency` in particular is never
assumed to be USD — the catalogue is not USD-only, and a euro-denominated
carbon price labelled in dollars is plausible enough to pass unnoticed. The
descriptive fields are read as strings or refused, never cast, so a payload
that sends `unit` as something other than a string fails loudly instead of
arriving as a mislabelled quantity.

## Several Prices In One Request

`by_code` accepts up to **20 comma-separated commodity codes**, and the whole call
Expand Down
79 changes: 68 additions & 11 deletions src/Price.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,22 @@ public function __construct(
* `created_at`/`updated_at` for the timestamp and `change_24h`/
* `change_percent_24h` for the 24h change.
*
* A row that does not carry a usable code and a numeric price is rejected
* rather than defaulted: a manufactured $0.00 is indistinguishable from a
* real quote once it leaves the SDK. Legitimate zero and negative prices
* are preserved.
* A row that does not carry a usable code, a numeric price and a currency
* is rejected rather than defaulted: a manufactured $0.00 is
* indistinguishable from a real quote once it leaves the SDK. Legitimate
* zero and negative prices are preserved.
*
* `currency` is required. It used to default to 'USD', which labelled a
* euro-denominated carbon price as dollars - and unlike a $0.00 Brent
* quote, `78.40 USD` on an EUA contract is plausible, roughly 8% wrong,
* and flows into a model undetected. The catalogue is not USD-only; the
* repo's own fixtures carry EU_CARBON_EUR.
*
* The descriptive fields are read, not coerced. `(string)` on an array
* produced the literal 'Array' plus a PHP warning, on `true` produced '1',
* and on the ISO numeric currency 978 produced '978'. A mislabelled unit -
* barrel where the payload said tonne - is the same harm class as a wrong
* number, and harder to spot because the number beside it is right.
*
* The timestamp is held to the same standard. Only an unambiguous absolute
* value is accepted ({@see self::TIMESTAMP_FORMATS}); anything PHP would
Expand All @@ -79,7 +91,8 @@ public function __construct(
*
* @param array<string, mixed> $data
*
* @throws ApiException when a required field is missing or unparseable
* @throws ApiException when a required field is missing, unparseable or of
* the wrong type
*/
public static function fromArray(array $data): self
{
Expand All @@ -98,6 +111,17 @@ public static function fromArray(array $data): self
));
}

$currency = $data['currency'] ?? null;
if (!is_string($currency) || trim($currency) === '') {
throw new ApiException(sprintf(
'Price row for %s is missing a usable currency (got %s); refusing to label '
. 'it USD by default, because a mislabelled currency is a plausible wrong '
. 'number rather than an obvious one.',
$data['code'],
get_debug_type($currency),
));
}

$timestamp = $data['created_at'] ?? $data['updated_at'] ?? null;
$updatedAt = null;
if (is_string($timestamp) && $timestamp !== '') {
Expand All @@ -109,14 +133,17 @@ public static function fromArray(array $data): self
return new self(
code: $data['code'],
price: (float) $data['price'],
currency: (string) ($data['currency'] ?? 'USD'),
// The only value this DTO adjusts: insignificant surrounding
// whitespace, so `$price->currency === 'EUR'` behaves. Nothing
// about the label changes.
currency: trim($currency),
updatedAt: $updatedAt,
change24h: is_numeric($change) ? (float) $change : null,
name: isset($data['name']) ? (string) $data['name'] : null,
unit: isset($data['unit']) ? (string) $data['unit'] : null,
source: isset($data['source']) ? (string) $data['source'] : null,
type: isset($data['type']) ? (string) $data['type'] : null,
formatted: isset($data['formatted']) ? (string) $data['formatted'] : null,
name: self::optionalString($data, 'name', $data['code']),
unit: self::optionalString($data, 'unit', $data['code']),
source: self::optionalString($data, 'source', $data['code']),
type: self::optionalString($data, 'type', $data['code']),
formatted: self::optionalString($data, 'formatted', $data['code']),
);
}

Expand Down Expand Up @@ -171,6 +198,36 @@ private static function parseTimestamp(string $value, string $code): DateTimeImm
));
}

/**
* Read an optional descriptive field, or refuse it.
*
* Absent and explicitly null both mean "not provided". Anything that is
* present but not a string is a malformed row, not something to cast: the
* cast is what turned `['EUR']` into 'Array' and `978` into '978'.
*
* @param array<string, mixed> $data
*
* @throws ApiException when the field is present with a non-string value
*/
private static function optionalString(array $data, string $key, string $code): ?string
{
if (!array_key_exists($key, $data) || $data[$key] === null) {
return null;
}

if (!is_string($data[$key])) {
throw new ApiException(sprintf(
'Price row for %s carries a non-string %s (%s); refusing to coerce it into a '
. 'label, because a wrong label is as costly as a wrong number.',
$code,
$key,
get_debug_type($data[$key]),
));
}

return $data[$key];
}

/**
* @return array<string, mixed>
*/
Expand Down
4 changes: 2 additions & 2 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ public function testHistoricalPeriodEndpoints(): void
'status' => 'success',
'data' => [
'prices' => [
['price' => 70.00, 'created_at' => '2026-07-01T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'],
['price' => 71.00, 'created_at' => '2026-07-02T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'],
['price' => 70.00, 'created_at' => '2026-07-01T00:00:00Z', 'code' => 'BRENT_CRUDE_USD', 'currency' => 'USD'],
['price' => 71.00, 'created_at' => '2026-07-02T00:00:00Z', 'code' => 'BRENT_CRUDE_USD', 'currency' => 'USD'],
],
],
];
Expand Down
7 changes: 6 additions & 1 deletion tests/MalformedPriceRowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ public function testLegitimateNumericPricesArePreserved(float|int|string $price)
{
$this->transport->queue(200, [
'status' => 'success',
'data' => ['prices' => [['code' => 'WTI_USD', 'price' => $price, 'created_at' => '2026-04-20T00:00:00Z']]],
'data' => ['prices' => [[
'code' => 'WTI_USD',
'price' => $price,
'currency' => 'USD',
'created_at' => '2026-04-20T00:00:00Z',
]]],
]);

$prices = $this->client()->pastDay('WTI_USD');
Expand Down
Loading