@polymorphism-tech/morph-spec 8.45.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.
Files changed (146) hide show
  1. package/CLAUDE.md +6 -5
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -4
  4. package/bin/validate.js +4 -25
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +2 -2
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -2
  9. package/framework/MORPH.md +9 -9
  10. package/framework/commands/create-umbraco-lp.md +2 -0
  11. package/framework/commands/migrate-lp-to-umbraco.md +2 -0
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +1 -1
  14. package/framework/commands/morph-infra.md +17 -203
  15. package/framework/commands/morph-media.md +3 -0
  16. package/framework/commands/morph-preflight.md +5 -28
  17. package/framework/commands/morph-proposal.md +2 -2
  18. package/framework/commands/morph-troubleshoot.md +2 -14
  19. package/framework/docs/CLI.md +8 -7
  20. package/framework/hooks/claude-code/core/state-sync.js +13 -1
  21. package/framework/hooks/shared/e2e-check.js +3 -4
  22. package/framework/hooks/shared/state-writer.js +1 -0
  23. package/framework/rules/ai-agents-standards.md +18 -16
  24. package/framework/rules/asaas-standards.md +3 -1
  25. package/framework/rules/csharp-standards.md +10 -8
  26. package/framework/rules/data-standards.md +5 -5
  27. package/framework/rules/dotnet-templates.md +1 -1
  28. package/framework/rules/frontend-standards.md +4 -2
  29. package/framework/rules/gsap-standards.md +10 -5
  30. package/framework/rules/hangfire-standards.md +3 -1
  31. package/framework/rules/infrastructure-standards.md +4 -2
  32. package/framework/rules/morph-workflow.md +2 -2
  33. package/framework/rules/neon-auth-standards.md +3 -1
  34. package/framework/rules/neon-standards.md +6 -4
  35. package/framework/rules/nextjs-standards.md +13 -11
  36. package/framework/rules/nextjs-templates.md +1 -1
  37. package/framework/rules/resend-standards.md +3 -1
  38. package/framework/schemas/config.schema.json +4 -0
  39. package/framework/schemas/feature.schema.json +4 -4
  40. package/framework/skills/README.md +1 -1
  41. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
  42. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
  43. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  44. package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
  45. package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
  46. package/framework/skills/core/morph-implement/SKILL.md +3 -3
  47. package/framework/skills/core/morph-machine/SKILL.md +3 -3
  48. package/framework/skills/core/morph-media/SKILL.md +3 -3
  49. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  50. package/framework/skills/core/morph-proposal/SKILL.md +3 -3
  51. package/framework/skills/core/morph-review/SKILL.md +30 -17
  52. package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
  53. package/framework/skills/core/morph-standards/SKILL.md +6 -6
  54. package/framework/skills/core/morph-uiux/SKILL.md +3 -3
  55. package/framework/standards/STANDARDS.json +59 -56
  56. package/framework/standards/ai-agents/evals-with-cache.md +1 -1
  57. package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
  58. package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
  59. package/framework/standards/ai-agents/prompt-sources.md +6 -4
  60. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  61. package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
  62. package/framework/standards/ai-agents/setup.md +1 -1
  63. package/framework/standards/ai-agents/structured-output.md +1 -1
  64. package/framework/standards/ai-agents/sweet-spot.md +1 -1
  65. package/framework/standards/architecture/code-comments.md +2 -2
  66. package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
  67. package/framework/standards/backend/api/validation.md +135 -47
  68. package/framework/standards/backend/database/postgresql/database.md +11 -7
  69. package/framework/standards/backend/database/vector-search-rag.md +207 -324
  70. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
  71. package/framework/standards/backend/dotnet/result-pattern.md +40 -6
  72. package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
  73. package/framework/standards/backend/dotnet/scrutor.md +1 -1
  74. package/framework/standards/backend/dotnet/testing.md +1 -1
  75. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
  76. package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
  77. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
  78. package/framework/standards/data/nosql/cache/redis.md +120 -44
  79. package/framework/standards/data/nosql/cosmos-db.md +114 -71
  80. package/framework/standards/frontend/design-system/animations.md +1 -1
  81. package/framework/standards/frontend/design-system/naming.md +1 -1
  82. package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
  83. package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
  84. package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
  85. package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
  86. package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
  87. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  88. package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
  89. package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
  90. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
  91. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
  92. package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
  93. package/framework/templates/infrastructure/github/README.md +2 -1
  94. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
  95. package/package.json +1 -1
  96. package/src/commands/observability/telemetry.js +3 -5
  97. package/src/commands/project/init.js +2 -1
  98. package/src/commands/project/update.js +16 -18
  99. package/src/commands/state/approve.js +17 -0
  100. package/src/commands/state/gate-check.js +4 -14
  101. package/src/commands/validation/verify.js +99 -57
  102. package/src/core/paths/output-schema.js +1 -1
  103. package/src/dashboard/app/App.jsx +0 -2
  104. package/src/dashboard/app/components/CommandPalette.jsx +2 -3
  105. package/src/dashboard/app/components/EntityLink.jsx +0 -1
  106. package/src/dashboard/app/components/Markdown.jsx +1 -1
  107. package/src/dashboard/app/components/Sidebar.jsx +1 -2
  108. package/src/dashboard/app/pages/RulesMap.jsx +3 -3
  109. package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
  110. package/src/dashboard/scanner/activity-scanner.js +1 -1
  111. package/src/dashboard/scanner/docs-scanner.js +5 -23
  112. package/src/dashboard/scanner/features-scanner.js +1 -1
  113. package/src/dashboard/scanner/index.js +0 -2
  114. package/src/dashboard/scanner/rules-scanner.js +15 -10
  115. package/src/dashboard/scanner/skills-scanner.js +2 -3
  116. package/src/lib/ci/ci-verify.js +118 -0
  117. package/src/lib/ci/github-ci.js +192 -0
  118. package/src/lib/ci/workflow-scaffold.js +122 -0
  119. package/src/lib/evaluator-report.js +13 -0
  120. package/src/lib/risk-detector.js +8 -0
  121. package/src/lib/stack/stack-profile.js +1 -1
  122. package/src/lib/telemetry/verify-event.js +3 -2
  123. package/src/lib/validators/reuse/reuse-validator.js +2 -3
  124. package/src/lib/validators/reuse/symbol-scanner.js +2 -4
  125. package/src/lib/validators/validation-runner.js +8 -88
  126. package/src/lib/validators/verify-runner.js +22 -72
  127. package/src/scripts/setup-infra.js +33 -22
  128. package/src/utils/agents-installer.js +20 -8
  129. package/src/utils/file-copier.js +7 -5
  130. package/framework/commands/commit.md +0 -166
  131. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  132. package/framework/rubrics/backend.json +0 -41
  133. package/framework/rubrics/default.json +0 -36
  134. package/framework/rubrics/frontend.json +0 -36
  135. package/framework/templates/project/validate.js +0 -97
  136. package/src/dashboard/app/pages/Rubrics.jsx +0 -97
  137. package/src/dashboard/scanner/rubrics-scanner.js +0 -12
  138. package/src/lib/validators/architecture/architecture-validator.js +0 -270
  139. package/src/lib/validators/architecture/index.js +0 -7
  140. package/src/lib/validators/css/css-validator.js +0 -340
  141. package/src/lib/validators/css/index.js +0 -7
  142. package/src/lib/validators/design-system/design-system-validator.js +0 -237
  143. package/src/lib/validators/design-system/index.js +0 -7
  144. package/src/lib/validators/score-aggregator.js +0 -244
  145. package/src/lib/validators/ui/index.js +0 -7
  146. 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 (no official Node SDK — direct REST). Last-verified: 2026-05-20.
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
- **Stack:** .NET Backend
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**: All payment state changes via webhooks
32
- 2. **Idempotency**: Use idempotency keys for all POST requests
33
- 3. **Security**: Validate webhook signatures, never store full card data
34
- 4. **Brazil-Specific**: Handle CPF/CNPJ validation, Brazilian tax rules
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
- ### Environment Variables
41
+ ### Configuration (user-secrets in dev, Coolify environment variables in prod)
41
42
 
