From 485e954a6641add0bf95ea5e8a2d173df259efc4 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 23 Jul 2026 13:40:18 +0200 Subject: [PATCH 1/4] feat: DX overhaul and upgrade to gacela 1.19 / router 0.13 Developer-experience pass across the library plus the framework upgrade that the new gacela-router 0.13 release unblocks. DX / correctness: - Build LNURL-pay metadata via a shared LnurlPayMetadata value object using json_encode instead of string concatenation (a quote in the description no longer produces invalid JSON); removes duplication between CallbackUrl and InvoiceGenerator. - Wire HttpApi to catch Symfony transport/HTTP errors and JsonException and return null, so the backend surfaces a clean "unreachable" error instead of leaking a stack trace (the graceful path was previously dead code). - Remove dump($e) from the controller; normalize error responses to the LNURL {status, reason} shape. - Add a BackendType enum and clear errors in LightningConfig::addBackendsFile (missing file, invalid JSON, missing/unknown type); add addBackend() to register backends programmatically without a JSON file. - Centralize app-config keys in ConfigKey so the config writer/reader cannot drift; fix the nonsensical REQUEST_URI receiver default. - Add return-shape docblocks to the facade/generator for IDE autocomplete. - Rename the misleadingly named nostr.json backends file to backends.json. - Rewrite the README (install, config, HTTP API, programmatic use, reference). Gacela upgrade (gacela 1.19, router 0.13, container 0.10, phpstan-ext 0.4): - Add SendableRange::__set_state for gacela's var_export'd merged-config cache. - Add generic @extends params for the now-templated gacela base classes. - Swap deprecated DocBlockResolverAwareTrait for ServiceResolverAwareTrait. Tests: add coverage for metadata escaping, the backend-type enum, programmatic addBackend, and the hardened backends-file error paths. --- .gitignore | 2 +- README.md | 167 ++++++++++++++++-- nostr.dist.json => backends.dist.json | 0 composer.json | 8 +- lightning-config.dist.php | 7 +- src/Config/Backend/BackendType.php | 26 +++ src/Config/LightningConfig.php | 83 +++++---- src/Invoice/Application/CallbackUrl.php | 16 +- src/Invoice/Application/InvoiceGenerator.php | 33 +++- .../Controller/InvoiceController.php | 7 +- src/Invoice/Infrastructure/Http/HttpApi.php | 21 ++- src/Invoice/InvoiceConfig.php | 17 +- src/Invoice/InvoiceDependencyProvider.php | 3 + src/Invoice/InvoiceFacade.php | 13 ++ src/Invoice/InvoiceFactory.php | 2 + src/Shared/Config/ConfigKey.php | 22 +++ src/Shared/Value/LnurlPayMetadata.php | 41 +++++ src/Shared/Value/SendableRange.php | 10 ++ tests/Feature/InvoiceFacadeTest.php | 2 +- tests/Feature/{nostr.json => backends.json} | 0 tests/Unit/Config/Backend/BackendTypeTest.php | 25 +++ tests/Unit/Config/LightningConfigTest.php | 39 ++++ .../Shared/Value/LnurlPayMetadataTest.php | 44 +++++ 23 files changed, 494 insertions(+), 94 deletions(-) rename nostr.dist.json => backends.dist.json (100%) create mode 100644 src/Config/Backend/BackendType.php create mode 100644 src/Shared/Config/ConfigKey.php create mode 100644 src/Shared/Value/LnurlPayMetadata.php rename tests/Feature/{nostr.json => backends.json} (100%) create mode 100644 tests/Unit/Config/Backend/BackendTypeTest.php create mode 100644 tests/Unit/Shared/Value/LnurlPayMetadataTest.php diff --git a/.gitignore b/.gitignore index 5e174c9..a903034 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ var/ *.cache composer.lock lightning-config.php -nostr.json +backends.json diff --git a/README.md b/README.md index 761776a..4eab038 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # PHP Lightning Address -PHP Lightning Address is an easy way to get a [lightning address](https://lightningaddress.com/) in PHP. -

