Files
route-commerce/src/actions/shipping/fedex-rates.ts
T

338 lines
12 KiB
TypeScript

"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.
*
* TODO(migration): shipping is dormant in the SaaS rebuild. The
* legacy `shipping_settings` table (with the FedEx credential columns
* this file needs) is still present in the database — originally from
* the now-archived supabase migrations, consolidated into
* `db/migrations/0001_init.sql` — but the new `db/schema/` does not
* declare it. The data reads below hit the legacy table directly via
* `pool.query`. When shipping comes back, declare the table in
* `db/schema/shipping.ts` and switch the reads to Drizzle.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { readFedExToken, writeFedExToken } from "@/lib/shipping/fedex-token-cache";
// ── 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";
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)
const cached = readFedExToken();
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
return { accessToken: cached.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();
writeFedExToken(data.access_token, data.expires_in);
return { accessToken: data.access_token };
}
// ── Perishable Detection ──────────────────────────────────────────────────────
/**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC.
* The function still lives in the database (see migration 083) so we
* hit it via `pool.query` instead of the Supabase REST gateway.
*/
async function isOrderPerishable(
orderId: string,
_brandId: string | null
): Promise<boolean> {
try {
const { rows } = await pool.query<{ is_perishable: boolean }>(
"SELECT * FROM get_order_items_perishable($1)",
[orderId]
);
return Boolean(rows[0]?.is_perishable);
} catch {
return false;
}
}
// ── Shipping Settings Row ─────────────────────────────────────────────────────
type ShippingSettingsRow = {
id: string;
brand_id: string | null;
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;
};
async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSettingsRow | null> {
const { rows } = await pool.query<ShippingSettingsRow>(
`SELECT id, brand_id, fedex_account_number, fedex_api_key, fedex_api_secret,
COALESCE(fedex_use_production, false) AS fedex_use_production,
refrigerated_handling_notes, fragile_handling_notes
FROM shipping_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId]
);
return rows[0] ?? null;
}
// ── Get FedEx Rates ────────────────────────────────────────────────────────────
export async function getFedExRates(
orderId: string
): Promise<RateResult> {
await getSession(); 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" };
}
// Read the order from the new orders schema (no `customer_address` /
// `customer_*` columns; addresses aren't part of the SaaS rebuild).
// The FedEx rate call needs a city/state/postal — without that on the
// order we cannot hit the FedEx rate API. We try to surface a
// meaningful error rather than silently call the API with junk.
const { rows: orderRows } = await pool.query<{
id: string;
brand_id: string | null;
status: string;
}>(
`SELECT id::text AS id, brand_id::text AS brand_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
);
const order = orderRows[0];
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
}
const settings = await loadShippingSettingsForBrand(effectiveBrandId);
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 and fetch FedEx token — these are
// independent, so fetch them in parallel and save a round-trip.
const [perishable, tokenResult] = await Promise.all([
isOrderPerishable(orderId, effectiveBrandId),
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;
const allowedServicesSet = new Set<FedExServiceType>(allowedServices);
// Build FedEx rate request. The SaaS rebuild doesn't store the
// recipient's city/state/zip on the order — FedEx rate quotes
// require a destination. We fall back to a no-op (shipper) request
// so the FedEx API call still validates the token + account even
// without a real destination; the rate quotes will be empty.
const rateRequest = {
accountNumber: { value: settings.fedex_account_number },
requestedShipment: {
shipper: {
address: {
city: "Stuart",
stateOrProvinceCode: "FL",
postalCode: "34994",
countryCode: "US",
residential: false,
},
},
recipients: [
{
address: {
city: "Unknown",
stateOrProvinceCode: "FL",
postalCode: "00000",
countryCode: "US",
residential: true,
},
},
],
serviceType: null,
preferredServiceType: null,
shippingChargesPayment: {
paymentType: "SENDER",
payor: {
responsibleParty: {
accountNumber: { value: settings.fedex_account_number },
},
},
},
rateRequestType: ["ACCOUNT"],
requestedPackageLineItems: [
{
weight: { units: "LB", value: 10 },
},
],
},
};
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 (!allowedServicesSet.has(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,
};
}