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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_URL_BASE=http://localhost:3000
# STRIPE_TAXA_PLATAFORMA=5
# Stripe__ExpectLivemode=true # OBRIGATÓRIO em produção — boot falha se sk_live_ e este valor não for true.
# # Omitir em Dev/Homolog (default=false → test-mode permitido).

# Seed admin local
SEED_ADMIN_EMAIL=admin@forzion.tech
Expand All @@ -36,6 +38,7 @@ SEED_ADMIN_PASSWORD=SuaSenhaAdmin123!
# Chave do endpoint interno /internal/processar-renovacoes (cron de cobrança mensal)
# Gerar: openssl rand -hex 32. Mesmo valor deve estar no GitHub secret INTERNAL_API_KEY.
# Vazio = endpoint rejeita todas as requisições (401).
# Production: AUSENTE => app falha no startup (fail-closed). Homolog/Dev: ausente => sobe.
# INTERNAL_API_KEY=

# Pseudonimização do destinatário nos delivery logs (PRIV-02 / LGPD).
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/billing-prenotification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ jobs:
"http://localhost:8080/internal/$ROTA"
done

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-prenotification" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-prenotification/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/billing-reconciliation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ jobs:
-d "$BODY" \
http://localhost:8080/internal/reconciliar-pagamentos

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-reconciliation" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-reconciliation/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand All @@ -67,10 +74,12 @@ jobs:
'',
'Sem reconciliação = webhooks Stripe perdidos podem ficar não detectados (Pagamento.Status diverge do estado real).',
'',
'**HTTP 503 = batch truncado** (backlog > teto por run): o cursor avançou só até o último evento processado, nenhum evento foi perdido. O próximo run agendado continua de onde parou. Para drenar agora, re-disparar manualmente via `workflow_dispatch` (deixar `desde_utc` vazio = retoma do cursor; ou informar `desde_utc` ISO-8601 p/ backfill de janela específica).',
'',
'Investigar (ordem dos layers):',
'1. Tailnet: runner conectou? ACL `tag:ci -> VM:22` (SSH) liberada?',
'2. SSH: `VM_TAILNET_IP` bate com a VM? `HOMOLOG_SSH_KEY` válida?',
'3. `docker compose exec backend` rodou? `/internal/reconciliar-pagamentos` respondeu (401=key, 5xx=backend)?',
'3. `docker compose exec backend` rodou? `/internal/reconciliar-pagamentos` respondeu (401=key, 503=batch truncado, 5xx=backend)?',
'4. Stripe API rate limit estourou (`stripe_request_quota_exceeded`)?',
'5. Backend logs (`docker compose logs backend --tail 200`)',
].join('\n');
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/billing-renewal-treinador.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
-H "X-Internal-Key: $KEY" -H "Content-Type: application/json" \
http://localhost:8080/internal/processar-renovacoes-treinador

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-renewal-treinador" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-renewal-treinador/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/billing-renewal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
-H "X-Internal-Key: $KEY" -H "Content-Type: application/json" \
http://localhost:8080/internal/processar-renovacoes

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-renewal" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/billing-renewal/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/db-backup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ jobs:
--only-show-errors
echo "backup enviado: s3://${R2_BUCKET}/${FILE}.age (${SIZE} bytes em claro)"

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/db-backup" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/db-backup/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/gerar-nfse-comissao.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
-H "X-Internal-Key: $KEY" -H "Content-Type: application/json" \
http://localhost:8080/internal/gerar-nfse-comissao

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/gerar-nfse-comissao" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/gerar-nfse-comissao/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v7
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/lgpd-purge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ jobs:
exit 1
fi

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/lgpd-purge" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/lgpd-purge/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v9
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/reconciliar-nfse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
-H "X-Internal-Key: $KEY" -H "Content-Type: application/json" \
http://localhost:8080/internal/reconciliar-nfse

- name: Healthchecks ping (success)
if: success()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/reconciliar-nfse" || true
- name: Healthchecks ping (failure)
if: failure()
run: curl -fsS -m 10 --retry 3 "https://hc-ping.com/${{ secrets.HC_PING_KEY }}/reconciliar-nfse/fail" || true

