@seedgrid/fe-billing-user 2026.7.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 (43) hide show
  1. package/README.md +78 -0
  2. package/dist/billing.d.ts +234 -0
  3. package/dist/billing.d.ts.map +1 -0
  4. package/dist/billing.js +4 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +5 -0
  8. package/dist/messages.d.ts +348 -0
  9. package/dist/messages.d.ts.map +1 -0
  10. package/dist/messages.js +148 -0
  11. package/dist/module.d.ts +3 -0
  12. package/dist/module.d.ts.map +1 -0
  13. package/dist/module.js +13 -0
  14. package/dist/module.json +15 -0
  15. package/dist/paths.d.ts +29 -0
  16. package/dist/paths.d.ts.map +1 -0
  17. package/dist/paths.js +35 -0
  18. package/dist/server/create-billing-user-server.d.ts +34 -0
  19. package/dist/server/create-billing-user-server.d.ts.map +1 -0
  20. package/dist/server/create-billing-user-server.js +33 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +2 -0
  24. package/package.json +43 -0
  25. package/scaffold/README.md +72 -0
  26. package/scaffold/src/app/api/billing/cancel/route.ts +10 -0
  27. package/scaffold/src/app/api/billing/cancel-scheduled-change/route.ts +10 -0
  28. package/scaffold/src/app/api/billing/change-plan/route.ts +14 -0
  29. package/scaffold/src/app/api/billing/change-preview/route.ts +13 -0
  30. package/scaffold/src/app/api/billing/checkout/route.ts +14 -0
  31. package/scaffold/src/app/api/billing/invoices/route.ts +14 -0
  32. package/scaffold/src/app/api/billing/me/route.ts +15 -0
  33. package/scaffold/src/app/api/billing/plans/route.ts +10 -0
  34. package/scaffold/src/app/api/billing/portal/route.ts +10 -0
  35. package/scaffold/src/app/api/billing/reactivate/route.ts +10 -0
  36. package/scaffold/src/app/api/billing/upcoming/route.ts +10 -0
  37. package/scaffold/src/app/billing/invoices/page.tsx +213 -0
  38. package/scaffold/src/app/billing/page.tsx +273 -0
  39. package/scaffold/src/app/billing/plans/page.tsx +248 -0
  40. package/scaffold/src/modules/billing-user/config.ts +7 -0
  41. package/scaffold/src/modules/billing-user/index.ts +17 -0
  42. package/scaffold/src/modules/billing-user/server.ts.hbs +34 -0
  43. package/scaffold/src/modules/billing-user/useBillingUserAppShellSections.tsx +59 -0
