/**
* UCP Checkout Session Manager
* Implements the 3-state machine: incomplete → requires_escalation → ready_for_complete
*/
import { randomUUID } from 'node:crypto';
import type {
CheckoutSession,
CheckoutStatus,
CheckoutTotals,
LineItem,
BuyerInfo,
Address,
PaymentInstrument,
CheckoutMessage,
} from '../types.js';
/** Fields that can be updated on a checkout session. */
export interface CheckoutSessionUpdate {
line_items?: LineItem[];
buyer?: BuyerInfo;
shipping_address?: Address;
billing_address?: Address;
payment_instruments?: PaymentInstrument[];
currency?: string;
continue_url?: string;
}
/**
* Determines the checkout status based on the session's current data.
*
* - ready_for_complete: all required fields present (line items, buyer email,
* shipping address, at least one payment instrument)
* - requires_escalation: has messages with severity "requires_buyer_input"
* - incomplete: otherwise
*/
function determineStatus(session: CheckoutSession): CheckoutStatus {
const hasLineItems = session.line_items.length > 0;
const hasBuyerEmail = !!session.buyer?.email;
const hasShippingAddress = !!session.shipping_address;
const hasPayment = session.payment_instruments.length > 0;
// Check for escalation signals
const hasEscalation = session.messages.some(
(m) => m.severity === 'requires_buyer_input',
);
if (hasEscalation) {
return 'requires_escalation';
}
// All required fields present
if (hasLineItems && hasBuyerEmail && hasShippingAddress && hasPayment) {
return 'ready_for_complete';
}
return 'incomplete';
}
/**
* Build missing-field messages for the session.
*/
function buildMessages(session: CheckoutSession): CheckoutMessage[] {
const messages: CheckoutMessage[] = [];
if (session.line_items.length === 0) {
messages.push({
type: 'warning',
code: 'missing_line_items',
message: 'Cart is empty. Add at least one line item.',
});
}
if (!session.buyer?.email) {
messages.push({
type: 'warning',
code: 'missing_buyer_email',
message: 'Buyer email is required.',
});
}
if (!session.shipping_address) {
messages.push({
type: 'warning',
code: 'missing_shipping_address',
message: 'Shipping address is required.',
});
}
if (session.payment_instruments.length === 0) {
messages.push({
type: 'warning',
code: 'missing_payment',
message: 'At least one payment instrument is required.',
});
}
return messages;
}
/**
* Recalculate totals from line items.
*/
function recalculateTotals(lineItems: LineItem[], currency: string): CheckoutTotals {
const productItems = lineItems.filter((li) => li.type === 'product' || li.type === 'service');
const subtotal = productItems.reduce((sum, li) => sum + li.total_amount, 0);
const taxRate = 0.08; // placeholder 8% tax
const tax = Math.round(subtotal * taxRate);
const shipping = 0; // determined by shipping option selection
const discountItems = lineItems.filter((li) => li.type === 'discount');
const discount = Math.abs(discountItems.reduce((sum, li) => sum + li.total_amount, 0));
const feeRate = 0.005; // 0.5%
const fee = Math.round(subtotal * feeRate);
const total = subtotal + tax + shipping - discount + fee;
return {
subtotal,
tax,
shipping,
discount,
fee,
total,
currency,
};
}
/**
* Manages checkout sessions in an in-memory store.
* Will be replaced with DynamoDB persistence later.
*/
export class CheckoutSessionManager {
private readonly sessions: Map<string, CheckoutSession> = new Map();
/**
* Create a new checkout session.
*/
create(currency = 'USD'): CheckoutSession {
const now = new Date().toISOString();
const session: CheckoutSession = {
id: randomUUID(),
status: 'incomplete',
currency,
line_items: [],
totals: {
subtotal: 0,
tax: 0,
shipping: 0,
discount: 0,
fee: 0,
total: 0,
currency,
},
payment_instruments: [],
messages: [
{
type: 'warning',
code: 'missing_line_items',
message: 'Cart is empty. Add at least one line item.',
},
{
type: 'warning',
code: 'missing_buyer_email',
message: 'Buyer email is required.',
},
{
type: 'warning',
code: 'missing_shipping_address',
message: 'Shipping address is required.',
},
{
type: 'warning',
code: 'missing_payment',
message: 'At least one payment instrument is required.',
},
],
created_at: now,
updated_at: now,
};
this.sessions.set(session.id, session);
return session;
}
/**
* Update an existing checkout session. Recalculates totals and auto-detects status.
*/
update(id: string, updates: CheckoutSessionUpdate): CheckoutSession {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Checkout session not found: ${id}`);
}
// Apply updates
if (updates.line_items !== undefined) {
session.line_items = updates.line_items;
}
if (updates.buyer !== undefined) {
session.buyer = updates.buyer;
}
if (updates.shipping_address !== undefined) {
session.shipping_address = updates.shipping_address;
}
if (updates.billing_address !== undefined) {
session.billing_address = updates.billing_address;
}
if (updates.payment_instruments !== undefined) {
session.payment_instruments = updates.payment_instruments;
}
if (updates.currency !== undefined) {
session.currency = updates.currency;
}
if (updates.continue_url !== undefined) {
session.continue_url = updates.continue_url;
}
// Recalculate totals
session.totals = recalculateTotals(session.line_items, session.currency);
// Rebuild messages and auto-detect status
session.messages = buildMessages(session);
session.status = determineStatus(session);
session.updated_at = new Date().toISOString();
this.sessions.set(id, session);
return session;
}
/**
* Get the current status of a checkout session.
*/
getStatus(id: string): CheckoutStatus {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Checkout session not found: ${id}`);
}
return session.status;
}
/**
* Force a status transition. Validates the transition is legal.
* Legal transitions:
* incomplete → requires_escalation
* incomplete → ready_for_complete
* requires_escalation → incomplete
* requires_escalation → ready_for_complete
*
* ready_for_complete is a terminal pre-completion state — once reached,
* the session should be completed (submitted as an order), not transitioned back.
*/
transition(id: string, target: CheckoutStatus): CheckoutSession {
const session = this.sessions.get(id);
if (!session) {
throw new Error(`Checkout session not found: ${id}`);
}
const current = session.status;
const allowed = this.getAllowedTransitions(current);
if (!allowed.includes(target)) {
throw new Error(
`Invalid transition: ${current} → ${target}. Allowed: ${allowed.join(', ')}`,
);
}
session.status = target;
session.updated_at = new Date().toISOString();
this.sessions.set(id, session);
return session;
}
/**
* Retrieve a session by ID. Returns undefined if not found.
*/
get(id: string): CheckoutSession | undefined {
return this.sessions.get(id);
}
/**
* Returns the list of allowed target states from the given state.
*/
private getAllowedTransitions(current: CheckoutStatus): CheckoutStatus[] {
switch (current) {
case 'incomplete':
return ['requires_escalation', 'ready_for_complete'];
case 'requires_escalation':
return ['incomplete', 'ready_for_complete'];
case 'ready_for_complete':
return [];
default:
return [];
}
}
}