Skip to content
Open
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
102 changes: 102 additions & 0 deletions .github/workflows/deploy-on-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,115 @@ 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:
tag_name: ${{ steps.version.outputs.tag }}
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: <https://packagist.org/packages/facturapi/facturapi-php#${version}|View package>`,
`• 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: |
Expand Down
6 changes: 3 additions & 3 deletions VERSION.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion src/Exceptions/FacturapiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@ class FacturapiException extends Exception
private mixed $errorData;
private ?int $statusCode;
private ?string $rawBody;
private array $headers;

public function __construct(
string $message = '',
int $code = 0,
?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
Expand All @@ -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;
}
}
12 changes: 11 additions & 1 deletion src/Http/BaseClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
23 changes: 22 additions & 1 deletion tests/Http/ErrorHandlingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,31 @@ public function testApiErrorShapeIsFullyAvailableOnException(): void
$errorBody = [
'message' => 'Request validation failed',
'code' => 'validation_error',
'path' => 'customer.tax_id',
'location' => 'body',
'details' => [
[
'path' => 'customer.tax_id',
'message' => 'customer.tax_id must be a valid RFC',
'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]);
Expand All @@ -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']);
}
Expand Down
Loading