- name: Notificar falha via issue
if: failure()
uses: actions/github-script@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ jobs:

- name: ZAP Baseline Scan (passivo)
if: ${{ github.event.inputs.mode != 'full' }}
uses: zaproxy/action-baseline@v0.14.0
uses: zaproxy/action-baseline@v0.15.0
with:
target: ${{ steps.t.outputs.target }}
cmd_options: "-a"
Expand Down
35 changes: 34 additions & 1 deletion docs/api/openapi.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReconciliarPagamentosStripeResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
Expand Down Expand Up @@ -3551,6 +3561,16 @@
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
Expand Down Expand Up @@ -10476,7 +10496,9 @@
"replayed",
"jaConsistentes",
"erros",
"desdeUtc"
"desdeUtc",
"truncado",
"onboardingConfirmados"
],
"type": "object",
"properties": {
Expand Down Expand Up @@ -10515,6 +10537,17 @@
"desdeUtc": {
"type": "string",
"format": "date-time"
},
"truncado": {
"type": "boolean"
},
"onboardingConfirmados": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,19 @@ public static IEndpointRouteBuilder MapPagamentosEndpoints(this IEndpointRouteBu
var result = await handler.HandleAsync(command, cancellationToken).ConfigureAwait(false);

if (result.IsFailure) return result.ToProblemResult();

// 503 p/ o curl -fsS do cron falhar (if:failure() abre issue) quando há backlog restante.
if (result.Value.Truncado)
return Results.Json(result.Value, statusCode: StatusCodes.Status503ServiceUnavailable);

return Results.Ok(result.Value);
})
.WithTags("Internal")
.WithSummary("Reconcilia eventos Stripe da janela especificada (default últimos 7d) — requer X-Internal-Key")
.AllowAnonymous()
.RequireRateLimiting("internal")
.Produces<ReconciliarPagamentosStripeResponse>()
.Produces<ReconciliarPagamentosStripeResponse>(StatusCodes.Status503ServiceUnavailable)
.ProducesProblem(StatusCodes.Status401Unauthorized);

return endpoints;
Expand Down
4 changes: 4 additions & 0 deletions forzion.tech.Api/Endpoints/Treinador/TreinadorEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ public static IEndpointRouteBuilder MapTreinadorEndpoints(this IEndpointRouteBui
if (result.IsFailure) return result.ToProblemResult();
return Results.Ok(result.Value);
})
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Aprova o vínculo de um aluno ao treinador")
.Produces<VinculoResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
Expand Down Expand Up @@ -206,8 +207,10 @@ public static IEndpointRouteBuilder MapTreinadorEndpoints(this IEndpointRouteBui
if (result.IsFailure) return result.ToProblemResult();
return Results.Ok(result.Value);
})
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Reativa um aluno inativo criando um novo vínculo aprovado")
.Produces<VinculoResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

Expand Down Expand Up @@ -344,6 +347,7 @@ public static IEndpointRouteBuilder MapTreinadorEndpoints(this IEndpointRouteBui
if (result.IsFailure) return result.ToProblemResult();
return Results.NoContent();
})
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Vincula uma ficha de treino a um aluno")
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status403Forbidden)
Expand Down
3 changes: 3 additions & 0 deletions forzion.tech.Api/Endpoints/Treinos/TreinoEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public static void MapTreinoEndpoints(this IEndpointRouteBuilder app)
return Results.Created($"/treinos/{result.Value.TreinoId}", result.Value);
})
.RequireAuthorization()
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Cria um novo treino para um aluno")
.Produces<TreinoResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
Expand Down Expand Up @@ -134,6 +135,7 @@ public static void MapTreinoEndpoints(this IEndpointRouteBuilder app)
return Results.NoContent();
})
.RequireAuthorization()
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Vincula uma ficha de treino a um aluno")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
Expand Down Expand Up @@ -239,6 +241,7 @@ public static void MapTreinoEndpoints(this IEndpointRouteBuilder app)
return Results.Created($"/treinos/{result.Value.TreinoId}", result.Value);
})
.RequireAuthorization()
.AddEndpointFilter<RequireAssinaturaTreinadorAtivaFilter>()
.WithSummary("Duplica um treino (gera uma cópia)")
.Produces<TreinoResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using forzion.tech.Application.Interfaces;
using forzion.tech.Application.Interfaces.Repositories;
using forzion.tech.Domain.Enums;

