"use server"; import Stripe from "stripe"; import { eq } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { brandSettings } from "@/db/schema"; import { pool } from "@/lib/db"; import { getSession } from "@/lib/auth"; export type TaxCalculationResult = { taxAmount: number; taxRate: number; taxLocation: string; }; /** * Tax settings are stored on `brand_settings.feature_flags` (jsonb). * The SaaS rebuild uses the same keys the legacy `get_brand_settings` * RPC exposed: `collect_sales_tax` (boolean) and `nexus_states` * (string[] of US state codes). Reading them out of the JSON column * avoids a per-brand settings table for a few toggle fields. */ type BrandTaxSettings = { collect_sales_tax: boolean | null; nexus_states: string[] | null; }; export async function calculateOrderTax(params: { brandId: string; subtotal: number; items: Array<{ id: string; quantity: number; price: number; name?: string; is_taxable?: boolean }>; fulfillment: "pickup" | "ship"; shippingAddress?: { state: string; postal_code: string; city?: string }; }): Promise { await getSession(); // 1. If not collecting tax, return zero const taxSettings = await getBrandTaxSettings(params.brandId); if (!taxSettings?.collect_sales_tax) { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } // 2. Pickup orders are not taxable in most states if (params.fulfillment === "pickup") { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } // 3. Must have a shipping address in nexus states if (!params.shippingAddress?.state) { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } const nexus = taxSettings.nexus_states ?? []; const shipState = params.shippingAddress.state.toUpperCase(); if (!nexus.map((s) => s.toUpperCase()).includes(shipState)) { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } // 4. Stripe Tax calculation const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } try { const stripe = new Stripe(stripeKey); // Build Stripe line items for tax calculation — only taxable items const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = []; for (const item of params.items) { if (item.is_taxable === false) continue; lineItems.push({ amount: Math.round(item.price * item.quantity * 100), // cents reference: item.id, tax_behavior: "exclusive", }); } // If no taxable items, return zero tax if (lineItems.length === 0) { return { taxAmount: 0, taxRate: 0, taxLocation: `STATE:${shipState}` }; } const calculation = await stripe.tax.calculations.create({ currency: "usd", line_items: lineItems, customer_details: { address: { state: shipState, postal_code: params.shippingAddress.postal_code, city: params.shippingAddress.city, country: "US", }, address_source: "shipping", }, }); if (!calculation.tax_amount_exclusive) { return { taxAmount: 0, taxRate: 0, taxLocation: `STATE:${shipState}` }; } const taxAmount = calculation.tax_amount_exclusive / 100; // Derive effective rate from subtotal const taxRate = params.subtotal > 0 ? taxAmount / params.subtotal : 0; return { taxAmount: Math.round(taxAmount * 100) / 100, taxRate: Math.round(taxRate * 10000) / 10000, taxLocation: `STATE:${shipState}`, }; } catch (err) { return { taxAmount: 0, taxRate: 0, taxLocation: "" }; } } /** * Read tax-related feature flags for a brand. The two flags * (`collect_sales_tax`, `nexus_states`) used to live in a dedicated * `get_brand_settings` SECURITY DEFINER RPC; in the SaaS rebuild they * live in `brand_settings.feature_flags` (jsonb). Tolerant of missing * / malformed JSON by falling back to `null`. */ async function getBrandTaxSettings(brandId: string): Promise { try { const rows = await withBrand(brandId, async (db) => db .select({ featureFlags: brandSettings.featureFlags }) .from(brandSettings) .where(eq(brandSettings.brandId, brandId)) .limit(1), ); const flags = rows[0]?.featureFlags as | Partial<{ collect_sales_tax: boolean; nexus_states: string[]; }> | null | undefined; if (!flags) return null; return { collect_sales_tax: typeof flags.collect_sales_tax === "boolean" ? flags.collect_sales_tax : null, nexus_states: Array.isArray(flags.nexus_states) ? flags.nexus_states.filter((s): s is string => typeof s === "string") : null, }; } catch { return null; } } // ── Tax Dashboard read-side actions ─────────────────────────────────────────── // // TODO(migration): the tax dashboard reads from the legacy `orders` // table via the `get_tax_summary` and `get_taxable_orders` SECURITY // DEFINER RPCs (originally from the now-archived supabase migrations; // consolidated into `db/migrations/0001_init.sql`). Both the table and // the RPCs still exist; we call the RPCs through `pool.query`. When the // SaaS rebuild's orders table grows a `tax_amount` column or the tax // dashboard is rebuilt against the new schema, these helpers should be // rewritten against the Drizzle `orders` table directly. export type TaxByStateRow = { state: string; total_tax: number; gross_sales: number; order_count: number; }; export type TaxSummaryData = { total_tax_collected: number; total_gross_sales: number; order_count: number; tax_by_state: TaxByStateRow[]; }; export type TaxOrderRow = { order_id: string; date: string; customer_name: string; city: string; state: string; taxable_amount: number; tax_amount: number; tax_rate: number; tax_location: string; }; export type GetTaxSummaryResult = | { success: true; data: TaxSummaryData } | { success: false; error: string }; export type GetTaxableOrdersResult = | { success: true; data: TaxOrderRow[] } | { success: false; error: string }; export async function getTaxSummaryAction(params: { brandId: string; startDate: string; endDate: string; }): Promise { if (!params.brandId) return { success: false, error: "Brand required" }; await getSession(); try { const { rows } = await pool.query<{ total_tax_collected: number | string; total_gross_sales: number | string; order_count: number | string; tax_by_state: TaxByStateRow[] | null; }>( "SELECT * FROM get_tax_summary($1::uuid, $2::date, $3::date)", [params.brandId, params.startDate, params.endDate] ); const row = rows[0]; if (!row) return { success: false, error: "No data" }; return { success: true, data: { total_tax_collected: Number(row.total_tax_collected ?? 0), total_gross_sales: Number(row.total_gross_sales ?? 0), order_count: Number(row.order_count ?? 0), tax_by_state: Array.isArray(row.tax_by_state) ? row.tax_by_state : [], }, }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to load tax summary", }; } } export async function getTaxableOrdersAction(params: { brandId: string; startDate: string; endDate: string; }): Promise { if (!params.brandId) return { success: false, error: "Brand required" }; await getSession(); try { const { rows } = await pool.query<{ order_id: string; date: string; customer_name: string | null; city: string | null; state: string | null; taxable_amount: number | string; tax_amount: number | string; tax_rate: number | string; tax_location: string | null; }>( "SELECT * FROM get_taxable_orders($1::uuid, $2::date, $3::date)", [params.brandId, params.startDate, params.endDate] ); return { success: true, data: rows.map((r) => ({ order_id: String(r.order_id ?? ""), date: String(r.date ?? ""), customer_name: String(r.customer_name ?? ""), city: String(r.city ?? ""), state: String(r.state ?? ""), taxable_amount: Number(r.taxable_amount ?? 0), tax_amount: Number(r.tax_amount ?? 0), tax_rate: Number(r.tax_rate ?? 0), tax_location: String(r.tax_location ?? ""), })), }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Failed to load taxable orders", }; } }