From d3581a47b7045ef152859075664fb61374ba280a Mon Sep 17 00:00:00 2001 From: QaliAI Date: Thu, 24 Sep 2026 21:34:07 -0500 Subject: [PATCH] fix(rev-ops): harden paid order truth, owner alerts, fulfillment queues, and commercial repeat purchase --- src/app/admin/orders/page.tsx | 181 +++++++++++++----- .../api/admin/orders/resend-email/route.ts | 81 +++++++- .../api/orders/[orderNumber]/status/route.ts | 10 +- src/app/api/webhooks/stripe/route.ts | 174 ++++++++++++----- src/app/order-confirmation/[orderId]/page.tsx | 73 ++++++- src/components/COAModal.tsx | 2 +- src/components/CartDrawer.tsx | 52 ++++- src/components/admin/OrderDetailDrawer.tsx | 57 +++++- src/lib/admin/order-classification.mjs | 111 ++++++++++- src/lib/admin/order-events.ts | 5 +- src/lib/commerce/repeat-purchase.mjs | 66 +++++++ src/lib/email/templates/internal-order.ts | 23 ++- tests/fulfillment-queues.test.mjs | 123 ++++++++++++ tests/owner-alerts.test.mjs | 65 +++++++ tests/repeat-purchase.test.mjs | 128 +++++++++++++ 15 files changed, 1015 insertions(+), 136 deletions(-) create mode 100644 src/lib/commerce/repeat-purchase.mjs create mode 100644 tests/fulfillment-queues.test.mjs create mode 100644 tests/owner-alerts.test.mjs create mode 100644 tests/repeat-purchase.test.mjs diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index bc8db78..5bf547c 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import React, { useState, useEffect, Suspense } from 'react'; +import React, { useState, useEffect, useMemo, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import { OrderDetailDrawer } from '../../../components/admin/OrderDetailDrawer'; -import { ShoppingCart, Search, CheckCircle2, Truck, Mail, Archive, AlertCircle, RefreshCw } from 'lucide-react'; +import { ShoppingCart, Search, CheckCircle2, Truck, Mail, Archive, AlertCircle, RefreshCw, Bell } from 'lucide-react'; +import { classifyFulfillmentQueue, FULFILLMENT_QUEUES } from '@/lib/admin/order-classification.mjs'; interface OrderItem { id: string; @@ -42,11 +43,17 @@ interface TableColumn { className?: string; } +const QUEUE_TABS = [ + { id: 'ALL', label: 'ALL' }, + ...FULFILLMENT_QUEUES, + { id: 'AWAITING_PAYMENT', label: 'AWAITING PAYMENT' }, +]; + function AdminOrdersPageInner() { const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('ALL'); + const [queueFilter, setQueueFilter] = useState('ALL'); const [actionMessage, setActionMessage] = useState(''); const [editingTrackingId, setEditingTrackingId] = useState(null); const [trackingInput, setTrackingInput] = useState(''); @@ -135,7 +142,7 @@ function AdminOrdersPageInner() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId, emailType: 'order' }), }); - setActionMessage('Order verification email resent'); + setActionMessage('Customer verification email resent'); } catch (err) { console.error('[admin/orders/resend-email] error:', err); setActionMessage('Failed to resend email'); @@ -143,14 +150,53 @@ function AdminOrdersPageInner() { setTimeout(() => setActionMessage(''), 3000); }; - const filteredOrders = orders.filter(o => { - const matchSearch = - o.order_number.toLowerCase().includes(searchQuery.toLowerCase()) || - o.customer_name.toLowerCase().includes(searchQuery.toLowerCase()) || - o.customer_email.toLowerCase().includes(searchQuery.toLowerCase()); - const matchStatus = statusFilter === 'ALL' || o.status.toLowerCase() === statusFilter.toLowerCase(); - return matchSearch && matchStatus; - }); + const handleResendOwnerEmail = async (orderId: string) => { + try { + const res = await fetch('/api/admin/orders/resend-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId, emailType: 'owner_alert' }), + }); + const data = await res.json(); + if (data.success) { + setActionMessage('Owner notification alert resent'); + } else { + setActionMessage(data.error || 'Failed to resend owner alert'); + } + } catch (err) { + console.error('[admin/orders/resend-owner-email] error:', err); + setActionMessage('Failed to resend owner alert'); + } + setTimeout(() => setActionMessage(''), 3000); + }; + + const queueCounts = useMemo(() => { + const counts: Record = { ALL: orders.length }; + for (const tab of QUEUE_TABS) { + counts[tab.id] = 0; + } + counts.ALL = orders.length; + + for (const o of orders) { + const q = classifyFulfillmentQueue(o); + counts[q] = (counts[q] || 0) + 1; + } + return counts; + }, [orders]); + + const filteredOrders = useMemo(() => { + return orders.filter(o => { + const matchSearch = + o.order_number.toLowerCase().includes(searchQuery.toLowerCase()) || + o.customer_name.toLowerCase().includes(searchQuery.toLowerCase()) || + o.customer_email.toLowerCase().includes(searchQuery.toLowerCase()); + if (!matchSearch) return false; + + if (queueFilter === 'ALL') return true; + const q = classifyFulfillmentQueue(o); + return q === queueFilter; + }); + }, [orders, searchQuery, queueFilter]); return (
@@ -158,7 +204,7 @@ function AdminOrdersPageInner() {

Orders & Fulfillment

-

Inspect research procurement orders, update status lifecycles, and issue tracking notices

+

Inspect research procurement orders, update status lifecycles, and manage fulfillment queues

+ ); + })} +
+ + {/* Filter / Search Bar */}
@@ -188,22 +260,6 @@ function AdminOrdersPageInner() { className="w-full pl-9 pr-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-white placeholder-slate-500 focus:outline-none focus:border-brand-graphite" />
- -
- {['ALL', 'new', 'pending_payment', 'paid', 'preparing', 'shipped', 'fulfilled', 'canceled', 'refunded'].map(st => ( - - ))} -
{/* Orders Table */} @@ -216,7 +272,7 @@ function AdminOrdersPageInner() { Line Items Total Due Payment - Status + Queue & Status Tracking Actions @@ -251,21 +307,43 @@ function AdminOrdersPageInner() { )} - + {(() => { + const q = classifyFulfillmentQueue(o); + return ( +
+ + {q.replace(/_/g, ' ')} + +
+ +
+
+ ); + })()} {editingTrackingId === o.id ? ( @@ -301,14 +379,21 @@ function AdminOrdersPageInner() {
)} - + + ))} diff --git a/src/app/api/admin/orders/resend-email/route.ts b/src/app/api/admin/orders/resend-email/route.ts index a9d53bb..bdb9db7 100644 --- a/src/app/api/admin/orders/resend-email/route.ts +++ b/src/app/api/admin/orders/resend-email/route.ts @@ -4,7 +4,15 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { sendEmailSafely } from "@/lib/email/resend"; import { renderOrderConfirmationEmail } from "@/lib/email/templates/order"; import { renderTrackingUpdateEmail } from "@/lib/email/templates/tracking"; +import { renderInternalOrderNotificationEmail } from "@/lib/email/templates/internal-order"; import { recordOrderEvent } from "@/lib/admin/order-events"; +import { getBrandConfig } from "@/config/brand"; +import { classifyFulfillmentQueue } from "@/lib/admin/order-classification.mjs"; + +function adminOrderUrl(orderNumber: string) { + const base = (process.env.NEXT_PUBLIC_SITE_URL || "https://www.vialfoundry.com").replace(/\/$/, ""); + return `${base}/admin/orders?order=${encodeURIComponent(orderNumber)}`; +} export async function POST(req: Request) { const isAuth = await verifyAdminSession(); @@ -28,9 +36,64 @@ export async function POST(req: Request) { } let sentResult: { success: boolean; error?: string }; - const emailLabel = emailType === "tracking" ? "tracking update" : "order confirmation"; + let emailLabel: string; + let targetRecipient: string; + let isInternal = false; + + if (emailType === "owner_alert") { + isInternal = true; + emailLabel = "owner alert"; + const brand = getBrandConfig(); + const admins = brand.orderNotificationEmails; + if (!admins.length) { + return NextResponse.json({ success: false, error: "No owner notification emails configured" }, { status: 400 }); + } + targetRecipient = admins.join(", "); - if (emailType === "tracking" && order.tracking_number) { + const queue = classifyFulfillmentQueue(order); + const fulfillmentNextStep = + queue === "WAITING_ON_INVENTORY" + ? "WAITING ON INVENTORY: Insufficient stock. Replenishment required before packing." + : queue === "WAITING_ON_DOCUMENTATION" + ? "WAITING ON DOCUMENTATION: Customer follow-up or documentation needed." + : queue === "PAID_NEEDS_FULFILLMENT" + ? "PAID — READY TO PICK AND PACK. Verify items and print shipping label." + : `Current operational queue: ${queue}`; + + const email = renderInternalOrderNotificationEmail({ + orderNumber: order.order_number, + customerName: order.customer_name, + customerEmail: order.customer_email, + customerPhone: order.customer_phone, + items: (order.manual_order_items || []).map((i: any) => ({ + name: i.product_name, + sku: i.sku, + quantity: i.quantity, + unitPriceCents: i.unit_price_amount || 0, + lineTotalCents: i.line_total_amount || 0, + })), + subtotalCents: order.subtotal_amount, + discountCents: order.discount_amount, + shippingCents: order.shipping_amount, + totalCents: order.total_amount, + shippingAddress: order.shipping_address_snapshot || {}, + paymentMethod: order.preferred_payment_method || "card (Stripe)", + paymentStatus: order.payment_status || "paid", + fulfillmentNextStep, + promoCode: order.promo_code, + affiliateCode: order.affiliate_code, + isTest: Boolean(order.is_test) || order.stripe_livemode === false, + adminOrderUrl: adminOrderUrl(order.order_number), + }); + + sentResult = await sendEmailSafely({ + to: admins, + subject: `[ADMIN RESEND] ${order.is_test || order.stripe_livemode === false ? "[TEST] " : ""}[PAID] ${order.order_number} — ${order.customer_name} ($${((order.total_amount || 0) / 100).toFixed(2)})`, + html: email.html, + }); + } else if (emailType === "tracking" && order.tracking_number) { + emailLabel = "tracking update"; + targetRecipient = order.customer_email; const email = renderTrackingUpdateEmail({ orderNumber: order.order_number, customerName: order.customer_name, @@ -43,11 +106,14 @@ export async function POST(req: Request) { html: email.html, }); } else { + emailLabel = "order confirmation"; + targetRecipient = order.customer_email; const email = renderOrderConfirmationEmail({ orderNumber: order.order_number, customerName: order.customer_name, items: (order.manual_order_items || []).map((i: any) => ({ name: i.product_name, + sku: i.sku, quantity: i.quantity, unitPriceCents: i.unit_price_amount || 0, lineTotalCents: i.line_total_amount || 0, @@ -57,7 +123,7 @@ export async function POST(req: Request) { shippingCents: order.shipping_amount, totalCents: order.total_amount, paymentMethod: order.preferred_payment_method, - paymentState: order.payment_status === 'paid' ? 'paid' : 'awaiting_payment', + paymentState: order.payment_status === "paid" ? "paid" : "awaiting_payment", shippingAddress: order.shipping_address_snapshot || {}, }); @@ -73,10 +139,11 @@ export async function POST(req: Request) { orderId: order.id, type: 'email_failed', actor: 'admin-resend', - message: `Admin resend of ${emailLabel} FAILED to ${order.customer_email}: ${sentResult.error}`, + message: `Admin resend of ${emailLabel} FAILED to ${targetRecipient}: ${sentResult.error}`, + metadata: { error: sentResult.error, emailType, targetRecipient }, }); return NextResponse.json( - { success: false, error: sentResult.error || 'Failed to send email via Resend' }, + { success: false, error: sentResult.error || "Failed to send email via Resend" }, { status: 502 } ); } @@ -85,7 +152,8 @@ export async function POST(req: Request) { orderId: order.id, type: 'email_sent', actor: 'admin-resend', - message: `Admin resent ${emailLabel} email to ${order.customer_email}`, + message: `Admin resent ${emailLabel} email to ${targetRecipient}`, + metadata: { emailType, targetRecipient }, }); return NextResponse.json({ success: true }); @@ -94,3 +162,4 @@ export async function POST(req: Request) { return NextResponse.json({ success: false, error: err.message }, { status: 500 }); } } + diff --git a/src/app/api/orders/[orderNumber]/status/route.ts b/src/app/api/orders/[orderNumber]/status/route.ts index 292edfa..f6290b5 100644 --- a/src/app/api/orders/[orderNumber]/status/route.ts +++ b/src/app/api/orders/[orderNumber]/status/route.ts @@ -36,7 +36,7 @@ export async function GET( let query = supabase .from('manual_orders') .select( - 'order_number, status, payment_status, payment_provider, total_amount, subtotal_amount, shipping_amount, discount_amount, currency, customer_name, shipping_address_snapshot, paid_at, created_at, tracking_number, carrier', + 'id, order_number, status, payment_status, payment_provider, total_amount, subtotal_amount, shipping_amount, discount_amount, currency, customer_name, shipping_address_snapshot, paid_at, created_at, tracking_number, carrier', ) .eq('order_number', params.orderNumber); @@ -56,12 +56,8 @@ export async function GET( const { data: items } = await supabase .from('manual_order_items') - .select('product_name, quantity, unit_price_amount, line_total_amount') - .eq('manual_order_id', (await supabase - .from('manual_orders') - .select('id') - .eq('order_number', params.orderNumber) - .maybeSingle()).data?.id ?? ''); + .select('product_id, product_name, sku, configuration_label, quantity, unit_price_amount, line_total_amount') + .eq('manual_order_id', data.id); return NextResponse.json({ orderNumber: data.order_number, diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index cf669c9..38ce497 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -230,9 +230,33 @@ export async function POST(req: Request) { .maybeSingle(); if (!won) { + // Backfill missing session, payment intent, or customer IDs if this duplicate/subsequent event provides them + const backfill: Record = {}; + if (sessionId && !order.stripe_checkout_session_id) backfill.stripe_checkout_session_id = sessionId; + if (paymentIntentId && !order.stripe_payment_intent_id) backfill.stripe_payment_intent_id = paymentIntentId; + if (stripeCustomerId && !order.stripe_customer_id) backfill.stripe_customer_id = stripeCustomerId; + if (Object.keys(backfill).length > 0) { + await supabase.from('manual_orders').update(backfill).eq('id', order.id); + } return NextResponse.json({ received: true, alreadyPaid: true }); } + // Link customer_id if order was created without one + if (!order.customer_id && order.customer_email) { + try { + const { data: cust } = await supabase + .from('customers') + .select('id') + .eq('email', order.customer_email) + .maybeSingle(); + if (cust?.id) { + await supabase.from('manual_orders').update({ customer_id: cust.id }).eq('id', order.id); + } + } catch (custErr) { + console.warn('[stripe-webhook] customer linking warning:', custErr); + } + } + if (order.affiliate_id) { await supabase .from('referral_revenue') @@ -248,8 +272,8 @@ export async function POST(req: Request) { metadata: { eventId: event.id, paymentIntentId, sessionId }, }); - await decrementInventoryForOrder(supabase, { ...order, payment_status: 'paid' }); - await sendPaidEmails(supabase, { ...order, payment_status: 'paid' }, brand); + const decResult = await decrementInventoryForOrder(supabase, { ...order, payment_status: 'paid' }); + await sendPaidEmails(supabase, { ...order, payment_status: 'paid' }, brand, decResult); } if (event.type === 'payment_intent.payment_failed') { @@ -384,9 +408,9 @@ export async function POST(req: Request) { async function decrementInventoryForOrder( supabase: NonNullable>, order: any, -) { +): Promise<{ hasOversell: boolean; oversoldItems: string[] }> { const decision = shouldDecrementInventory(order); - if (!decision.apply) return; + if (!decision.apply) return { hasOversell: false, oversoldItems: [] }; const { data: claimed } = await supabase .from('manual_orders') @@ -395,13 +419,15 @@ async function decrementInventoryForOrder( .is('inventory_decremented_at', null) .select('id') .maybeSingle(); - if (!claimed) return; + if (!claimed) return { hasOversell: false, oversoldItems: [] }; const { data: lines } = await supabase .from('manual_order_items') .select('product_id, product_name, sku, quantity') .eq('manual_order_id', order.id); + const oversoldItems: string[] = []; + for (const line of lines || []) { if (!line.product_id) continue; const qty = Number(line.quantity) || 0; @@ -417,6 +443,9 @@ async function decrementInventoryForOrder( const previous = Number(row.previous_quantity) || 0; const next = Number(row.new_quantity); const oversell = isOversell(previous, qty); + if (oversell) { + oversoldItems.push(line.product_name || line.sku || 'Item'); + } const product = { id: line.product_id }; await supabase.from('inventory_transactions').insert({ product_id: product.id, @@ -433,14 +462,31 @@ async function decrementInventoryForOrder( await recordOrderEvent({ orderId: order.id, - type: 'inventory_adjusted', + type: oversell ? 'inventory_oversell' : 'inventory_adjusted', actor: 'stripe-webhook', message: oversell - ? `Inventory oversell: ${line.product_name} requested ${qty}, on hand ${previous}` + ? `Inventory oversell: ${line.product_name} requested ${qty}, on hand was ${previous}. Transitioning to waiting_inventory.` : `Inventory −${qty} ${line.product_name} (${previous} → ${next})`, metadata: { productId: product.id, quantity: qty, previous, next, oversell }, }); } + + if (oversoldItems.length > 0) { + await supabase + .from('manual_orders') + .update({ status: 'waiting_inventory', updated_at: new Date().toISOString() }) + .eq('id', order.id); + + await recordOrderEvent({ + orderId: order.id, + type: 'status_changed', + actor: 'stripe-webhook', + message: `Order status set to waiting_inventory due to oversold items: ${oversoldItems.join(', ')}`, + metadata: { oversoldItems }, + }); + } + + return { hasOversell: oversoldItems.length > 0, oversoldItems }; } /** Customer receipt + operations notification, sent once, after payment. */ @@ -448,6 +494,7 @@ async function sendPaidEmails( supabase: NonNullable>, order: any, brand: ReturnType, + inventoryResult?: { hasOversell: boolean; oversoldItems: string[] }, ) { const { data: claimed } = await supabase .from('manual_orders') @@ -482,50 +529,87 @@ async function sendPaidEmails( shippingAddress: (order.shipping_address_snapshot || {}) as Record, }; - const customer = renderOrderConfirmationEmail({ - ...shared, - paymentMethod: 'card', - paymentState: 'paid', - }); - const sent = await sendEmailSafely({ - to: order.customer_email, - subject: `[Vial Foundry] Payment received — order ${order.order_number}`, - html: customer.html, - }); - await recordOrderEvent({ - orderId: order.id, - type: sent.success ? 'email_sent' : 'email_failed', - actor: 'stripe-webhook', - message: sent.success - ? `Payment confirmation sent to ${order.customer_email}` - : `Payment confirmation FAILED to ${order.customer_email}: ${sent.error}`, - }); - - const admins = brand.orderNotificationEmails; - if (admins.length > 0) { - const internal = renderInternalOrderNotificationEmail({ + // 1. Customer confirmation email (isolated try/catch so failure never throws) + try { + const customer = renderOrderConfirmationEmail({ ...shared, - customerEmail: order.customer_email, - customerPhone: order.customer_phone, - paymentMethod: 'card (Stripe)', - paymentStatus: 'paid', - promoCode: order.promo_code, - affiliateCode: order.affiliate_code, - isTest: Boolean(order.is_test) || order.stripe_livemode === false, - adminOrderUrl: adminOrderUrl(order.order_number), + paymentMethod: 'card', + paymentState: 'paid', + }); + const sent = await sendEmailSafely({ + to: order.customer_email, + subject: `[Vial Foundry] Payment received — order ${order.order_number}`, + html: customer.html, }); - const sentInternal = await sendEmailSafely({ - to: admins, - subject: `${order.is_test || order.stripe_livemode === false ? '[TEST] ' : ''}[PAID] ${order.order_number} — ${order.customer_name} ($${((order.total_amount || 0) / 100).toFixed(2)})`, - html: internal.html, + await recordOrderEvent({ + orderId: order.id, + type: sent.success ? 'email_sent' : 'email_failed', + actor: 'stripe-webhook', + message: sent.success + ? `Payment confirmation sent to ${order.customer_email}` + : `Payment confirmation FAILED to ${order.customer_email}: ${sent.error}`, + metadata: { error: sent.error, recipient: order.customer_email, template: 'customer_order_confirmation' }, }); + } catch (custErr: any) { + console.error(`[stripe-webhook] customer email exception for ${order.order_number}:`, custErr); await recordOrderEvent({ orderId: order.id, - type: sentInternal.success ? 'email_sent' : 'email_failed', + type: 'email_failed', actor: 'stripe-webhook', - message: sentInternal.success - ? `Paid order alert sent to ${admins.join(', ')}` - : `Paid order alert FAILED to ${admins.join(', ')}: ${sentInternal.error}`, + message: `Customer email dispatch threw: ${custErr?.message || custErr}`, + metadata: { error: custErr?.message || String(custErr), recipient: order.customer_email }, }); } + + // 2. Owner alert email (isolated try/catch so failure never throws) + const admins = brand.orderNotificationEmails; + if (admins.length > 0) { + try { + const fulfillmentNextStep = inventoryResult?.hasOversell + ? `WAITING ON INVENTORY: Insufficient stock for ${inventoryResult.oversoldItems.join(', ')}. Replenishment required before packing.` + : 'PAID — READY TO PICK AND PACK. Verify items and generate shipping label.'; + + const internal = renderInternalOrderNotificationEmail({ + ...shared, + customerEmail: order.customer_email, + customerPhone: order.customer_phone, + paymentMethod: 'card (Stripe)', + paymentStatus: 'paid', + fulfillmentNextStep, + promoCode: order.promo_code, + affiliateCode: order.affiliate_code, + isTest: Boolean(order.is_test) || order.stripe_livemode === false, + adminOrderUrl: adminOrderUrl(order.order_number), + }); + const sentInternal = await sendEmailSafely({ + to: admins, + subject: `${order.is_test || order.stripe_livemode === false ? '[TEST] ' : ''}[PAID] ${order.order_number} — ${order.customer_name} ($${((order.total_amount || 0) / 100).toFixed(2)})`, + html: internal.html, + }); + await recordOrderEvent({ + orderId: order.id, + type: sentInternal.success ? 'email_sent' : 'email_failed', + actor: 'stripe-webhook', + message: sentInternal.success + ? `Paid order alert sent to ${admins.join(', ')}` + : `Paid order alert FAILED to ${admins.join(', ')}: ${sentInternal.error}`, + metadata: { + error: sentInternal.error, + recipients: admins, + template: 'internal_order_notification', + fulfillmentNextStep, + }, + }); + } catch (adminErr: any) { + console.error(`[stripe-webhook] admin email exception for ${order.order_number}:`, adminErr); + await recordOrderEvent({ + orderId: order.id, + type: 'email_failed', + actor: 'stripe-webhook', + message: `Admin notification dispatch threw: ${adminErr?.message || adminErr}`, + metadata: { error: adminErr?.message || String(adminErr), recipients: admins }, + }); + } + } } + diff --git a/src/app/order-confirmation/[orderId]/page.tsx b/src/app/order-confirmation/[orderId]/page.tsx index 2e99183..3069546 100644 --- a/src/app/order-confirmation/[orderId]/page.tsx +++ b/src/app/order-confirmation/[orderId]/page.tsx @@ -2,11 +2,13 @@ import React, { useEffect, useState, Suspense } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { CheckCircle2, ShieldCheck, ArrowRight } from 'lucide-react'; +import { CheckCircle2, ShieldCheck, ArrowRight, RotateCcw } from 'lucide-react'; import { getPaymentMethod } from '../../../data/payment'; import { PaymentInstructions } from '../../../components/PaymentInstructions'; import { trackEvent } from '../../../lib/analytics'; import { useCart } from '../../../context/CartContext'; +import { saveRecentOrder, resolveProductForReorder } from '../../../lib/commerce/repeat-purchase.mjs'; +import { PRODUCTS } from '../../../data/products'; interface OrderStatus { paid: boolean; @@ -18,14 +20,22 @@ interface OrderStatus { discountCents: number; customerName: string; shippingAddress: Record; - items: Array<{ product_name: string; quantity: number; line_total_amount: number }>; + items: Array<{ + product_id?: string; + product_name: string; + sku?: string; + configuration_label?: string; + quantity: number; + unit_price_amount?: number; + line_total_amount: number; + }>; } function Confirmation() { const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); - const { clearCart } = useCart(); + const { clearCart, addToCart } = useCart(); const orderId = params?.orderId as string; const supportEmail = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || 'support@vialfoundry.com'; @@ -86,6 +96,46 @@ function Confirmation() { return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [stripeSessionId, orderId]); + + useEffect(() => { + if (!serverOrder || !serverOrder.items?.length) return; + saveRecentOrder({ + orderNumber: orderId, + date: new Date().toISOString(), + items: serverOrder.items.map((i) => ({ + productId: i.product_id, + productName: i.product_name, + sku: i.sku, + configurationLabel: i.configuration_label, + quantity: i.quantity, + unitPriceCents: i.unit_price_amount, + })), + }); + }, [serverOrder, orderId]); + + const handleReorder = () => { + if (!serverOrder?.items?.length) return; + let count = 0; + for (const item of serverOrder.items) { + const resolved = resolveProductForReorder( + { + productId: item.product_id, + sku: item.sku, + productName: item.product_name, + quantity: item.quantity, + }, + PRODUCTS, + ); + if (resolved) { + addToCart(resolved, item.quantity); + count += 1; + } + } + if (count > 0) { + router.push('/checkout'); + } + }; + const total = storedOrder ? storedOrder.totalCents / 100 : parseFloat(searchParams.get('total') || '0'); @@ -239,20 +289,29 @@ function Confirmation() { : 'A confirmation email is on its way to the address you provided. No credit card is charged on this site. All products are supplied strictly for laboratory research use only.'}

-
+
+ {serverOrder?.items && serverOrder.items.length > 0 && ( + + )}
diff --git a/src/components/COAModal.tsx b/src/components/COAModal.tsx index 333a704..45681a6 100644 --- a/src/components/COAModal.tsx +++ b/src/components/COAModal.tsx @@ -113,7 +113,7 @@ export const COAModal: React.FC = ({ batch, onClose }) => { aria-modal="true" aria-label={`Certificate of analysis for lot ${batch.lotNumber}`} > -
+
diff --git a/src/components/CartDrawer.tsx b/src/components/CartDrawer.tsx index 9c4320f..ab8a86d 100644 --- a/src/components/CartDrawer.tsx +++ b/src/components/CartDrawer.tsx @@ -1,23 +1,36 @@ -import React, { useEffect } from 'react'; -import { X, Trash2, Plus, Minus, ShoppingBag, ArrowRight, ShieldCheck, CheckCircle2 } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { X, Trash2, Plus, Minus, ShoppingBag, ArrowRight, ShieldCheck, CheckCircle2, RotateCcw } from 'lucide-react'; import { useCart } from '../context/CartContext'; import { productTitle, productSize } from '../lib/catalog-display'; import { FREE_STANDARD_SHIPPING_THRESHOLD_CENTS } from '../lib/manual-orders/shipping.mjs'; import { trackEvent } from '../lib/analytics'; +import { getRecentOrders, resolveProductForReorder } from '../lib/commerce/repeat-purchase.mjs'; +import { PRODUCTS } from '../data/products'; interface CartDrawerProps { navigate: (path: string) => void; } export const CartDrawer: React.FC = ({ navigate }) => { - const { cart, isCartOpen, setIsCartOpen, removeFromCart, updateQuantity, subtotal, totalItems } = useCart(); + const { cart, isCartOpen, setIsCartOpen, addToCart, removeFromCart, updateQuantity, subtotal, totalItems } = useCart(); + const [recentOrders, setRecentOrders] = useState([]); useEffect(() => { if (isCartOpen) { trackEvent('cart_viewed', { totalItems, subtotalCents: Math.round(subtotal * 100) }); + setRecentOrders(getRecentOrders()); } }, [isCartOpen, totalItems, subtotal]); + const handleReorderRecent = (recent: any) => { + for (const item of recent.items || []) { + const resolved = resolveProductForReorder(item, PRODUCTS); + if (resolved) { + addToCart(resolved, item.quantity); + } + } + }; + if (!isCartOpen) return null; const subtotalCents = Math.round(subtotal * 100); @@ -33,8 +46,8 @@ export const CartDrawer: React.FC = ({ navigate }) => { className="absolute inset-0 bg-brand-ink/60 backdrop-blur-xs animate-in fade-in duration-150" /> -
-
+
+
{/* Header */}
@@ -87,7 +100,7 @@ export const CartDrawer: React.FC = ({ navigate }) => { {/* Item List */}
{cart.length === 0 ? ( -
+
@@ -101,6 +114,33 @@ export const CartDrawer: React.FC = ({ navigate }) => { > Browse Catalog + + {recentOrders.length > 0 && ( +
+
+ Previous Order + {recentOrders[0].orderNumber} +
+
+ {recentOrders[0].items.map((item: any, idx: number) => ( +
+ {item.productName} + ×{item.quantity} +
+ ))} + +
+

+ Reference standards supplied strictly for laboratory research use only. Reorders duplicate previous catalog items and quantities. +

+
+ )}
) : ( cart.map(({ product, quantity }) => ( diff --git a/src/components/admin/OrderDetailDrawer.tsx b/src/components/admin/OrderDetailDrawer.tsx index 38d5dff..2ed3c42 100644 --- a/src/components/admin/OrderDetailDrawer.tsx +++ b/src/components/admin/OrderDetailDrawer.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useCallback, useEffect, useState } from 'react'; -import { X, Truck, RotateCcw, Mail, Clock, CreditCard } from 'lucide-react'; +import { X, Truck, RotateCcw, Mail, Clock, CreditCard, AlertCircle } from 'lucide-react'; +import { classifyFulfillmentQueue } from '@/lib/admin/order-classification.mjs'; /** * Everything about one order, in one place: what was bought, what was paid, @@ -17,9 +18,11 @@ const NEXT_STATUSES: Record = { new: ['pending_payment', 'paid', 'canceled'], invoice_sent: ['pending_payment', 'paid', 'canceled'], pending_payment: ['paid', 'canceled'], - paid: ['preparing', 'packed', 'shipped', 'canceled'], - preparing: ['packed', 'shipped'], - packed: ['shipped'], + paid: ['waiting_inventory', 'waiting_documentation', 'preparing', 'packed', 'shipped', 'canceled'], + waiting_inventory: ['paid', 'preparing', 'packed', 'shipped', 'canceled'], + waiting_documentation: ['paid', 'preparing', 'packed', 'shipped', 'canceled'], + preparing: ['waiting_inventory', 'waiting_documentation', 'packed', 'shipped'], + packed: ['shipped', 'fulfilled'], shipped: ['fulfilled'], fulfilled: [], canceled: [], @@ -104,15 +107,32 @@ export const OrderDetailDrawer: React.FC = ({ orderKey, onClose, onChange const res = await fetch('/api/admin/orders/resend-email', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: o.id }), + body: JSON.stringify({ orderId: o.id, emailType: 'order' }), }); const j = await res.json(); - setMsg(j.success ? 'Order email resent.' : j.error || 'Could not resend.'); + setMsg(j.success ? 'Customer order receipt resent.' : j.error || 'Could not resend receipt.'); + if (j.success) { await load(); onChanged?.(); } + } finally { setBusy(false); } + }; + + const resendOwnerEmail = async () => { + if (!o) return; + setBusy(true); setMsg(null); + try { + const res = await fetch('/api/admin/orders/resend-email', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId: o.id, emailType: 'owner_alert' }), + }); + const j = await res.json(); + setMsg(j.success ? 'Owner notification alert resent.' : j.error || 'Could not resend owner alert.'); + if (j.success) { await load(); onChanged?.(); } } finally { setBusy(false); } }; const addr = o?.shipping_address_snapshot || {}; const refundable = (o?.total_amount || 0) - (o?.amount_refunded || 0); + const queue = o ? classifyFulfillmentQueue(o) : null; return (
@@ -124,7 +144,20 @@ export const OrderDetailDrawer: React.FC = ({ orderKey, onClose, onChange
{o?.order_number || orderKey}

{o?.customer_name || 'Order'}

-
+
+ {queue && ( + + {queue.replace(/_/g, ' ')} + + )} {o?.payment_provider === 'stripe' && ( {o.stripe_livemode === true ? 'STRIPE LIVE' : 'STRIPE TEST'} @@ -260,10 +293,14 @@ export const OrderDetailDrawer: React.FC = ({ orderKey, onClose, onChange )} {/* Actions */} -
+
+
diff --git a/src/lib/admin/order-classification.mjs b/src/lib/admin/order-classification.mjs index d097967..9060281 100644 --- a/src/lib/admin/order-classification.mjs +++ b/src/lib/admin/order-classification.mjs @@ -3,6 +3,8 @@ export const MANUAL_ORDER_STATUSES = [ "invoice_sent", "pending_payment", "paid", + "waiting_inventory", + "waiting_documentation", "preparing", "packed", "shipped", @@ -25,8 +27,38 @@ export const VALID_STATUS_TRANSITIONS = { new: ["invoice_sent", "pending_payment", "paid", "canceled", "cancelled"], invoice_sent: ["pending_payment", "paid", "canceled", "cancelled"], pending_payment: ["paid", "canceled", "cancelled"], - paid: ["preparing", "packed", "shipped", "fulfilled", "refunded", "canceled", "cancelled"], - preparing: ["packed", "shipped", "fulfilled", "refunded"], + paid: [ + "waiting_inventory", + "waiting_documentation", + "preparing", + "packed", + "shipped", + "fulfilled", + "refunded", + "canceled", + "cancelled", + ], + waiting_inventory: [ + "paid", + "waiting_documentation", + "preparing", + "packed", + "shipped", + "refunded", + "canceled", + "cancelled", + ], + waiting_documentation: [ + "paid", + "waiting_inventory", + "preparing", + "packed", + "shipped", + "refunded", + "canceled", + "cancelled", + ], + preparing: ["waiting_inventory", "waiting_documentation", "packed", "shipped", "fulfilled", "refunded"], packed: ["shipped", "fulfilled", "refunded"], shipped: ["fulfilled", "refunded"], fulfilled: ["refunded"], @@ -52,7 +84,15 @@ export function isValidStatusTransition(currentStatus, nextStatus) { export function isOrderReportableAsRevenue(order) { if (!order || order.is_test || order.archived_at) return false; const status = String(order.status || "").toLowerCase(); - return ["paid", "preparing", "shipped", "fulfilled"].includes(status); + return [ + "paid", + "waiting_inventory", + "waiting_documentation", + "preparing", + "packed", + "shipped", + "fulfilled", + ].includes(status); } export function isOrderPendingPayment(order) { @@ -60,3 +100,68 @@ export function isOrderPendingPayment(order) { const status = String(order.status || "").toLowerCase(); return ["new", "invoice_sent", "pending_payment"].includes(status); } + +export const FULFILLMENT_QUEUES = [ + { id: "PAID_NEEDS_FULFILLMENT", label: "PAID — NEEDS FULFILLMENT" }, + { id: "WAITING_ON_INVENTORY", label: "WAITING ON INVENTORY" }, + { id: "WAITING_ON_DOCUMENTATION", label: "WAITING ON DOCUMENTATION" }, + { id: "SHIPPED", label: "SHIPPED" }, + { id: "DELIVERED_COMPLETED", label: "DELIVERED/COMPLETED" }, + { id: "REFUND_EXCEPTION", label: "REFUND/EXCEPTION" }, +]; + +/** + * Classifies an order into exactly one operational fulfillment exception queue. + * + * @param {object} order + * @returns {"PAID_NEEDS_FULFILLMENT" | "WAITING_ON_INVENTORY" | "WAITING_ON_DOCUMENTATION" | "SHIPPED" | "DELIVERED_COMPLETED" | "REFUND_EXCEPTION" | "AWAITING_PAYMENT"} + */ +export function classifyFulfillmentQueue(order) { + if (!order) return "AWAITING_PAYMENT"; + + const status = String(order.status || "").toLowerCase(); + const paymentStatus = String(order.payment_status || "").toLowerCase(); + const hasRefund = Number(order.amount_refunded || 0) > 0 || paymentStatus.includes("refund"); + + // 1. Exceptions & Refunds + if ( + status === "refunded" || + isCancelledStatus(status) || + paymentStatus === "failed" || + hasRefund + ) { + return "REFUND_EXCEPTION"; + } + + // 2. Delivered / Completed + if (status === "fulfilled" || status === "delivered" || status === "completed") { + return "DELIVERED_COMPLETED"; + } + + // 3. Shipped + if (status === "shipped") { + return "SHIPPED"; + } + + // 4. Waiting on Inventory (explicit status or flagged oversell) + if ( + status === "waiting_inventory" || + order.inventory_status === "oversell" || + order.has_oversell === true + ) { + return "WAITING_ON_INVENTORY"; + } + + // 5. Waiting on Documentation + if (status === "waiting_documentation" || order.documentation_status === "pending") { + return "WAITING_ON_DOCUMENTATION"; + } + + // 6. Paid — Needs Fulfillment + if (paymentStatus === "paid" || ["paid", "preparing", "packed"].includes(status)) { + return "PAID_NEEDS_FULFILLMENT"; + } + + // 7. Awaiting Payment + return "AWAITING_PAYMENT"; +} diff --git a/src/lib/admin/order-events.ts b/src/lib/admin/order-events.ts index 413f959..17264e8 100644 --- a/src/lib/admin/order-events.ts +++ b/src/lib/admin/order-events.ts @@ -19,10 +19,13 @@ export type OrderEventType = | "tracking_added" | "email_sent" | "email_failed" + | "admin_notification_sent" + | "admin_notification_failed" | "refund_initiated" | "refund_completed" | "note_added" - | "inventory_adjusted"; + | "inventory_adjusted" + | "inventory_oversell"; export interface OrderEventInput { orderId: string; diff --git a/src/lib/commerce/repeat-purchase.mjs b/src/lib/commerce/repeat-purchase.mjs new file mode 100644 index 0000000..f093899 --- /dev/null +++ b/src/lib/commerce/repeat-purchase.mjs @@ -0,0 +1,66 @@ +export const RECENT_ORDERS_STORAGE_KEY = 'vf_recent_orders'; + +/** + * Saves a completed order to local browser storage so returning customers + * can reorder identical reference standard items and configurations. + * + * @param {{ orderNumber: string, date: string, items: Array<{ productId?: string, sku?: string, productName: string, configurationLabel?: string, quantity: number, unitPriceCents?: number }> }} order + */ +export function saveRecentOrder(order) { + if (typeof window === 'undefined') return; + try { + const raw = localStorage.getItem(RECENT_ORDERS_STORAGE_KEY); + const existing = raw ? JSON.parse(raw) : []; + const filtered = existing.filter((o) => o.orderNumber !== order.orderNumber); + filtered.unshift(order); + localStorage.setItem(RECENT_ORDERS_STORAGE_KEY, JSON.stringify(filtered.slice(0, 5))); + } catch { + /* storage blocked or quota exceeded: ignore gracefully */ + } +} + +/** + * Retrieves past order records for this customer browser session. + * @returns {Array} + */ +export function getRecentOrders() { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(RECENT_ORDERS_STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +/** + * Matches an item from a historical order against current purchasable catalog SKUs. + * Preserves exact product ID, SKU and configuration. + * + * @param {{ productId?: string, sku?: string, productName?: string, quantity?: number }} item + * @param {Array} catalog + * @returns {any | undefined} + */ +export function resolveProductForReorder(item, catalog = []) { + if (!item || !Array.isArray(catalog)) return undefined; + + if (item.productId) { + const byId = catalog.find((p) => p.id === item.productId && p.purchasable !== false); + if (byId) return byId; + } + + if (item.sku) { + const bySku = catalog.find((p) => p.sku === item.sku && p.purchasable !== false); + if (bySku) return bySku; + } + + if (item.productName) { + const norm = String(item.productName).trim().toLowerCase(); + const byName = catalog.find( + (p) => p.name && String(p.name).trim().toLowerCase() === norm && p.purchasable !== false, + ); + if (byName) return byName; + } + + return undefined; +} diff --git a/src/lib/email/templates/internal-order.ts b/src/lib/email/templates/internal-order.ts index e964ed3..3c5fe9f 100644 --- a/src/lib/email/templates/internal-order.ts +++ b/src/lib/email/templates/internal-order.ts @@ -26,6 +26,7 @@ export function renderInternalOrderNotificationEmail(params: { paymentMethod: string; /** Real payment state. Manual-invoice orders are unpaid until reconciled. */ paymentStatus: string; + fulfillmentNextStep?: string | null; shippingAddress: Record; promoCode?: string | null; affiliateCode?: string | null; @@ -71,6 +72,10 @@ export function renderInternalOrderNotificationEmail(params: { `; const unpaid = params.paymentStatus.toLowerCase() !== "paid"; + const defaultNextStep = unpaid + ? "Awaiting payment receipt. Reconcile funds before releasing inventory for fulfillment." + : "Paid and verified. Pack items from inventory and issue carrier tracking."; + const nextStep = params.fulfillmentNextStep || defaultNextStep; const body = ` ${ @@ -89,7 +94,7 @@ export function renderInternalOrderNotificationEmail(params: {
+ style="background-color: ${unpaid ? "#FFF7ED" : "#F0FDF4"}; border: 1px solid ${unpaid ? "#FDBA74" : "#86EFAC"}; border-radius: 10px; margin-bottom: 12px;"> + +
@@ -97,7 +102,21 @@ export function renderInternalOrderNotificationEmail(params: {
${escapeHtml(params.paymentStatus.toUpperCase())} - · intends to pay by ${escapeHtml(params.paymentMethod)} + · ${escapeHtml(params.paymentMethod)} +
+
+ + + + diff --git a/tests/fulfillment-queues.test.mjs b/tests/fulfillment-queues.test.mjs new file mode 100644 index 0000000..2c793ff --- /dev/null +++ b/tests/fulfillment-queues.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { + classifyFulfillmentQueue, + FULFILLMENT_QUEUES, + isValidStatusTransition, + isOrderReportableAsRevenue, + MANUAL_ORDER_STATUSES, +} from '../src/lib/admin/order-classification.mjs'; + +test('order classification includes waiting_inventory and waiting_documentation', () => { + assert.ok(MANUAL_ORDER_STATUSES.includes('waiting_inventory')); + assert.ok(MANUAL_ORDER_STATUSES.includes('waiting_documentation')); +}); + +test('FULFILLMENT_QUEUES contains all 6 operational fulfillment queues', () => { + const ids = FULFILLMENT_QUEUES.map((q) => q.id); + assert.deepStrictEqual(ids, [ + 'PAID_NEEDS_FULFILLMENT', + 'WAITING_ON_INVENTORY', + 'WAITING_ON_DOCUMENTATION', + 'SHIPPED', + 'DELIVERED_COMPLETED', + 'REFUND_EXCEPTION', + ]); +}); + +test('classifyFulfillmentQueue assigns orders to exact operational queues', () => { + // Refund / Exception + assert.strictEqual( + classifyFulfillmentQueue({ status: 'refunded', payment_status: 'paid' }), + 'REFUND_EXCEPTION', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'canceled', payment_status: 'paid' }), + 'REFUND_EXCEPTION', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'paid', payment_status: 'paid', amount_refunded: 500 }), + 'REFUND_EXCEPTION', + ); + + // Delivered / Completed + assert.strictEqual( + classifyFulfillmentQueue({ status: 'fulfilled', payment_status: 'paid' }), + 'DELIVERED_COMPLETED', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'delivered', payment_status: 'paid' }), + 'DELIVERED_COMPLETED', + ); + + // Shipped + assert.strictEqual( + classifyFulfillmentQueue({ status: 'shipped', payment_status: 'paid' }), + 'SHIPPED', + ); + + // Waiting on inventory + assert.strictEqual( + classifyFulfillmentQueue({ status: 'waiting_inventory', payment_status: 'paid' }), + 'WAITING_ON_INVENTORY', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'paid', payment_status: 'paid', inventory_status: 'oversell' }), + 'WAITING_ON_INVENTORY', + ); + + // Waiting on documentation + assert.strictEqual( + classifyFulfillmentQueue({ status: 'waiting_documentation', payment_status: 'paid' }), + 'WAITING_ON_DOCUMENTATION', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'paid', payment_status: 'paid', documentation_status: 'pending' }), + 'WAITING_ON_DOCUMENTATION', + ); + + // Paid — Needs Fulfillment + assert.strictEqual( + classifyFulfillmentQueue({ status: 'paid', payment_status: 'paid' }), + 'PAID_NEEDS_FULFILLMENT', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'preparing', payment_status: 'paid' }), + 'PAID_NEEDS_FULFILLMENT', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'packed', payment_status: 'paid' }), + 'PAID_NEEDS_FULFILLMENT', + ); + + // Awaiting Payment + assert.strictEqual( + classifyFulfillmentQueue({ status: 'new', payment_status: 'pending_payment' }), + 'AWAITING_PAYMENT', + ); + assert.strictEqual( + classifyFulfillmentQueue({ status: 'pending_payment', payment_status: 'pending_payment' }), + 'AWAITING_PAYMENT', + ); +}); + +test('status transitions allow moving between paid, waiting_inventory, and packaging states', () => { + assert.ok(isValidStatusTransition('paid', 'waiting_inventory')); + assert.ok(isValidStatusTransition('paid', 'waiting_documentation')); + assert.ok(isValidStatusTransition('paid', 'preparing')); + assert.ok(isValidStatusTransition('waiting_inventory', 'preparing')); + assert.ok(isValidStatusTransition('waiting_inventory', 'packed')); + assert.ok(isValidStatusTransition('waiting_inventory', 'shipped')); + assert.ok(isValidStatusTransition('waiting_documentation', 'preparing')); + assert.ok(isValidStatusTransition('preparing', 'shipped')); + assert.ok(isValidStatusTransition('shipped', 'fulfilled')); +}); + +test('isOrderReportableAsRevenue counts waiting_inventory and waiting_documentation as revenue', () => { + assert.strictEqual(isOrderReportableAsRevenue({ status: 'paid' }), true); + assert.strictEqual(isOrderReportableAsRevenue({ status: 'waiting_inventory' }), true); + assert.strictEqual(isOrderReportableAsRevenue({ status: 'waiting_documentation' }), true); + assert.strictEqual(isOrderReportableAsRevenue({ status: 'pending_payment' }), false); + assert.strictEqual(isOrderReportableAsRevenue({ status: 'canceled' }), false); + assert.strictEqual(isOrderReportableAsRevenue({ status: 'refunded' }), false); +}); diff --git a/tests/owner-alerts.test.mjs b/tests/owner-alerts.test.mjs new file mode 100644 index 0000000..b0f0100 --- /dev/null +++ b/tests/owner-alerts.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.resolve(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(root, rel), 'utf8'); +} + +test('internal order notification template defines fulfillment next step and excludes sensitive payment data', () => { + const tplSrc = read('src/lib/email/templates/internal-order.ts'); + assert.ok(tplSrc.includes('fulfillmentNextStep?: string | null;'), 'template interface must declare fulfillmentNextStep'); + assert.ok(tplSrc.includes('Fulfillment next step'), 'template must render fulfillment next step heading'); + assert.ok(tplSrc.includes('escapeHtml(nextStep)'), 'template must safely escape fulfillment next step'); + + // Never expose sensitive card tokens or numbers + assert.ok(!tplSrc.includes('cvc')); + assert.ok(!tplSrc.includes('exp_month')); + assert.ok(!tplSrc.includes('client_secret')); +}); + +test('stripe webhook ensures email failure does not lose the paid order', () => { + const webhookSrc = read('src/app/api/webhooks/stripe/route.ts'); + + // Customer email wrapped in try/catch + assert.ok( + webhookSrc.includes('// 1. Customer confirmation email (isolated try/catch so failure never throws)'), + 'customer email dispatch must be isolated in try/catch', + ); + + // Owner alert wrapped in try/catch + assert.ok( + webhookSrc.includes('// 2. Owner alert email (isolated try/catch so failure never throws)'), + 'owner alert dispatch must be isolated in try/catch', + ); + + // Pass fulfillment next step + assert.ok( + webhookSrc.includes('fulfillmentNextStep'), + 'webhook must provide fulfillmentNextStep in owner alert', + ); + + // Order transition to waiting_inventory on oversell + assert.ok( + webhookSrc.includes("status: 'waiting_inventory'"), + 'webhook must transition order status to waiting_inventory on oversell', + ); +}); + +test('admin resend-email route supports owner_alert with proper event logging', () => { + const resendSrc = read('src/app/api/admin/orders/resend-email/route.ts'); + assert.ok( + resendSrc.includes('emailType === "owner_alert"'), + 'resend route must support owner_alert email type', + ); + assert.ok( + resendSrc.includes('renderInternalOrderNotificationEmail'), + 'resend route must render internal order notification for owner alert', + ); +}); diff --git a/tests/repeat-purchase.test.mjs b/tests/repeat-purchase.test.mjs new file mode 100644 index 0000000..2564d4c --- /dev/null +++ b/tests/repeat-purchase.test.mjs @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveProductForReorder } from '../src/lib/commerce/repeat-purchase.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.resolve(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(root, rel), 'utf8'); +} + +const mockCatalog = [ + { + id: 'vf-std-001', + sku: 'VF-BPC-5MG', + name: 'BPC-157 5mg', + purchasable: true, + }, + { + id: 'vf-std-002', + sku: 'VF-TB-5MG', + name: 'TB-500 5mg', + purchasable: true, + }, + { + id: 'vf-std-010', + sku: 'VF-BAC-10ML', + name: 'Bacteriostatic Water 10ml', + purchasable: false, // retired + }, +]; + +test('resolveProductForReorder matches catalog products by id, sku, or name', () => { + // By ID + const resolvedById = resolveProductForReorder( + { + productId: 'vf-std-001', + productName: 'Different Name', + quantity: 1, + }, + mockCatalog, + ); + assert.strictEqual(resolvedById?.id, 'vf-std-001'); + + // By SKU + const resolvedBySku = resolveProductForReorder( + { + sku: 'VF-TB-5MG', + productName: 'Different Name', + quantity: 1, + }, + mockCatalog, + ); + assert.strictEqual(resolvedBySku?.id, 'vf-std-002'); + + // By Name + const resolvedByName = resolveProductForReorder( + { + productName: 'bpc-157 5mg', + quantity: 1, + }, + mockCatalog, + ); + assert.strictEqual(resolvedByName?.id, 'vf-std-001'); +}); + +test('resolveProductForReorder refuses unpurchasable or retired products', () => { + // Legacy bac water is retired/unpurchasable (vf-std-010) + const legacy = resolveProductForReorder( + { + productId: 'vf-std-010', + productName: 'Bacteriostatic Water 10ml', + quantity: 1, + }, + mockCatalog, + ); + assert.strictEqual(legacy, undefined); +}); + +test('repeat purchase copy enforces strictly commercial language and NO medical claims', () => { + const confirmationSrc = read('src/app/order-confirmation/[orderId]/page.tsx'); + const cartDrawerSrc = read('src/components/CartDrawer.tsx'); + const repeatHelperSrc = read('src/lib/commerce/repeat-purchase.mjs'); + + const combined = `${confirmationSrc}\n${cartDrawerSrc}\n${repeatHelperSrc}`.toLowerCase(); + + // Forbidden medical, dosing, clinical or therapeutic claims + const forbiddenTerms = [ + 'dosage', + 'dosing', + 'injection', + 'subcutaneous', + 'treatment', + 'cure', + 'heal', + 'bodybuilding', + 'weight loss', + 'human use', + 'protocol', + ]; + + for (const term of forbiddenTerms) { + const occurrences = combined.split(term).length - 1; + if (term === 'human use') { + // Must be prefixed by "not for" + assert.ok( + combined.includes('not for human use'), + 'human use reference must be negative disclaimer', + ); + } else { + assert.strictEqual( + occurrences, + 0, + `Repeat purchase copy must not contain medical term "${term}"`, + ); + } + } + + // Commercial terms present + assert.ok( + combined.includes('reorder items') || combined.includes('reorder previous materials'), + 'must contain clear commercial reorder button copy', + ); +});
+
+ Fulfillment next step +
+
+ ${escapeHtml(nextStep)}