Files
route-commerce/src/actions/checkout.ts
T
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
2026-06-25 23:49:37 -06:00

293 lines
8.0 KiB
TypeScript

"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type CartItem = {
id: string;
name: string;
price: string;
quantity: number;
fulfillment: "pickup" | "ship";
brand_id: string;
brand_slug: string;
};
type CheckoutItem = {
id: string;
name: string;
price: number;
quantity: number;
fulfillment: "pickup" | "ship";
is_taxable?: boolean;
};
type CreatedOrder = {
id: string;
customer_name: string;
customer_email: string;
customer_phone: string;
subtotal: number;
status: string;
stop_id: string | null;
brand_id: string;
stop_city: string;
stop_state: string;
stop_date: string;
stop_time: string | null;
stop_location: string;
items: {
product_id: string;
product_name: string;
quantity: number;
price: number;
fulfillment: string;
}[];
};
type CheckoutResult =
| { success: true; order: CreatedOrder }
| { success: false; error: string };
type ShippingAddress = {
state: string;
postal_code: string;
city?: string;
};
export async function createOrder(
idempotencyKey: string,
customerName: string,
customerEmail: string,
customerPhone: string,
stopId: string | null,
items: CheckoutItem[],
brandId?: string,
shippingAddress?: ShippingAddress
): Promise<CheckoutResult> {
await getSession(); // ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0;
let taxRate = 0;
let taxLocation = "";
if (brandId && shippingAddress) {
try {
const { calculateOrderTax } = await import("@/actions/tax");
const taxResult = await calculateOrderTax({
brandId,
subtotal: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
items: items.map((i) => ({ id: i.id, quantity: i.quantity, price: i.price, is_taxable: i.is_taxable })),
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
shippingAddress,
});
taxAmount = taxResult.taxAmount;
taxRate = taxResult.taxRate;
taxLocation = taxResult.taxLocation;
} catch {
// Tax calculation failure should not block checkout
}
}
const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>(
`SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`,
[
idempotencyKey,
customerName,
customerEmail,
customerPhone,
stopId,
JSON.stringify(items),
taxAmount,
taxRate,
taxLocation || null,
],
);
const data = rows[0]?.create_order_with_items;
if (!data || !data.id) {
return { success: false, error: "Order created but data not returned" };
}
// Send order receipt email
try {
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
const { getBrandSettingsPublic } = await import("@/actions/brand-settings");
const rawItems = data.items ?? [];
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
// Look up brand settings to get the logo URL
let logoUrl: string | null = null;
if (brandId) {
// Resolve brand slug from brand ID, then fetch settings
const { rows: brandRows } = await pool.query<{ slug: string }>(
"SELECT slug FROM brands WHERE id = $1",
[brandId]
);
if (brandRows[0]) {
const settings = await getBrandSettingsPublic(brandRows[0].slug);
logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
}
}
const result = await sendOrderReceiptEmail({
customerName,
customerEmail,
orderId: data.id,
items: rawItems.map((i) => ({
name: i.product_name,
quantity: i.quantity,
price: i.price,
})),
subtotal: data.subtotal ?? 0,
taxAmount,
total: (data.subtotal ?? 0) + taxAmount,
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
stopCity: data.stop_city ?? undefined,
stopState: data.stop_state ?? undefined,
stopDate: data.stop_date ?? undefined,
stopTime: data.stop_time ?? undefined,
stopLocation: data.stop_location ?? undefined,
brandName: "Tuxedo Corn",
logoUrl,
});
if (!result.ok) {
console.error("[checkout] Order receipt email failed:", result.error);
}
} catch {
// Email failure should not fail the order
}
return { success: true, order: data };
}
// ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> {
await getSession(); try {
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`SELECT get_user_cart($1) AS "get_user_cart"`,
[userId],
);
const data = rows[0]?.get_user_cart;
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function mergeLocalCart(
localCart: CartItem[],
userId: string
): Promise<{ merged: CartItem[] }> {
await getSession(); if (!localCart || localCart.length === 0) return { merged: [] };
// Fetch server cart
let serverCart: CartItem[] = [];
try {
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`SELECT get_user_cart($1) AS "get_user_cart"`,
[userId],
);
const data = rows[0]?.get_user_cart;
serverCart = Array.isArray(data) ? data : [];
} catch {
// proceed with local cart only
}
// Merge: server items take precedence for fulfillment conflicts;
// quantities are summed for same product IDs.
const mergedMap = new Map<string, CartItem>();
// Add server items first
for (const item of serverCart) {
mergedMap.set(item.id, { ...item });
}
// Overlay local items — sum quantities, prefer server fulfillment
for (const local of localCart) {
const existing = mergedMap.get(local.id);
if (existing) {
mergedMap.set(local.id, {
...existing,
quantity: existing.quantity + local.quantity,
// Server fulfillment wins if set
fulfillment: existing.fulfillment ?? local.fulfillment,
});
} else {
mergedMap.set(local.id, local);
}
}
const merged = Array.from(mergedMap.values());
// Persist merged cart to server
try {
await pool.query(
`SELECT upsert_user_cart($1, $2::jsonb)`,
[userId, JSON.stringify(merged)],
);
} catch {
// best-effort — localStorage is still source of truth for now
}
return { merged };
}
export async function clearServerCart(userId: string): Promise<void> {
await getSession(); try {
await pool.query(
`SELECT clear_user_cart($1)`,
[userId],
);
} catch {
// ignore
}
}
// ── Stop picker (used by cart + checkout client components) ───────────────
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
await getSession(); const { rows } = await pool.query<PublicStop>(
`SELECT id, city, state, date, time, location, brand_id
FROM stops
WHERE active = true AND brand_id = $1
ORDER BY date`,
[brandId],
);
return rows;
}
export type ProductAvailability = {
product_id: string;
is_available: boolean;
};
export async function checkStopProductAvailability(
stopId: string,
productIds: string[]
): Promise<ProductAvailability[]> {
await getSession(); if (!productIds || productIds.length === 0) return [];
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
[stopId, productIds],
);
const data = rows[0]?.check_stop_product_availability;
return Array.isArray(data) ? data : [];
}