@neetru/cli 2.25.0 → 2.26.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/dist/commands/build.js +12 -17
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/dev-up.d.ts +10 -26
- package/dist/commands/dev-up.js +9 -141
- package/dist/commands/dev-up.js.map +1 -1
- package/dist/commands/logs.d.ts +29 -0
- package/dist/commands/logs.js +161 -0
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/webhooks.d.ts +34 -0
- package/dist/commands/webhooks.js +162 -0
- package/dist/commands/webhooks.js.map +1 -0
- package/dist/index.js +109 -22
- package/dist/index.js.map +1 -1
- package/dist/lib/compose-generator.js +11 -7
- package/dist/lib/compose-generator.js.map +1 -1
- package/dist/utils/safe-exit.d.ts +23 -0
- package/dist/utils/safe-exit.js +53 -1
- package/dist/utils/safe-exit.js.map +1 -1
- package/package.json +1 -1
- package/templates/billing/checkout.ts +25 -9
- package/templates/billing/page.tsx +56 -15
- package/templates/billing/webhooks.ts +83 -0
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template — `src/
|
|
2
|
+
* Template — `src/app/api/billing/checkout/route.ts` (gerado por `neetru add billing`).
|
|
3
3
|
*
|
|
4
|
-
* Inicia checkout Stripe via
|
|
4
|
+
* Inicia checkout Stripe via SDK server-side. O cliente chama este endpoint
|
|
5
|
+
* com { planId, productId } e recebe a URL de redirecionamento pro Stripe.
|
|
6
|
+
*
|
|
7
|
+
* Pré-requisito: NEETRU_API_KEY e NEETRU_PRODUCT_ID no .env.local.
|
|
5
8
|
*/
|
|
6
9
|
import { NextRequest, NextResponse } from 'next/server';
|
|
10
|
+
import { createNeetruClient } from '@neetru/sdk';
|
|
7
11
|
|
|
8
12
|
export const runtime = 'nodejs';
|
|
9
13
|
|
|
10
14
|
export async function POST(request: NextRequest) {
|
|
11
15
|
const body = await request.json();
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
const { planId, productId } = body ?? {};
|
|
17
|
+
const pid = productId || process.env.NEETRU_PRODUCT_ID;
|
|
18
|
+
|
|
19
|
+
if (!planId || !pid) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: 'planId e productId obrigatórios' },
|
|
22
|
+
{ status: 400 },
|
|
23
|
+
);
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
redirectUrl: `https://minhaconta.neetru.com/checkout?slug=${slug}`,
|
|
26
|
+
const client = createNeetruClient({
|
|
27
|
+
apiKey: process.env.NEETRU_API_KEY!,
|
|
28
|
+
productId: pid,
|
|
21
29
|
});
|
|
30
|
+
|
|
31
|
+
const result = await client.checkout.start({
|
|
32
|
+
productId: pid,
|
|
33
|
+
planId,
|
|
34
|
+
callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'}/billing/success`,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return NextResponse.json({ redirectUrl: result.redirectUrl });
|
|
22
38
|
}
|
|
@@ -1,40 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template — `src/
|
|
2
|
+
* Template — `src/app/(marketing)/billing/page.tsx` (gerado por `neetru add billing`).
|
|
3
3
|
*
|
|
4
|
-
* Página
|
|
4
|
+
* Página de planos: lista os planos do produto e usa <CheckoutLink> do SDK
|
|
5
|
+
* pra redirecionar ao Stripe. O <CheckoutLink> usa OIDC — o usuário precisa
|
|
6
|
+
* estar logado (veja `neetru add auth`).
|
|
7
|
+
*
|
|
8
|
+
* Pré-requisito: NEXT_PUBLIC_NEETRU_API_KEY e NEXT_PUBLIC_NEETRU_PRODUCT_ID
|
|
9
|
+
* no .env.local.
|
|
5
10
|
*/
|
|
6
11
|
'use client';
|
|
7
12
|
|
|
13
|
+
import { createNeetruClient } from '@neetru/sdk';
|
|
14
|
+
import { CheckoutLink } from '@neetru/sdk/react';
|
|
8
15
|
import { useEffect, useState } from 'react';
|
|
9
|
-
import { createNeetruClient, type Product } from '@neetru/sdk';
|
|
10
16
|
|
|
11
17
|
const client = createNeetruClient({
|
|
12
|
-
apiKey: process.env.NEXT_PUBLIC_NEETRU_API_KEY
|
|
18
|
+
apiKey: process.env.NEXT_PUBLIC_NEETRU_API_KEY!,
|
|
19
|
+
productId: process.env.NEXT_PUBLIC_NEETRU_PRODUCT_ID!,
|
|
13
20
|
});
|
|
14
21
|
|
|
22
|
+
interface Plan {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
price: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export default function BillingPage() {
|
|
16
|
-
const [
|
|
31
|
+
const [plans, setPlans] = useState<Plan[]>([]);
|
|
32
|
+
const [loading, setLoading] = useState(true);
|
|
33
|
+
|
|
17
34
|
useEffect(() => {
|
|
18
35
|
client.catalog
|
|
19
36
|
.list()
|
|
20
|
-
.then(({ products }) =>
|
|
21
|
-
|
|
37
|
+
.then(({ products }) => {
|
|
38
|
+
const product = products.find(
|
|
39
|
+
(p) => p.id === process.env.NEXT_PUBLIC_NEETRU_PRODUCT_ID,
|
|
40
|
+
);
|
|
41
|
+
setPlans(
|
|
42
|
+
product?.plans?.map((plan: { id: string; name: string; description?: string; amount?: number; interval?: string; slug?: string }) => ({
|
|
43
|
+
id: plan.id,
|
|
44
|
+
name: plan.name,
|
|
45
|
+
description: plan.description ?? '',
|
|
46
|
+
price: plan.amount ? `R$ ${(plan.amount / 100).toFixed(2)}/${plan.interval ?? 'mês'}` : 'Sob consulta',
|
|
47
|
+
slug: plan.slug ?? plan.id,
|
|
48
|
+
})) ?? [],
|
|
49
|
+
);
|
|
50
|
+
})
|
|
51
|
+
.catch(console.error)
|
|
52
|
+
.finally(() => setLoading(false));
|
|
22
53
|
}, []);
|
|
23
54
|
|
|
55
|
+
if (loading) return <main className="p-8"><p>Carregando planos...</p></main>;
|
|
56
|
+
|
|
24
57
|
return (
|
|
25
58
|
<main className="p-8">
|
|
26
59
|
<h1 className="text-3xl font-bold mb-6">Planos</h1>
|
|
27
60
|
<div className="grid gap-4 md:grid-cols-3">
|
|
28
|
-
{
|
|
29
|
-
<article key={
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
{plans.map((plan) => (
|
|
62
|
+
<article key={plan.id} className="border p-6 flex flex-col gap-4">
|
|
63
|
+
<div>
|
|
64
|
+
<h2 className="text-xl font-semibold">{plan.name}</h2>
|
|
65
|
+
<p className="text-2xl font-bold mt-2">{plan.price}</p>
|
|
66
|
+
{plan.description && (
|
|
67
|
+
<p className="text-sm text-zinc-600 mt-1">{plan.description}</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
<CheckoutLink
|
|
71
|
+
client={client}
|
|
72
|
+
productId={process.env.NEXT_PUBLIC_NEETRU_PRODUCT_ID!}
|
|
73
|
+
planId={plan.id}
|
|
74
|
+
callbackUrl={`${typeof window !== 'undefined' ? window.location.origin : ''}/billing/success`}
|
|
75
|
+
className="inline-block mt-auto px-4 py-2 bg-zinc-900 text-white text-center"
|
|
35
76
|
>
|
|
36
77
|
Assinar
|
|
37
|
-
</
|
|
78
|
+
</CheckoutLink>
|
|
38
79
|
</article>
|
|
39
80
|
))}
|
|
40
81
|
</div>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template — `src/app/api/webhooks/neetru/route.ts` (gerado por `neetru add billing`).
|
|
3
|
+
*
|
|
4
|
+
* Recebe webhooks do Neetru Core: subscription.activated, .cancelled,
|
|
5
|
+
* .payment_failed, .trial_ending e outros.
|
|
6
|
+
*
|
|
7
|
+
* O secret HMAC deve bater com o registrado via `neetru webhooks register`
|
|
8
|
+
* ou com NEETRU_WEBHOOK_SECRET no .env.local (dev).
|
|
9
|
+
*
|
|
10
|
+
* Pré-requisito: registrar o endpoint via:
|
|
11
|
+
* neetru webhooks register --product-id <id> --url <URL> --events subscription.activated,subscription.cancelled,subscription.payment_failed
|
|
12
|
+
*/
|
|
13
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
14
|
+
import { verifyWebhookRequest } from '@neetru/sdk/webhooks';
|
|
15
|
+
|
|
16
|
+
export const runtime = 'nodejs';
|
|
17
|
+
|
|
18
|
+
const WEBHOOK_SECRET = process.env.NEETRU_WEBHOOK_SECRET;
|
|
19
|
+
|
|
20
|
+
interface WebhookPayload {
|
|
21
|
+
type: string;
|
|
22
|
+
data: Record<string, unknown>;
|
|
23
|
+
timestamp?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function handleSubscriptionActivated(data: Record<string, unknown>) {
|
|
27
|
+
const { subscriptionId, productId, tenantId } = data as Record<string, string>;
|
|
28
|
+
console.log('Assinatura ativada:', { subscriptionId, productId, tenantId });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function handleSubscriptionCancelled(data: Record<string, unknown>) {
|
|
32
|
+
const { subscriptionId, productId, tenantId } = data as Record<string, string>;
|
|
33
|
+
console.log('Assinatura cancelada:', { subscriptionId, productId, tenantId });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function handleSubscriptionPaymentFailed(data: Record<string, unknown>) {
|
|
37
|
+
const { subscriptionId, productId, tenantId } = data as Record<string, string>;
|
|
38
|
+
console.log('Pagamento falhou:', { subscriptionId, productId, tenantId });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function handleTrialEnding(data: Record<string, unknown>) {
|
|
42
|
+
const { subscriptionId, productId, tenantId } = data as Record<string, string>;
|
|
43
|
+
console.log('Trial terminando:', { subscriptionId, productId, tenantId });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const HANDLERS: Record<string, (data: Record<string, unknown>) => Promise<void>> = {
|
|
47
|
+
'subscription.activated': handleSubscriptionActivated,
|
|
48
|
+
'subscription.cancelled': handleSubscriptionCancelled,
|
|
49
|
+
'subscription.payment_failed': handleSubscriptionPaymentFailed,
|
|
50
|
+
'subscription.trial_ending': handleTrialEnding,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function POST(request: NextRequest) {
|
|
54
|
+
if (!WEBHOOK_SECRET) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: 'NEETRU_WEBHOOK_SECRET não configurado' },
|
|
57
|
+
{ status: 500 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rawBody = await request.text();
|
|
62
|
+
const signature = request.headers.get('x-neetru-signature') ?? '';
|
|
63
|
+
const timestamp = request.headers.get('x-neetru-timestamp') ?? '';
|
|
64
|
+
|
|
65
|
+
const isValid = verifyWebhookRequest(rawBody, signature, WEBHOOK_SECRET, { timestamp });
|
|
66
|
+
if (!isValid) {
|
|
67
|
+
return NextResponse.json({ error: 'invalid_signature' }, { status: 401 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let payload: WebhookPayload;
|
|
71
|
+
try {
|
|
72
|
+
payload = JSON.parse(rawBody);
|
|
73
|
+
} catch {
|
|
74
|
+
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const handler = HANDLERS[payload.type];
|
|
78
|
+
if (handler) {
|
|
79
|
+
await handler(payload.data);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return NextResponse.json({ ok: true });
|
|
83
|
+
}
|