/** * Utilities to update order shipping status (Paid, Booked, Fulfilled) with retries and Sentry monitoring. */ import apiFetch from '@wordpress/api-fetch'; import { addBreadcrumb, captureException, captureMessage, setContext, withSpan, } from '../../../shared/sentry'; import { CheckoutOrderResult, CheckoutValidateRequest, GenericPaymentResponse, Order } from '../../../types'; import * as Sentry from '@sentry/browser'; const MAX_RETRIES = 5; const SHIPPING_STATUS_PATH = '/parcel2go-shipping/v1/orders'; export interface PaymentDetails { orderId: string; completeHash: string; } export interface ShippingStatusResult { orderId: string; success: boolean; error?: string; } export interface ShippingStatusBatchResult { success: boolean; results: ShippingStatusResult[]; } /** Courier/service details to store when marking order as Paid. */ export interface CourierDetails { courier: string; serviceName: string; serviceSlug: string; /** Same as quote.service.courierSlug — used with getCourierLogo(). */ courierSlug?: string; } interface ShippingStatusBody { status: 'Paid' | 'Booked' | 'Fulfilled'; paymentDetails?: PaymentDetails; labelHash?: string; orderLineIds?: string[]; courier?: string; serviceName?: string; serviceSlug?: string; courierSlug?: string; trackingNumber?: string; parcelNumbers?: string[]; } /** * Attempt a single POST to update shipping status for one order, with retries. */ async function updateShippingStatusWithRetry( orderId: string, body: ShippingStatusBody, operation: string ): Promise { let lastError: Error | null = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { addBreadcrumb( `Shipping status update attempt`, { orderId, status: body.status, attempt, maxRetries: MAX_RETRIES, }, 'http' ); try { lastError = null; const result = (await apiFetch({ path: `${SHIPPING_STATUS_PATH}/${orderId}/shipping-status`, method: 'POST', data: body, })) as { success?: boolean }; if (result?.success) { addBreadcrumb( `Shipping status updated`, { orderId, status: body.status, attempt, }, 'http' ); return { orderId, success: true }; } // API responded but success: false – don't retry (e.g. validation / already completed) const errorMessage = 'API returned success: false'; addBreadcrumb( `Shipping status update rejected`, { orderId, status: body.status, attempt, }, 'http' ); return { orderId, success: false, error: errorMessage }; } catch (err) { // Network or transient failure – retry lastError = err instanceof Error ? err : new Error(String(err)); addBreadcrumb( `Shipping status update failed`, { orderId, status: body.status, attempt, error: lastError.message, }, 'http' ); if (attempt < MAX_RETRIES) { await new Promise((r) => setTimeout(r, 500 * attempt)); } } } // All retries exhausted (network/transient failures only) const errorMessage = lastError?.message ?? 'Unknown error'; captureException(lastError ?? new Error(errorMessage), { tags: { operation, status: body.status }, extra: { orderId, attempts: MAX_RETRIES }, }); captureMessage( `Shipping status update failed after ${MAX_RETRIES} attempts`, 'warning', { orderId, status: body.status, operation, } ); return { orderId, success: false, error: errorMessage, }; } /** * Mark one or more orders as Paid (after payment success). * Stores P2G orderId, completeHash and optional courier details for polling and display. */ export async function markOrderAsPaid( orderIds: string[], paymentDetails: PaymentDetails, courierDetails?: CourierDetails | null ): Promise { setContext('shipping_status_paid', { orderIds, p2gOrderId: paymentDetails.orderId, }); const body: ShippingStatusBody = { status: 'Paid', paymentDetails, ...(courierDetails && { courier: courierDetails.courier ?? '', serviceName: courierDetails.serviceName ?? '', serviceSlug: courierDetails.serviceSlug ?? '', ...(courierDetails.courierSlug && { courierSlug: courierDetails.courierSlug, }), }), }; const results: ShippingStatusResult[] = []; for (const orderId of orderIds) { const result = await withSpan( `Mark order ${orderId} as Paid`, 'http.client', () => updateShippingStatusWithRetry(orderId, body, 'markOrderAsPaid'), { orderId, status: 'Paid' } ); results.push(result); } const allSuccess = results.every((r) => r.success); return { success: allSuccess, results }; } /** * Mark one or more orders as Booked (when P2G polling returns book === true). * Pass labelHash from the P2G order response so it can be stored for the label API. */ export async function markOrderAsBooked( orderIds: string[], labelHash?: string, orderLineIds?: string[] ): Promise { setContext('shipping_status_booked', { orderIds }); const body: ShippingStatusBody = { status: 'Booked', ...(labelHash && { labelHash }), ...(orderLineIds && orderLineIds.length > 0 && { orderLineIds }), }; const results: ShippingStatusResult[] = []; for (const orderId of orderIds) { const result = await withSpan( `Mark order ${orderId} as Booked`, 'http.client', () => updateShippingStatusWithRetry( orderId, body, 'markOrderAsBooked' ), { orderId, status: 'Booked' } ); results.push(result); } const allSuccess = results.every((r) => r.success); return { success: allSuccess, results }; } /** * Mark one or more orders as Fulfilled (when tracking is available). * Saves tracking number and marks WooCommerce order as completed. */ export async function markOrderAsComplete( orderIds: string[], parcelNumbers: string[], orderLineIds?: string[] ): Promise { setContext('shipping_status_fulfilled', { orderIds, hasParcelNumbers: Boolean(parcelNumbers.length > 0), }); const body: ShippingStatusBody = { status: 'Fulfilled', ...(orderLineIds && orderLineIds.length > 0 && { orderLineIds }), ...(parcelNumbers.length > 0 && { parcelNumbers }), }; const results: ShippingStatusResult[] = []; for (const orderId of orderIds) { const result = await withSpan( `Mark order ${orderId} as Fulfilled`, 'http.client', () => updateShippingStatusWithRetry( orderId, body, 'markOrderAsComplete' ), { orderId, status: 'Fulfilled' } ); results.push(result); } const allSuccess = results.every((r) => r.success); return { success: allSuccess, results }; } export async function markBulkOrdersAsPaid( orders: Order[], paymentResponse: GenericPaymentResponse ) { const orderIds = orders.map((line) => String(line.id)).filter(Boolean) as string[]; const paymentDetails: PaymentDetails = { orderId: paymentResponse.orderId, completeHash: paymentResponse.completeHash, }; return withSpan('Mark bulk orders as paid', 'function', async () => { const span = Sentry.getActiveSpan(); span.setAttribute('orders.count', orderIds.length); addBreadcrumb( 'Starting bulk mark as paid', { orderIds, orderCount: orderIds.length }, 'payment' ); const failedOrders: string[] = []; for (const orderline of orders) { const orderId = (orderline.id).toString(); if (!orderId) { addBreadcrumb('Skipping orderline with no ref', { orderline }, 'payment'); continue; } const courierDetails = orderline?.cheapestQuote?.service != null ? { courier: orderline.cheapestQuote.service.courier ?? '', serviceName: orderline.cheapestQuote.service.name ?? '', serviceSlug: orderline.cheapestQuote.service.slug ?? '', courierSlug: orderline.cheapestQuote.service.courierSlug ?? '', } : null; let success = false; let lastError: Error | null = null; await withSpan(`Mark order as paid: ${orderId}`, 'function', async () => { const orderSpan = Sentry.getActiveSpan(); orderSpan.setAttribute('order.id', orderId); for (let attempt = 1; attempt <= 5; attempt++) { try { await markOrderAsPaid([orderId], paymentDetails, courierDetails); success = true; orderSpan.setAttribute('order.attempts', attempt); addBreadcrumb( `Order ${orderId} marked as paid`, { orderId, attempt }, 'payment' ); break; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); orderSpan.setAttribute('order.attempts', attempt); if (attempt === 5) { captureException(lastError, { tags: { action: 'mark_order_as_paid', orderId }, extra: { orderId, attempt, paymentDetails }, }); addBreadcrumb( `Order ${orderId} failed after ${attempt} attempts`, { orderId, attempt, error: lastError.message }, 'payment' ); } else { const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); addBreadcrumb( `Order ${orderId} attempt ${attempt} failed, retrying in ${delay}ms`, { orderId, attempt, delay, error: lastError?.message }, 'payment' ); await new Promise((resolve) => setTimeout(resolve, delay)); } } } }); if (!success) { failedOrders.push(orderId); } } span.setAttribute('orders.failed', failedOrders.length); span.setAttribute('orders.succeeded', orderIds.length - failedOrders.length); if (failedOrders.length > 0) { const errorMessage = `Failed to mark ${failedOrders.length} order(s) as paid after retries: ${failedOrders.join(', ')}`; const error = new Error(errorMessage); captureException(error, { tags: { action: 'mark_bulk_orders_as_paid' }, extra: { failedOrders, totalOrders: orderIds.length, paymentDetails }, }); throw error; } addBreadcrumb( 'Bulk mark as paid complete', { totalOrders: orderIds.length, failedOrders: failedOrders.length }, 'payment' ); }); }