42
43
  ```bash
43
- # .env.local
44
- ASAAS_API_KEY=your_api_key_here
45
- ASAAS_WEBHOOK_SECRET=your_webhook_secret
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
- ### REST Client Setup
50
-
51
- Asaas has **no official Node SDK** — integrate against the documented REST API
52
- directly. The API key goes in the `access_token` header; the base URL switches
53
- between sandbox and production.
54
-
55
- ```typescript
56
- // lib/asaas/client.ts
57
- const ASAAS_BASE_URL =
58
- process.env.ASAAS_ENVIRONMENT === 'production'
59
- ? 'https://api.asaas.com/v3'
60
- : 'https://api-sandbox.asaas.com/v3';
61
-
62
- export async function asaasFetch<T>(
63
- path: string,
64
- init?: RequestInit,
65
- ): Promise<T> {
66
- const res = await fetch(`${ASAAS_BASE_URL}${path}`, {
67
- ...init,
68
- headers: {
69
- 'Content-Type': 'application/json',
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
- ```typescript
88
- // app/api/payments/pix/route.ts
89
- import { asaasFetch } from '@/lib/asaas/client';
90
-
91
- export async function POST(req: Request) {
92
- const { amount, customerId, description } = await req.json();
93
-
94
- // POST /v3/payments — billingType PIX
95
- const payment = await asaasFetch<{ id: string }>('/payments', {
96
- method: 'POST',
97
- body: JSON.stringify({
98
- customer: customerId,
99
- billingType: 'PIX',
100
- value: amount,
101
- dueDate: new Date().toISOString().split('T')[0],
102
- description,
103
- }),
104
- });
105
-
106
- // GET /v3/payments/{id}/pixQrCode — PIX copy-paste payload + QR image
107
- const pix = await asaasFetch<{ payload: string; encodedImage: string }>(
108
- `/payments/${payment.id}/pixQrCode`,
109
- );
110
-
111
- return Response.json({
112
- paymentId: payment.id,
113
- pixCode: pix.payload,
114
- qrCode: pix.encodedImage,
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. Instead, every webhook request carries the
122
- `authToken` you configured for the webhook in the `asaas-access-token` header —
123
- compare it against your stored secret. The `authToken` is mandatory: a `POST
124
- /v3/webhooks` without one makes Asaas auto-generate a high-security token
125
- (32–255 chars, shown once in the response).
126
-
127
- ```typescript
128
- // app/api/webhooks/asaas/route.ts
129
- import { headers } from 'next/headers';
130
-
131
- export async function POST(req: Request) {
132
- const body = await req.text();
133
-
134
- // Asaas sends the webhook's authToken in the asaas-access-token header
135
- const token = headers().get('asaas-access-token');
136
- if (token !== process.env.ASAAS_WEBHOOK_SECRET) {
137
- return new Response('Invalid token', { status: 401 });
138
- }
139
-
140
- const event = JSON.parse(body);
141
-
142
- // Handle payment events
143
- switch (event.event) {
144
- case 'PAYMENT_RECEIVED':
145
- await handlePaymentReceived(event.payment);
146
- break;
147
- case 'PAYMENT_CONFIRMED':
148
- await handlePaymentConfirmed(event.payment);
149
- break;
150
- }
151
-
152
- return new Response('OK');
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
- -- Store payment records
162
- create table payments (
163
- id uuid primary key default gen_random_uuid(),
164
- user_id uuid references auth.users(id),
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 decimal(10,2) not null,
167
- status text not null, -- pending, confirmed, received
168
- billing_type text not null, -- PIX, BOLETO, CREDIT_CARD
169
- created_at timestamptz default now(),
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
- -- RLS policies
174
- alter table payments enable row level security;
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
- ```typescript
188
- export function validateCPF(cpf: string): boolean {
189
- cpf = cpf.replace(/[^\d]/g, '');
190
- if (cpf.length !== 11) return false;
191
-
192
- // Validation logic...
193
- return true;
194
- }
195
-
196
- export function validateCNPJ(cnpj: string): boolean {
197
- cnpj = cnpj.replace(/[^\d]/g, '');
198
- if (cnpj.length !== 14) return false;
199
-
200
- // Validation logic...
201
- return true;
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
- ### Idempotency Keys
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
- Pass a stable idempotency key in the request header so a retried POST does not
208
- create a duplicate charge:
380
+ ### Never store card data
209
381
 
210
- ```typescript
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 a JSON body shaped `{ errors: [{ code,
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
- try {
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*