Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { FedExServiceType } from "./fedex-rates";
|
||||
|
||||
export type CreateShipmentResult =
|
||||
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(settings: {
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
useProduction: boolean;
|
||||
}): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
|
||||
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
async function getFedExTokenForCreate(
|
||||
brandId: string | null,
|
||||
adminUserId: string | null
|
||||
): Promise<{ accessToken: string } | { error: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Try to get from shipping_settings
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret) {
|
||||
return { error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
return getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
}
|
||||
|
||||
async function isShipmentPerishable(orderId: string): Promise<boolean> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return !!data?.is_perishable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createFedExShipment — Creates a FedEx shipment for an order.
|
||||
*
|
||||
* @param orderId — The order to ship
|
||||
* @param serviceType — The FedEx service type selected (FEDEX_OVERNIGHT, etc.)
|
||||
* @param rateCharged — The rate quoted and charged (cents)
|
||||
* @param estimatedDeliveryDate — ISO date string from rate quote
|
||||
*/
|
||||
export async function createFedExShipment(
|
||||
orderId: string,
|
||||
serviceType: FedExServiceType,
|
||||
rateCharged: number, // cents
|
||||
estimatedDeliveryDate: string
|
||||
): Promise<CreateShipmentResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return { success: false, error: "Not authorized to create shipments" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch order
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
|
||||
const orders: Array<{
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
customer_name: string;
|
||||
customer_address: string;
|
||||
customer_city: string;
|
||||
customer_state: string;
|
||||
customer_zip: string;
|
||||
customer_country: string;
|
||||
customer_phone: string | null;
|
||||
customer_email: string | null;
|
||||
}> = await orderRes.json();
|
||||
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Get FedEx settings
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
refrigerated_handling_notes: string | null;
|
||||
fragile_handling_notes: string | null;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
|
||||
return { success: false, error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
// Check perishable status
|
||||
const perishable = await isShipmentPerishable(orderId);
|
||||
|
||||
// Validate perishable-only services
|
||||
if (perishable && serviceType !== "FEDEX_OVERNIGHT" && serviceType !== "FEDEX_2_DAY_AIR") {
|
||||
return {
|
||||
success: false,
|
||||
error: `Perishable orders can only ship via Overnight or 2-Day Air. Selected: ${serviceType}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get FedEx token
|
||||
const tokenResult = await getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
return { success: false, error: tokenResult.error };
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken;
|
||||
const base = settings.fedex_use_production ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Build special services
|
||||
const specialServicesRequested: string[] = [];
|
||||
if (perishable) {
|
||||
specialServicesRequested.push("REFRIGERATED");
|
||||
}
|
||||
|
||||
// Create FedEx shipment
|
||||
// NOTE: customer_address is TEXT field — may contain full address on one line.
|
||||
// For full FedEx compliance this would ideally be parsed into address_line_1/2.
|
||||
// Using it as city/state/zip for now, with customer_name as contact.
|
||||
const createShipmentRequest = {
|
||||
labelResponseOptions: "URL_ONLY",
|
||||
requestedShipment: {
|
||||
shipDateStamp: new Date().toISOString().split("T")[0],
|
||||
serviceType,
|
||||
packagingType: "YOUR_PACKAGING",
|
||||
shipper: {
|
||||
contact: {
|
||||
companyName: "Route Commerce",
|
||||
phoneNumber: "7725897500",
|
||||
emailAddress: "orders@routecommerce.com",
|
||||
},
|
||||
address: {
|
||||
streetLines: ["PO Box 1234"],
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
contact: {
|
||||
personName: order.customer_name,
|
||||
phoneNumber: order.customer_phone ?? "0000000000",
|
||||
emailAddress: order.customer_email ?? "unknown@email.com",
|
||||
},
|
||||
address: {
|
||||
streetLines: [order.customer_address || "No address provided"],
|
||||
city: order.customer_city || "Unknown",
|
||||
stateOrProvinceCode: order.customer_state || "FL",
|
||||
postalCode: order.customer_zip || "00000",
|
||||
countryCode: order.customer_country || "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
},
|
||||
},
|
||||
},
|
||||
specialServicesRequested: {
|
||||
specialServiceTypes: specialServicesRequested.length > 0 ? specialServicesRequested : undefined,
|
||||
...(perishable && {
|
||||
refrigeratedShipment: {
|
||||
heavyWeightNotification: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
emailNotificationDetail: {
|
||||
emailNotificationRecipientType: "RECIPIENT",
|
||||
notificationEvents: ["DELIVERED"],
|
||||
notificationFormat: "HTML",
|
||||
notificationLanguage: "en",
|
||||
recipientEmails: order.customer_email ? [order.customer_email] : [],
|
||||
},
|
||||
requestedPackageLineItems: [
|
||||
{
|
||||
weight: {
|
||||
units: "LB",
|
||||
value: 10, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time
|
||||
},
|
||||
dimensions: {
|
||||
length: 12,
|
||||
width: 8,
|
||||
height: 6,
|
||||
units: "IN",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
};
|
||||
|
||||
const shipRes = await fetch(`${base}/ship/v1/shipments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify(createShipmentRequest),
|
||||
});
|
||||
|
||||
if (!shipRes.ok) {
|
||||
const text = await shipRes.text();
|
||||
return { success: false, error: `FedEx shipment creation failed: ${text.slice(0, 300)}` };
|
||||
}
|
||||
|
||||
const shipData = await shipRes.json();
|
||||
|
||||
const jobId = shipData?.output?.jobId;
|
||||
const trackingNumber = shipData?.output?.pieceResponses?.[0]?.trackingNumber ?? "";
|
||||
const labelUrl = shipData?.output?.pieceResponses?.[0]?.label?.url ?? "";
|
||||
const fedexShipmentId = shipData?.output?.shipmentIdentification ?? "";
|
||||
|
||||
// Store shipment record
|
||||
const insertRes = await fetch(`${supabaseUrl}/rest/v1/shipments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
order_id: orderId,
|
||||
carrier: "fedex",
|
||||
service_type: serviceType,
|
||||
tracking_number: trackingNumber,
|
||||
label_url: labelUrl,
|
||||
rate_charged: rateCharged / 100,
|
||||
estimated_delivery_date: estimatedDeliveryDate,
|
||||
is_refrigerated: perishable,
|
||||
is_fragile: false,
|
||||
handling_notes: perishable ? (settings.refrigerated_handling_notes ?? null) : (settings.fragile_handling_notes ?? null),
|
||||
status: "created",
|
||||
fedex_shipment_id: fedexShipmentId || null,
|
||||
created_by: adminUser.user_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!insertRes.ok) {
|
||||
const errText = await insertRes.text();
|
||||
return { success: false, error: `Shipment created but failed to store record: ${errText.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const shipmentRecords: Array<{ id: string }> = await insertRes.json();
|
||||
const shipmentId = shipmentRecords[0]?.id;
|
||||
|
||||
if (!shipmentId) {
|
||||
return { success: false, error: "Shipment created but failed to retrieve shipment ID" };
|
||||
}
|
||||
|
||||
// Update order shipping_status and tracking_number
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_shipping_status: "label_created",
|
||||
p_tracking_number: trackingNumber,
|
||||
p_brand_id: effectiveBrandId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shipmentId,
|
||||
trackingNumber,
|
||||
labelUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* FedEx Rate Quote + Shipment Creation Actions
|
||||
*
|
||||
* getFedExRates(orderId) — Fetches available FedEx rates for an order,
|
||||
* filtering service types based on perishable items.
|
||||
*
|
||||
* createFedExShipment(orderId, selectedRate, specialServices) — Creates a
|
||||
* FedEx shipment and stores the label/tracking info.
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FedExServiceType =
|
||||
| "FEDEX_OVERNIGHT"
|
||||
| "FEDEX_2_DAY_AIR"
|
||||
| "FEDEX_EXPRESS_SAVER"
|
||||
| "FEDEX_GROUND";
|
||||
|
||||
export type FedExRate = {
|
||||
serviceType: FedExServiceType;
|
||||
serviceDescription: string;
|
||||
deliveryDate: string; // ISO date string
|
||||
deliveryDayOfWeek: string;
|
||||
deliveryDateLabel: string;
|
||||
baseCharge: number; // cents
|
||||
totalCharge: number; // cents
|
||||
isPerishableOnly: boolean; // true if only available because order is all-perishable
|
||||
};
|
||||
|
||||
export type ShipmentResult =
|
||||
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type RateResult =
|
||||
| { success: true; rates: FedExRate[]; isPerishable: boolean; orderId: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── FedEx API Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(settings: {
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
useProduction: boolean;
|
||||
}): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Reuse cached token if still valid (5 min buffer)
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
|
||||
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
// ── Perishable Detection ──────────────────────────────────────────────────────
|
||||
|
||||
async function isOrderPerishable(
|
||||
orderId: string,
|
||||
brandId: string | null
|
||||
): Promise<boolean> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_order_items_perishable?p_order_id=${encodeURIComponent(orderId)}`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return !!data?.is_perishable;
|
||||
}
|
||||
|
||||
// Fallback: query products directly
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/order_items?order_id=eq.${encodeURIComponent(orderId)}&select=product_id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return false;
|
||||
|
||||
const items: { product_id: string }[] = await orderRes.json();
|
||||
if (items.length === 0) return false;
|
||||
|
||||
const productIds = items.map((i) => `id=eq.${i.product_id}`).join("&");
|
||||
const productRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?${productIds}&select=is_perishable`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!productRes.ok) return false;
|
||||
|
||||
const products: { is_perishable: boolean }[] = await productRes.json();
|
||||
return products.length > 0 && products.every((p) => p.is_perishable);
|
||||
}
|
||||
|
||||
// ── Get FedEx Rates ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFedExRates(
|
||||
orderId: string
|
||||
): Promise<RateResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return { success: false, error: "Not authorized to view shipping rates" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch order + brand + address
|
||||
const orderRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/orders?id=eq.${encodeURIComponent(orderId)}&select=*,brand:brands(id,name)`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) return { success: false, error: "Failed to fetch order" };
|
||||
const orders: Array<{
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
customer_address: string;
|
||||
customer_city: string;
|
||||
customer_state: string;
|
||||
customer_zip: string;
|
||||
customer_country: string;
|
||||
brand: { id: string; name: string } | null;
|
||||
}> = await orderRes.json();
|
||||
|
||||
const order = orders[0];
|
||||
if (!order) return { success: false, error: "Order not found" };
|
||||
|
||||
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
|
||||
|
||||
// Fetch shipping settings for this brand
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(effectiveBrandId ?? "NULL")}&limit=1`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const settingsData: Array<{
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
}> = await settingsRes.json();
|
||||
|
||||
const settings = settingsData[0];
|
||||
if (!settings?.fedex_api_key || !settings?.fedex_api_secret || !settings?.fedex_account_number) {
|
||||
return { success: false, error: "FedEx not configured for this brand" };
|
||||
}
|
||||
|
||||
// Check perishable status
|
||||
const perishable = await isOrderPerishable(orderId, effectiveBrandId);
|
||||
|
||||
// Get FedEx token
|
||||
const tokenResult = await getFedExToken({
|
||||
fedexApiKey: settings.fedex_api_key,
|
||||
fedexApiSecret: settings.fedex_api_secret,
|
||||
useProduction: settings.fedex_use_production,
|
||||
});
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
return { success: false, error: tokenResult.error };
|
||||
}
|
||||
|
||||
const accessToken = tokenResult.accessToken;
|
||||
const base = settings.fedex_use_production ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Determine available service types
|
||||
const ALL_SERVICES: Array<FedExServiceType> = [
|
||||
"FEDEX_OVERNIGHT",
|
||||
"FEDEX_2_DAY_AIR",
|
||||
"FEDEX_EXPRESS_SAVER",
|
||||
"FEDEX_GROUND",
|
||||
];
|
||||
|
||||
// Perishable orders: only OVERNIGHT and 2DAY
|
||||
const allowedServices = perishable
|
||||
? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[])
|
||||
: ALL_SERVICES;
|
||||
|
||||
// Build FedEx rate request
|
||||
// NOTE: customer_address is a TEXT field — it may be a single-line address.
|
||||
// For full FedEx compliance, address parsing would need separate address_line_1/2 fields.
|
||||
const rateRequest = {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
requestedShipment: {
|
||||
shipper: {
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
address: {
|
||||
city: order.customer_city || "Unknown",
|
||||
stateOrProvinceCode: order.customer_state || "FL",
|
||||
postalCode: order.customer_zip || "00000",
|
||||
countryCode: order.customer_country || "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
serviceType: null, // Request all allowed services by omitting — FedEx returns all matching
|
||||
preferredServiceType: null,
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: settings.fedex_account_number },
|
||||
},
|
||||
},
|
||||
},
|
||||
rateRequestType: ["ACCOUNT"],
|
||||
requestedPackageLineItems: [
|
||||
{
|
||||
weight: { units: "LB", value: 10 }, // Weight is hardcoded to 10LB for MVP — actual weight should be calculated from package dimensions and product weights at order time
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const rateRes = await fetch(`${base}/rate/v1/rates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify(rateRequest),
|
||||
});
|
||||
|
||||
if (!rateRes.ok) {
|
||||
const text = await rateRes.text();
|
||||
return { success: false, error: `FedEx rate API error: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const rateData = await rateRes.json();
|
||||
|
||||
const rates: FedExRate[] = [];
|
||||
const output = rateData?.output?.rateReplyDetails ?? [];
|
||||
|
||||
for (const detail of output) {
|
||||
const serviceType = detail.serviceType as FedExServiceType;
|
||||
if (!allowedServices.includes(serviceType)) continue;
|
||||
|
||||
const dayOption = detail.derivedDetails?.deliveryDayOfWeek ?? "";
|
||||
const deliveryDate = detail.derivedDetails?.deliveryDate ?? "";
|
||||
const deliveryDateLabel = deliveryDate
|
||||
? new Date(deliveryDate).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: dayOption;
|
||||
|
||||
const rateDetail = detail.ratedShipmentDetails?.[0]?.shipmentRateDetail ?? {};
|
||||
const baseCharge = Math.round((rateDetail.baseCharge ?? 0) * 100);
|
||||
const totalCharge = Math.round((rateDetail.totalBaseCharge ?? rateDetail.baseCharge ?? 0) * 100);
|
||||
|
||||
const SERVICE_LABELS: Record<FedExServiceType, string> = {
|
||||
FEDEX_OVERNIGHT: "FedEx Overnight",
|
||||
FEDEX_2_DAY_AIR: "FedEx 2-Day Air",
|
||||
FEDEX_EXPRESS_SAVER: "FedEx Express Saver",
|
||||
FEDEX_GROUND: "FedEx Ground",
|
||||
};
|
||||
|
||||
rates.push({
|
||||
serviceType,
|
||||
serviceDescription: SERVICE_LABELS[serviceType] ?? serviceType,
|
||||
deliveryDate,
|
||||
deliveryDayOfWeek: dayOption,
|
||||
deliveryDateLabel,
|
||||
baseCharge,
|
||||
totalCharge,
|
||||
isPerishableOnly: perishable && (serviceType === "FEDEX_OVERNIGHT" || serviceType === "FEDEX_2_DAY_AIR"),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: overnight first, then 2day, express, ground
|
||||
const SORT_ORDER: Record<FedExServiceType, number> = {
|
||||
FEDEX_OVERNIGHT: 0,
|
||||
FEDEX_2_DAY_AIR: 1,
|
||||
FEDEX_EXPRESS_SAVER: 2,
|
||||
FEDEX_GROUND: 3,
|
||||
};
|
||||
rates.sort((a, b) => (SORT_ORDER[a.serviceType] ?? 99) - (SORT_ORDER[b.serviceType] ?? 99));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
rates,
|
||||
isPerishable: perishable,
|
||||
orderId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShippingSettings = {
|
||||
id: string;
|
||||
brand_id: string | null;
|
||||
carrier: string;
|
||||
fedex_account_number: string | null;
|
||||
fedex_api_key: string | null;
|
||||
fedex_api_secret: string | null;
|
||||
fedex_use_production: boolean;
|
||||
default_service_type: string;
|
||||
refrigerated_handling_notes: string | null;
|
||||
fragile_handling_notes: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type GetShippingSettingsResult =
|
||||
| { success: true; settings: ShippingSettings | null }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type SaveShippingSettingsResult =
|
||||
| { success: true; settings: ShippingSettings }
|
||||
| { success: false; error: string };
|
||||
|
||||
export type TestConnectionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── FedEx Auth Helper (mirrors fedex-rates.ts) ─────────────────────────────────
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
interface FedExAuthToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
async function getFedExToken(apiKey: string, apiSecret: string, useProduction: boolean): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = btoa(`${apiKey}:${apiSecret}`);
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
// ── Get Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(brandId)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to fetch shipping settings" };
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data[0] ?? null };
|
||||
}
|
||||
|
||||
// ── Save Settings ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveShippingSettings(params: {
|
||||
brandId: string;
|
||||
fedexAccountNumber?: string;
|
||||
fedexApiKey?: string;
|
||||
fedexApiSecret?: string;
|
||||
fedexUseProduction?: boolean;
|
||||
defaultServiceType?: string;
|
||||
refrigeratedHandlingNotes?: string;
|
||||
fragileHandlingNotes?: string;
|
||||
}): Promise<SaveShippingSettingsResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
|
||||
return { success: false, error: "Not authorized for this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Get existing settings to get ID for update
|
||||
const existing = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings?brand_id=eq.${encodeURIComponent(params.brandId)}&select=id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
const existingData: Array<{ id: string }> = await existing.json();
|
||||
const existingId = existingData[0]?.id;
|
||||
|
||||
const payload = {
|
||||
...(existingId ? { id: existingId } : {}),
|
||||
brand_id: params.brandId,
|
||||
carrier: "fedex",
|
||||
fedex_account_number: params.fedexAccountNumber ?? null,
|
||||
fedex_api_key: params.fedexApiKey ?? null,
|
||||
fedex_api_secret: params.fedexApiSecret ?? null,
|
||||
fedex_use_production: params.fedexUseProduction ?? false,
|
||||
default_service_type: params.defaultServiceType ?? "FEDEX_GROUND",
|
||||
refrigerated_handling_notes: params.refrigeratedHandlingNotes ?? null,
|
||||
fragile_handling_notes: params.fragileHandlingNotes ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/shipping_settings`,
|
||||
{
|
||||
method: existingId ? "PATCH" : "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { success: true, settings: data[0] };
|
||||
}
|
||||
|
||||
// ── Test Connection ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function testFedExConnection(
|
||||
fedexApiKey: string,
|
||||
fedexApiSecret: string,
|
||||
fedexUseProduction: boolean
|
||||
): Promise<TestConnectionResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const tokenResult = await getFedExToken(fedexApiKey, fedexApiSecret, fedexUseProduction);
|
||||
|
||||
if ("error" in tokenResult) {
|
||||
// Distinguish auth failure from network failure
|
||||
const msg = tokenResult.error.toLowerCase();
|
||||
if (msg.includes("401") || msg.includes("403") || msg.includes("authentication")) {
|
||||
return { success: false, error: "Invalid API key or secret. Check your FedEx Developer credentials." };
|
||||
}
|
||||
return { success: false, error: `FedEx connection failed: ${tokenResult.error.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const base = fedexUseProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
// Quick ping: request rates for a dummy shipment to verify token + account are valid
|
||||
const pingRes = await fetch(`${base}/rate/v1/rates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-locale": "en_US",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountNumber: { value: "000000000" }, // dummy — won't return real rates but validates auth
|
||||
requestedShipment: {
|
||||
shipper: {
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: false,
|
||||
},
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
address: {
|
||||
city: "Stuart",
|
||||
stateOrProvinceCode: "FL",
|
||||
postalCode: "34994",
|
||||
countryCode: "US",
|
||||
residential: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
serviceType: "FEDEX_GROUND",
|
||||
shippingChargesPayment: {
|
||||
paymentType: "SENDER",
|
||||
payor: {
|
||||
responsibleParty: {
|
||||
accountNumber: { value: "000000000" },
|
||||
},
|
||||
},
|
||||
},
|
||||
rateRequestType: ["ACCOUNT"],
|
||||
requestedPackageLineItems: [
|
||||
{ weight: { units: "LB", value: 1 } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// 400 means auth worked but account/destination invalid — still a success for "credentials work"
|
||||
// 401/403 means bad credentials
|
||||
if (pingRes.status === 401 || pingRes.status === 403) {
|
||||
return { success: false, error: "Authentication failed. Verify your API key and secret are correct." };
|
||||
}
|
||||
|
||||
if (!pingRes.ok && pingRes.status !== 400) {
|
||||
const text = await pingRes.text();
|
||||
return { success: false, error: `FedEx API error: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: fedexUseProduction
|
||||
? "Connected to FedEx Production. Credentials are valid."
|
||||
: "Connected to FedEx Sandbox. Credentials are valid.",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user