@@ -0,0 +1,34 @@
1
+ import type { SecurityServer } from "@seedgrid/fe-security";
2
+ import type { BillingInvoiceEnvelope, CancelScheduledChangeResponse, CancelSubscriptionResponse, ChangePlanRequest, ChangePlanResponse, ChangePreviewResponse, CheckoutRequest, CheckoutSessionResponse, PlansCatalogResponse, PortalSessionResponse, ReactivateSubscriptionResponse, SubscriptionResponse, UpcomingInvoiceResponse, UsageStatementResponse, UsageSummaryResponse } from "../billing";
3
+ export interface BillingUserServer {
4
+ /** Assinatura corrente (null se não houver). */
5
+ getSubscription(): Promise<SubscriptionResponse | null>;
6
+ /** Catálogo de planos assináveis. */
7
+ listPlans(): Promise<PlansCatalogResponse>;
8
+ /** Resumo de uso do ciclo (null se sem assinatura). */
9
+ getUsageSummary(): Promise<UsageSummaryResponse | null>;
10
+ /** Extrato detalhado de uso. Requer permissão STATEMENT_READ (403 sem ela). */
11
+ getUsageStatement(): Promise<UsageStatementResponse | null>;
12
+ /** Cria sessão de checkout (1ª assinatura) — devolve checkoutUrl pra redirecionar. */
13
+ createCheckout(req: CheckoutRequest): Promise<CheckoutSessionResponse>;
14
+ /** Cria sessão do customer portal — devolve portalUrl pra redirecionar. Sem payload. */
15
+ openPortal(): Promise<PortalSessionResponse>;
16
+ /** Troca o plano de uma assinatura já ATIVA (upgrade imediato ou downgrade agendado). */
17
+ changePlan(req: ChangePlanRequest): Promise<ChangePlanResponse>;
18
+ /** Cancela uma troca de plano (downgrade) agendada. Idempotente. */
19
+ cancelScheduledChange(): Promise<CancelScheduledChangeResponse>;
20
+ /** Agenda cancelamento da assinatura ao fim do ciclo vigente. */
21
+ cancelSubscription(): Promise<CancelSubscriptionResponse>;
22
+ /** Reverte um cancelamento agendado (enquanto ainda vigente). */
23
+ reactivateSubscription(): Promise<ReactivateSubscriptionResponse>;
24
+ /** Extrato de faturas do provedor, mais recentes primeiro. */
25
+ listInvoices(limit?: number): Promise<BillingInvoiceEnvelope>;
26
+ /** Prévia da próxima fatura (null se não houver, ex.: cancelada). */
27
+ getUpcomingInvoice(): Promise<{
28
+ upcoming: UpcomingInvoiceResponse | null;
29
+ }>;
30
+ /** Prévia do impacto (rateio) de uma troca de plano antes de confirmar. */
31
+ getChangePreview(planId: string): Promise<ChangePreviewResponse>;
32
+ }
33
+ export declare function createBillingUserServer(session: SecurityServer): BillingUserServer;
34
+ //# sourceMappingURL=create-billing-user-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-billing-user-server.d.ts","sourceRoot":"","sources":["../../src/server/create-billing-user-server.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAG5D,OAAO,KAAK,EACV,sBAAsB,EACtB,6BAA6B,EAC7B,0BAA0B,EAC1B,iBAAiB,EACjB,kBAAkB,EAClB,qBAAqB,EACrB,eAAe,EACf,uBAAuB,EACvB,oBAAoB,EACpB,qBAAqB,EACrB,8BAA8B,EAC9B,oBAAoB,EACpB,uBAAuB,EACvB,sBAAsB,EACtB,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,eAAe,IAAI,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACxD,qCAAqC;IACrC,SAAS,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAC3C,uDAAuD;IACvD,eAAe,IAAI,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAC;IACxD,+EAA+E;IAC/E,iBAAiB,IAAI,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAAC;IAC5D,sFAAsF;IACtF,cAAc,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACvE,wFAAwF;IACxF,UAAU,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,yFAAyF;IACzF,UAAU,CAAC,GAAG,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAChE,oEAAoE;IACpE,qBAAqB,IAAI,OAAO,CAAC,6BAA6B,CAAC,CAAC;IAChE,iEAAiE;IACjE,kBAAkB,IAAI,OAAO,CAAC,0BAA0B,CAAC,CAAC;IAC1D,iEAAiE;IACjE,sBAAsB,IAAI,OAAO,CAAC,8BAA8B,CAAC,CAAC;IAClE,8DAA8D;IAC9D,YAAY,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC9D,qEAAqE;IACrE,kBAAkB,IAAI,OAAO,CAAC;QAAE,QAAQ,EAAE,uBAAuB,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IAC5E,2EAA2E;IAC3E,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAAC;CAClE;AAED,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,cAAc,GACtB,iBAAiB,CA4DnB"}
@@ -0,0 +1,33 @@
1
+ // Helpers server-side de billing do usuário, sobre o motor de sessão do fe-security.
2
+ // SERVER-ONLY (action/route).
3
+ //
4
+ // const session = createSecurityServer({ ... });
5
+ // export const billing = createBillingUserServer(session);
6
+ //
7
+ // change-plan/cancel/cancel-scheduled-change/reactivate/invoices/upcoming/change-preview
8
+ // foram confirmados lendo `MeBillingResource`/`BillingCheckoutResource` reais
9
+ // (`seedgrid-report-api`) e adicionados aqui — ver `scaffold/README.md` pras divergências
10
+ // encontradas (paths errados, CheckoutRequest/PortalRequest com shape incorreto).
11
+ import { BillingUserPaths } from "../paths";
12
+ export function createBillingUserServer(session) {
13
+ return {
14
+ getSubscription: () => session.apiFetchSecurity(BillingUserPaths.subscription),
15
+ listPlans: () => session.apiFetchSecurity(BillingUserPaths.plans),
16
+ getUsageSummary: () => session.apiFetchSecurity(BillingUserPaths.usageSummary),
17
+ getUsageStatement: () => session.apiFetchSecurity(BillingUserPaths.usageStatement),
18
+ createCheckout: (req) => session.apiFetchSecurity(BillingUserPaths.checkout, { method: "POST", body: JSON.stringify(req) }),
19
+ openPortal: () => session.apiFetchSecurity(BillingUserPaths.portal, {
20
+ method: "POST",
21
+ }),
22
+ changePlan: (req) => session.apiFetchSecurity(BillingUserPaths.changePlan, {
23
+ method: "POST",
24
+ body: JSON.stringify(req),
25
+ }),
26
+ cancelScheduledChange: () => session.apiFetchSecurity(BillingUserPaths.cancelScheduledChange, { method: "POST" }),
27
+ cancelSubscription: () => session.apiFetchSecurity(BillingUserPaths.cancel, { method: "POST" }),
28
+ reactivateSubscription: () => session.apiFetchSecurity(BillingUserPaths.reactivate, { method: "POST" }),
29
+ listInvoices: (limit) => session.apiFetchSecurity(`${BillingUserPaths.invoices}${typeof limit === "number" ? `?limit=${encodeURIComponent(limit)}` : ""}`),
30
+ getUpcomingInvoice: () => session.apiFetchSecurity(BillingUserPaths.upcoming),
31
+ getChangePreview: (planId) => session.apiFetchSecurity(`${BillingUserPaths.changePreview}?planId=${encodeURIComponent(planId)}`),
32
+ };
33
+ }
@@ -0,0 +1,2 @@
1
+ export { createBillingUserServer, type BillingUserServer, } from "./create-billing-user-server";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,uBAAuB,EACvB,KAAK,iBAAiB,GACvB,MAAM,8BAA8B,CAAC"}
@@ -0,0 +1,2 @@
1
+ // Barrel SERVER (@seedgrid/fe-billing-user/server). Roda em action/route.
2
+ export { createBillingUserServer, } from "./create-billing-user-server";
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@seedgrid/fe-billing-user",
3
+ "version": "2026.7.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/SeedGrid/seedgrid-fe-components.git"
10
+ },
11
+ "homepage": "https://github.com/SeedGrid/seedgrid-fe-components",
12
+ "bugs": {
13
+ "url": "https://github.com/SeedGrid/seedgrid-fe-components/issues"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./server": {
21
+ "types": "./dist/server/index.d.ts",
22
+ "default": "./dist/server/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "scaffold"
28
+ ],
29
+ "peerDependencies": {
30
+ "@seedgrid/fe-core": "*",
31
+ "@seedgrid/fe-security": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@seedgrid/fe-core": "2026.7.1",
35
+ "@seedgrid/fe-security": "2026.7.2"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "postbuild": "node scripts/emit-module-manifest.mjs",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "pnpm run build && node --test tests/module-manifest.test.mjs"
42
+ }
43
+ }
@@ -0,0 +1,72 @@
1
+ # `fe-billing-user` scaffold
2
+
3
+ Conteúdo copiado para dentro do app gerado pelo CLI (git do app, editável à
4
+ vontade — ver `seedgrid-cli/docs/adr/0004-module-manifest-contract.md`).
5
+
6
+ ## Sem fonte histórica — tela construída do zero
7
+
8
+ Ao contrário do `fe-security`/`fe-subdomain-tenancy`/`fe-backup` (extraídos de
9
+ `seedgrid-cli/templates/modules/...`), este pacote **não tinha nenhuma tela
10
+ pra extrair**. A única coisa parecida encontrada foi
11
+ `admin-web/app/billing/page.tsx` — mas essa tela é a visão da PLATAFORMA
12
+ (lista agregada de assinaturas de TODOS os tenants, MRR estimado, tenants em
13
+ excedente), não a visão do usuário final vendo a própria assinatura. Servem
14
+ públicos diferentes (staff da plataforma vs. cliente final), então não foi
15
+ adaptada — a tela aqui (`app/billing/page.tsx`) foi desenhada do zero em cima
16
+ do engine já pronto (`src/server/create-billing-user-server.ts`), seguindo o
17
+ mesmo layout/componentes (`PageFrame`, `RouteGuard`, `SgCard`, `useI18n()` +
18
+ chaves de tradução) já estabelecidos no `fe-security`.
19
+
20
+ **Revisão humana recomendada** — telas construídas do zero.
21
+
22
+ Cobertura atual (todas as capacidades do `BillingUserServer` têm UI):
23
+ - **Visão geral** (`app/billing/page.tsx`): plano atual, uso do ciclo, portal;
24
+ agora também **cancelar** (`cancel`), **reativar** (`reactivate`) e
25
+ **cancelar troca agendada** (`cancel-scheduled-change`), com confirmação.
26
+ - **Catálogo/checkout** (`app/billing/plans/page.tsx`): lista `listPlans`; sem
27
+ assinatura → **assinar** (`createCheckout` → redirect ao provedor); com
28
+ assinatura → **trocar** com prévia (`getChangePreview` → diálogo →
29
+ `changePlan`, upgrade imediato / downgrade agendado).
30
+ - **Faturas** (`app/billing/invoices/page.tsx`): próxima fatura
31
+ (`getUpcomingInvoice`) + histórico (`listInvoices`, item cru do provedor
32
+ lido defensivamente).
33
+ - **Navegação**: submenu Billing (overview / plans / invoices), gated por
34
+ `BILLING_READ`, wireado via o campo `navigation` do `module.ts`.
35
+
36
+ ## O que já existia (trabalho de um agente anterior, verificado)
37
+
38
+ `src/billing.ts` (DTOs) e `src/paths.ts` já tinham sido conferidos contra o
39
+ backend real (`MeBillingResource`/`BillingCheckoutResource`/`SubscriptionResource`
40
+ em `seedgrid-report-api`) **antes** desta sessão continuar o trabalho —
41
+ várias correções já registradas nos comentários desses arquivos (paths com
42
+ prefixo errado, `CheckoutRequest`/`PortalRequest` com campos que o backend
43
+ não aceita, endpoints de troca/cancelamento de assinatura adicionados). Não
44
+ refiz essa auditoria, só continuei a partir dela.
45
+
46
+ ## Adicionado nesta passada
47
+
48
+ - `src/messages.ts` (`billingUserMessages`) — mensagens da tela, en/pt-BR
49
+ completas, es/fr/pt-PT com fallback pro inglês (tradução real pendente).
50
+ - `scaffold/src/modules/billing-user/server.ts` (`.hbs` — só arquivo com
51
+ templating real, nome de cookie por `{{project_slug}}`) — bootstrap que
52
+ compõe `createSecurityServer` (fe-security) + `createBillingUserServer`
53
+ (este pacote). Mesma convenção de nome de cookie que
54
+ `modules/security/server/server-api.ts` usa, pra funcionar com a MESMA
55
+ sessão do usuário logado.
56
+ - `scaffold/src/app/api/billing/{me,portal}/route.ts` — rotas finas
57
+ (`toRouteResponse` de `@seedgrid/fe-security/server`), thin proxies pro
58
+ engine.
59
+ - `scaffold/src/app/billing/page.tsx` — a tela em si.
60
+
61
+ ## Follow-ups
62
+
63
+ - `getUsageStatement` (extrato detalhado de uso, requer `STATEMENT_READ`)
64
+ ainda não tem tela — só o resumo (`getUsageSummary`) aparece na visão geral.
65
+ - O item de fatura (`listInvoices`) vem cru do provedor (shape não confirmado);
66
+ a tabela lê os campos comuns (estilo Stripe: `number`/`amount_paid`/`status`/
67
+ `created`/`hosted_invoice_url`) de forma defensiva. Apertar quando o DTO real
68
+ for confirmado.
69
+ - Traduzir `es`/`fr`/`pt-PT` de verdade em `messages.ts` (hoje caem no
70
+ fallback do inglês).
71
+ - Validado: app com `security` + `billing-user` typecheca com **0 erros**;
72
+ nav + i18n wirados pelo CLI.
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function POST() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.cancelSubscription();
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function POST() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.cancelScheduledChange();
9
+ });
10
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest } from "next/server";
2
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
3
+
4
+ import { getBillingUserServer } from "@/modules/billing-user/server";
5
+ import type { ChangePlanRequest } from "@seedgrid/fe-billing-user";
6
+
7
+ export async function POST(request: NextRequest) {
8
+ const body = (await request.json()) as ChangePlanRequest;
9
+
10
+ return toRouteResponse(async () => {
11
+ const billing = await getBillingUserServer();
12
+ return billing.changePlan(body);
13
+ });
14
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest } from "next/server";
2
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
3
+
4
+ import { getBillingUserServer } from "@/modules/billing-user/server";
5
+
6
+ export async function GET(request: NextRequest) {
7
+ const planId = request.nextUrl.searchParams.get("planId") ?? "";
8
+
9
+ return toRouteResponse(async () => {
10
+ const billing = await getBillingUserServer();
11
+ return billing.getChangePreview(planId);
12
+ });
13
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest } from "next/server";
2
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
3
+
4
+ import { getBillingUserServer } from "@/modules/billing-user/server";
5
+ import type { CheckoutRequest } from "@seedgrid/fe-billing-user";
6
+
7
+ export async function POST(request: NextRequest) {
8
+ const body = (await request.json()) as CheckoutRequest;
9
+
10
+ return toRouteResponse(async () => {
11
+ const billing = await getBillingUserServer();
12
+ return billing.createCheckout(body);
13
+ });
14
+ }
@@ -0,0 +1,14 @@
1
+ import { NextRequest } from "next/server";
2
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
3
+
4
+ import { getBillingUserServer } from "@/modules/billing-user/server";
5
+
6
+ export async function GET(request: NextRequest) {
7
+ const limitParam = request.nextUrl.searchParams.get("limit");
8
+ const limit = limitParam ? Number(limitParam) : undefined;
9
+
10
+ return toRouteResponse(async () => {
11
+ const billing = await getBillingUserServer();
12
+ return billing.listInvoices(Number.isFinite(limit) ? limit : undefined);
13
+ });
14
+ }
@@ -0,0 +1,15 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function GET() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ const [subscription, usage] = await Promise.all([
9
+ billing.getSubscription(),
10
+ billing.getUsageSummary(),
11
+ ]);
12
+
13
+ return { subscription, usage };
14
+ });
15
+ }
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function GET() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.listPlans();
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function POST() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.openPortal();
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function POST() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.reactivateSubscription();
9
+ });
10
+ }
@@ -0,0 +1,10 @@
1
+ import { toRouteResponse } from "@seedgrid/fe-security/server";
2
+
3
+ import { getBillingUserServer } from "@/modules/billing-user/server";
4
+
5
+ export async function GET() {
6
+ return toRouteResponse(async () => {
7
+ const billing = await getBillingUserServer();
8
+ return billing.getUpcomingInvoice();
9
+ });
10
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import {
6
+ SgButton,
7
+ SgCard,
8
+ SgSkeleton,
9
+ SgStack,
10
+ sgWhistle,
11
+ } from "@seedgrid/fe-components";
12
+
13
+ import { useI18n } from "@/i18n";
14
+ import { PageFrame, RouteGuard } from "@/modules/security";
15
+ import type {
16
+ BillingInvoiceEnvelope,
17
+ UpcomingInvoiceResponse,
18
+ } from "@seedgrid/fe-billing-user";
19
+
20
+ export default function InvoicesPage() {
21
+ return (
22
+ <RouteGuard permissions={["BILLING_READ"]}>
23
+ <InvoicesScreen />
24
+ </RouteGuard>
25
+ );
26
+ }
27
+
28
+ // O item de invoice é repassado cru do provedor (shape não confirmado — ver a
29
+ // lib). Leitura defensiva dos campos mais comuns (estilo Stripe), sem travar
30
+ // o tipo.
31
+ function pickString(record: Record<string, unknown>, ...keys: string[]): string | null {
32
+ for (const key of keys) {
33
+ const value = record[key];
34
+ if (typeof value === "string" && value.trim()) return value;
35
+ if (typeof value === "number") return String(value);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function pickAmount(record: Record<string, unknown>, ...keys: string[]): number | null {
41
+ for (const key of keys) {
42
+ const value = record[key];
43
+ if (typeof value === "number" && Number.isFinite(value)) return value;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function formatCurrency(value: number | string | null | undefined, currency?: string | null) {
49
+ if (value == null) return "-";
50
+ const numeric = typeof value === "number" ? value : Number.parseFloat(value);
51
+ if (!Number.isFinite(numeric)) return "-";
52
+ return new Intl.NumberFormat(undefined, {
53
+ style: "currency",
54
+ currency: (currency || "BRL").toUpperCase(),
55
+ }).format(numeric);
56
+ }
57
+
58
+ function formatDate(value: string | number | null | undefined) {
59
+ if (value == null) return "-";
60
+ // Stripe timestamps vêm em segundos (epoch); ISO strings também são aceitas.
61
+ const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
62
+ if (Number.isNaN(date.getTime())) return "-";
63
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
64
+ }
65
+
66
+ function InvoicesScreen() {
67
+ const { t } = useI18n();
68
+ const [upcoming, setUpcoming] = useState<UpcomingInvoiceResponse | null>(null);
69
+ const [invoices, setInvoices] = useState<Record<string, unknown>[]>([]);
70
+ const [loading, setLoading] = useState(true);
71
+
72
+ const load = useCallback(async () => {
73
+ setLoading(true);
74
+ try {
75
+ const [upcomingRes, invoicesRes] = await Promise.all([
76
+ fetch("/api/billing/upcoming", { cache: "no-store" }),
77
+ fetch("/api/billing/invoices?limit=12", { cache: "no-store" }),
78
+ ]);
79
+ if (upcomingRes.ok) {
80
+ const payload = (await upcomingRes.json()) as { upcoming: UpcomingInvoiceResponse | null };
81
+ setUpcoming(payload.upcoming ?? null);
82
+ }
83
+ if (invoicesRes.ok) {
84
+ const payload = (await invoicesRes.json()) as BillingInvoiceEnvelope;
85
+ setInvoices(payload.invoices ?? []);
86
+ }
87
+ } catch {
88
+ sgWhistle.error({ message: t("billing.user.invoices.load_error") });
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ }, [t]);
93
+
94
+ useEffect(() => {
95
+ void load();
96
+ }, [load]);
97
+
98
+ return (
99
+ <PageFrame
100
+ title={t("billing.user.invoices.title")}
101
+ description={t("billing.user.invoices.description")}
102
+ >
103
+ {loading ? (
104
+ <SgStack gap={16}>
105
+ <SgSkeleton height={120} />
106
+ <SgSkeleton height={200} />
107
+ </SgStack>
108
+ ) : (
109
+ <SgStack gap={16}>
110
+ <SgCard cardStyle="elevated" title={t("billing.user.invoices.upcoming.title")}>
111
+ {upcoming ? (
112
+ <SgStack gap={8}>
113
+ <div className="text-2xl font-semibold text-slate-900">
114
+ {formatCurrency(upcoming.amountDue ?? upcoming.total, upcoming.currency)}
115
+ </div>
116
+ <span className="text-sm text-slate-600">
117
+ {t("billing.user.invoices.upcoming.next_attempt", {
118
+ date: formatDate(upcoming.nextPaymentAttempt),
119
+ })}
120
+ </span>
121
+ {upcoming.lines?.length ? (
122
+ <SgStack gap={4} className="mt-2">
123
+ {upcoming.lines.map((line, index) => (
124
+ <div
125
+ key={index}
126
+ className="flex justify-between border-b border-slate-100 py-1 text-sm last:border-b-0"
127
+ >
128
+ <span className="text-slate-600">{line.description ?? "-"}</span>
129
+ <span className="text-slate-900">
130
+ {formatCurrency(line.amount, upcoming.currency)}
131
+ </span>
132
+ </div>
133
+ ))}
134
+ </SgStack>
135
+ ) : null}
136
+ </SgStack>
137
+ ) : (
138
+ <span className="text-sm text-slate-500">
139
+ {t("billing.user.invoices.upcoming.none")}
140
+ </span>
141
+ )}
142
+ </SgCard>
143
+
144
+ <SgCard cardStyle="elevated" title={t("billing.user.invoices.history.title")}>
145
+ {invoices.length ? (
146
+ <div className="overflow-x-auto">
147
+ <table className="w-full text-sm">
148
+ <thead>
149
+ <tr className="border-b border-slate-200 text-left text-slate-500">
150
+ <th className="py-2 pr-4">{t("billing.user.invoices.history.column.number")}</th>
151
+ <th className="py-2 pr-4">{t("billing.user.invoices.history.column.date")}</th>
152
+ <th className="py-2 pr-4">{t("billing.user.invoices.history.column.amount")}</th>
153
+ <th className="py-2 pr-4">{t("billing.user.invoices.history.column.status")}</th>
154
+ <th className="py-2">{" "}</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {invoices.map((invoice, index) => {
159
+ const number = pickString(invoice, "number", "id");
160
+ const amountRaw = pickAmount(invoice, "amount_paid", "amount_due", "total");
161
+ const amount = amountRaw != null ? amountRaw / 100 : null;
162
+ const currency = pickString(invoice, "currency");
163
+ const status = pickString(invoice, "status");
164
+ const created = invoice["created"];
165
+ const url = pickString(invoice, "hosted_invoice_url", "invoice_pdf");
166
+
167
+ return (
168
+ <tr key={number ?? index} className="border-b border-slate-100 last:border-b-0">
169
+ <td className="py-2 pr-4 text-slate-900">{number ?? "-"}</td>
170
+ <td className="py-2 pr-4 text-slate-600">
171
+ {formatDate(typeof created === "number" || typeof created === "string" ? created : null)}
172
+ </td>
173
+ <td className="py-2 pr-4 text-slate-900">{formatCurrency(amount, currency)}</td>
174
+ <td className="py-2 pr-4 text-slate-600">{status ?? "-"}</td>
175
+ <td className="py-2">
176
+ {url ? (
177
+ <a
178
+ href={url}
179
+ target="_blank"
180
+ rel="noreferrer"
181
+ className="text-sky-600 hover:text-sky-800"
182
+ >
183
+ {t("billing.user.invoices.history.view")}
184
+ </a>
185
+ ) : null}
186
+ </td>
187
+ </tr>
188
+ );
189
+ })}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ ) : (
194
+ <span className="text-sm text-slate-500">
195
+ {t("billing.user.invoices.history.empty")}
196
+ </span>
197
+ )}
198
+ </SgCard>
199
+
200
+ <div>
201
+ <SgButton
202
+ appearance="outline"
203
+ severity="secondary"
204
+ shape="rounded"
205
+ label={t("billing.user.invoices.refresh")}
206
+ onClick={() => void load()}
207
+ />
208
+ </div>
209
+ </SgStack>
210
+ )}
211
+ </PageFrame>
212
+ );
213
+ }