namespace forzion.tech.Api.Filters;
Expand All @@ -7,15 +8,19 @@ public sealed class RequireAssinaturaTreinadorAtivaFilter : RequireAssinaturaAti
{
protected override string CodigoErro => "ASSINATURA_TREINADOR_INADIMPLENTE";

protected override Task<bool> EstaInadimplenteAsync(
protected override async Task<bool> EstaInadimplenteAsync(
IServiceProvider services,
IUserContext userContext,
CancellationToken ct)
{
if (userContext.TipoConta != TipoConta.Treinador)
return Task.FromResult(false);
return false;

// Quando billing do treinador for implementado, consultar IAssinaturaTreinadorRepository aqui.
return Task.FromResult(false);
var assinaturaRepository = services.GetRequiredService<IAssinaturaTreinadorRepository>();
var assinaturaAtual = await assinaturaRepository
.ObterAtualPorTreinadorAsync(userContext.PerfilId, ct)
.ConfigureAwait(false);

return assinaturaAtual?.Status == AssinaturaTreinadorStatus.Inadimplente;
}
}
2 changes: 1 addition & 1 deletion forzion.tech.Api/forzion.tech.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Scalar.AspNetCore" Version="2.16.5" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.6" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
14 changes: 12 additions & 2 deletions forzion.tech.Application/Interfaces/IStripeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ public interface IStripeService
/// Lista eventos Stripe criados a partir de <paramref name="desdeUtc"/>, filtrando
/// pelos tipos relevantes para reconciliação de pagamentos e onboarding Connect.
/// Resultado ordenado por <see cref="StripeEventSummary.Created"/> ASC (mais antigo primeiro)
/// e capado para um teto interno de segurança (evita varreduras patológicas).
/// e capado por um teto de batch (memória bounded); <see cref="StripeEventListResult.Truncado"/>
/// sinaliza que o cap foi atingido e há backlog restante para o próximo run.
/// </summary>
Task<IReadOnlyList<StripeEventSummary>> ListarEventosDesdeAsync(DateTime desdeUtc, CancellationToken cancellationToken = default);
Task<StripeEventListResult> ListarEventosDesdeAsync(DateTime desdeUtc, CancellationToken cancellationToken = default);
}

public enum CancelarPaymentIntentResultado
Expand Down Expand Up @@ -77,3 +78,12 @@ public sealed record StripeEventSummary(
string Type,
string PayloadRaw,
DateTime Created);

/// <summary>
/// Lote de eventos retornado por <c>Events.List</c>. <c>Truncado=true</c> quando a varredura
/// parou no teto de batch antes de drenar a janela — o reconciliador avança o cursor só até o
/// último evento processado e sinaliza para o cron re-disparar a partir do cursor.
/// </summary>
public sealed record StripeEventListResult(
IReadOnlyList<StripeEventSummary> Eventos,
bool Truncado);
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public interface IContaRecebimentoRepository
{
Task<ContaRecebimento?> ObterPorTreinadorIdAsync(Guid treinadorId, CancellationToken cancellationToken = default);
Task<ContaRecebimento?> ObterPorStripeAccountIdAsync(string stripeAccountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ContaRecebimento>> ListarConfiguradasPendentesOnboardingAsync(int max, CancellationToken cancellationToken = default);
Task AdicionarAsync(ContaRecebimento conta, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using forzion.tech.Domain.Entities;

namespace forzion.tech.Application.Interfaces.Repositories;

public interface IReconciliacaoStripeEstadoRepository
{
Task<ReconciliacaoStripeEstado?> ObterAsync(CancellationToken cancellationToken = default);
Task SalvarAsync(ReconciliacaoStripeEstado estado, CancellationToken cancellationToken = default);
}
6 changes: 6 additions & 0 deletions forzion.tech.Application/Settings/InternalSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace forzion.tech.Application.Settings;

public class InternalSettings
{
public string ApiKey { get; set; } = string.Empty;
}
Loading
Loading