306 lines
7.2 KiB
TypeScript
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;
|
|
}
|