feat: add admin billing system, SMTP email, rename to CloudLense
- Add payments, coupons, credits, invoices management to admin dashboard - Add 7 new admin tabs: Overview, Users, Orgs, Payments, Coupons, Credits, Invoices - Replace Resend with SMTP email via nodemailer (info@dk0.dev / mail.dk0.dev) - Add professional branded email templates (alerts, welcome, invoice, credit, password reset) - Add database migration for payments, coupons, coupon_redemptions, credit_transactions, invoices tables - Add credit_balance column to organizations - Add RLS policies for all new tables - Add 4 new API routes: /api/admin/{payments,coupons,credits,invoices} - Rename project from website-monitoring to CloudLense - Update all package.json names and README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const activeOnly = searchParams.get("activeOnly") === "true";
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("coupons")
|
||||
.select("*, creator:users!coupons_created_by_fkey(id, email, name)", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (activeOnly) query = query.eq("is_active", true);
|
||||
|
||||
const { data: coupons, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
coupons: coupons || [],
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching coupons:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch coupons" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
code,
|
||||
description,
|
||||
discount_type,
|
||||
discount_value,
|
||||
currency,
|
||||
max_redemptions,
|
||||
applicable_tiers,
|
||||
valid_from,
|
||||
valid_until,
|
||||
} = body;
|
||||
|
||||
if (!code || !discount_type || discount_value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "code, discount_type, and discount_value are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (discount_type === "percentage" && (discount_value < 1 || discount_value > 100)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Percentage discount must be between 1 and 100" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: coupon, error } = await supabase
|
||||
.from("coupons")
|
||||
.insert({
|
||||
code: code.toUpperCase().trim(),
|
||||
description,
|
||||
discount_type,
|
||||
discount_value: Math.round(discount_value),
|
||||
currency: currency || "EUR",
|
||||
max_redemptions: max_redemptions || null,
|
||||
applicable_tiers: applicable_tiers || ["free", "starter", "professional"],
|
||||
valid_from: valid_from || new Date().toISOString(),
|
||||
valid_until: valid_until || null,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === "23505") {
|
||||
return NextResponse.json({ error: "A coupon with this code already exists" }, { status: 409 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({ coupon });
|
||||
} catch (error) {
|
||||
console.error("Error creating coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to create coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, ...updates } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
"description",
|
||||
"discount_type",
|
||||
"discount_value",
|
||||
"max_redemptions",
|
||||
"applicable_tiers",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"is_active",
|
||||
];
|
||||
|
||||
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
for (const key of allowedFields) {
|
||||
if (key in updates) safeUpdates[key] = updates[key];
|
||||
}
|
||||
|
||||
if (updates.code) safeUpdates.code = updates.code.toUpperCase().trim();
|
||||
|
||||
const { data: coupon, error } = await supabase
|
||||
.from("coupons")
|
||||
.update(safeUpdates)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ coupon });
|
||||
} catch (error) {
|
||||
console.error("Error updating coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to update coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Coupon id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("coupons").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting coupon:", error);
|
||||
return NextResponse.json({ error: "Failed to delete coupon" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const orgId = searchParams.get("orgId");
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
if (orgId) {
|
||||
// Get specific org's credit balance and transaction history
|
||||
const [orgResult, transactionsResult] = await Promise.all([
|
||||
supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance")
|
||||
.eq("id", orgId)
|
||||
.single(),
|
||||
supabase
|
||||
.from("credit_transactions")
|
||||
.select(
|
||||
"*, creator:users!credit_transactions_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.eq("organization_id", orgId)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1),
|
||||
]);
|
||||
|
||||
if (orgResult.error) throw orgResult.error;
|
||||
|
||||
return NextResponse.json({
|
||||
organization: orgResult.data,
|
||||
transactions: transactionsResult.data || [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: transactionsResult.count || 0,
|
||||
pages: Math.ceil((transactionsResult.count || 0) / limit),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// List all organizations with credit balances
|
||||
const { data: orgs, count, error } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance, subscription_tier", { count: "exact" })
|
||||
.order("credit_balance", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: orgs || [],
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching credits:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch credits" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, type, reason, notes } = body;
|
||||
|
||||
if (!organization_id || amount === undefined || !type || !reason) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id, amount, type, and reason are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!["credit", "debit"].includes(type)) {
|
||||
return NextResponse.json({ error: "type must be 'credit' or 'debit'" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get current balance
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.select("id, name, credit_balance")
|
||||
.eq("id", organization_id)
|
||||
.single();
|
||||
|
||||
if (orgError || !org) {
|
||||
return NextResponse.json({ error: "Organization not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const absAmount = Math.abs(Math.round(amount));
|
||||
const signedAmount = type === "credit" ? absAmount : -absAmount;
|
||||
const currentBalance = typeof org.credit_balance === "number" ? org.credit_balance : 0;
|
||||
const newBalance = currentBalance + signedAmount;
|
||||
|
||||
if (newBalance < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Insufficient balance. Current: ${currentBalance}, debit: ${absAmount}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Insert transaction and update balance atomically
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from("credit_transactions")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: signedAmount,
|
||||
balance_after: newBalance,
|
||||
type,
|
||||
reason,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (txError) throw txError;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("organizations")
|
||||
.update({ credit_balance: newBalance })
|
||||
.eq("id", organization_id);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
return NextResponse.json({
|
||||
transaction,
|
||||
new_balance: newBalance,
|
||||
organization: { id: org.id, name: org.name },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing credit transaction:", error);
|
||||
return NextResponse.json({ error: "Failed to process credit transaction" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
import { sendEmail } from "@/lib/email";
|
||||
import { invoiceEmail } from "@/lib/email-templates";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const orgId = searchParams.get("orgId");
|
||||
const status = searchParams.get("status");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("invoices")
|
||||
.select(
|
||||
"*, organization:organizations(id, name, billing_email), creator:users!invoices_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (orgId) query = query.eq("organization_id", orgId);
|
||||
if (status) query = query.eq("status", status);
|
||||
|
||||
const { data: invoices, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
invoices: invoices || [],
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching invoices:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch invoices" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, currency, items, due_date, notes, status: invoiceStatus } = body;
|
||||
|
||||
if (!organization_id || amount === undefined || !items?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id, amount, and items are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate invoice number: INV-YYYYMM-XXXX
|
||||
const now = new Date();
|
||||
const prefix = `INV-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const { count } = await supabase
|
||||
.from("invoices")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.like("invoice_number", `${prefix}%`);
|
||||
const seq = String((count || 0) + 1).padStart(4, "0");
|
||||
|
||||
const { data: invoice, error } = await supabase
|
||||
.from("invoices")
|
||||
.insert({
|
||||
invoice_number: `${prefix}-${seq}`,
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
currency: currency || "EUR",
|
||||
status: invoiceStatus || "draft",
|
||||
items,
|
||||
due_date: due_date || null,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select("*, organization:organizations(id, name, billing_email)")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error("Error creating invoice:", error);
|
||||
return NextResponse.json({ error: "Failed to create invoice" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, action, ...updates } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Invoice id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Send invoice via email
|
||||
if (action === "send") {
|
||||
const { data: invoice } = await supabase
|
||||
.from("invoices")
|
||||
.select("*, organization:organizations(id, name, billing_email)")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const org = invoice.organization as { id: string; name: string; billing_email?: string } | null;
|
||||
const recipientEmail = org?.billing_email;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Organization has no billing email configured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
const template = invoiceEmail({
|
||||
organizationName: org.name,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
dueDate: invoice.due_date,
|
||||
items: (invoice.items as Array<{ description: string; amount: number }>) || [],
|
||||
dashboardUrl: `${appUrl}/dashboard/settings`,
|
||||
});
|
||||
|
||||
const sent = await sendEmail({
|
||||
to: recipientEmail,
|
||||
subject: template.subject,
|
||||
html: template.html,
|
||||
text: template.text,
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("invoices")
|
||||
.update({ status: "sent", updated_at: new Date().toISOString() })
|
||||
.eq("id", id);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Invoice sent" });
|
||||
}
|
||||
|
||||
// Mark as paid
|
||||
if (action === "mark_paid") {
|
||||
const { data: invoice, error } = await supabase
|
||||
.from("invoices")
|
||||
.update({ status: "paid", paid_at: new Date().toISOString(), updated_at: new Date().toISOString() })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ invoice });
|
||||
}
|
||||
|
||||
// Generic update
|
||||
const allowedFields = ["status", "amount", "items", "due_date", "notes"];
|
||||
const safeUpdates: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
for (const key of allowedFields) {
|
||||
if (key in updates) safeUpdates[key] = updates[key];
|
||||
}
|
||||
|
||||
const { data: invoice, error } = await supabase
|
||||
.from("invoices")
|
||||
.update(safeUpdates)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice:", error);
|
||||
return NextResponse.json({ error: "Failed to update invoice" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { requireAdmin } from "@/lib/apiAuth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
const orgId = searchParams.get("orgId");
|
||||
const status = searchParams.get("status");
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from("payments")
|
||||
.select(
|
||||
"*, organization:organizations(id, name), creator:users!payments_created_by_fkey(id, email, name)",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (orgId) query = query.eq("organization_id", orgId);
|
||||
if (status) query = query.eq("status", status);
|
||||
|
||||
const { data: payments, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({
|
||||
payments: payments || [],
|
||||
pagination: { page, limit, total: count || 0, pages: Math.ceil((count || 0) / limit) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching payments:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch payments" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authResult = await requireAdmin(request);
|
||||
if (authResult instanceof NextResponse) return authResult;
|
||||
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { organization_id, amount, currency, status, method, description, notes } = body;
|
||||
|
||||
if (!organization_id || amount === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "organization_id and amount are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: payment, error } = await supabase
|
||||
.from("payments")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
currency: currency || "EUR",
|
||||
status: status || "completed",
|
||||
method: method || "manual",
|
||||
description,
|
||||
notes,
|
||||
created_by: authResult.userId,
|
||||
})
|
||||
.select("*, organization:organizations(id, name)")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// If payment is completed, optionally add credits
|
||||
if (payment.status === "completed" && body.add_as_credit) {
|
||||
const { data: org } = await supabase
|
||||
.from("organizations")
|
||||
.select("credit_balance")
|
||||
.eq("id", organization_id)
|
||||
.single();
|
||||
|
||||
const currentBalance = typeof org?.credit_balance === "number" ? org.credit_balance : 0;
|
||||
const newBalance = currentBalance + Math.round(amount);
|
||||
|
||||
await supabase
|
||||
.from("credit_transactions")
|
||||
.insert({
|
||||
organization_id,
|
||||
amount: Math.round(amount),
|
||||
balance_after: newBalance,
|
||||
type: "credit",
|
||||
reason: "payment",
|
||||
reference_id: payment.id,
|
||||
created_by: authResult.userId,
|
||||
});
|
||||
|
||||
await supabase
|
||||
.from("organizations")
|
||||
.update({ credit_balance: newBalance })
|
||||
.eq("id", organization_id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ payment });
|
||||
} catch (error) {
|
||||
console.error("Error creating payment:", error);
|
||||
return NextResponse.json({ error: "Failed to create payment" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user