mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Verify admin/manager
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token);
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: roles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", user.id);
|
||||
|
||||
const isAdmin = roles?.some((r: any) =>
|
||||
["admin", "manager"].includes(r.role)
|
||||
);
|
||||
if (!isAdmin) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
||||
status: 403,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { association_id, amount_cents, description, enrollment_ids } = body;
|
||||
|
||||
if (!association_id || !amount_cents || amount_cents <= 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "association_id and valid amount_cents are required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Stripe mapping
|
||||
const { data: mapping } = await supabase
|
||||
.from("stripe_account_mappings")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!mapping?.stripe_secret_key) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No Stripe configuration for this association" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get active enrollments
|
||||
let enrollmentQuery = supabase
|
||||
.from("autopay_enrollments")
|
||||
.select("*")
|
||||
.eq("association_id", association_id)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (enrollment_ids?.length) {
|
||||
enrollmentQuery = enrollmentQuery.in("id", enrollment_ids);
|
||||
}
|
||||
|
||||
const { data: enrollments, error: enrollError } = await enrollmentQuery;
|
||||
if (enrollError) throw enrollError;
|
||||
|
||||
if (!enrollments || enrollments.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No active autopay enrollments found", results: [] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const stripeSecretKey = mapping.stripe_secret_key;
|
||||
const results: any[] = [];
|
||||
|
||||
for (const enrollment of enrollments) {
|
||||
try {
|
||||
// Calculate fees if applicable
|
||||
let feeCents = 0;
|
||||
const isAch = enrollment.payment_method_type === "us_bank_account";
|
||||
if (mapping.pass_processing_fee) {
|
||||
if (isAch) {
|
||||
feeCents = Math.min(Math.ceil(amount_cents * 0.008), 500);
|
||||
} else {
|
||||
const feePercent = Number(mapping.processing_fee_percent) || 0.029;
|
||||
const feeFixed = Number(mapping.processing_fee_fixed_cents) || 30;
|
||||
const grossed = Math.ceil(
|
||||
(amount_cents + feeFixed) / (1 - feePercent)
|
||||
);
|
||||
feeCents = grossed - amount_cents;
|
||||
}
|
||||
}
|
||||
|
||||
const totalCents = amount_cents + feeCents;
|
||||
|
||||
// Create PaymentIntent with saved payment method
|
||||
const params = new URLSearchParams();
|
||||
params.append("amount", String(totalCents));
|
||||
params.append("currency", "usd");
|
||||
params.append("customer", enrollment.stripe_customer_id);
|
||||
params.append("payment_method", enrollment.stripe_payment_method_id);
|
||||
params.append("off_session", "true");
|
||||
params.append("confirm", "true");
|
||||
params.append(
|
||||
"description",
|
||||
description || "HOA Autopay Assessment"
|
||||
);
|
||||
params.append("metadata[association_id]", association_id);
|
||||
params.append("metadata[enrollment_id]", enrollment.id);
|
||||
params.append("metadata[autopay]", "true");
|
||||
if (enrollment.owner_id)
|
||||
params.append("metadata[owner_id]", enrollment.owner_id);
|
||||
if (enrollment.unit_id)
|
||||
params.append("metadata[unit_id]", enrollment.unit_id);
|
||||
|
||||
if (isAch) {
|
||||
params.append("payment_method_types[]", "us_bank_account");
|
||||
}
|
||||
|
||||
const stripeRes = await fetch(
|
||||
"https://api.stripe.com/v1/payment_intents",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${stripeSecretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const stripeData = await stripeRes.json();
|
||||
|
||||
// Record payment
|
||||
await supabase.from("stripe_payments").insert({
|
||||
association_id,
|
||||
owner_id: enrollment.owner_id || null,
|
||||
unit_id: enrollment.unit_id || null,
|
||||
stripe_payment_intent_id: stripeData.id,
|
||||
amount_cents,
|
||||
fee_cents: feeCents,
|
||||
total_cents: totalCents,
|
||||
payment_method_type: isAch ? "us_bank_account" : "card",
|
||||
status: stripeRes.ok ? "succeeded" : "failed",
|
||||
description: description || "HOA Autopay Assessment",
|
||||
});
|
||||
|
||||
results.push({
|
||||
enrollment_id: enrollment.id,
|
||||
owner_id: enrollment.owner_id,
|
||||
success: stripeRes.ok,
|
||||
payment_intent_id: stripeData.id,
|
||||
amount_cents: totalCents,
|
||||
error: stripeRes.ok ? null : stripeData.error?.message,
|
||||
});
|
||||
} catch (chargeErr: any) {
|
||||
results.push({
|
||||
enrollment_id: enrollment.id,
|
||||
owner_id: enrollment.owner_id,
|
||||
success: false,
|
||||
error: chargeErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ results, summary: { total: results.length, succeeded, failed } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("Error in process-autopay:", err);
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Internal server error";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user