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
119 changes: 119 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,124 @@ jobs:
user: ${{ secrets.NUGET_USER }}

- name: Publish to NuGet
id: publish_step
if: steps.version-check.outputs.exists == 'false'
run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json

- name: Notify Slack (success)
if: steps.version-check.outputs.exists == 'false' && steps.publish_step.outcome == 'success'
env:
SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
VERSION: ${{ steps.version-check.outputs.version }}
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;
let latestChanges = 'See CHANGELOG for details.';

if (fs.existsSync('CHANGELOG.md')) {
const lines = fs.readFileSync('CHANGELOG.md', 'utf8').split(/\r?\n/);
const bullets = [];
let inTargetSection = false;
for (const line of lines) {
if (line.startsWith(`## [${version}]`)) {
inTargetSection = true;
continue;
}
if (inTargetSection && line.startsWith('## [')) break;
if (inTargetSection && line.startsWith('- ')) {
bullets.push(line.slice(2).trim());
}
}
if (bullets.length) {
latestChanges = bullets.slice(0, 5).map((bullet) => `• ${bullet}`).join('\n');
}
}

fs.writeFileSync('/tmp/slack_payload.json', JSON.stringify({
text: `Facturapi .NET SDK ${version} published to NuGet`,
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: `.NET SDK ${version} published to NuGet`,
},
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Package:* \`Facturapi ${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*',
`• NuGet: <https://www.nuget.org/packages/Facturapi/${version}|View package>`,
`• Workflow run: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}|Open run>`,
`• Changelog: <${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/blob/${process.env.GITHUB_SHA}/CHANGELOG.md|Read changes>`,
].join('\n'),
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Latest changes*\n${latestChanges}`,
},
},
],
}));
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-check.outputs.exists == 'false' && steps.publish_step.outcome == 'failure'
env:
SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
VERSION: ${{ steps.version-check.outputs.version }}
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 .NET SDK ${process.env.VERSION} publish failed`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: [
`*.NET SDK ${process.env.VERSION} publish 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.5.0] - 2026-06-07
### Added
- Expose structured API error metadata on `FacturapiException`, including `Code`, `Path`, `Location`, `Errors`, `LogId`, and response `Headers`.

## [6.4.0]
### Added
- Added `facturapi.Receipt.ToInvoiceAsync(Dictionary<string, object> data)` to call `POST /receipts/to-invoice`.
Expand Down
25 changes: 24 additions & 1 deletion FacturAPIException.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;

namespace Facturapi
{
public class FacturapiException : Exception
{
public int? Status { get; private set; }
public string Code { get; private set; }
public string Path { get; private set; }
public string Location { get; private set; }
public JArray Errors { get; private set; }
public string LogId { get; private set; }
public IReadOnlyDictionary<string, string> Headers { get; private set; }

public FacturapiException() : base() { }
public FacturapiException(string message, int? status = null) : base(message)
public FacturapiException(
string message,
int? status = null,
string code = null,
string path = null,
string location = null,
JArray errors = null,
string logId = null,
IReadOnlyDictionary<string, string> headers = null
) : base(message)
{
Status = status;
Code = code;
Path = path;
Location = location;
Errors = errors;
LogId = logId;
Headers = headers ?? new Dictionary<string, string>();
}
}
}
31 changes: 31 additions & 0 deletions FacturapiTest/WrapperBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,37 @@ public async Task ErrorMapping_UsesStatusFromString()
Assert.Equal("bad request", exception.Message);
}

[Fact]
public async Task ErrorMapping_ExposesApiErrorFieldsAndHeaders()
{
var handler = new RecordingHandler((request, cancellationToken) =>
{
var response = new HttpResponseMessage((HttpStatusCode)429)
{
Content = new StringContent(
"{\"message\":\"too many requests\",\"status\":429,\"code\":\"RATE_LIMIT_EXCEEDED\",\"path\":\"date\",\"location\":\"query\",\"errors\":[{\"code\":\"required\",\"message\":\"date is required\",\"path\":\"date\",\"location\":\"query\"}]}",
Encoding.UTF8,
"application/json")
};
response.Headers.Add("Retry-After", "3");
response.Headers.Add("x-facturapi-log-id", "log_123");
return Task.FromResult(response);
});

var wrapper = new CustomerWrapper("test_key", "v2", CreateHttpClient(handler));
var exception = await Assert.ThrowsAsync<FacturapiException>(() => wrapper.ListAsync());

Assert.Equal(429, exception.Status);
Assert.Equal("too many requests", exception.Message);
Assert.Equal("RATE_LIMIT_EXCEEDED", exception.Code);
Assert.Equal("date", exception.Path);
Assert.Equal("query", exception.Location);
Assert.Equal("log_123", exception.LogId);
Assert.Equal("required", exception.Errors[0]["code"]?.ToString());
Assert.Equal("3", exception.Headers["retry-after"]);
Assert.Equal("log_123", exception.Headers["x-facturapi-log-id"]);
}

[Fact]
public async Task ErrorMapping_UsesStatusFromFloat()
{
Expand Down
34 changes: 33 additions & 1 deletion Wrappers/BaseWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
Expand Down Expand Up @@ -67,7 +68,38 @@ protected FacturapiException CreateException(string resultString, HttpResponseMe
status = (int)response.StatusCode;
}

return new FacturapiException(message, status);
var headers = NormalizeResponseHeaders(response);
return new FacturapiException(
message,
status,
error?["code"]?.Type == JTokenType.String ? error["code"]?.ToString() : null,
error?["path"]?.Type == JTokenType.String ? error["path"]?.ToString() : null,
error?["location"]?.Type == JTokenType.String ? error["location"]?.ToString() : null,
error?["errors"] as JArray,
headers.TryGetValue("x-facturapi-log-id", out var logId) ? logId : null,
headers
);
}

private static IReadOnlyDictionary<string, string> NormalizeResponseHeaders(HttpResponseMessage response)
{
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (response == null)
{
return headers;
}
foreach (var header in response.Headers)
{
headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
}
if (response.Content != null)
{
foreach (var header in response.Content.Headers)
{
headers[header.Key.ToLowerInvariant()] = string.Join(", ", header.Value);
}
}
return headers;
}

protected async Task ThrowIfErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
Expand Down
2 changes: 1 addition & 1 deletion facturapi-net.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<Summary>SDK oficial de Facturapi para .NET para facturación electrónica en México (CFDI), envío de documentos, búsqueda y trazabilidad.</Summary>
<PackageTags>factura factura-electronica facturacion cfdi cfdi40 sat invoice invoicing facturapi mexico</PackageTags>
<Title>Facturapi</Title>
<Version>6.4.0</Version>
<Version>6.5.0</Version>
<PackageVersion>$(Version)</PackageVersion>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
Expand Down
Loading