peterlupu/curs-bnr

Official exchange rates from the National Bank of Romania (BNR / curs valutar BNR) XML feeds, with Fiscal Code art. 290 'valid on' vs 'communicated on' semantics. Framework-agnostic, zero dependencies.

Maintainers

Package info

github.com/peterlupu/curs-bnr

pkg:composer/peterlupu/curs-bnr

Transparency log

Statistics

Installs: 31

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.4.0 2026-08-06 10:12 UTC

This package is auto-updated.

Last update: 2026-08-06 10:47:45 UTC


README

CI Packagist Version PHP Version PHPStan Code Style Downloads License AI Disclosure

Official BNR exchange rates for PHP โ€” with the fiscal semantics done right.

curs-bnr fetches the official exchange rates of the National Bank of Romania (cursul valutar BNR) and answers the question every Romanian invoicing, accounting, or pricing app eventually asks: "which rate applies on this date?" โ€” exactly as the Fiscal Code defines it.

  • ๐Ÿชถ Zero dependencies โ€” just PHP โ‰ฅ 8.1 with ext-simplexml. No PSR plumbing, no HTTP discovery magic.
  • ๐Ÿงพ Fiscal Code art. 290 semantics built in โ€” validOn() vs communicatedOn(), so you never write subDay() + walk-back loops again.
  • ๐Ÿ“… Historical rates back to 2005, weekends, legal holidays, and year boundaries handled for you.
  • ๐Ÿ” Transient-failure retries with configurable backoff.
  • ๐Ÿงช Fully unit-tested against real BNR documents โ€” both the old and the new XML namespace (yes, BNR changed it in 2026 and broke half the ecosystem).
  • ๐Ÿงฉ Framework-agnostic โ€” a plain PHP class you can use anywhere and wire into any container.
  • ๐ŸŽญ Batteries-included testing โ€” an ExchangeRates interface plus a shipped FakeCursBnr that runs the same semantics code as the real client, so your tests never touch the network.

๐Ÿ“ฆ Installation

composer require peterlupu/curs-bnr

๐Ÿš€ Quick start

use CursBnr\CursBnr;

$bnr = new CursBnr();

// The latest communicated rate
$rate = $bnr->latest('EUR');
$rate->value;                 // 5.246  (RON per 1 EUR)
$rate->communicatedOnYmd();   // '2026-08-04'

// The rate legally valid on a given date (this is the one you invoice with!)
$rate = $bnr->validOn('EUR', new DateTimeImmutable('2024-01-27'));
$rate->value;                 // 4.9763 โ€” communicated Friday Jan 26

// Convert between any two currencies
$factor = $bnr->pair('EUR', 'RON', new DateTimeImmutable('2024-01-27'));
$amountInRon = $amountInEur * $factor;

That's it. No API keys, no configuration โ€” BNR's feeds are public.

๐Ÿงพ "Which rate applies today?" โ€” the D vs D+1 rule

This is the part every BNR consumer gets subtly wrong, so it deserves its own section.

BNR communicates rates each banking day around 13:00 Romanian time. Per Fiscal Code art. 290, the rate communicated on day D is valid on day D+1 โ€” and it stays valid through any weekend or legal holiday that follows, until the next communication.

A real example, January 2024:

