diff --git a/.github/workflows/deploy-on-main.yml b/.github/workflows/deploy-on-main.yml index 8236d54..2437f85 100644 --- a/.github/workflows/deploy-on-main.yml +++ b/.github/workflows/deploy-on-main.yml @@ -98,6 +98,7 @@ jobs: git push origin "$TAG" - name: Create GitHub release + id: release if: steps.version.outputs.should_deploy == 'true' uses: softprops/action-gh-release@v2 with: @@ -105,6 +106,107 @@ jobs: name: ${{ steps.version.outputs.tag }} body_path: RELEASE_NOTES.md + - name: Notify Slack (success) + if: steps.version.outputs.should_deploy == 'true' && steps.release.outcome == 'success' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.version.outputs.tag }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + + const version = process.env.VERSION; + const releaseNotes = fs.existsSync('RELEASE_NOTES.md') + ? fs.readFileSync('RELEASE_NOTES.md', 'utf8').trim() + : 'See release notes for details.'; + + fs.writeFileSync('/tmp/slack_payload.json', JSON.stringify({ + text: `Facturapi PHP SDK ${version} released`, + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `PHP SDK ${version} released`, + }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Package:* \`facturapi/facturapi-php:${version}\`` }, + { type: 'mrkdwn', text: `*Branch:* \`${process.env.GITHUB_REF_NAME}\`` }, + { type: 'mrkdwn', text: `*Commit:* \`${process.env.GITHUB_SHA}\`` }, + { type: 'mrkdwn', text: `*Actor:* \`${process.env.GITHUB_ACTOR}\`` }, + ], + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + '*Useful links*', + `• Packagist: `, + `• GitHub release: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/releases/tag/${version}|Open release>`, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + ].join('\n'), + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Latest changes*\n${releaseNotes}`, + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true + + - name: Notify Slack (failure) + if: failure() && steps.version.outputs.should_deploy == 'true' + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.version.outputs.tag }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + node <<'JS' + const fs = require('node:fs'); + + fs.writeFileSync('/tmp/slack_payload_failure.json', JSON.stringify({ + text: `Facturapi PHP SDK ${process.env.VERSION} release failed`, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + `*PHP SDK ${process.env.VERSION} release failed*`, + `• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`, + `• Commit: \`${process.env.GITHUB_SHA}\``, + ].join('\n'), + }, + }, + ], + })); + JS + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true + - name: Deploy skipped if: steps.version.outputs.should_deploy != 'true' run: | diff --git a/VERSION.md b/VERSION.md index db95fa3..ff3a77f 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,4 @@ -4.4.0 +4.5.0 + ## Added -- Added `Receipts->toInvoice(array $data)` to call `POST /receipts/multi-invoice`. -- Added `Receipts->previewToInvoicePdf(array $data)` to call `POST /receipts/multi-invoice/preview/pdf`. +- Expose structured API error metadata on `FacturapiException`, including `code`, `path`, `location`, `errors`, `logId`, and response headers. diff --git a/src/Exceptions/FacturapiException.php b/src/Exceptions/FacturapiException.php index 5ad4f83..958dadd 100644 --- a/src/Exceptions/FacturapiException.php +++ b/src/Exceptions/FacturapiException.php @@ -10,6 +10,7 @@ class FacturapiException extends Exception private mixed $errorData; private ?int $statusCode; private ?string $rawBody; + private array $headers; public function __construct( string $message = '', @@ -17,12 +18,14 @@ public function __construct( ?Throwable $previous = null, mixed $errorData = null, ?int $statusCode = null, - ?string $rawBody = null + ?string $rawBody = null, + array $headers = array() ) { parent::__construct($message, $code, $previous); $this->errorData = $errorData; $this->statusCode = $statusCode; $this->rawBody = $rawBody; + $this->headers = $headers; } public function getErrorData(): mixed @@ -44,4 +47,42 @@ public function getRawBody(): ?string { return $this->rawBody; } + + public function getErrorCode(): mixed + { + return is_array($this->errorData) ? ($this->errorData['code'] ?? null) : null; + } + + public function getErrorPath(): ?string + { + return is_array($this->errorData) && is_string($this->errorData['path'] ?? null) + ? $this->errorData['path'] + : null; + } + + public function getErrorLocation(): ?string + { + return is_array($this->errorData) && is_string($this->errorData['location'] ?? null) + ? $this->errorData['location'] + : null; + } + + public function getErrors(): ?array + { + return is_array($this->errorData) && is_array($this->errorData['errors'] ?? null) + ? $this->errorData['errors'] + : null; + } + + public function getLogId(): ?string + { + return is_string($this->headers['x-facturapi-log-id'] ?? null) + ? $this->headers['x-facturapi-log-id'] + : null; + } + + public function getResponseHeaders(): array + { + return $this->headers; + } } diff --git a/src/Http/BaseClient.php b/src/Http/BaseClient.php index d7baae3..295742d 100644 --- a/src/Http/BaseClient.php +++ b/src/Http/BaseClient.php @@ -372,13 +372,23 @@ protected function executeRequest($method, $url, $headers = array(), $body = nul null, $errorData, $this->lastStatus, - $output + $output, + $this->normalizeResponseHeaders($response->getHeaders()) ); } return $output; } + private function normalizeResponseHeaders(array $headers): array + { + $normalized = array(); + foreach ($headers as $name => $values) { + $normalized[strtolower($name)] = is_array($values) ? implode(', ', $values) : (string) $values; + } + return $normalized; + } + /** * Extracts a human-readable message from known API error shapes. * diff --git a/tests/Http/ErrorHandlingTest.php b/tests/Http/ErrorHandlingTest.php index f98ffc5..d22e7d2 100644 --- a/tests/Http/ErrorHandlingTest.php +++ b/tests/Http/ErrorHandlingTest.php @@ -17,6 +17,8 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void $errorBody = [ 'message' => 'Request validation failed', 'code' => 'validation_error', + 'path' => 'customer.tax_id', + 'location' => 'body', 'details' => [ [ 'path' => 'customer.tax_id', @@ -24,10 +26,22 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void 'code' => 'invalid_rfc', ], ], + 'errors' => [ + [ + 'path' => 'customer.tax_id', + 'location' => 'body', + 'message' => 'customer.tax_id must be a valid RFC', + 'code' => 'invalid_rfc', + ], + ], ]; $httpClient = new FakeHttpClient( - new Response(422, ['Content-Type' => 'application/json'], json_encode($errorBody)) + new Response(422, [ + 'Content-Type' => 'application/json', + 'Retry-After' => '3', + 'x-facturapi-log-id' => 'log_123', + ], json_encode($errorBody)) ); $invoices = new Invoices('sk_test_abc123', ['httpClient' => $httpClient]); @@ -43,6 +57,13 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void self::assertSame(json_encode($errorBody), $exception->getRawBody()); self::assertSame('validation_error', $exception->getErrorData()['code']); + self::assertSame('validation_error', $exception->getErrorCode()); + self::assertSame('customer.tax_id', $exception->getErrorPath()); + self::assertSame('body', $exception->getErrorLocation()); + self::assertSame($errorBody['errors'], $exception->getErrors()); + self::assertSame('log_123', $exception->getLogId()); + self::assertSame('3', $exception->getResponseHeaders()['retry-after']); + self::assertSame('log_123', $exception->getResponseHeaders()['x-facturapi-log-id']); self::assertSame('customer.tax_id', $exception->getErrorData()['details'][0]['path']); self::assertSame('invalid_rfc', $exception->getErrorData()['details'][0]['code']); }