@polymorphism-tech/morph-spec 8.46.0 → 8.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +1 -4
- package/bin/validate.js +4 -25
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +8 -8
- package/framework/commands/create-umbraco-lp.md +2 -0
- package/framework/commands/migrate-lp-to-umbraco.md +2 -0
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/commands/morph-infra.md +17 -203
- package/framework/commands/morph-media.md +3 -0
- package/framework/commands/morph-preflight.md +5 -28
- package/framework/commands/morph-proposal.md +2 -2
- package/framework/commands/morph-troubleshoot.md +2 -14
- package/framework/docs/CLI.md +3 -3
- package/framework/hooks/claude-code/core/state-sync.js +13 -1
- package/framework/hooks/shared/e2e-check.js +3 -4
- package/framework/hooks/shared/state-writer.js +1 -0
- package/framework/rules/ai-agents-standards.md +18 -16
- package/framework/rules/asaas-standards.md +3 -1
- package/framework/rules/csharp-standards.md +10 -8
- package/framework/rules/data-standards.md +5 -5
- package/framework/rules/dotnet-templates.md +1 -1
- package/framework/rules/frontend-standards.md +4 -2
- package/framework/rules/gsap-standards.md +10 -5
- package/framework/rules/hangfire-standards.md +3 -1
- package/framework/rules/infrastructure-standards.md +4 -2
- package/framework/rules/morph-workflow.md +2 -2
- package/framework/rules/neon-auth-standards.md +3 -1
- package/framework/rules/neon-standards.md +6 -4
- package/framework/rules/nextjs-standards.md +13 -11
- package/framework/rules/nextjs-templates.md +1 -1
- package/framework/rules/resend-standards.md +3 -1
- package/framework/schemas/feature.schema.json +1 -3
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
- package/framework/skills/core/morph-apply/SKILL.md +3 -3
- package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
- package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
- package/framework/skills/core/morph-implement/SKILL.md +3 -3
- package/framework/skills/core/morph-machine/SKILL.md +3 -3
- package/framework/skills/core/morph-media/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-proposal/SKILL.md +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
- package/framework/skills/core/morph-standards/SKILL.md +6 -6
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/standards/STANDARDS.json +59 -56
- package/framework/standards/ai-agents/evals-with-cache.md +1 -1
- package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
- package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
- package/framework/standards/ai-agents/prompt-sources.md +6 -4
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/ai-agents/structured-output.md +1 -1
- package/framework/standards/ai-agents/sweet-spot.md +1 -1
- package/framework/standards/architecture/code-comments.md +2 -2
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
- package/framework/standards/backend/api/validation.md +135 -47
- package/framework/standards/backend/database/postgresql/database.md +11 -7
- package/framework/standards/backend/database/vector-search-rag.md +207 -324
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
- package/framework/standards/backend/dotnet/result-pattern.md +40 -6
- package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
- package/framework/standards/backend/dotnet/scrutor.md +1 -1
- package/framework/standards/backend/dotnet/testing.md +1 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
- package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
- package/framework/standards/data/nosql/cache/redis.md +120 -44
- package/framework/standards/data/nosql/cosmos-db.md +114 -71
- package/framework/standards/frontend/design-system/animations.md +1 -1
- package/framework/standards/frontend/design-system/naming.md +1 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
- package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
- package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
- package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
- package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
- package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
- package/framework/templates/infrastructure/github/README.md +2 -1
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
- package/package.json +1 -1
- package/src/commands/observability/telemetry.js +3 -5
- package/src/commands/project/init.js +2 -1
- package/src/commands/project/update.js +16 -18
- package/src/commands/state/approve.js +17 -0
- package/src/commands/state/gate-check.js +4 -14
- package/src/commands/validation/verify.js +3 -12
- package/src/core/paths/output-schema.js +1 -1
- package/src/dashboard/app/App.jsx +0 -2
- package/src/dashboard/app/components/CommandPalette.jsx +2 -3
- package/src/dashboard/app/components/EntityLink.jsx +0 -1
- package/src/dashboard/app/components/Markdown.jsx +1 -1
- package/src/dashboard/app/components/Sidebar.jsx +1 -2
- package/src/dashboard/app/pages/RulesMap.jsx +3 -3
- package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
- package/src/dashboard/scanner/activity-scanner.js +1 -1
- package/src/dashboard/scanner/docs-scanner.js +5 -23
- package/src/dashboard/scanner/features-scanner.js +1 -1
- package/src/dashboard/scanner/index.js +0 -2
- package/src/dashboard/scanner/rules-scanner.js +15 -10
- package/src/dashboard/scanner/skills-scanner.js +2 -3
- package/src/lib/evaluator-report.js +13 -0
- package/src/lib/risk-detector.js +8 -0
- package/src/lib/stack/stack-profile.js +1 -1
- package/src/lib/validators/reuse/reuse-validator.js +2 -3
- package/src/lib/validators/reuse/symbol-scanner.js +2 -4
- package/src/lib/validators/validation-runner.js +8 -88
- package/src/lib/validators/verify-runner.js +22 -72
- package/src/scripts/setup-infra.js +33 -22
- package/src/utils/agents-installer.js +20 -8
- package/src/utils/file-copier.js +7 -5
- package/framework/commands/commit.md +0 -166
- package/framework/rubrics/backend.json +0 -41
- package/framework/rubrics/default.json +0 -36
- package/framework/rubrics/frontend.json +0 -36
- package/framework/templates/project/validate.js +0 -97
- package/src/dashboard/app/pages/Rubrics.jsx +0 -97
- package/src/dashboard/scanner/rubrics-scanner.js +0 -12
- package/src/lib/validators/architecture/architecture-validator.js +0 -270
- package/src/lib/validators/architecture/index.js +0 -7
- package/src/lib/validators/css/css-validator.js +0 -340
- package/src/lib/validators/css/index.js +0 -7
- package/src/lib/validators/design-system/design-system-validator.js +0 -237
- package/src/lib/validators/design-system/index.js +0 -7
- package/src/lib/validators/score-aggregator.js +0 -244
- package/src/lib/validators/ui/index.js +0 -7
- package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
> **Keywords:** asaas, payment, brazilian, pix, boleto, billing
|
|
6
6
|
> **Load When:** asaas or brazilian payment keywords detected
|
|
7
7
|
|
|
8
|
-
**Verified against:** Asaas REST API v3 (
|
|
8
|
+
**Verified against:** Asaas REST API v3 (docs read via context7 on 2026-10-03: headers, `POST /payments`, `GET /payments?externalReference=`, `pixQrCode`, webhook payload, delivery and error rules) + .NET 10 (SDK 10.0.400-preview, `IHttpClientFactory` from the ASP.NET Core shared framework) + EF Core 10.0.12. The options, client, registration, PIX slice, webhook slice/endpoint and CPF/CNPJ blocks compile with `TreatWarningsAsErrors` over the `Result`/`ErrorHttpMapping` of `result-pattern.md`; the CPF/CNPJ validator was run against valid and invalid documents (numeric and the alphanumeric `12.ABC.345/01DE-35`), and the `BaseUrl` trailing-slash rule and the `dueDate` JSON shape were checked by running them; the SQL is documentary. Last-verified: 2026-10-03.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
Brazilian payment gateway integration for PIX, Boleto, and credit card processing.
|
|
12
|
+
Brazilian payment gateway integration for PIX, Boleto, and credit card processing, from the .NET backend. The Next.js frontend never calls Asaas and never sees the API key: it calls the backend endpoints through the proxy (TanStack Query), and the backend talks to Asaas.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -22,239 +22,383 @@ Asaas is a Brazilian payment service provider offering:
|
|
|
22
22
|
- Subscription billing
|
|
23
23
|
- Split payments
|
|
24
24
|
|
|
25
|
-
**
|
|
25
|
+
Asaas publishes **no official .NET SDK** -- integrate against the documented REST API with a typed `HttpClient`. Community wrappers exist but are unofficial and vary in maintenance.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
29
|
## Core Principles
|
|
30
30
|
|
|
31
|
-
1. **Webhook-First**:
|
|
32
|
-
2. **
|
|
33
|
-
3. **Security**:
|
|
34
|
-
4. **Brazil-Specific**:
|
|
31
|
+
1. **Webhook-First**: payment state changes arrive by webhook; the local record follows the events
|
|
32
|
+
2. **No duplicate charges**: every charge carries your own id in `externalReference`; before retrying a create, look it up (the create endpoint is not idempotent and Asaas documents no idempotency-key header)
|
|
33
|
+
3. **Security**: compare the webhook token in constant time, never store full card data, the API key lives in configuration
|
|
34
|
+
4. **Brazil-Specific**: validate CPF/CNPJ (including the alphanumeric CNPJ), compute due dates in `America/Sao_Paulo`
|
|
35
|
+
5. **Result, not exceptions**: Asaas 4xx answers become `Result` failures mapped by `ErrorType`; only network/5xx surprises propagate as exceptions
|
|
35
36
|
|
|
36
37
|
---
|
|
37
38
|
|
|
38
39
|
## API Configuration
|
|
39
40
|
|
|
40
|
-
###
|
|
41
|
+
### Configuration (user-secrets in dev, Coolify environment variables in prod)
|
|
41
42
|
|
|
42
43
|
```bash
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ASAAS_ENVIRONMENT=sandbox # or production
|
|
44
|
+
dotnet user-secrets set "Asaas:ApiKey" "<sandbox-api-key>"
|
|
45
|
+
dotnet user-secrets set "Asaas:WebhookToken" "<webhook-auth-token>"
|
|
46
|
+
# Coolify: Asaas__ApiKey, Asaas__WebhookToken, Asaas__BaseUrl, Asaas__UserAgent
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Asaas
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
access_token: process.env.ASAAS_API_KEY!,
|
|
71
|
-
...init?.headers,
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
if (!res.ok) {
|
|
75
|
-
throw new Error(`Asaas ${res.status}: ${await res.text()}`);
|
|
76
|
-
}
|
|
77
|
-
return res.json() as Promise<T>;
|
|
49
|
+
| Key | Sandbox | Production |
|
|
50
|
+
|-----|---------|------------|
|
|
51
|
+
| `Asaas:BaseUrl` | `https://api-sandbox.asaas.com/v3/` | `https://api.asaas.com/v3/` |
|
|
52
|
+
| `Asaas:ApiKey` | sandbox key | production key (keys are per environment) |
|
|
53
|
+
| `Asaas:UserAgent` | `MyApp/1.0.0` | `MyApp/1.0.0` |
|
|
54
|
+
| `Asaas:WebhookToken` | token set on the sandbox webhook | token set on the production webhook |
|
|
55
|
+
|
|
56
|
+
### Options and typed client
|
|
57
|
+
|
|
58
|
+
Every request carries three headers: `access_token` (the API key -- Asaas does **not** use `Authorization: Bearer`), `Content-Type: application/json`, and `User-Agent` (mandatory for root accounts created from 06/11/2024; send it always). A wrong or missing key answers 401.
|
|
59
|
+
|
|
60
|
+
```csharp
|
|
61
|
+
// Infrastructure/Asaas/AsaasOptions.cs
|
|
62
|
+
using System.ComponentModel.DataAnnotations;
|
|
63
|
+
|
|
64
|
+
public sealed class AsaasOptions
|
|
65
|
+
{
|
|
66
|
+
[Required, Url] public string BaseUrl { get; init; } = string.Empty;
|
|
67
|
+
[Required] public string ApiKey { get; init; } = string.Empty;
|
|
68
|
+
[Required] public string UserAgent { get; init; } = string.Empty;
|
|
69
|
+
[Required] public string WebhookToken { get; init; } = string.Empty;
|
|
78
70
|
}
|
|
79
71
|
```
|
|
80
72
|
|
|
73
|
+
```csharp
|
|
74
|
+
// Infrastructure/Asaas/AsaasClient.cs
|
|
75
|
+
using System.Net;
|
|
76
|
+
using System.Net.Http.Json;
|
|
77
|
+
|
|
78
|
+
public sealed record AsaasCreatePayment(
|
|
79
|
+
string Customer, string BillingType, decimal Value, DateOnly DueDate, string? Description, string ExternalReference);
|
|
80
|
+
public sealed record AsaasPayment(string Id, string Status, decimal Value, string? ExternalReference);
|
|
81
|
+
public sealed record AsaasPixQrCode(string EncodedImage, string Payload, string? ExpirationDate);
|
|
82
|
+
public sealed record AsaasPage<T>(IReadOnlyList<T> Data);
|
|
83
|
+
public sealed record AsaasErrorItem(string Code, string Description);
|
|
84
|
+
public sealed record AsaasErrorBody(IReadOnlyList<AsaasErrorItem> Errors);
|
|
85
|
+
|
|
86
|
+
public sealed class AsaasClient(HttpClient http)
|
|
87
|
+
{
|
|
88
|
+
public async Task<Result<AsaasPayment>> CreatePaymentAsync(AsaasCreatePayment payment, CancellationToken ct) =>
|
|
89
|
+
await ReadAsync<AsaasPayment>(await http.PostAsJsonAsync("payments", payment, ct), ct);
|
|
90
|
+
|
|
91
|
+
public async Task<Result<AsaasPage<AsaasPayment>>> FindByExternalReferenceAsync(string externalReference, CancellationToken ct) =>
|
|
92
|
+
await ReadAsync<AsaasPage<AsaasPayment>>(
|
|
93
|
+
await http.GetAsync($"payments?externalReference={Uri.EscapeDataString(externalReference)}", ct), ct);
|
|
94
|
+
|
|
95
|
+
public async Task<Result<AsaasPixQrCode>> GetPixQrCodeAsync(string paymentId, CancellationToken ct) =>
|
|
96
|
+
await ReadAsync<AsaasPixQrCode>(await http.GetAsync($"payments/{paymentId}/pixQrCode", ct), ct);
|
|
97
|
+
|
|
98
|
+
private static async Task<Result<T>> ReadAsync<T>(HttpResponseMessage response, CancellationToken ct)
|
|
99
|
+
{
|
|
100
|
+
using (response)
|
|
101
|
+
{
|
|
102
|
+
if (response.IsSuccessStatusCode)
|
|
103
|
+
return Result.Create(await response.Content.ReadFromJsonAsync<T>(ct));
|
|
104
|
+
|
|
105
|
+
if (response.StatusCode == HttpStatusCode.BadRequest)
|
|
106
|
+
{
|
|
107
|
+
var body = await response.Content.ReadFromJsonAsync<AsaasErrorBody>(ct);
|
|
108
|
+
var errors = body?.Errors.Select(e => Error.Validation($"Asaas.{e.Code}", e.Description)).ToArray() ?? [];
|
|
109
|
+
return errors.Length == 1 ? errors[0] : new ValidationError(errors);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return response.StatusCode == HttpStatusCode.NotFound
|
|
113
|
+
? Error.NotFound("Asaas.NotFound", "Recurso não encontrado no Asaas.")
|
|
114
|
+
: Error.Unexpected("Asaas.Unexpected", $"Asaas respondeu {(int)response.StatusCode}.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```csharp
|
|
121
|
+
// Program.cs (or an AddAsaas extension)
|
|
122
|
+
using Microsoft.Extensions.Options;
|
|
123
|
+
|
|
124
|
+
public static class AsaasRegistration
|
|
125
|
+
{
|
|
126
|
+
public static IServiceCollection AddAsaas(this IServiceCollection services)
|
|
127
|
+
{
|
|
128
|
+
services.AddOptions<AsaasOptions>()
|
|
129
|
+
.BindConfiguration("Asaas")
|
|
130
|
+
.ValidateDataAnnotations()
|
|
131
|
+
.ValidateOnStart();
|
|
132
|
+
|
|
133
|
+
services.AddHttpClient<AsaasClient>((sp, http) =>
|
|
134
|
+
{
|
|
135
|
+
var options = sp.GetRequiredService<IOptions<AsaasOptions>>().Value;
|
|
136
|
+
http.BaseAddress = new Uri(options.BaseUrl);
|
|
137
|
+
http.DefaultRequestHeaders.Add("access_token", options.ApiKey);
|
|
138
|
+
http.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return services;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- `BaseUrl` **ends with `/`** and the relative paths do **not** start with `/` (`"payments"`): with `https://api.asaas.com/v3` (no slash) `HttpClient` resolves `payments` to `https://api.asaas.com/payments` and every call 404s.
|
|
147
|
+
- `AsaasClient` is the adapter to an external API (a port), not a service layer between handler and data: the slice handler injects it next to the `ApplicationDbContext`.
|
|
148
|
+
- `ReadFromJsonAsync`/`PostAsJsonAsync` use web defaults (camelCase), which is what Asaas speaks; `DateOnly` serializes as `yyyy-MM-dd`, the `dueDate` format.
|
|
149
|
+
|
|
81
150
|
---
|
|
82
151
|
|
|
83
152
|
## Common Operations
|
|
84
153
|
|
|
85
154
|
### Create PIX Payment
|
|
86
155
|
|
|
87
|
-
```
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
156
|
+
```csharp
|
|
157
|
+
// Features/PaymentFeature/CreatePixPayment/CreatePixPaymentHandler.cs
|
|
158
|
+
using Microsoft.EntityFrameworkCore;
|
|
159
|
+
|
|
160
|
+
public sealed record CreatePixPaymentRequest(Guid OrderId, string AsaasCustomerId, decimal Value, string? Description);
|
|
161
|
+
public sealed record CreatePixPaymentResponse(Guid PaymentId, string AsaasPaymentId, string PixCode, string QrCodeImage);
|
|
162
|
+
|
|
163
|
+
public sealed class CreatePixPaymentHandler(
|
|
164
|
+
ApplicationDbContext db,
|
|
165
|
+
AsaasClient asaas,
|
|
166
|
+
TimeProvider timeProvider)
|
|
167
|
+
: IHandler<CreatePixPaymentRequest, Result<CreatePixPaymentResponse>>
|
|
168
|
+
{
|
|
169
|
+
private static readonly TimeZoneInfo SaoPaulo = TimeZoneInfo.FindSystemTimeZoneById("America/Sao_Paulo");
|
|
170
|
+
|
|
171
|
+
public async Task<Result<CreatePixPaymentResponse>> HandleAsync(
|
|
172
|
+
CreatePixPaymentRequest request, CancellationToken cancellationToken)
|
|
173
|
+
{
|
|
174
|
+
var externalReference = request.OrderId.ToString();
|
|
175
|
+
|
|
176
|
+
var existing = await asaas.FindByExternalReferenceAsync(externalReference, cancellationToken);
|
|
177
|
+
if (existing.IsFailure)
|
|
178
|
+
return existing.Error;
|
|
179
|
+
|
|
180
|
+
var remote = existing.Value.Data.FirstOrDefault();
|
|
181
|
+
if (remote is null)
|
|
182
|
+
{
|
|
183
|
+
var dueDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), SaoPaulo).DateTime);
|
|
184
|
+
var created = await asaas.CreatePaymentAsync(
|
|
185
|
+
new AsaasCreatePayment(request.AsaasCustomerId, "PIX", request.Value, dueDate, request.Description, externalReference),
|
|
186
|
+
cancellationToken);
|
|
187
|
+
if (created.IsFailure)
|
|
188
|
+
return created.Error;
|
|
189
|
+
remote = created.Value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
var pix = await asaas.GetPixQrCodeAsync(remote.Id, cancellationToken);
|
|
193
|
+
if (pix.IsFailure)
|
|
194
|
+
return pix.Error;
|
|
195
|
+
|
|
196
|
+
var payment = await db.Payments.FirstOrDefaultAsync(p => p.AsaasPaymentId == remote.Id, cancellationToken);
|
|
197
|
+
if (payment is null)
|
|
198
|
+
{
|
|
199
|
+
payment = new Payment
|
|
200
|
+
{
|
|
201
|
+
Id = Guid.CreateVersion7(),
|
|
202
|
+
OrderId = request.OrderId,
|
|
203
|
+
AsaasPaymentId = remote.Id,
|
|
204
|
+
Amount = remote.Value,
|
|
205
|
+
Status = remote.Status,
|
|
206
|
+
BillingType = "PIX",
|
|
207
|
+
CreatedAt = timeProvider.GetUtcNow()
|
|
208
|
+
};
|
|
209
|
+
db.Payments.Add(payment);
|
|
210
|
+
await db.SaveChangesAsync(cancellationToken);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return new CreatePixPaymentResponse(payment.Id, remote.Id, pix.Value.Payload, pix.Value.EncodedImage);
|
|
214
|
+
}
|
|
116
215
|
}
|
|
117
216
|
```
|
|
118
217
|
|
|
218
|
+
- `POST /payments` with `billingType: "PIX"`, then `GET /payments/{id}/pixQrCode` for the copy-and-paste `payload` and the base64 `encodedImage`.
|
|
219
|
+
- The `GET /payments?externalReference=` before the create is what makes a retried request (double click, timeout, job retry) reuse the charge instead of creating a second one.
|
|
220
|
+
- The due date is "today in São Paulo": `DateTime.UtcNow.Date` rolls to tomorrow after 21:00 BRT.
|
|
221
|
+
|
|
119
222
|
### Webhook Handler
|
|
120
223
|
|
|
121
|
-
Asaas does not HMAC-sign webhooks.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
224
|
+
Asaas does not HMAC-sign webhooks. Every webhook request carries the `authToken` configured on that webhook in the `asaas-access-token` header -- compare it to the stored secret (constant time) and answer 401 on mismatch. The `authToken` is mandatory: a `POST /v3/webhooks` without one makes Asaas generate a high-security token (32-255 chars, shown once in the response).
|
|
225
|
+
|
|
226
|
+
Delivery is **at-least-once**: the same event (`id` like `evt_...`) can arrive more than once, so the handler records processed event ids. Asaas counts only HTTP 200 as delivered; after 15 consecutive failures the queue is interrupted (events kept for 14 days). Unknown events and payments you do not know still answer 200.
|
|
227
|
+
|
|
228
|
+
```csharp
|
|
229
|
+
// Features/PaymentFeature/AsaasWebhook/AsaasWebhook.cs
|
|
230
|
+
using System.Security.Cryptography;
|
|
231
|
+
using System.Text;
|
|
232
|
+
using Microsoft.EntityFrameworkCore;
|
|
233
|
+
using Microsoft.Extensions.Options;
|
|
234
|
+
using ProjectName.Abstractions;
|
|
235
|
+
using ProjectName.Extensions;
|
|
236
|
+
|
|
237
|
+
public sealed record AsaasWebhookPayment(string Id, string? Status, string? ExternalReference);
|
|
238
|
+
public sealed record AsaasWebhookEvent(string Id, string Event, AsaasWebhookPayment? Payment);
|
|
239
|
+
|
|
240
|
+
public sealed class ProcessAsaasWebhookHandler(ApplicationDbContext db, TimeProvider timeProvider)
|
|
241
|
+
: IHandler<AsaasWebhookEvent, Result>
|
|
242
|
+
{
|
|
243
|
+
public async Task<Result> HandleAsync(AsaasWebhookEvent webhook, CancellationToken cancellationToken)
|
|
244
|
+
{
|
|
245
|
+
if (await db.ProcessedWebhookEvents.AnyAsync(e => e.EventId == webhook.Id, cancellationToken))
|
|
246
|
+
return Result.Success();
|
|
247
|
+
|
|
248
|
+
if (webhook.Event is "PAYMENT_CONFIRMED" or "PAYMENT_RECEIVED" && webhook.Payment is not null)
|
|
249
|
+
{
|
|
250
|
+
var payment = await db.Payments.FirstOrDefaultAsync(p => p.AsaasPaymentId == webhook.Payment.Id, cancellationToken);
|
|
251
|
+
if (payment is not null)
|
|
252
|
+
payment.Status = webhook.Payment.Status ?? webhook.Event;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
db.ProcessedWebhookEvents.Add(new ProcessedWebhookEvent
|
|
256
|
+
{
|
|
257
|
+
EventId = webhook.Id,
|
|
258
|
+
ProcessedAt = timeProvider.GetUtcNow()
|
|
259
|
+
});
|
|
260
|
+
await db.SaveChangesAsync(cancellationToken);
|
|
261
|
+
return Result.Success();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
internal sealed class AsaasWebhookEndpoint : IApiEndpoint
|
|
266
|
+
{
|
|
267
|
+
public void MapEndpoint(IEndpointRouteBuilder app)
|
|
268
|
+
{
|
|
269
|
+
app.MapPost("webhooks/asaas", async (
|
|
270
|
+
HttpRequest request,
|
|
271
|
+
AsaasWebhookEvent webhook,
|
|
272
|
+
IOptions<AsaasOptions> options,
|
|
273
|
+
IHandler<AsaasWebhookEvent, Result> handler,
|
|
274
|
+
CancellationToken cancellationToken) =>
|
|
275
|
+
{
|
|
276
|
+
var received = Encoding.UTF8.GetBytes(request.Headers["asaas-access-token"].ToString());
|
|
277
|
+
var expected = Encoding.UTF8.GetBytes(options.Value.WebhookToken);
|
|
278
|
+
if (!CryptographicOperations.FixedTimeEquals(received, expected))
|
|
279
|
+
return Results.Unauthorized();
|
|
280
|
+
|
|
281
|
+
var result = await handler.HandleAsync(webhook, cancellationToken);
|
|
282
|
+
return result.Match(
|
|
283
|
+
onSuccess: () => Results.Ok(),
|
|
284
|
+
onFailure: error => error.ToHttpResult());
|
|
285
|
+
})
|
|
286
|
+
.ExcludeFromDescription();
|
|
287
|
+
}
|
|
153
288
|
}
|
|
154
289
|
```
|
|
155
290
|
|
|
291
|
+
`ProcessedWebhookEvent.EventId` has a **unique index**: two deliveries of the same event racing each other make the second `SaveChangesAsync` fail, Asaas sees a non-200 and redelivers, and the redelivery finds the id and answers 200. No catch is needed for that path.
|
|
292
|
+
|
|
156
293
|
---
|
|
157
294
|
|
|
158
295
|
## Database Schema (PostgreSQL / Neon)
|
|
159
296
|
|
|
160
297
|
```sql
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
user_id uuid references
|
|
298
|
+
create table public.payments (
|
|
299
|
+
id uuid primary key, -- Guid.CreateVersion7() in the handler
|
|
300
|
+
order_id uuid not null,
|
|
301
|
+
user_id uuid references public.users(id),
|
|
165
302
|
asaas_payment_id text unique not null,
|
|
166
|
-
amount
|
|
167
|
-
status text not null,
|
|
168
|
-
billing_type text not null,
|
|
169
|
-
created_at timestamptz
|
|
170
|
-
updated_at timestamptz default now()
|
|
303
|
+
amount numeric(10,2) not null,
|
|
304
|
+
status text not null, -- PENDING, CONFIRMED, RECEIVED, ...
|
|
305
|
+
billing_type text not null, -- PIX, BOLETO, CREDIT_CARD
|
|
306
|
+
created_at timestamptz not null,
|
|
307
|
+
updated_at timestamptz default now() not null
|
|
171
308
|
);
|
|
172
309
|
|
|
173
|
-
|
|
174
|
-
|
|
310
|
+
create table public.processed_webhook_events (
|
|
311
|
+
event_id text primary key, -- Asaas evt_... id
|
|
312
|
+
processed_at timestamptz not null
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
alter table public.payments enable row level security;
|
|
175
316
|
|
|
176
317
|
create policy "Users read own payments"
|
|
177
|
-
on payments for select
|
|
318
|
+
on public.payments for select
|
|
178
319
|
using (auth.user_id() = user_id);
|
|
179
320
|
```
|
|
180
321
|
|
|
322
|
+
Ids come from the app (`Guid.CreateVersion7()`), never from a `gen_random_uuid()` default; with EF Core the tables come from migrations (`backend/database/postgresql/database.md`).
|
|
323
|
+
|
|
181
324
|
---
|
|
182
325
|
|
|
183
326
|
## Best Practices
|
|
184
327
|
|
|
185
328
|
### CPF/CNPJ Validation
|
|
186
329
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
330
|
+
CPF is 11 digits; CNPJ is 14 characters and, for CNPJs issued from July 2026, **alphanumeric** in the first 12 positions (IN RFB 2.229/2024) -- a digits-only validator rejects valid new companies. Both use the mod-11 check digits; for the CNPJ each character is worth its ASCII code minus 48.
|
|
331
|
+
|
|
332
|
+
```csharp
|
|
333
|
+
// Features/Shared/BrazilianDocument.cs
|
|
334
|
+
public static class BrazilianDocument
|
|
335
|
+
{
|
|
336
|
+
public static bool IsValidCpf(string value)
|
|
337
|
+
{
|
|
338
|
+
var digits = new string(value.Where(char.IsDigit).ToArray());
|
|
339
|
+
if (digits.Length != 11 || digits.Distinct().Count() == 1)
|
|
340
|
+
return false;
|
|
341
|
+
|
|
342
|
+
return CheckDigit(digits[..9], 10) == digits[9] - '0'
|
|
343
|
+
&& CheckDigit(digits[..10], 11) == digits[10] - '0';
|
|
344
|
+
|
|
345
|
+
static int CheckDigit(string body, int startWeight)
|
|
346
|
+
{
|
|
347
|
+
var sum = body.Select((c, i) => (c - '0') * (startWeight - i)).Sum();
|
|
348
|
+
var rest = sum % 11;
|
|
349
|
+
return rest < 2 ? 0 : 11 - rest;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
public static bool IsValidCnpj(string value)
|
|
354
|
+
{
|
|
355
|
+
var chars = new string(value.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
|
|
356
|
+
if (chars.Length != 14 || !chars[..12].All(c => char.IsAsciiDigit(c) || char.IsAsciiLetterUpper(c))
|
|
357
|
+
|| !chars[12..].All(char.IsAsciiDigit) || chars.Distinct().Count() == 1)
|
|
358
|
+
return false;
|
|
359
|
+
|
|
360
|
+
return CheckDigit(chars[..12]) == chars[12] - '0'
|
|
361
|
+
&& CheckDigit(chars[..13]) == chars[13] - '0';
|
|
362
|
+
|
|
363
|
+
static int CheckDigit(string body)
|
|
364
|
+
{
|
|
365
|
+
var sum = 0;
|
|
366
|
+
for (var i = 0; i < body.Length; i++)
|
|
367
|
+
{
|
|
368
|
+
var weight = 2 + (body.Length - 1 - i) % 8;
|
|
369
|
+
sum += (body[i] - '0') * weight;
|
|
370
|
+
}
|
|
371
|
+
var rest = sum % 11;
|
|
372
|
+
return rest < 2 ? 0 : 11 - rest;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
202
375
|
}
|
|
203
376
|
```
|
|
204
377
|
|
|
205
|
-
|
|
378
|
+
Use them in the slice validator (`RuleFor(x => x.CpfCnpj).Must(v => BrazilianDocument.IsValidCpf(v) || BrazilianDocument.IsValidCnpj(v))`) so a bad document becomes a 400 before any Asaas call.
|
|
206
379
|
|
|
207
|
-
|
|
208
|
-
create a duplicate charge:
|
|
380
|
+
### Never store card data
|
|
209
381
|
|
|
210
|
-
|
|
211
|
-
await asaasFetch('/payments', {
|
|
212
|
-
method: 'POST',
|
|
213
|
-
headers: { 'Idempotency-Key': `payment-${userId}-${requestId}` },
|
|
214
|
-
body: JSON.stringify({ /* ... payment data ... */ }),
|
|
215
|
-
});
|
|
216
|
-
```
|
|
382
|
+
Card numbers and CVV go from the browser to Asaas (tokenization / hosted checkout), never through or into your database. Store only the Asaas ids and statuses.
|
|
217
383
|
|
|
218
384
|
---
|
|
219
385
|
|
|
220
386
|
## Error Handling
|
|
221
387
|
|
|
222
|
-
Asaas returns failures as HTTP 400 with
|
|
223
|
-
description }] }`. `asaasFetch` throws on non-OK responses — catch and inspect
|
|
224
|
-
the `errors[]` array:
|
|
225
|
-
|
|
226
|
-
```typescript
|
|
227
|
-
type AsaasError = { errors: { code: string; description: string }[] };
|
|
388
|
+
Asaas returns validation failures as HTTP 400 with `{ "errors": [{ "code": "...", "description": "..." }] }` (e.g. `invalid_cpfCnpj`). `AsaasClient.ReadAsync` turns each item into `Error.Validation("Asaas.<code>", description)` (several into a `ValidationError`), 404 into `NotFound` and any other non-success into `Unexpected`; the endpoint maps them with `error.ToHttpResult()` (400 with `errors[]`, 404, 500). There is no `try/catch`: a network failure or timeout is an exception nobody handles locally, which surfaces as a 500 (or, inside a Hangfire job, as a retry).
|
|
228
389
|
|
|
229
|
-
|
|
230
|
-
await asaasFetch('/payments', { method: 'POST', body: JSON.stringify(data) });
|
|
231
|
-
} catch (error) {
|
|
232
|
-
// asaasFetch throws `Asaas <status>: <raw body>` — parse the body
|
|
233
|
-
const body = (error as Error).message.replace(/^Asaas \d+: /, '');
|
|
234
|
-
let parsed: AsaasError | null = null;
|
|
235
|
-
try { parsed = JSON.parse(body) as AsaasError; } catch { /* non-JSON */ }
|
|
236
|
-
|
|
237
|
-
const code = parsed?.errors?.[0]?.code;
|
|
238
|
-
if (code === 'invalid_cpfCnpj') {
|
|
239
|
-
return Response.json({ error: 'CPF/CNPJ inválido' }, { status: 400 });
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
console.error('Asaas API error:', body);
|
|
243
|
-
return Response.json({ error: 'Erro no processamento' }, { status: 500 });
|
|
244
|
-
}
|
|
245
|
-
```
|
|
390
|
+
Credit-card charges (`POST /payments/{id}/payWithCreditCard`) are explicitly **not idempotent**: never retry one automatically; read the charge status first.
|
|
246
391
|
|
|
247
392
|
---
|
|
248
393
|
|
|
249
394
|
## References
|
|
250
395
|
|
|
251
396
|
- [Asaas API Documentation](https://docs.asaas.com/)
|
|
397
|
+
- [Asaas Authentication (headers)](https://docs.asaas.com/docs/authentication-2)
|
|
252
398
|
- [Asaas Webhooks](https://docs.asaas.com/docs/about-webhooks)
|
|
399
|
+
- [Webhook idempotence](https://docs.asaas.com/docs/how-to-implement-idempotence-in-webhooks)
|
|
253
400
|
- PIX Specification: Brazilian Central Bank
|
|
254
401
|
|
|
255
|
-
> Asaas publishes **no official Node SDK** — integrate against the REST API
|
|
256
|
-
> directly. Community wrappers exist but are unofficial and vary in maintenance.
|
|
257
|
-
|
|
258
402
|
---
|
|
259
403
|
|
|
260
404
|
*MORPH-SPEC by Polymorphism Tech*
|