GitHub Build Status @@ -20,43 +18,180 @@ PHP Lightning Address is an easy way to get a [lightning address](https://lightn

-## Usage / Development +Self-host your own [Lightning Address](https://lightningaddress.com) in PHP: a human-readable identifier like `you@yourdomain.com` that any Lightning wallet can pay. It implements [LNURL-pay (LUD-06)](https://github.com/lnurl/luds/blob/luds/06.md) and is backend-agnostic — [LNbits](https://lnbits.com) is the backend available today. Built on the [Gacela](https://gacela-project.com) framework. + +## Requirements + +- PHP >= 8.2 + +## Install + +```bash +composer require php-lightning/lnaddress +``` + +`composer install` runs a post-install step that copies `backends.dist.json` → `backends.json` if the latter does not exist yet. + +Prefer starting from a working project? Use the ready-made [demo template](https://github.com/php-lightning/demo-lnaddress). It depends on this library, so a `composer update` pulls in new features and fixes as they land here. + +## Configure + +There are two config files: `lightning-config.php` (settings) and `backends.json` (per-user invoice backends). -Set up your custom config: +### 1. Settings — `lightning-config.php` ```bash cp lightning-config.dist.php lightning-config.php -# or just simply the nostr.json to define the backends/user-settings -cp nostr.dist.json nostr.json ``` -You can customize the invoice description and the success message by editing -`lightning-config.php`: +`LightningConfig` has a fluent API: ```php use PhpLightning\Config\LightningConfig; return (new LightningConfig()) - ->setDescriptionTemplate('Pay to %s on mynode') - ->setSuccessMessage('Thanks for the payment!'); + ->setDomain('yourdomain.com') + ->setReceiver('default-receiver') + ->setDescriptionTemplate('Pay to %s') // %s = the lightning address + ->setSuccessMessage('Thanks for the payment!') + ->setInvoiceMemo('') + ->setSendableRange(min: 100_000, max: 10_000_000_000) // millisats + ->setCallbackUrl('https://yourdomain.com') + ->addBackendsFile(getcwd() . '/backends.json'); +``` + +### 2. Backends — `backends.json` + +```bash +cp backends.dist.json backends.json +``` + +Each username maps to its own invoice backend: + +```json +{ + "bob": { "type": "lnbits", "api_key": "abc...123", "api_endpoint": "http://localhost:5000" }, + "alice": { "type": "lnbits", "api_key": "def...456", "api_endpoint": "http://localhost:5000" } +} +``` + +### Register backends programmatically (no JSON file) + +You can skip `backends.json` and register backends directly in `lightning-config.php`: + +```php +use PhpLightning\Config\Backend\LnBitsBackendConfig; + +$config->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'abc...123')); ``` -Run a local PHP server listening `public/index.php` +## Run the server ```bash composer serve ``` -### Demo template +This starts `php -S localhost:8080 public/index.php`. + +## HTTP API + +One route serves the full LNURL-pay flow: `GET /{username?}`. The username is optional — when omitted, the request resolves to the default `receiver@domain` from your config. + +### Step 1 — pay params + +`GET /bob` (no `amount`) returns the LNURL-pay parameters: + +```json +{ + "callback": "https://yourdomain.com", + "maxSendable": 10000000000, + "minSendable": 100000, + "metadata": "[[\"text/plain\",\"Pay to bob@yourdomain.com\"],[\"text/identifier\",\"bob@yourdomain.com\"]]", + "tag": "payRequest", + "commentAllowed": false +} +``` + +### Step 2 — invoice + +`GET /bob?amount=` returns a bolt11 invoice for that amount: + +```json +{ + "bolt11": "lnbc20n1p...", + "status": "OK", + "memo": "", + "successAction": { "tag": "message", "message": "Thanks for the payment!" }, + "routes": [], + "disposable": false, + "error": null +} +``` + +### Errors + +Failures return an LNURL error object, for example when the amount falls outside the sendable range or the backend is unreachable: + +```json +{ "status": "ERROR", "reason": "Amount is not between minimum and maximum sendable amount" } +``` + +> **Units:** the sendable range and the `amount` query param are in **millisats**. The backend is billed in **sats** (`millisats / 1000`). + +## Use as a library (programmatic) + +You can call the facade directly instead of going over HTTP: + +```php +use Gacela\Framework\Gacela; +use PhpLightning\Invoice\InvoiceFacade; + +Gacela::bootstrap(__DIR__); + +$facade = new InvoiceFacade(); +$payParams = $facade->getCallbackUrl('bob'); // LNURL-pay params +$invoice = $facade->generateInvoice('bob', 2_000); // millisats +``` + +## Configuration reference + +| Setter | Purpose | Default | +| --- | --- | --- | +| `setDomain(string)` | Your domain (URL scheme is stripped) | — | +| `setReceiver(string)` | Default username when none is in the URL | — | +| `setSendableRange(int $min, int $max)` | Allowed amounts, in millisats | `100_000` – `10_000_000_000` | +| `setCallbackUrl(string)` | Public callback base URL wallets call back to | — | +| `setDescriptionTemplate(string)` | LNURL metadata description (`%s` = the address) | `Pay to %s` | +| `setSuccessMessage(string)` | Message shown after a successful payment | `Payment received!` | +| `setInvoiceMemo(string)` | Memo attached to the invoice | `''` | +| `addBackendsFile(string $path)` / `addBackend(string $username, ...)` | Register invoice backends | — | + +## Adding a new backend + +Backends are keyed by a `type` string, resolved through the `PhpLightning\Config\Backend\BackendType` enum. To add one: + +- Add a case to `PhpLightning\Config\Backend\BackendType`. +- Handle that case in `LightningConfig::createBackendConfig()`. +- Implement `PhpLightning\Invoice\Domain\BackendInvoice\BackendInvoiceInterface`. + +## Development / Testing + +```bash +composer test-all # quality + phpunit + rector (dry-run) +``` + +Other useful scripts: -We prepared a demo template, so you can use this project as a dependency. The benefits from this approach is that you can update your project with `composer update` whenever there are new features or improvements on this `lnaddress` repository. +- `composer test-phpunit` — run the PHPUnit suite +- `composer quality` — php-cs-fixer (dry-run), psalm, phpstan +- `composer fix` — php-cs-fixer + rector (apply fixes) -> [https://github.com/php-lightning/demo-lnaddress](https://github.com/php-lightning/demo-lnaddress) +See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before opening a PR. ## Wiki -Check the wiki for more details: [https://github.com/php-lightning/lnaddress/wiki](https://github.com/php-lightning/lnaddress/wiki) +More details in the [wiki](https://github.com/php-lightning/lnaddress/wiki). ## Contributions -Feel free to open issues & PR if you want to contribute to this project. +Issues and pull requests are welcome. Licensed under [MIT](LICENSE). diff --git a/nostr.dist.json b/backends.dist.json similarity index 100% rename from nostr.dist.json rename to backends.dist.json diff --git a/composer.json b/composer.json index 6db052e..8cb1374 100644 --- a/composer.json +++ b/composer.json @@ -4,13 +4,13 @@ "license": "MIT", "require": { "php": ">=8.2", - "gacela-project/gacela": "^1.9", - "gacela-project/router": "^0.12", + "gacela-project/gacela": "^1.19", + "gacela-project/router": "^0.13", "symfony/http-client": "^7.2" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", - "gacela-project/phpstan-extension": "^0.3", + "gacela-project/phpstan-extension": "^0.4", "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^9.6", "psalm/plugin-phpunit": "^0.19", @@ -41,7 +41,7 @@ }, "scripts": { "post-install-cmd": [ - "[ ! -f nostr.json ] && cp nostr.dist.json nostr.json || true" + "[ ! -f backends.json ] && cp backends.dist.json backends.json || true" ], "ctal": [ "@static-clear-cache", diff --git a/lightning-config.dist.php b/lightning-config.dist.php index 1ce8000..8846a4e 100644 --- a/lightning-config.dist.php +++ b/lightning-config.dist.php @@ -7,9 +7,12 @@ return (new LightningConfig()) ->setDomain('localhost') ->setReceiver('default-receiver') + // %s is replaced with the payer-facing lightning address ->setDescriptionTemplate('Pay to %s') ->setSuccessMessage('Payment received!') ->setInvoiceMemo('') + // min/max are in millisats (sat * 1000) ->setSendableRange(min: 100_000, max: 10_000_000_000) - ->setCallbackUrl('localhost:8000/callback') - ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'nostr.json'); + // public URL wallets call back to request the invoice + ->setCallbackUrl('http://localhost:8080') + ->addBackendsFile(getcwd() . DIRECTORY_SEPARATOR . 'backends.json'); diff --git a/src/Config/Backend/BackendType.php b/src/Config/Backend/BackendType.php new file mode 100644 index 0000000..512896f --- /dev/null +++ b/src/Config/Backend/BackendType.php @@ -0,0 +1,26 @@ + $t->value, self::cases())), + )); + } +} diff --git a/src/Config/LightningConfig.php b/src/Config/LightningConfig.php index 7b50481..2a4d112 100644 --- a/src/Config/LightningConfig.php +++ b/src/Config/LightningConfig.php @@ -5,10 +5,16 @@ namespace PhpLightning\Config; use JsonSerializable; +use PhpLightning\Config\Backend\BackendConfigInterface; +use PhpLightning\Config\Backend\BackendType; use PhpLightning\Config\Backend\LnBitsBackendConfig; +use PhpLightning\Shared\Config\ConfigKey; use PhpLightning\Shared\Value\SendableRange; use RuntimeException; +use function is_array; +use function sprintf; + final class LightningConfig implements JsonSerializable { private ?BackendsConfig $backends = null; @@ -63,33 +69,29 @@ public function setInvoiceMemo(string $memo): self return $this; } - public function addBackendsFile(string $path): self + public function addBackend(string $username, BackendConfigInterface $backendConfig): self { $this->backends ??= new BackendsConfig(); + $this->backends->add($username, $backendConfig); + return $this; + } + + public function addBackendsFile(string $path): self + { + if (!is_file($path)) { + throw new RuntimeException(sprintf('Backends file not found: "%s"', $path)); + } - $jsonAsString = (string)file_get_contents($path); - /** @var array $json - */ - $json = json_decode($jsonAsString, true); - - foreach ($json as $user => $settings) { - if (!isset($settings['type'])) { - throw new RuntimeException('"type" missing'); - } - - if ($settings['type'] === 'lnbits') { // TODO: refactor - $this->backends->add( - $user, - LnBitsBackendConfig::withEndpointAndKey( - $settings['api_endpoint'] ?? '', - $settings['api_key'] ?? '', - ), - ); - } + /** @var mixed $json */ + $json = json_decode((string)file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + if (!is_array($json)) { + throw new RuntimeException(sprintf('Backends file "%s" must contain a JSON object', $path)); + } + + /** @var array $json */ + foreach ($json as $username => $settings) { + // A numeric username in JSON arrives as an int array key; cast so strict_types holds. + $this->addBackend((string)$username, $this->createBackendConfig((string)$username, $settings)); } return $this; @@ -99,30 +101,47 @@ public function jsonSerialize(): array { $result = []; if ($this->backends instanceof BackendsConfig) { - $result['backends'] = $this->backends->jsonSerialize(); + $result[ConfigKey::BACKENDS] = $this->backends->jsonSerialize(); } if ($this->domain !== null) { - $result['domain'] = $this->domain; + $result[ConfigKey::DOMAIN] = $this->domain; } if ($this->receiver !== null) { - $result['receiver'] = $this->receiver; + $result[ConfigKey::RECEIVER] = $this->receiver; } if ($this->sendableRange instanceof SendableRange) { - $result['sendable-range'] = $this->sendableRange; + $result[ConfigKey::SENDABLE_RANGE] = $this->sendableRange; } if ($this->callbackUrl !== null) { - $result['callback-url'] = $this->callbackUrl; + $result[ConfigKey::CALLBACK_URL] = $this->callbackUrl; } if ($this->descriptionTemplate !== null) { - $result['description-template'] = $this->descriptionTemplate; + $result[ConfigKey::DESCRIPTION_TEMPLATE] = $this->descriptionTemplate; } if ($this->successMessage !== null) { - $result['success-message'] = $this->successMessage; + $result[ConfigKey::SUCCESS_MESSAGE] = $this->successMessage; } if ($this->invoiceMemo !== null) { - $result['invoice-memo'] = $this->invoiceMemo; + $result[ConfigKey::INVOICE_MEMO] = $this->invoiceMemo; } return $result; } + + /** + * @param array{type?: string, api_endpoint?: string, api_key?: string} $settings + */ + private function createBackendConfig(string $username, array $settings): BackendConfigInterface + { + if (!isset($settings['type'])) { + throw new RuntimeException(sprintf('Missing "type" for backend "%s"', $username)); + } + + return match (BackendType::fromString($settings['type'])) { + BackendType::Lnbits => LnBitsBackendConfig::withEndpointAndKey( + $settings['api_endpoint'] ?? '', + $settings['api_key'] ?? '', + ), + }; + } } diff --git a/src/Invoice/Application/CallbackUrl.php b/src/Invoice/Application/CallbackUrl.php index 6adcfc2..5fcde3c 100644 --- a/src/Invoice/Application/CallbackUrl.php +++ b/src/Invoice/Application/CallbackUrl.php @@ -6,10 +6,9 @@ use PhpLightning\Invoice\Domain\CallbackUrl\CallbackUrlInterface; use PhpLightning\Invoice\Domain\CallbackUrl\LnAddressGeneratorInterface; +use PhpLightning\Shared\Value\LnurlPayMetadata; use PhpLightning\Shared\Value\SendableRange; -use function sprintf; - final readonly class CallbackUrl implements CallbackUrlInterface { private const TAG_PAY_REQUEST = 'payRequest'; @@ -25,21 +24,14 @@ public function __construct( public function getCallbackUrl(string $username): array { $lnAddress = $this->lnAddressGenerator->generate($username); - // Modify the description if you want to custom it - // This will be the description on the wallet that pays your ln address - // TODO: Make this customizable from some external configuration file - $description = sprintf($this->descriptionTemplate, $lnAddress); - - // TODO: images not implemented yet; `',["image/jpeg;base64","' . base64_encode($response) . '"]';` - $imageMetadata = ''; - $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $lnAddress . '"]' . $imageMetadata . ']'; + $metadata = new LnurlPayMetadata($this->descriptionTemplate, $lnAddress); - // payRequest json data, spec : https://github.com/lnurl/luds/blob/luds/06.md + // payRequest json data, spec: https://github.com/lnurl/luds/blob/luds/06.md return [ 'callback' => $this->callback, 'maxSendable' => $this->sendableRange->max(), 'minSendable' => $this->sendableRange->min(), - 'metadata' => $metadata, + 'metadata' => (string)$metadata, 'tag' => self::TAG_PAY_REQUEST, 'commentAllowed' => false, // TODO: Not implemented yet ]; diff --git a/src/Invoice/Application/InvoiceGenerator.php b/src/Invoice/Application/InvoiceGenerator.php index 4c3dbd1..55cbc9a 100644 --- a/src/Invoice/Application/InvoiceGenerator.php +++ b/src/Invoice/Application/InvoiceGenerator.php @@ -5,12 +5,10 @@ namespace PhpLightning\Invoice\Application; use PhpLightning\Invoice\Domain\BackendInvoice\BackendInvoiceInterface; - use PhpLightning\Shared\Transfer\InvoiceTransfer; +use PhpLightning\Shared\Value\LnurlPayMetadata; use PhpLightning\Shared\Value\SendableRange; -use function sprintf; - final readonly class InvoiceGenerator { public function __construct( @@ -23,6 +21,17 @@ public function __construct( ) { } + /** + * @return array{ + * bolt11: string, + * status: string, + * memo: string, + * successAction: array{tag: string, message: string}, + * routes: list, + * disposable: bool, + * error: string|null, + * }|array{status: string, reason: string} + */ public function generateInvoice(int $milliSats): array { if (!$this->sendableRange->contains($milliSats)) { @@ -31,17 +40,25 @@ public function generateInvoice(int $milliSats): array 'reason' => 'Amount is not between minimum and maximum sendable amount', ]; } - $description = sprintf($this->descriptionTemplate, $this->lnAddress); - // TODO: images not implemented yet - $imageMetadata = ''; - $metadata = '[["text/plain","' . $description . '"],["text/identifier","' . $this->lnAddress . '"]' . $imageMetadata . ']'; + $metadata = new LnurlPayMetadata($this->descriptionTemplate, $this->lnAddress); - $invoice = $this->backendInvoice->requestInvoice((int)($milliSats / 1000), $metadata, $this->memo); + $invoice = $this->backendInvoice->requestInvoice((int)($milliSats / 1000), (string)$metadata, $this->memo); return $this->mapResponseAsArray($invoice); } + /** + * @return array{ + * bolt11: string, + * status: string, + * memo: string, + * successAction: array{tag: string, message: string}, + * routes: list, + * disposable: bool, + * error: string|null, + * } + */ private function mapResponseAsArray(InvoiceTransfer $invoice): array { return [ diff --git a/src/Invoice/Infrastructure/Controller/InvoiceController.php b/src/Invoice/Infrastructure/Controller/InvoiceController.php index cade223..c6472e8 100644 --- a/src/Invoice/Infrastructure/Controller/InvoiceController.php +++ b/src/Invoice/Infrastructure/Controller/InvoiceController.php @@ -4,7 +4,7 @@ namespace PhpLightning\Invoice\Infrastructure\Controller; -use Gacela\Framework\DocBlockResolverAwareTrait; +use Gacela\Framework\ServiceResolverAwareTrait; use Gacela\Router\Entities\JsonResponse; use Gacela\Router\Entities\Request; use PhpLightning\Invoice\InvoiceFacade; @@ -15,7 +15,7 @@ */ final class InvoiceController { - use DocBlockResolverAwareTrait; + use ServiceResolverAwareTrait; public function __construct( private Request $request, @@ -40,10 +40,9 @@ public function __invoke(string $username = ''): JsonResponse $this->getFacade()->generateInvoice($username, $amount), ); } catch (Throwable $e) { - dump($e); return new JsonResponse([ 'status' => 'ERROR', - 'message' => $e->getMessage(), + 'reason' => $e->getMessage(), ]); } } diff --git a/src/Invoice/Infrastructure/Http/HttpApi.php b/src/Invoice/Infrastructure/Http/HttpApi.php index 0540f35..69279d7 100644 --- a/src/Invoice/Infrastructure/Http/HttpApi.php +++ b/src/Invoice/Infrastructure/Http/HttpApi.php @@ -4,19 +4,28 @@ namespace PhpLightning\Invoice\Infrastructure\Http; +use JsonException; use PhpLightning\Invoice\Domain\Http\HttpApiInterface; use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; final class HttpApi implements HttpApiInterface { public function postRequestInvoice(string $uri, string $body, array $headers = []): ?array { - $response = HttpClient::create() - ->request('POST', $uri, [ - 'headers' => $headers, - 'body' => $body, - ]); + try { + $response = HttpClient::create() + ->request('POST', $uri, [ + 'headers' => $headers, + 'body' => $body, + ]); - return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + // getContent() throws on transport failure and on 3xx/4xx/5xx, json_decode + // on a malformed 200 body; null lets the backend surface a clean error + // instead of a raw stack trace. + return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (ExceptionInterface|JsonException) { + return null; + } } } diff --git a/src/Invoice/InvoiceConfig.php b/src/Invoice/InvoiceConfig.php index 11766ec..c242a4a 100644 --- a/src/Invoice/InvoiceConfig.php +++ b/src/Invoice/InvoiceConfig.php @@ -5,6 +5,7 @@ namespace PhpLightning\Invoice; use Gacela\Framework\AbstractConfig; +use PhpLightning\Shared\Config\ConfigKey; use PhpLightning\Shared\Value\SendableRange; use RuntimeException; @@ -14,7 +15,7 @@ final class InvoiceConfig extends AbstractConfig { public function getCallback(): string { - return (string)$this->get('callback-url', 'undefined:callback-url'); + return (string)$this->get(ConfigKey::CALLBACK_URL, 'undefined:callback-url'); } public function getDefaultLnAddress(): string @@ -28,7 +29,7 @@ public function getDefaultLnAddress(): string public function getBackends(): array { /** @psalm-suppress MixedReturnTypeCoercion */ - return (array)$this->get('backends'); // @phpstan-ignore-line + return (array)$this->get(ConfigKey::BACKENDS); // @phpstan-ignore-line } /** @@ -51,31 +52,31 @@ public function getBackendOptionsFor(string $username): array public function getSendableRange(): SendableRange { - return $this->get('sendable-range', SendableRange::default()); + return $this->get(ConfigKey::SENDABLE_RANGE, SendableRange::default()); } public function getDescriptionTemplate(): string { - return (string)$this->get('description-template', 'Pay to %s'); + return (string)$this->get(ConfigKey::DESCRIPTION_TEMPLATE, 'Pay to %s'); } public function getSuccessMessage(): string { - return (string)$this->get('success-message', 'Payment received!'); + return (string)$this->get(ConfigKey::SUCCESS_MESSAGE, 'Payment received!'); } public function getInvoiceMemo(): string { - return (string)$this->get('invoice-memo', ''); + return (string)$this->get(ConfigKey::INVOICE_MEMO, ''); } public function getDomain(): string { - return (string)$this->get('domain', $_SERVER['HTTP_HOST'] ?? 'localhost'); + return (string)$this->get(ConfigKey::DOMAIN, $_SERVER['HTTP_HOST'] ?? 'localhost'); } private function getReceiver(): string { - return (string)$this->get('receiver', $_SERVER['REQUEST_URI'] ?? 'unknown-receiver'); + return (string)$this->get(ConfigKey::RECEIVER, 'unknown-receiver'); } } diff --git a/src/Invoice/InvoiceDependencyProvider.php b/src/Invoice/InvoiceDependencyProvider.php index 330a00f..31f7fed 100644 --- a/src/Invoice/InvoiceDependencyProvider.php +++ b/src/Invoice/InvoiceDependencyProvider.php @@ -8,6 +8,9 @@ use Gacela\Framework\Container\Container; use PhpLightning\Invoice\Infrastructure\Http\HttpApi; +/** + * @extends AbstractProvider + */ final class InvoiceDependencyProvider extends AbstractProvider { public const HTTP_API = 'HTTP_API'; diff --git a/src/Invoice/InvoiceFacade.php b/src/Invoice/InvoiceFacade.php index 19a142d..d93bf66 100644 --- a/src/Invoice/InvoiceFacade.php +++ b/src/Invoice/InvoiceFacade.php @@ -7,6 +7,8 @@ use Gacela\Framework\AbstractFacade; /** + * @extends AbstractFacade + * * @method InvoiceFactory getFactory() */ final class InvoiceFacade extends AbstractFacade @@ -28,6 +30,17 @@ public function getCallbackUrl(string $username): array ->getCallbackUrl($username); } + /** + * @return array{ + * bolt11: string, + * status: string, + * memo: string, + * successAction: array{tag: string, message: string}, + * routes: list, + * disposable: bool, + * error: string|null, + * }|array{status: string, reason: string} + */ public function generateInvoice(string $username, int $milliSats): array { return $this->getFactory() diff --git a/src/Invoice/InvoiceFactory.php b/src/Invoice/InvoiceFactory.php index 3418d45..e485f98 100644 --- a/src/Invoice/InvoiceFactory.php +++ b/src/Invoice/InvoiceFactory.php @@ -15,6 +15,8 @@ use PhpLightning\Invoice\Domain\Http\HttpApiInterface; /** + * @extends AbstractFactory + * * @method InvoiceConfig getConfig() */ final class InvoiceFactory extends AbstractFactory diff --git a/src/Shared/Config/ConfigKey.php b/src/Shared/Config/ConfigKey.php new file mode 100644 index 0000000..2e8f4e0 --- /dev/null +++ b/src/Shared/Config/ConfigKey.php @@ -0,0 +1,22 @@ +description()], + ['text/identifier', $this->lnAddress], + ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + public function description(): string + { + return sprintf($this->descriptionTemplate, $this->lnAddress); + } +} diff --git a/src/Shared/Value/SendableRange.php b/src/Shared/Value/SendableRange.php index 931abcf..1c3fc09 100644 --- a/src/Shared/Value/SendableRange.php +++ b/src/Shared/Value/SendableRange.php @@ -18,6 +18,16 @@ private function __construct( ) { } + /** + * Rehydrates the instance from Gacela's var_export'd merged-config file cache. + * + * @param array{min: int, max: int} $state + */ + public static function __set_state(array $state): self + { + return new self($state['min'], $state['max']); + } + public static function default(): self { return self::withMinMax( diff --git a/tests/Feature/InvoiceFacadeTest.php b/tests/Feature/InvoiceFacadeTest.php index 7d573a3..c2235e5 100644 --- a/tests/Feature/InvoiceFacadeTest.php +++ b/tests/Feature/InvoiceFacadeTest.php @@ -71,7 +71,7 @@ private function bootstrapGacela(): void ->setDomain('domain.com') ->setReceiver('receiver') ->setSendableRange(1_000, 10_000) - ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'nostr.json') + ->addBackendsFile(__DIR__ . DIRECTORY_SEPARATOR . 'backends.json') ->jsonSerialize(), ); }); diff --git a/tests/Feature/nostr.json b/tests/Feature/backends.json similarity index 100% rename from tests/Feature/nostr.json rename to tests/Feature/backends.json diff --git a/tests/Unit/Config/Backend/BackendTypeTest.php b/tests/Unit/Config/Backend/BackendTypeTest.php new file mode 100644 index 0000000..6de2aef --- /dev/null +++ b/tests/Unit/Config/Backend/BackendTypeTest.php @@ -0,0 +1,25 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown backend type "paypal". Supported types: lnbits'); + + BackendType::fromString('paypal'); + } +} diff --git a/tests/Unit/Config/LightningConfigTest.php b/tests/Unit/Config/LightningConfigTest.php index f3e8041..f305273 100644 --- a/tests/Unit/Config/LightningConfigTest.php +++ b/tests/Unit/Config/LightningConfigTest.php @@ -4,9 +4,11 @@ namespace PhpLightningTest\Unit\Config; +use PhpLightning\Config\Backend\LnBitsBackendConfig; use PhpLightning\Config\LightningConfig; use PhpLightning\Shared\Value\SendableRange; use PHPUnit\Framework\TestCase; +use RuntimeException; final class LightningConfigTest extends TestCase { @@ -68,4 +70,41 @@ public function test_description_and_success_message(): void 'success-message' => 'Thanks!', ], $config->jsonSerialize()); } + + public function test_add_backend_programmatically_without_a_file(): void + { + $config = (new LightningConfig()) + ->addBackend('bob', LnBitsBackendConfig::withEndpointAndKey('http://localhost:5000', 'key-123')); + + self::assertSame([ + 'backends' => [ + 'bob' => [ + 'api_endpoint' => 'http://localhost:5000', + 'api_key' => 'key-123', + ], + ], + ], $config->jsonSerialize()); + } + + public function test_add_backends_file_reports_missing_path(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Backends file not found: "/does/not/exist.json"'); + + (new LightningConfig())->addBackendsFile('/does/not/exist.json'); + } + + public function test_add_backends_file_reports_missing_type(): void + { + $path = (string)tempnam(sys_get_temp_dir(), 'lnaddr'); + file_put_contents($path, (string)json_encode(['bob' => ['api_key' => 'x']])); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing "type" for backend "bob"'); + (new LightningConfig())->addBackendsFile($path); + } finally { + unlink($path); + } + } } diff --git a/tests/Unit/Shared/Value/LnurlPayMetadataTest.php b/tests/Unit/Shared/Value/LnurlPayMetadataTest.php new file mode 100644 index 0000000..e40f76f --- /dev/null +++ b/tests/Unit/Shared/Value/LnurlPayMetadataTest.php @@ -0,0 +1,44 @@ +description()); + } + + /** + * A quote in the description used to break the hand-built JSON string; + * json_encode escapes it so the output stays parseable per LUD-06. + */ + public function test_escapes_quotes_to_stay_valid_json(): void + { + $metadata = new LnurlPayMetadata('Pay "now" to %s', 'bob@domain.com'); + + $decoded = json_decode((string)$metadata, true, flags: JSON_THROW_ON_ERROR); + + self::assertSame([ + ['text/plain', 'Pay "now" to bob@domain.com'], + ['text/identifier', 'bob@domain.com'], + ], $decoded); + } +} From d5fc11e998e2affcc8b8b211e2e1f15ac87b9306 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 23 Jul 2026 13:43:46 +0200 Subject: [PATCH 2/4] fix: use LUD-06 'pr' key for the invoice callback response LNURL-pay (LUD-06) mandates the callback response carry the bolt11 invoice in a field named 'pr'. The response keyed it 'bolt11', so spec-compliant wallets saw the invoice as missing. Rename the output key to 'pr' (the internal InvoiceTransfer property and the LNbits API field stay 'bolt11'). --- README.md | 2 +- src/Invoice/Application/InvoiceGenerator.php | 7 ++++--- src/Invoice/InvoiceFacade.php | 2 +- tests/Feature/InvoiceFacadeTest.php | 2 +- .../Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4eab038..b69c6b8 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ One route serves the full LNURL-pay flow: `GET /{username?}`. The username is op ```json { - "bolt11": "lnbc20n1p...", + "pr": "lnbc20n1p...", "status": "OK", "memo": "", "successAction": { "tag": "message", "message": "Thanks for the payment!" }, diff --git a/src/Invoice/Application/InvoiceGenerator.php b/src/Invoice/Application/InvoiceGenerator.php index 55cbc9a..f3afeed 100644 --- a/src/Invoice/Application/InvoiceGenerator.php +++ b/src/Invoice/Application/InvoiceGenerator.php @@ -23,7 +23,7 @@ public function __construct( /** * @return array{ - * bolt11: string, + * pr: string, * status: string, * memo: string, * successAction: array{tag: string, message: string}, @@ -50,7 +50,7 @@ public function generateInvoice(int $milliSats): array /** * @return array{ - * bolt11: string, + * pr: string, * status: string, * memo: string, * successAction: array{tag: string, message: string}, @@ -62,7 +62,8 @@ public function generateInvoice(int $milliSats): array private function mapResponseAsArray(InvoiceTransfer $invoice): array { return [ - 'bolt11' => $invoice->bolt11, + // LUD-06 names the invoice field "pr" (bech32 bolt11); wallets read this key. + 'pr' => $invoice->bolt11, 'status' => $invoice->status, 'memo' => $invoice->memo, 'successAction' => [ diff --git a/src/Invoice/InvoiceFacade.php b/src/Invoice/InvoiceFacade.php index d93bf66..e6c21eb 100644 --- a/src/Invoice/InvoiceFacade.php +++ b/src/Invoice/InvoiceFacade.php @@ -32,7 +32,7 @@ public function getCallbackUrl(string $username): array /** * @return array{ - * bolt11: string, + * pr: string, * status: string, * memo: string, * successAction: array{tag: string, message: string}, diff --git a/tests/Feature/InvoiceFacadeTest.php b/tests/Feature/InvoiceFacadeTest.php index c2235e5..6753912 100644 --- a/tests/Feature/InvoiceFacadeTest.php +++ b/tests/Feature/InvoiceFacadeTest.php @@ -48,7 +48,7 @@ public function test_ln_bits_feature(): void $json = $this->facade->generateInvoice('alice', 2_000); self::assertEquals([ - 'bolt11' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68', + 'pr' => 'lnbc10u1p5r9lmwpp53magnx9u5m3f3tnrm36ztj9rfdfhx5ga3zns7mefh2v0svax8uzqcqzyssp54twf429a8cvz6tflw5lt705gfnvuykhdeewey009tugjcuamt38q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqrrssrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclll4ttz7sp6kpvqqqqlgqqqqqeqqjq0uu89sejjllry5ye43x0v42jn48c6alfc9mfnjla2u6kmwy444pzrjmtu25nk2shshuh2mrqtehygmzya9xg89ppszuuhd9296vvcxspkpwc68', 'status' => 'pending', 'successAction' => [ 'tag' => 'message', diff --git a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php index d08c772..e3c0630 100644 --- a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php +++ b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php @@ -50,7 +50,7 @@ public function test_unknown_backend(): void $actual = $invoice->generateInvoice(2_000); self::assertEquals([ - 'bolt11' => '', + 'pr' => '', 'status' => 'ERROR', 'memo' => '', 'successAction' => [ @@ -80,7 +80,7 @@ public function test_successful_payment_request_with_amount(): void $actual = $invoice->generateInvoice(2_000); self::assertEquals([ - 'bolt11' => 'ln123456789', + 'pr' => 'ln123456789', 'status' => 'OK', 'memo' => 'Custom memo', 'successAction' => [ From f2bca2d16117118feee7a8b6d2a96e37ca3b3355 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 23 Jul 2026 13:49:18 +0200 Subject: [PATCH 3/4] feat: add CORS middleware and global exception handler (router 0.13) Use gacela-router 0.13's new middleware support: - CorsMiddleware sets Access-Control-Allow-Origin on every response and answers OPTIONS preflight directly, so browser-based wallets can call the LNURL endpoints. The route is registered for GET and OPTIONS so preflight reaches the middleware. - InvoiceExceptionHandler is registered as the router's global handler for Exception, rendering any uncaught error as the LNURL {status, reason} object. The controller no longer needs its own try/catch. Tests: unit-cover the middleware's preflight short-circuit / delegation and the handler's error rendering (process-isolated for the header() calls). --- README.md | 2 + .../Controller/InvoiceController.php | 23 +++------ .../Handler/InvoiceExceptionHandler.php | 23 +++++++++ .../Middleware/CorsMiddleware.php | 27 ++++++++++ .../Plugin/InvoiceRoutesPlugin.php | 18 +++++-- .../Handler/InvoiceExceptionHandlerTest.php | 27 ++++++++++ .../Middleware/CorsMiddlewareTest.php | 51 +++++++++++++++++++ 7 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php create mode 100644 src/Invoice/Infrastructure/Middleware/CorsMiddleware.php create mode 100644 tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php create mode 100644 tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php diff --git a/README.md b/README.md index b69c6b8..0e78877 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ This starts `php -S localhost:8080 public/index.php`. One route serves the full LNURL-pay flow: `GET /{username?}`. The username is optional — when omitted, the request resolves to the default `receiver@domain` from your config. +Every response carries permissive CORS headers (`Access-Control-Allow-Origin: *`) so browser-based wallets can call it, and `OPTIONS` preflight requests are answered directly. Uncaught errors are turned into the LNURL error object by a global handler. + ### Step 1 — pay params `GET /bob` (no `amount`) returns the LNURL-pay parameters: diff --git a/src/Invoice/Infrastructure/Controller/InvoiceController.php b/src/Invoice/Infrastructure/Controller/InvoiceController.php index c6472e8..05b0811 100644 --- a/src/Invoice/Infrastructure/Controller/InvoiceController.php +++ b/src/Invoice/Infrastructure/Controller/InvoiceController.php @@ -8,7 +8,6 @@ use Gacela\Router\Entities\JsonResponse; use Gacela\Router\Entities\Request; use PhpLightning\Invoice\InvoiceFacade; -use Throwable; /** * @method InvoiceFacade getFacade() @@ -27,23 +26,17 @@ public function __construct( */ public function __invoke(string $username = ''): JsonResponse { - try { - $amount = (int)$this->request->get('amount'); - - if ($amount === 0) { - return new JsonResponse( - $this->getFacade()->getCallbackUrl($username), - ); - } + // Errors bubble to InvoiceExceptionHandler (registered in InvoiceRoutesPlugin). + $amount = (int)$this->request->get('amount'); + if ($amount === 0) { return new JsonResponse( - $this->getFacade()->generateInvoice($username, $amount), + $this->getFacade()->getCallbackUrl($username), ); - } catch (Throwable $e) { - return new JsonResponse([ - 'status' => 'ERROR', - 'reason' => $e->getMessage(), - ]); } + + return new JsonResponse( + $this->getFacade()->generateInvoice($username, $amount), + ); } } diff --git a/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php b/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php new file mode 100644 index 0000000..6894fb7 --- /dev/null +++ b/src/Invoice/Infrastructure/Handler/InvoiceExceptionHandler.php @@ -0,0 +1,23 @@ + 'ERROR', + 'reason' => $exception->getMessage(), + ]); + } +} diff --git a/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php b/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..54b6c91 --- /dev/null +++ b/src/Invoice/Infrastructure/Middleware/CorsMiddleware.php @@ -0,0 +1,27 @@ +isMethod(Request::METHOD_OPTIONS)) { + header('Access-Control-Allow-Methods: GET, OPTIONS'); + return ''; + } + + return $next($request); + } +} diff --git a/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php b/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php index 11e9209..b08c3a1 100644 --- a/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php +++ b/src/Invoice/Infrastructure/Plugin/InvoiceRoutesPlugin.php @@ -4,9 +4,15 @@ namespace PhpLightning\Invoice\Infrastructure\Plugin; +use Exception; +use Gacela\Router\Configure\Handlers; +use Gacela\Router\Configure\Middlewares; use Gacela\Router\Configure\Routes; +use Gacela\Router\Entities\Request; use Gacela\Router\RouterInterface; use PhpLightning\Invoice\Infrastructure\Controller\InvoiceController; +use PhpLightning\Invoice\Infrastructure\Handler\InvoiceExceptionHandler; +use PhpLightning\Invoice\Infrastructure\Middleware\CorsMiddleware; final readonly class InvoiceRoutesPlugin { @@ -17,8 +23,14 @@ public function __construct( public function __invoke(): void { - $this->router->configure(static function (Routes $routes): void { - $routes->get('{username?}', InvoiceController::class); - }); + $this->router->configure( + static function (Routes $routes, Middlewares $middlewares, Handlers $handlers): void { + // OPTIONS is registered so CORS preflight reaches CorsMiddleware, + // which short-circuits it before InvoiceController runs. + $routes->match([Request::METHOD_GET, Request::METHOD_OPTIONS], '{username?}', InvoiceController::class); + $middlewares->add(new CorsMiddleware()); + $handlers->handle(Exception::class, new InvoiceExceptionHandler()); + }, + ); } } diff --git a/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php b/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php new file mode 100644 index 0000000..f97496f --- /dev/null +++ b/tests/Unit/Invoice/Infrastructure/Handler/InvoiceExceptionHandlerTest.php @@ -0,0 +1,27 @@ + 'ERROR', + 'reason' => 'Missing backend options for bob', + ], json_decode($json, true, flags: JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php b/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php new file mode 100644 index 0000000..9376ed1 --- /dev/null +++ b/tests/Unit/Invoice/Infrastructure/Middleware/CorsMiddlewareTest.php @@ -0,0 +1,51 @@ +handle( + Request::fromGlobals(), + static function () use (&$nextCalled): string { + $nextCalled = true; + return 'NEXT'; + }, + ); + + self::assertSame('', $result); + self::assertFalse($nextCalled); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function test_get_request_delegates_to_next(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $result = (new CorsMiddleware())->handle( + Request::fromGlobals(), + static fn (): string => 'NEXT', + ); + + self::assertSame('NEXT', $result); + } +} From 66e8cdd61ebf846593d5d5e346e11e8e20401456 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 23 Jul 2026 13:52:57 +0200 Subject: [PATCH 4/4] chore: modernize dev tooling (PHPUnit 10, PHPStan 2, Rector 2) - PHPUnit 9.6 -> 10.5: migrated phpunit.xml to the 10.x schema (coverage , deprecated attributes removed, cache directory added). Tests needed no code changes. - PHPStan 1.12 -> 2.2: clean at level max, no code changes required. - Rector 1.2 -> 2.0: applied its new suggestions (narrow a test fake's return type, sort named arguments to constructor-declaration order). - Remove symfony/var-dumper: it only backed the dump() call removed earlier. --- .gitignore | 2 + composer.json | 7 ++- phpunit.xml | 50 +++++++------------ tests/Feature/Fake/FakeHttpApi.php | 2 +- .../LnbitsBackendInvoiceTest.php | 2 +- .../Domain/LnAddress/InvoiceGeneratorTest.php | 2 +- 6 files changed, 26 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index a903034..6071ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ var/ composer.lock lightning-config.php backends.json +.phpunit.cache +.phpunit.result.cache diff --git a/composer.json b/composer.json index 8cb1374..19c2e32 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,10 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", "gacela-project/phpstan-extension": "^0.4", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^10.5", "psalm/plugin-phpunit": "^0.19", - "rector/rector": "^1.2", - "symfony/var-dumper": "^7.2", + "rector/rector": "^2.0", "vimeo/psalm": "^6.11" }, "config": { diff --git a/phpunit.xml b/phpunit.xml index cd6ac7e..fc6c350 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,34 +1,20 @@ - - - - - - - - - tests/Unit - - - tests/Feature - - - - - - src - - + + + + + + + + tests/Unit + + + tests/Feature + + + + + src + + diff --git a/tests/Feature/Fake/FakeHttpApi.php b/tests/Feature/Fake/FakeHttpApi.php index ee414ae..9acc2e4 100644 --- a/tests/Feature/Fake/FakeHttpApi.php +++ b/tests/Feature/Fake/FakeHttpApi.php @@ -8,7 +8,7 @@ final class FakeHttpApi implements HttpApiInterface { - public function postRequestInvoice(string $uri, string $body, array $headers = []): ?array + public function postRequestInvoice(string $uri, string $body, array $headers = []): array { return [ 'checking_id' => '8efa8998bca6e298ae63dc7425c8a34b5373511d88a70f6f29ba98f833a63f04', diff --git a/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php b/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php index a9bd230..255f18b 100644 --- a/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php +++ b/tests/Unit/Invoice/Domain/BackendInvoice/LnbitsBackendInvoiceTest.php @@ -22,7 +22,7 @@ public function test_request_invoice_when_api_returns_null(): void ]); $actual = $invoice->requestInvoice(100, '', ''); - $expected = new InvoiceTransfer(error: 'Backend "LnBits" unreachable', status: 'ERROR'); + $expected = new InvoiceTransfer(status: 'ERROR', error: 'Backend "LnBits" unreachable'); self::assertEquals($expected, $actual); } diff --git a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php index e3c0630..a18b650 100644 --- a/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php +++ b/tests/Unit/Invoice/Domain/LnAddress/InvoiceGeneratorTest.php @@ -37,7 +37,7 @@ public function test_unknown_backend(): void { $invoiceFacade = $this->createStub(BackendInvoiceInterface::class); $invoiceFacade->method('requestInvoice') - ->willReturn(new InvoiceTransfer(error: 'some reason', status: 'ERROR')); + ->willReturn(new InvoiceTransfer(status: 'ERROR', error: 'some reason')); $invoice = new InvoiceGenerator( $invoiceFacade,