rspace-online/modules/rswag/fulfillment.ts

306 lines
7.2 KiB
TypeScript

/**
* Order fulfillment service — routes rCart orders to POD providers.
*
* Flow:
* 1. rCart order → paid status
* 2. This service picks up the order and routes to correct POD provider
* 3. POD webhooks update tracking → pushed back to rCart order doc
*/
import { PrintfulClient } from "./pod/printful";
import { ProdigiClient } from "./pod/prodigi";
import type { PodOrder, PodRecipient, PodTracking } from "./pod/types";
// ── Types ──
export interface FulfillmentRequest {
/** rCart order ID */
orderId: string;
/** Items to fulfill */
items: FulfillmentItem[];
/** Shipping address */
recipient: PodRecipient;
/** Optional metadata */
metadata?: Record<string, string>;
}
export interface FulfillmentItem {
/** Design image public URL */
imageUrl: string;
/** POD provider to use */
provider: "printful" | "prodigi";
/** Provider-specific SKU */
sku: string;
/** Quantity */
quantity: number;
/** Size variant (for apparel) */
size?: string;
/** Color variant (for apparel) */
color?: string;
/** Placement on product */
placement?: string;
}
export interface FulfillmentResult {
orderId: string;
provider: "printful" | "prodigi";
providerOrderId: string;
status: string;
items: FulfillmentItem[];
submittedAt: string;
}
// ── In-memory fulfillment tracking ──
// Maps rCart orderId → fulfillment results
const fulfillmentLog = new Map<string, FulfillmentResult[]>();
// ── Service ──
const printful = new PrintfulClient();
const prodigi = new ProdigiClient();
/**
* Submit an order to the appropriate POD provider(s).
*
* If items span multiple providers, creates separate orders per provider.
*/
export async function submitFulfillment(
request: FulfillmentRequest,
): Promise<FulfillmentResult[]> {
const results: FulfillmentResult[] = [];
// Group items by provider
const printfulItems = request.items.filter((i) => i.provider === "printful");
const prodigiItems = request.items.filter((i) => i.provider === "prodigi");
// Submit Printful items
if (printfulItems.length > 0) {
const result = await submitToPrintful(
request.orderId,
printfulItems,
request.recipient,
);
results.push(result);
}
// Submit Prodigi items
if (prodigiItems.length > 0) {
const result = await submitToProdigi(
request.orderId,
prodigiItems,
request.recipient,
request.metadata,
);
results.push(result);
}
// Store in tracking log
fulfillmentLog.set(request.orderId, results);
return results;
}
async function submitToPrintful(
orderId: string,
items: FulfillmentItem[],
recipient: PodRecipient,
): Promise<FulfillmentResult> {
if (!printful.enabled) {
throw new Error("Printful API not configured");
}
// Resolve catalog variant IDs for each item
const orderItems = await Promise.all(
items.map(async (item) => {
const productId = parseInt(item.sku, 10);
let catalogVariantId: number | null = null;
if (item.size) {
catalogVariantId = await printful.resolveVariantId(
productId,
item.size,
item.color || "Black",
);
}
if (!catalogVariantId) {
// Fallback: get first available variant
const variants = await printful.getCatalogVariants(productId);
catalogVariantId = variants[0]?.id || 0;
}
return {
catalogVariantId,
quantity: item.quantity,
imageUrl: item.imageUrl,
placement: item.placement || "front",
};
}),
);
const order = await printful.createOrder(orderItems, recipient);
return {
orderId,
provider: "printful",
providerOrderId: order.id,
status: order.status,
items,
submittedAt: new Date().toISOString(),
};
}
async function submitToProdigi(
orderId: string,
items: FulfillmentItem[],
recipient: PodRecipient,
metadata?: Record<string, string>,
): Promise<FulfillmentResult> {
if (!prodigi.enabled) {
throw new Error("Prodigi API not configured");
}
const prodigiItems = items.map((item) => ({
sku: item.sku,
copies: item.quantity,
sizing: "fillPrintArea" as const,
assets: [{ printArea: "default", url: item.imageUrl }],
}));
const order = await prodigi.createOrder(
prodigiItems,
recipient,
"Budget",
metadata ? { ...metadata, rCartOrderId: orderId } : { rCartOrderId: orderId },
);
return {
orderId,
provider: "prodigi",
providerOrderId: order.id,
status: order.status,
items,
submittedAt: new Date().toISOString(),
};
}
// ── Webhook handlers ──
export interface WebhookEvent {
provider: "printful" | "prodigi";
type: string;
orderId: string;
tracking?: PodTracking;
status?: string;
raw: unknown;
}
/**
* Process a Printful webhook event.
* Returns parsed event data for the caller to update rCart.
*/
export function parsePrintfulWebhook(body: any): WebhookEvent | null {
if (!body?.type || !body?.data) return null;
const event: WebhookEvent = {
provider: "printful",
type: body.type,
orderId: String(body.data?.order?.id || body.data?.id || ""),
raw: body,
};
// Extract tracking info from shipment events
if (body.type === "package_shipped" || body.type === "order_updated") {
const shipment = body.data?.shipment || body.data;
if (shipment?.tracking_number) {
event.tracking = {
carrier: shipment.carrier || "",
trackingNumber: shipment.tracking_number,
trackingUrl: shipment.tracking_url || "",
};
}
event.status = body.data?.order?.status || body.data?.status;
}
return event;
}
/**
* Process a Prodigi webhook event.
*/
export function parseProdigiWebhook(body: any): WebhookEvent | null {
if (!body?.topic) return null;
const order = body.order || body;
const event: WebhookEvent = {
provider: "prodigi",
type: body.topic,
orderId: String(order?.id || ""),
raw: body,
};
// Extract tracking from shipment events
if (body.topic === "order.shipped") {
const shipment = order?.shipments?.[0];
if (shipment) {
event.tracking = {
carrier: shipment.carrier || "",
trackingNumber: shipment.trackingNumber || "",
trackingUrl: shipment.trackingUrl || "",
};
}
}
event.status = order?.status?.stage;
return event;
}
// ── Tracking lookup ──
/**
* Get fulfillment status for an rCart order.
*/
export function getFulfillmentStatus(orderId: string): FulfillmentResult[] | null {
return fulfillmentLog.get(orderId) || null;
}
/**
* Get tracking info from the POD provider.
*/
export async function getTrackingInfo(
provider: "printful" | "prodigi",
providerOrderId: string,
): Promise<PodTracking | null> {
try {
if (provider === "printful") {
const order = await printful.getOrder(providerOrderId);
const raw = order.raw as any;
const shipment = raw?.shipments?.[0];
if (shipment?.tracking_number) {
return {
carrier: shipment.carrier || "",
trackingNumber: shipment.tracking_number,
trackingUrl: shipment.tracking_url || "",
};
}
} else {
const order = await prodigi.getOrder(providerOrderId);
const raw = order.raw as any;
const shipment = raw?.order?.shipments?.[0];
if (shipment?.trackingNumber) {
return {
carrier: shipment.carrier || "",
trackingNumber: shipment.trackingNumber,
trackingUrl: shipment.trackingUrl || "",
};
}
}
} catch (err) {
console.warn(`[rSwag] Failed to get tracking for ${provider} order ${providerOrderId}:`, err);
}
return null;
}