e15e6470d2
- Add SALEOR_API_TOKEN environment variable support - Update Apollo client to include auth header - Enable COD transaction creation after checkout
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
}
|
|
|
|
export interface CheckoutResult {
|
|
success: boolean;
|
|
order?: {
|
|
id: string;
|
|
number: string;
|
|
languageCode: string;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
export interface CheckoutStepResult<T = unknown> {
|
|
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<CheckoutStepResult> {
|
|
const { data } = await saleorClient.mutate<CheckoutEmailUpdateResponse>({
|
|
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<CheckoutStepResult> {
|
|
const { data } = await saleorClient.mutate<CheckoutLanguageCodeUpdateResponse>({
|
|
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<CheckoutStepResult> {
|
|
const { data } = await saleorClient.mutate<CheckoutShippingAddressUpdateResponse>({
|
|
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<CheckoutStepResult> {
|
|
const { data } = await saleorClient.mutate<CheckoutBillingAddressUpdateResponse>({
|
|
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<CheckoutStepResult> {
|
|
const { data } = await saleorClient.mutate<CheckoutShippingMethodUpdateResponse>({
|
|
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<string, string>
|
|
): Promise<CheckoutStepResult> {
|
|
const metadataArray = Object.entries(metadata).map(([key, value]) => ({ key, value }));
|
|
|
|
const { data } = await saleorClient.mutate<CheckoutMetadataUpdateResponse>({
|
|
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<CheckoutStepResult<{ id: string; number: string; languageCode: string }>> {
|
|
const { data } = await saleorClient.mutate<CheckoutCompleteResponse>({
|
|
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<CheckoutResult> {
|
|
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<Address>): 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<CheckoutStepResult> {
|
|
return updateCheckoutEmail(this.checkoutId, email);
|
|
}
|
|
|
|
async updateLanguage(languageCode: string): Promise<CheckoutStepResult> {
|
|
return updateCheckoutLanguage(this.checkoutId, languageCode);
|
|
}
|
|
|
|
async updateShippingAddress(address: Address): Promise<CheckoutStepResult> {
|
|
return updateShippingAddress(this.checkoutId, address);
|
|
}
|
|
|
|
async updateBillingAddress(address: Address): Promise<CheckoutStepResult> {
|
|
return updateBillingAddress(this.checkoutId, address);
|
|
}
|
|
|
|
async updateShippingMethod(shippingMethodId: string): Promise<CheckoutStepResult> {
|
|
return updateShippingMethod(this.checkoutId, shippingMethodId);
|
|
}
|
|
|
|
async updateMetadata(metadata: Record<string, string>): Promise<CheckoutStepResult> {
|
|
return updateCheckoutMetadata(this.checkoutId, metadata);
|
|
}
|
|
|
|
async complete(): Promise<CheckoutStepResult<{ id: string; number: string; languageCode: string }>> {
|
|
return completeCheckout(this.checkoutId);
|
|
}
|
|
|
|
/**
|
|
* Execute full checkout with validation
|
|
*/
|
|
async execute(input: Omit<CheckoutInput, "checkoutId">): Promise<CheckoutResult> {
|
|
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);
|
|
}
|