Date What happened Valid rate (EUR)
Thu 25 BNR communicates 4.9765 the rate from Wed 24โ€ฆ
Fri 26 BNR communicates 4.9763 4.9765 (Thursday's)
Sat 27 nothing communicated 4.9763 (Friday's)
Sun 28 nothing communicated 4.9763 (Friday's)
Mon 29 BNR communicates again 4.9763 (still Friday's)

Pick your method by the question you're asking:

You're askingโ€ฆ Use Weekend/holiday behavior
Which rate is legally valid on this date? (invoices, VAT, contracts) validOn($currency, $date) walks back to the last communication before the date โœ…
What did BNR communicate on this exact day? communicatedOn($currency, $date) throws NoRateCommunicatedException ๐Ÿšซ
What's the most recent rate right now? latest($currency) n/a โ€” reads the daily feed

๐Ÿ’ก If you were previously doing $date->subDay() and then looping backwards until a rate showed up โ€” validOn() is that, done correctly, including holiday runs and year boundaries.

๐Ÿ’ถ Rates, multipliers, and the Rate object

Every lookup returns a CursBnr\Rate:

$rate = $bnr->validOn('HUF', new DateTimeImmutable('2024-01-27'));

$rate->currency;              // 'HUF'
$rate->value;                 // 0.013620 โ€” RON per 1 HUF (multiplier already applied!)
$rate->multiplier;            // 100 โ€” BNR quotes HUF per 100 units; we normalize for you
$rate->communicatedAt;        // DateTimeImmutable of the communication date
$rate->validFrom();           // communicatedAt + 1 day, per art. 290

Notes worth knowing:

  • ๐Ÿ‡ญ๐Ÿ‡บ Multiplier currencies (quoted per 100 units โ€” currently HUF, IDR, ISK, JPY, KRW; read from the feed, not hardcoded) are normalized to per-1-unit values. You never touch the multiplier unless you want to display it.
  • ๐Ÿฅ‡ XAU is RON per gram of gold, as published by BNR.
  • ๐Ÿ‡ท๐Ÿ‡ด RON itself is never in the feed (everything is quoted against it), so latest('RON') throws. For RON conversions use pair() โ€” read on.

๐Ÿ”€ Currency pairs and cross rates

pair($from, $to, $validOn = null) returns the factor that converts an amount of $from into $to:

$bnr->pair('EUR', 'RON');                             // 4.9763       โ€” the rate valid TODAY
$bnr->pair('RON', 'EUR');                             // 0.20095...   โ€” full precision, no rounding
$bnr->pair('EUR', 'USD');                             // cross rate via RON
$bnr->pair('EUR', 'EUR');                             // 1.0 โ€” no HTTP request at all
$bnr->pair('EUR', 'RON', new DateTimeImmutable('2024-01-27')); // valid-on-date semantics
  • With no date, it uses the rates valid today โ€” exactly the same as passing today's date. (If you want the freshly communicated rate that becomes valid tomorrow, use latest().)
  • With a date, both legs use the rate valid on that date (same walk-back as validOn()).
  • โš ๏ธ Inverse and cross rates are returned at full float precision. (florianv/swap used to truncate inverses to 4 decimals โ€” we don't. Round at display time, not in your data.)

๐Ÿ“š A whole day of rates in one call

Updating a rates table nightly? Don't make 30 requests:

$day = $bnr->latestDay();          // one HTTP call

$day->rate('EUR')->value;
$day->rate('USD')->value;
$day->rate('CHF')->value;
$day->communicatedAt;              // when this Cube was communicated
$day->validFrom();                 // the day these rates become valid
$day->has('MDL');                  // true
foreach ($day->rates as $code => $rate) { /* all ~30 of them */ }

The same works for any date โ€” one lookup, every currency:

$bnr->validDayOn(new DateTimeImmutable('2024-01-27'));        // rates valid on that day
$bnr->communicatedDayOn(new DateTimeImmutable('2024-01-26')); // rates communicated that exact day

๐Ÿ•ฐ๏ธ Dates, timezones, and other sharp edges

  • Any DateTimeInterface is accepted โ€” DateTime, DateTimeImmutable, Carbon, CarbonImmutable โ€” and is reduced to its calendar date (Y-m-d). No timezone conversion is performed: if you pass "2024-01-27" in any timezone, you get the rate for January 27.
  • ๐ŸŒ™ Near midnight this matters: new DateTime('now') on a UTC server at 23:30 Romanian time is still "yesterday" in UTC. If the exact day is important, build your dates in Europe/Bucharest.
  • ๐Ÿ“œ Historical data starts at 2005 (that's as far back as BNR's yearly XML files go). Earlier dates throw UnsupportedDateException.
  • ๐Ÿ”ฎ Future dates are refused, not guessed. The rate valid on a future day depends on a communication BNR hasn't made yet โ€” silently answering with today's rate would give you a number that changes once BNR publishes. Anything beyond tomorrow throws RateNotYetAvailableException; tomorrow itself is answered as soon as today's ~13:00 communication is out.
  • ๐Ÿšท validOn() gives up after 15 days of walking back (configurable) โ€” with real BNR data that never happens; if it does, the feed is broken and you'll get a WalkBackExhaustedException instead of an infinite loop.

๐Ÿšจ Error handling

Every runtime failure extends one base class (programmer errors โ€” a negative retry count, a malformed date string โ€” throw plain SPL \InvalidArgumentException/\LogicException, as usual):

use CursBnr\Exception\CursBnrException;

try {
    $rate = $bnr->validOn('EUR', $date);
} catch (CursBnrException $e) {
    // network down, feed broken, unknown currency, ... โ€” one catch to rule them all
}

Or catch precisely:

Exception When Useful properties
TransportException HTTP failed (after retries) $url, $attempts
ParseException feed returned something that isn't a valid BNR document (incl. non-numeric rate values) $url
UnknownCurrencyException currency not quoted by BNR (incl. RON in single-currency calls) $currency
NoRateCommunicatedException communicatedOn() hit a weekend/holiday โ€” or today, before ~13:00 $date
RateNotYetAvailableException dated lookup for a day whose communication hasn't happened yet $date, $today
WalkBackExhaustedException validOn() found nothing within the bound $date, $daysTried
UnsupportedDateException date (or walk-back) before 2005 $date

All exception properties are readonly โ€” safe to log, impossible to mangle.

๐Ÿ”ง Advanced configuration

Everything is constructor-injected and optional:

use CursBnr\CursBnr;
use CursBnr\FeedUrls;
use CursBnr\Http\RetryPolicy;

$bnr = new CursBnr(
    retryPolicy: new RetryPolicy(attempts: 5, backoffMs: [100, 500, 2000]),
    maxWalkBackDays: 30,
);

Retries ๐Ÿ”

Transient HTTP failures are retried automatically (default: 3 attempts, 200ms/400ms backoff). Tune it, give it a closure, or turn it off:

new RetryPolicy(attempts: 4, backoffMs: fn (int $retry) => $retry * 250);
RetryPolicy::none(); // fail fast

Only transport errors are retried โ€” a malformed document will never be re-fetched in a loop.

The clock ๐Ÿ•

The future-date guard needs to know what "today" is (Europe/Bucharest by default). For deterministic tests of code that uses the real client, inject one:

$bnr = new CursBnr(clock: fn () => new DateTimeImmutable('2024-01-29'));

(For application tests you'll usually reach for FakeCursBnr instead โ€” see below.)

Bring your own HTTP client ๐ŸŒ

The default client is built on PHP streams and needs nothing installed. If you'd rather route through your existing stack, implement the one-method interface:

use CursBnr\Http\HttpClientInterface;

final class MyClient implements HttpClientInterface
{
    public function get(string $url): string { /* ... */ }
}

$bnr = new CursBnr(httpClient: new MyClient());

โ€ฆor adapt any PSR-18 client (Guzzle shown; requires psr/http-client + a PSR-17 factory, which you likely already have):

use CursBnr\Http\Psr18HttpClient;

$bnr = new CursBnr(httpClient: new Psr18HttpClient(
    new \GuzzleHttp\Client(),
    new \GuzzleHttp\Psr7\HttpFactory(),
));

Custom feed URLs ๐Ÿงญ

Point at a mirror, a proxy, or a test server:

$bnr = new CursBnr(urls: new FeedUrls(
    daily: 'https://my-mirror.example/nbrfxrates.xml',
    yearlyTemplate: 'https://my-mirror.example/years/nbrfxrates{year}.xml',
));

๐Ÿ—„๏ธ Caching (there isn't any โ€” on purpose)

The package performs no caching: a rate for a past date never changes, but your app knows best how long to remember it and where. The only smartness inside is per-instance memoization of already-parsed yearly documents, so one walk-back over a weekend doesn't re-download the same file three times.

Wrapping it is a one-liner with whatever cache you already have (PSR-16 shown):

$key = "bnr:EUR:RON:{$date->format('Y-m-d')}";

$value = $cache->get($key);

if ($value === null) {
    $value = $bnr->pair('EUR', 'RON', $date);
    $cache->set($key, $value, 600);
}

In a framework, register CursBnr\CursBnr as a singleton in your DI container and inject it โ€” there's no service provider or bundle to install, and none is needed.

๐Ÿงฌ Data source & reliability notes

  • Feeds: curs.bnr.ro/nbrfxrates.xml (daily) and curs.bnr.ro/files/xml/years/nbrfxrates{year}.xml (per year, 2005+), the official endpoints per BNR's documentation.
  • The XML namespace is detected from the document, not hardcoded. BNR changed it from http://www.bnr.ro/xsd to https://www.bnr.ro/xsd in 2026 โ€” silently breaking every parser that had it hardcoded (this package exists partly because of that day ๐Ÿ™ƒ). Both variants are covered by tests.
  • BOMs, redirects, and multiplier quirks are handled; garbage responses fail loudly with ParseException.

๐Ÿงช Testing your app

The package ships everything you need to test code that depends on exchange rates โ€” no HTTP, no XML fixtures, no mocking library.

Step 1: depend on the interface

Type-hint CursBnr\ExchangeRates (not the concrete class) and bind the real client in your DI container:

use CursBnr\CursBnr;
use CursBnr\ExchangeRates;

final class InvoiceTotals
{
    public function __construct(private readonly ExchangeRates $bnr) {}

    public function inRon(float $amountEur, \DateTimeInterface $issuedOn): float
    {
        return $amountEur * $this->bnr->pair('EUR', 'RON', $issuedOn);
    }
}

// production wiring: bind ExchangeRates => new CursBnr()

Step 2: use the built-in fake in tests

CursBnr\Testing\FakeCursBnr is an in-memory ExchangeRates implementation. For most tests, constant mode is all you need โ€” one timeless answer for any date:

use CursBnr\Testing\FakeCursBnr;

$bnr = FakeCursBnr::constant(['EUR' => 5.0, 'USD' => 4.5]);

$totals = new InvoiceTotals($bnr);
$totals->inRon(100.0, new DateTimeImmutable('2024-01-27'));  // 500.0 โ€” always

Step 3: go date-aware when the calendar matters

Register specific communications and the fake behaves exactly like production โ€” strictly-before validOn(), weekend/holiday walk-back, the works:

$bnr = (new FakeCursBnr())
    ->communicate('2024-01-25', ['EUR' => 4.9765])
    ->communicate('2024-01-26', ['EUR' => 4.9763]);
    // nothing on 27-28 โ€” the weekend

$bnr->validOn('EUR', new DateTimeImmutable('2024-01-26'))->value;  // 4.9765 โ€” Thursday's (strictly before!)
$bnr->validOn('EUR', new DateTimeImmutable('2024-01-27'))->value;  // 4.9763 โ€” Friday's
$bnr->latest('EUR')->value;                                        // 4.9763 โ€” most recent registration

Step 4: test your error handling too

The fake throws the same typed exceptions as the real client, so your failure paths get real coverage instead of hand-waved mocks:

$bnr->communicatedOn('EUR', new DateTimeImmutable('2024-01-27')); // NoRateCommunicatedException (weekend)
$bnr->latest('CHF');                                              // UnknownCurrencyException (not registered)
FakeCursBnr::constant(['EUR' => 5.0], maxWalkBackDays: 3);        // bound configurable, like the real thing

// pin "today" to make the future-date guard deterministic in tests
new FakeCursBnr(today: '2024-01-29');

๐Ÿ”ฌ The fake cannot drift from the real client: both run the exact same internal semantics code (walk-back, future-date guard, 2005 floor) โ€” and a parity test runs a full scenario through both anyway, asserting identical values and identical exceptions.

Testing the wiring itself

If you specifically want to exercise the real parser/HTTP pipeline (rarely needed), the lower-level seam is still there: implement the one-method HttpClientInterface returning canned XML and point FeedUrls at test URLs.

๐Ÿงฉ Wiring it into your framework

For plain unit tests you don't need a container at all โ€” constructor-inject ExchangeRates and pass the fake directly:

$service = new InvoiceTotals(FakeCursBnr::constant(['EUR' => 5.0]));

The container binding matters for production wiring and for feature/integration tests, where your framework constructs the objects. Recipes:

Laravel

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(\CursBnr\ExchangeRates::class, \CursBnr\CursBnr::class);
}
// in a feature test โ€” every resolved service now sees the fake
$this->app->instance(
    \CursBnr\ExchangeRates::class,
    \CursBnr\Testing\FakeCursBnr::constant(['EUR' => 5.0]),
);

Symfony

# config/services.yaml
services:
    CursBnr\CursBnr: ~
    CursBnr\ExchangeRates: '@CursBnr\CursBnr'
// in a KernelTestCase / WebTestCase
static::getContainer()->set(
    \CursBnr\ExchangeRates::class,
    \CursBnr\Testing\FakeCursBnr::constant(['EUR' => 5.0]),
);

Any PSR-11 container (PHP-DI, League, ...)

Alias CursBnr\ExchangeRates to a shared CursBnr\CursBnr instance; in tests, register FakeCursBnr under the same id before booting the code under test.

// PHP-DI example
return [
    CursBnr\ExchangeRates::class => DI\autowire(CursBnr\CursBnr::class),
];

โ˜๏ธ Whatever the framework: depend on ExchangeRates, never on CursBnr directly, and the production/test split stays a one-line binding.

๐Ÿ“ Versioning & backward compatibility

The package follows semver. The public API is everything under CursBnr\ except classes and methods marked @internal โ€” those may change in any release. While on 0.x, breaking changes may land in minor versions and are always called out in the CHANGELOG; from 1.0 on, they only happen in major versions.

Security issues: see SECURITY.md โ€” please report privately.

๐Ÿค Development & AI transparency

This package is developed with AI assistance (Claude Code) โ€” disclosure level ai-generated per the ai-disclosure convention; details in AI_DISCLOSURE.md. Every release is human-reviewed and gated by the full test suite (run against real BNR XML documents), PHPStan at level max, code style checks, and CI across all supported PHP versions. Issues and PRs are read by a human โ€” me.

๐Ÿ“„ License

MIT ยฉ Peter Lupu