@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.
- package/README.md +78 -0
- package/dist/billing.d.ts +234 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/messages.d.ts +348 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +148 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +13 -0
- package/dist/module.json +15 -0
- package/dist/paths.d.ts +29 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +35 -0
- package/dist/server/create-billing-user-server.d.ts +34 -0
- package/dist/server/create-billing-user-server.d.ts.map +1 -0
- package/dist/server/create-billing-user-server.js +33 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/README.md +72 -0
- package/scaffold/src/app/api/billing/cancel/route.ts +10 -0
- package/scaffold/src/app/api/billing/cancel-scheduled-change/route.ts +10 -0
- package/scaffold/src/app/api/billing/change-plan/route.ts +14 -0
- package/scaffold/src/app/api/billing/change-preview/route.ts +13 -0
- package/scaffold/src/app/api/billing/checkout/route.ts +14 -0
- package/scaffold/src/app/api/billing/invoices/route.ts +14 -0
- package/scaffold/src/app/api/billing/me/route.ts +15 -0
- package/scaffold/src/app/api/billing/plans/route.ts +10 -0
- package/scaffold/src/app/api/billing/portal/route.ts +10 -0
- package/scaffold/src/app/api/billing/reactivate/route.ts +10 -0
- package/scaffold/src/app/api/billing/upcoming/route.ts +10 -0
- package/scaffold/src/app/billing/invoices/page.tsx +213 -0
- package/scaffold/src/app/billing/page.tsx +273 -0
- package/scaffold/src/app/billing/plans/page.tsx +248 -0
- package/scaffold/src/modules/billing-user/config.ts +7 -0
- package/scaffold/src/modules/billing-user/index.ts +17 -0
- package/scaffold/src/modules/billing-user/server.ts.hbs +34 -0
- 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 @@
|
|
|
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"}
|
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
|
+
}
|