/** * Checkout Service - Domain layer for checkout operations * * This module encapsulates all checkout business logic, making it: * - Testable: Pure functions with no UI dependencies * - Composable: Steps can be chained, mocked, or replaced * - Type-safe: All inputs/outputs are strictly typed * - Resilient: Clear contracts prevent ordering mistakes */ import { saleorClient } from "@/lib/saleor/client"; import type { Checkout, CheckoutLine } from "@/types/saleor"; import { CHECKOUT_SHIPPING_ADDRESS_UPDATE, CHECKOUT_BILLING_ADDRESS_UPDATE, CHECKOUT_COMPLETE, CHECKOUT_EMAIL_UPDATE, CHECKOUT_METADATA_UPDATE, CHECKOUT_SHIPPING_METHOD_UPDATE, CHECKOUT_LANGUAGE_CODE_UPDATE, } from "@/lib/saleor/mutations/Checkout"; // ============================================================================ // GraphQL Response Types // ============================================================================ interface GraphQLError { field?: string; message: string; code?: string; } interface CheckoutEmailUpdateResponse { checkoutEmailUpdate?: { checkout?: Checkout; errors?: GraphQLError[]; }; } interface CheckoutLanguageCodeUpdateResponse { checkoutLanguageCodeUpdate?: { checkout?: { id: string; languageCode: string }; errors?: GraphQLError[]; }; } interface CheckoutShippingAddressUpdateResponse { checkoutShippingAddressUpdate?: { checkout?: Checkout; errors?: GraphQLError[]; }; } interface CheckoutBillingAddressUpdateResponse { checkoutBillingAddressUpdate?: { checkout?: Checkout; errors?: GraphQLError[]; }; } interface CheckoutShippingMethodUpdateResponse { checkoutShippingMethodUpdate?: { checkout?: Checkout; errors?: GraphQLError[]; }; } interface CheckoutMetadataUpdateResponse { updateMetadata?: { item?: { id: string; metadata?: Array<{ key: string; value: string }>; }; errors?: GraphQLError[]; }; } interface CheckoutCompleteResponse { checkoutComplete?: { order?: { id: string; number: string; status: string; created: string; total?: { gross: { amount: number; currency: string; }; }; }; errors?: GraphQLError[]; }; } // ============================================================================ // Domain Types // ============================================================================ export interface Address { firstName: string; lastName: string; streetAddress1: string; streetAddress2?: string; city: string; postalCode: string; country: string; phone: string; } export interface CheckoutInput { checkoutId: string; email: string; shippingAddress: Address; billingAddress: Address; shippingMethodId: string; languageCode: string; metadata: Record; } export interface CheckoutResult { success: boolean; order?: { id: string; number: string; languageCode: string; }; error?: string; } export interface CheckoutStepResult { success: boolean; data?: T; error?: string; } // ============================================================================ // Individual Checkout Steps (Composable Units) // ============================================================================ /** * Step 1: Update checkout email * Isolated, testable unit that does one thing */ export async function updateCheckoutEmail( checkoutId: string, email: string ): Promise { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_EMAIL_UPDATE, variables: { checkoutId, email }, }); if (data?.checkoutEmailUpdate?.errors?.length) { const error = data.checkoutEmailUpdate.errors[0]; if (error.message.includes("Couldn't resolve to a node")) { return { success: false, error: "CHECKOUT_EXPIRED" }; } return { success: false, error: error.message }; } return { success: true }; } /** * Step 2: Update language code * CRITICAL: Must be called before checkoutComplete for correct email language */ export async function updateCheckoutLanguage( checkoutId: string, languageCode: string ): Promise { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_LANGUAGE_CODE_UPDATE, variables: { checkoutId, languageCode: languageCode.toUpperCase() }, }); if (data?.checkoutLanguageCodeUpdate?.errors?.length) { return { success: false, error: data.checkoutLanguageCodeUpdate.errors[0].message }; } return { success: true }; } /** * Step 3: Update shipping address */ export async function updateShippingAddress( checkoutId: string, address: Address ): Promise { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE, variables: { checkoutId, shippingAddress: { firstName: address.firstName, lastName: address.lastName, streetAddress1: address.streetAddress1, streetAddress2: address.streetAddress2 || "", city: address.city, postalCode: address.postalCode, country: address.country, phone: address.phone, }, }, }); if (data?.checkoutShippingAddressUpdate?.errors?.length) { return { success: false, error: data.checkoutShippingAddressUpdate.errors[0].message }; } return { success: true }; } /** * Step 4: Update billing address */ export async function updateBillingAddress( checkoutId: string, address: Address ): Promise { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_BILLING_ADDRESS_UPDATE, variables: { checkoutId, billingAddress: { firstName: address.firstName, lastName: address.lastName, streetAddress1: address.streetAddress1, streetAddress2: address.streetAddress2 || "", city: address.city, postalCode: address.postalCode, country: address.country, phone: address.phone, }, }, }); if (data?.checkoutBillingAddressUpdate?.errors?.length) { return { success: false, error: data.checkoutBillingAddressUpdate.errors[0].message }; } return { success: true }; } /** * Step 5: Update shipping method */ export async function updateShippingMethod( checkoutId: string, shippingMethodId: string ): Promise { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_SHIPPING_METHOD_UPDATE, variables: { checkoutId, shippingMethodId }, }); if (data?.checkoutShippingMethodUpdate?.errors?.length) { return { success: false, error: data.checkoutShippingMethodUpdate.errors[0].message }; } return { success: true }; } /** * Step 6: Update metadata * Non-critical - failures are logged but don't stop checkout */ export async function updateCheckoutMetadata( checkoutId: string, metadata: Record ): Promise { const metadataArray = Object.entries(metadata).map(([key, value]) => ({ key, value })); const { data } = await saleorClient.mutate({ mutation: CHECKOUT_METADATA_UPDATE, variables: { checkoutId, metadata: metadataArray }, }); if (data?.updateMetadata?.errors?.length) { // Metadata is non-critical, log but don't fail console.warn("Failed to save checkout metadata:", data.updateMetadata.errors); return { success: true }; // Still return success } return { success: true }; } /** * Final Step: Complete checkout * Returns the created order */ export async function completeCheckout( checkoutId: string ): Promise> { const { data } = await saleorClient.mutate({ mutation: CHECKOUT_COMPLETE, variables: { checkoutId }, }); if (data?.checkoutComplete?.errors?.length) { return { success: false, error: data.checkoutComplete.errors[0].message }; } const order = data?.checkoutComplete?.order; if (!order) { return { success: false, error: "Order creation failed - no order returned" }; } return { success: true, data: { id: order.id, number: order.number, languageCode: "EN", // Default fallback since checkoutComplete doesn't return languageCode directly }, }; } // ============================================================================ // Checkout Pipeline (Composed Steps) // ============================================================================ /** * Execute full checkout pipeline with proper ordering * * This function enforces the correct sequence of operations: * 1. Email (identifies customer) * 2. Language (MUST be before complete for email language!) * 3. Addresses * 4. Shipping method * 5. Metadata * 6. Complete * * If any step fails, the pipeline stops and returns the error. * This prevents partial checkouts and ensures data consistency. */ export async function executeCheckoutPipeline( input: CheckoutInput ): Promise { const { checkoutId, email, shippingAddress, billingAddress, shippingMethodId, languageCode, metadata } = input; // Step 1: Email const emailResult = await updateCheckoutEmail(checkoutId, email); if (!emailResult.success) { return { success: false, error: emailResult.error }; } // Step 2: Language (CRITICAL for email language) const languageResult = await updateCheckoutLanguage(checkoutId, languageCode); if (!languageResult.success) { return { success: false, error: languageResult.error }; } // Step 3: Shipping Address const shippingResult = await updateShippingAddress(checkoutId, shippingAddress); if (!shippingResult.success) { return { success: false, error: shippingResult.error }; } // Step 4: Billing Address const billingResult = await updateBillingAddress(checkoutId, billingAddress); if (!billingResult.success) { return { success: false, error: billingResult.error }; } // Step 5: Shipping Method const methodResult = await updateShippingMethod(checkoutId, shippingMethodId); if (!methodResult.success) { return { success: false, error: methodResult.error }; } // Step 6: Metadata (non-critical, continues on failure) await updateCheckoutMetadata(checkoutId, metadata); // Step 7: Complete checkout const completeResult = await completeCheckout(checkoutId); if (!completeResult.success || !completeResult.data) { return { success: false, error: completeResult.error }; } return { success: true, order: completeResult.data, }; } // ============================================================================ // Validation Helpers // ============================================================================ export function validateAddress(address: Partial
): string | null { if (!address.firstName?.trim()) return "First name is required"; if (!address.lastName?.trim()) return "Last name is required"; if (!address.streetAddress1?.trim()) return "Street address is required"; if (!address.city?.trim()) return "City is required"; if (!address.postalCode?.trim()) return "Postal code is required"; if (!address.country?.trim()) return "Country is required"; if (!address.phone?.trim() || address.phone.length < 8) return "Valid phone number is required"; return null; } export function validateEmail(email: string): string | null { if (!email?.trim()) return "Email is required"; if (!email.includes("@")) return "Invalid email format"; return null; } export function validateCheckoutInput(input: CheckoutInput): string | null { const emailError = validateEmail(input.email); if (emailError) return emailError; const shippingError = validateAddress(input.shippingAddress); if (shippingError) return `Shipping ${shippingError}`; const billingError = validateAddress(input.billingAddress); if (billingError) return `Billing ${billingError}`; if (!input.shippingMethodId) return "Shipping method is required"; if (!input.checkoutId) return "Checkout ID is required"; return null; } // ============================================================================ // Checkout Service Class (High-level API) // ============================================================================ export class CheckoutService { constructor(private checkoutId: string) {} async updateEmail(email: string): Promise { return updateCheckoutEmail(this.checkoutId, email); } async updateLanguage(languageCode: string): Promise { return updateCheckoutLanguage(this.checkoutId, languageCode); } async updateShippingAddress(address: Address): Promise { return updateShippingAddress(this.checkoutId, address); } async updateBillingAddress(address: Address): Promise { return updateBillingAddress(this.checkoutId, address); } async updateShippingMethod(shippingMethodId: string): Promise { return updateShippingMethod(this.checkoutId, shippingMethodId); } async updateMetadata(metadata: Record): Promise { return updateCheckoutMetadata(this.checkoutId, metadata); } async complete(): Promise> { return completeCheckout(this.checkoutId); } /** * Execute full checkout with validation */ async execute(input: Omit): Promise { const fullInput: CheckoutInput = { ...input, checkoutId: this.checkoutId, }; const validationError = validateCheckoutInput(fullInput); if (validationError) { return { success: false, error: validationError }; } return executeCheckoutPipeline(fullInput); } } // Factory function for creating checkout service export function createCheckoutService(checkoutId: string): CheckoutService { return new CheckoutService(checkoutId); }