import { ConvexError } from "convex/values"; import type { AddressValidationResult, RecommendedAddress, ShippoRate, ValidatedCartItem, } from "./checkout"; type ValidateAddressInput = { addressLine1: string; additionalInformation?: string; city: string; postalCode: string; country: string; name?: string; }; type ShippoRawResponse = { original_address: { address_line_1: string; address_line_2?: string; city_locality: string; state_province: string; postal_code: string; country_code: string; name?: string; organization?: string; }; recommended_address?: { address_line_1: string; address_line_2?: string; city_locality: string; state_province: string; postal_code: string; country_code: string; complete_address?: string; confidence_result: { score: "high" | "medium" | "low"; code: string; description: string; }; }; analysis: { validation_result: { value: "valid" | "partially_valid" | "invalid"; reasons: Array<{ code: string; description: string }>; }; address_type: | "residential" | "commercial" | "unknown" | "po_box" | "military"; changed_attributes?: string[]; }; geo?: { latitude: number; longitude: number; }; }; const SHIPPO_VALIDATE_URL = "https://api.goshippo.com/v2/addresses/validate"; /** * Calls Shippo Address Validation v2 and normalizes the response into * an `AddressValidationResult`. This is a pure async helper — it does NOT * export a Convex function; it's consumed by actions in `checkoutActions.ts`. */ export async function validateAddressWithShippo( input: ValidateAddressInput, ): Promise { const apiKey = process.env.SHIPPO_API_KEY; if (!apiKey) { throw new ConvexError( "Address validation is unavailable (missing API key configuration).", ); } const params = new URLSearchParams(); params.set("address_line_1", input.addressLine1); if (input.additionalInformation) params.set("address_line_2", input.additionalInformation); params.set("city_locality", input.city); params.set("postal_code", input.postalCode); params.set("country_code", input.country); if (input.name) params.set("name", input.name); let response: Response; try { response = await fetch(`${SHIPPO_VALIDATE_URL}?${params.toString()}`, { method: "GET", headers: { Authorization: `ShippoToken ${apiKey}` }, }); } catch (err) { throw new ConvexError( "Address validation service is unreachable. Please try again later.", ); } if (!response.ok) { throw new ConvexError( `Address validation service unavailable (status ${response.status}).`, ); } let body: ShippoRawResponse; try { body = (await response.json()) as ShippoRawResponse; } catch { throw new ConvexError( "Address validation returned an unexpected response. Please try again.", ); } if (!body.analysis?.validation_result) { throw new ConvexError( "Address validation returned a malformed response.", ); } const { analysis, recommended_address, original_address } = body; let recommendedAddress: RecommendedAddress | undefined; if (recommended_address) { recommendedAddress = { addressLine1: recommended_address.address_line_1, additionalInformation: recommended_address.address_line_2, city: recommended_address.city_locality, postalCode: recommended_address.postal_code, country: recommended_address.country_code, completeAddress: recommended_address.complete_address, confidenceScore: recommended_address.confidence_result.score, confidenceCode: recommended_address.confidence_result.code, confidenceDescription: recommended_address.confidence_result.description, }; } return { isValid: analysis.validation_result.value === "valid", validationValue: analysis.validation_result.value, reasons: analysis.validation_result.reasons.map((r) => ({ code: r.code, description: r.description, })), addressType: analysis.address_type, changedAttributes: analysis.changed_attributes ?? [], recommendedAddress, originalAddress: { addressLine1: original_address.address_line_1, additionalInformation: original_address.address_line_2, city: original_address.city_locality, postalCode: original_address.postal_code, country: original_address.country_code, }, }; } // ─── Shipping Rates Helpers ────────────────────────────────────────────────── export const PREFERRED_CARRIERS = ["DPD UK", "Evri UK", "UPS", "UDS"]; /** * Hard ceiling across preferred UK carriers. * DPD UK premium (door-to-door, Saturday/Sunday): 30kg * DPD UK standard (Classic, Next Day, Two Day): 20kg * Evri UK (Courier Collection, ParcelShop): 15kg */ export const MAX_PARCEL_WEIGHT_G = 30_000; const WEIGHT_TO_GRAMS: Record = { g: 1, kg: 1000, lb: 453.592, oz: 28.3495, }; const DIMENSION_TO_CM: Record, number> = { cm: 1, in: 2.54, }; type ParcelResult = { weight: string; mass_unit: "g"; length?: string; width?: string; height?: string; distance_unit?: "cm"; }; export function computeParcel(items: ValidatedCartItem[]): ParcelResult { let totalWeightGrams = 0; for (const item of items) { const factor = WEIGHT_TO_GRAMS[item.weightUnit]; totalWeightGrams += item.weight * factor * item.quantity; } const withDimensions = items.filter( (item): item is ValidatedCartItem & { length: number; width: number; height: number; dimensionUnit: "cm" | "in" } => item.length != null && item.width != null && item.height != null && item.dimensionUnit != null, ); if (withDimensions.length === 0) { return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g" }; } let maxLengthCm = 0; let maxWidthCm = 0; let totalHeightCm = 0; for (const item of withDimensions) { const factor = DIMENSION_TO_CM[item.dimensionUnit]; const lengthCm = item.length * factor; const widthCm = item.width * factor; const heightCm = item.height * factor * item.quantity; if (lengthCm > maxLengthCm) maxLengthCm = lengthCm; if (widthCm > maxWidthCm) maxWidthCm = widthCm; totalHeightCm += heightCm; } return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g", length: String(Math.round(maxLengthCm * 100) / 100), width: String(Math.round(maxWidthCm * 100) / 100), height: String(Math.round(totalHeightCm * 100) / 100), distance_unit: "cm", }; } const SHIPPO_SHIPMENTS_URL = "https://api.goshippo.com/shipments/"; export async function getShippingRatesFromShippo(input: { sourceAddressId: string; destinationAddress: { name: string; street1: string; street2?: string; city: string; zip: string; country: string; phone?: string; }; parcels: Array<{ weight: string; mass_unit: string; length?: string; width?: string; height?: string; distance_unit?: string; }>; }): Promise<{ shipmentObjectId: string; rates: ShippoRate[] }> { const apiKey = process.env.SHIPPO_API_KEY; if (!apiKey) { throw new ConvexError( "Shipping rate service is unavailable (missing API key configuration).", ); } const requestBody = { address_from: input.sourceAddressId, address_to: input.destinationAddress, parcels: input.parcels, async: false, }; let response: Response; try { response = await fetch(SHIPPO_SHIPMENTS_URL, { method: "POST", headers: { Authorization: `ShippoToken ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); } catch { throw new ConvexError( "Shipping rate service is unreachable. Please try again later.", ); } if (!response.ok) { let errorDetail = ""; try { const errBody = await response.json(); errorDetail = JSON.stringify(errBody); console.error("Shippo /shipments/ error:", response.status, errorDetail); } catch { console.error("Shippo /shipments/ error:", response.status, "(no parseable body)"); } throw new ConvexError( `Shipping rate service unavailable (status ${response.status}).`, ); } let body: { object_id: string; messages?: Array<{ source: string; text: string }>; rates: Array<{ object_id: string; provider: string; servicelevel: { name: string; token: string }; amount: string; currency: string; estimated_days: number; duration_terms: string; arrives_by?: string | null; carrier_account: string; }>; }; try { body = await response.json(); } catch { throw new ConvexError( "Shipping rate service returned an unexpected response. Please try again.", ); } if (body.rates.length === 0 && body.messages?.length) { console.warn( "Shippo returned 0 rates. Carrier messages:", body.messages.map((m) => `[${m.source}] ${m.text}`).join(" | "), ); } const rates: ShippoRate[] = body.rates.map((rate) => ({ objectId: rate.object_id, provider: rate.provider, servicelevelName: rate.servicelevel.name, servicelevelToken: rate.servicelevel.token, amount: rate.amount, currency: rate.currency, estimatedDays: rate.estimated_days, durationTerms: rate.duration_terms, arrivesBy: rate.arrives_by ?? null, carrierAccount: rate.carrier_account, })); return { shipmentObjectId: body.object_id, rates }; } export function selectBestRate(rates: ShippoRate[]): { selected: ShippoRate; alternatives: ShippoRate[]; } { if (rates.length === 0) { throw new ConvexError( "No shipping rates available for this address. Please verify your address and try again.", ); } const preferredLower = PREFERRED_CARRIERS.map((c) => c.toLowerCase()); const preferred = rates.filter((r) => preferredLower.includes(r.provider.toLowerCase()), ); const sortByDaysThenPrice = (a: ShippoRate, b: ShippoRate) => { const aDays = a.estimatedDays ?? Infinity; const bDays = b.estimatedDays ?? Infinity; const daysDiff = aDays - bDays; if (daysDiff !== 0) return daysDiff; return parseFloat(a.amount) - parseFloat(b.amount); }; if (preferred.length > 0) { preferred.sort(sortByDaysThenPrice); return { selected: preferred[0], alternatives: preferred.slice(1, 3) }; } console.warn( "No preferred carriers returned rates. Falling back to all carriers.", ); const sorted = [...rates].sort(sortByDaysThenPrice); return { selected: sorted[0], alternatives: sorted.slice(1, 3) }; }