mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 08:36:44 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
13b3951a1e
commit
5ef5bab121
9 changed files with 512 additions and 0 deletions
59
supabase/functions/_shared/paddle.ts
Normal file
59
supabase/functions/_shared/paddle.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { Environment, Paddle, EventName } from 'npm:@paddle/paddle-node-sdk';
|
||||
|
||||
export { EventName };
|
||||
|
||||
export type PaddleEnv = 'sandbox' | 'live';
|
||||
|
||||
const GATEWAY_BASE_URL = 'https://connector-gateway.lovable.dev/paddle';
|
||||
|
||||
export function getConnectionApiKey(env: PaddleEnv): string {
|
||||
return env === 'sandbox'
|
||||
? Deno.env.get('PADDLE_SANDBOX_API_KEY')!
|
||||
: Deno.env.get('PADDLE_LIVE_API_KEY')!;
|
||||
}
|
||||
|
||||
export function getPaddleClient(env: PaddleEnv): Paddle {
|
||||
const connectionApiKey = getConnectionApiKey(env);
|
||||
const lovableApiKey = Deno.env.get('LOVABLE_API_KEY')!;
|
||||
|
||||
return new Paddle(connectionApiKey, {
|
||||
environment: GATEWAY_BASE_URL as unknown as Environment,
|
||||
customHeaders: {
|
||||
'X-Connection-Api-Key': connectionApiKey,
|
||||
'Lovable-API-Key': lovableApiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function gatewayFetch(env: PaddleEnv, path: string, init?: RequestInit): Promise<Response> {
|
||||
const connectionApiKey = getConnectionApiKey(env);
|
||||
const lovableApiKey = Deno.env.get('LOVABLE_API_KEY')!;
|
||||
return fetch(`${GATEWAY_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Connection-Api-Key': connectionApiKey,
|
||||
'Lovable-API-Key': lovableApiKey,
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getWebhookSecret(env: PaddleEnv): string {
|
||||
return env === 'sandbox'
|
||||
? Deno.env.get('PAYMENTS_SANDBOX_WEBHOOK_SECRET')!
|
||||
: Deno.env.get('PAYMENTS_LIVE_WEBHOOK_SECRET')!;
|
||||
}
|
||||
|
||||
export async function verifyWebhook(req: Request, env: PaddleEnv) {
|
||||
const signature = req.headers.get('paddle-signature');
|
||||
const body = await req.text();
|
||||
const secret = getWebhookSecret(env);
|
||||
|
||||
if (!signature || !body) {
|
||||
throw new Error('Missing signature or body');
|
||||
}
|
||||
|
||||
const paddle = getPaddleClient(env);
|
||||
return await paddle.webhooks.unmarshal(body, secret, signature);
|
||||
}
|
||||
62
supabase/functions/create-portal-session/index.ts
Normal file
62
supabase/functions/create-portal-session/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
import { getPaddleClient, type PaddleEnv } from '../_shared/paddle.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'Missing auth' }), { status: 401, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const anonKey = Deno.env.get('SUPABASE_PUBLISHABLE_KEY') || Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||
const userClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
|
||||
const { data: userRes, error: userErr } = await userClient.auth.getUser();
|
||||
if (userErr || !userRes.user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const admin = createClient(supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
|
||||
const { data: sub } = await admin
|
||||
.from('subscriptions')
|
||||
.select('paddle_customer_id, paddle_subscription_id, environment')
|
||||
.eq('user_id', userRes.user.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!sub?.paddle_customer_id) {
|
||||
return new Response(JSON.stringify({ error: 'No subscription found' }), {
|
||||
status: 404,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const paddle = getPaddleClient(sub.environment as PaddleEnv);
|
||||
const subIds = sub.paddle_subscription_id ? [sub.paddle_subscription_id] : [];
|
||||
const portalSession = await paddle.customerPortalSessions.create(sub.paddle_customer_id, subIds);
|
||||
|
||||
return new Response(JSON.stringify({ url: portalSession.urls.general.overview }), {
|
||||
headers: corsHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('create-portal-session error:', e);
|
||||
return new Response(JSON.stringify({ error: (e as Error).message }), {
|
||||
status: 500,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
});
|
||||
42
supabase/functions/get-paddle-price/index.ts
Normal file
42
supabase/functions/get-paddle-price/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { gatewayFetch, type PaddleEnv } from '../_shared/paddle.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { priceId, environment } = await req.json();
|
||||
if (!priceId) {
|
||||
return new Response(JSON.stringify({ error: 'priceId required' }), {
|
||||
status: 400,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const env = (environment || 'sandbox') as PaddleEnv;
|
||||
const response = await gatewayFetch(env, `/prices?external_id=${encodeURIComponent(priceId)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.data?.length) {
|
||||
return new Response(JSON.stringify({ error: 'Price not found' }), {
|
||||
status: 404,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ paddleId: data.data[0].id }), { headers: corsHeaders });
|
||||
} catch (e) {
|
||||
console.error('get-paddle-price error:', e);
|
||||
return new Response(JSON.stringify({ error: (e as Error).message }), {
|
||||
status: 500,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
}
|
||||
});
|
||||
134
supabase/functions/payments-webhook/index.ts
Normal file
134
supabase/functions/payments-webhook/index.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { createClient } from 'npm:@supabase/supabase-js@2';
|
||||
import { verifyWebhook, EventName, type PaddleEnv } from '../_shared/paddle.ts';
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
);
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const env = (url.searchParams.get('env') || 'sandbox') as PaddleEnv;
|
||||
|
||||
try {
|
||||
const event = await verifyWebhook(req, env);
|
||||
console.log('Received event:', event.eventType, 'env:', env, 'id:', (event as any).eventId);
|
||||
|
||||
// Idempotência: tenta inserir, ignora se já existe
|
||||
const eventId = (event as any).eventId || (event as any).id;
|
||||
if (eventId) {
|
||||
const { error: insertErr } = await supabase
|
||||
.from('paddle_webhook_events')
|
||||
.insert({
|
||||
paddle_event_id: eventId,
|
||||
event_type: event.eventType,
|
||||
environment: env,
|
||||
payload: event as any,
|
||||
});
|
||||
// Se já existe (unique violation), ignora silenciosamente
|
||||
if (insertErr && insertErr.code !== '23505') {
|
||||
console.error('Failed to record event:', insertErr);
|
||||
} else if (insertErr?.code === '23505') {
|
||||
console.log('Event already processed:', eventId);
|
||||
return new Response(JSON.stringify({ received: true, duplicate: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.eventType) {
|
||||
case EventName.SubscriptionCreated:
|
||||
case EventName.SubscriptionUpdated:
|
||||
await upsertSubscription((event as any).data, env);
|
||||
break;
|
||||
case EventName.SubscriptionCanceled:
|
||||
await markCanceled((event as any).data, env);
|
||||
break;
|
||||
case EventName.TransactionCompleted:
|
||||
console.log('Transaction completed:', (event as any).data.id);
|
||||
break;
|
||||
case EventName.TransactionPaymentFailed:
|
||||
console.log('Payment failed:', (event as any).data.id);
|
||||
break;
|
||||
default:
|
||||
console.log('Unhandled event:', event.eventType);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Webhook error:', e);
|
||||
return new Response('Webhook error: ' + (e as Error).message, { status: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
async function upsertSubscription(data: any, env: PaddleEnv) {
|
||||
const { id, customerId, items, status, currentBillingPeriod, scheduledChange, customData } = data;
|
||||
|
||||
const userId = customData?.userId;
|
||||
if (!userId) {
|
||||
console.error('No userId in customData for subscription', id);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = items?.[0];
|
||||
const priceExt = item?.price?.importMeta?.externalId || item?.price?.id;
|
||||
const productExt = item?.product?.importMeta?.externalId || item?.product?.id;
|
||||
const billingCycle = item?.price?.billingCycle?.interval === 'year' ? 'yearly' : 'monthly';
|
||||
|
||||
// Resolve plan_id local pelo slug correspondente ao product externalId
|
||||
// Mapeamento: basic_plan -> basic, starter_plan -> starter, professional_plan -> professional
|
||||
const slugMap: Record<string, string> = {
|
||||
basic_plan: 'basic',
|
||||
starter_plan: 'starter',
|
||||
professional_plan: 'professional',
|
||||
};
|
||||
const planSlug = slugMap[productExt as string];
|
||||
let planId: string | null = null;
|
||||
if (planSlug) {
|
||||
const { data: planRow } = await supabase.from('plans').select('id').eq('slug', planSlug).maybeSingle();
|
||||
planId = planRow?.id ?? null;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('subscriptions').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
paddle_subscription_id: id,
|
||||
paddle_customer_id: customerId,
|
||||
product_id: productExt,
|
||||
price_id: priceExt,
|
||||
plan_id: planId,
|
||||
billing_cycle: billingCycle,
|
||||
status,
|
||||
current_period_start: currentBillingPeriod?.startsAt,
|
||||
current_period_end: currentBillingPeriod?.endsAt,
|
||||
cancel_at_period_end: scheduledChange?.action === 'cancel',
|
||||
environment: env,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'user_id,environment' }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Upsert subscription error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Atualiza paddle_customer_id no profile
|
||||
await supabase.from('profiles').update({ paddle_customer_id: customerId }).eq('id', userId);
|
||||
}
|
||||
|
||||
async function markCanceled(data: any, env: PaddleEnv) {
|
||||
await supabase
|
||||
.from('subscriptions')
|
||||
.update({ status: 'canceled', updated_at: new Date().toISOString() })
|
||||
.eq('paddle_subscription_id', data.id)
|
||||
.eq('environment', env);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue