/**
* DynamoDB-backed Checkout Session Store
* Replaces the in-memory CheckoutSessionManager with persistent DynamoDB storage.
* Implements the same public interface with async methods.
*/
import { randomUUID } from 'node:crypto';
import { PutCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { getDocClient } from './client.js';
import type { CheckoutSessionUpdate } from '../ucp/checkout-session.js';
import type {
CheckoutSession,
CheckoutStatus,
CheckoutTotals,
LineItem,
CheckoutMessage,
} from '../types.js';
// ─── TTL constant ───
const TTL_SECONDS = 24 * 60 * 60; // 24 hours
// ─── Pure helper functions (copied from checkout-session.ts) ───
/**
* 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,
};
}
// ─── DynamoDB Session Store ───
/**
* DynamoDB-backed checkout session store.
* Provides the same public interface as CheckoutSessionManager but persists
* sessions in DynamoDB with a 24-hour TTL.
*/
export class DynamoSessionStore {
private readonly tableName: string;
constructor(tableName: string) {
this.tableName = tableName;
}
/**
* Create a new checkout session and persist it to DynamoDB.
*/
async create(currency = 'USD'): Promise<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,
};
const ttl = Math.floor(Date.now() / 1000) + TTL_SECONDS;
await getDocClient().send(
new PutCommand({
TableName: this.tableName,
Item: {
sessionId: session.id,
...session,
ttl,
},
}),
);
return session;
}
/**
* Update an existing checkout session. Recalculates totals and auto-detects status.
*/
async update(id: string, updates: CheckoutSessionUpdate): Promise<CheckoutSession> {
const session = await this.getOrThrow(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();
await getDocClient().send(
new PutCommand({
TableName: this.tableName,
Item: {
sessionId: session.id,
...session,
ttl: Math.floor(Date.now() / 1000) + TTL_SECONDS,
},
}),
);
return session;
}
/**
* Get the current status of a checkout session.
*/
async getStatus(id: string): Promise<CheckoutStatus> {
const session = await this.getOrThrow(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.
*/
async transition(id: string, target: CheckoutStatus): Promise<CheckoutSession> {
const session = await this.getOrThrow(id);
const current = session.status;
const allowed = this.getAllowedTransitions(current);
if (!allowed.includes(target)) {
throw new Error(
`Invalid transition: ${current} → ${target}. Allowed: ${allowed.join(', ')}`,
);
}
const now = new Date().toISOString();
await getDocClient().send(
new UpdateCommand({
TableName: this.tableName,
Key: { sessionId: id },
UpdateExpression: 'SET #status = :status, updated_at = :now',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: {
':status': target,
':now': now,
},
}),
);
session.status = target;
session.updated_at = now;
return session;
}
/**
* Retrieve a session by ID. Returns undefined if not found.
*/
async get(id: string): Promise<CheckoutSession | undefined> {
const result = await getDocClient().send(
new GetCommand({
TableName: this.tableName,
Key: { sessionId: id },
}),
);
if (!result.Item) {
return undefined;
}
return this.itemToSession(result.Item);
}
// ─── Private helpers ───
/**
* Get a session or throw if not found.
*/
private async getOrThrow(id: string): Promise<CheckoutSession> {
const session = await this.get(id);
if (!session) {
throw new Error(`Checkout session not found: ${id}`);
}
return session;
}
/**
* 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 [];
}
}
/**
* Convert a raw DynamoDB item back to a CheckoutSession,
* stripping DynamoDB-specific fields (sessionId, ttl).
*/
private itemToSession(item: Record<string, unknown>): CheckoutSession {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { sessionId, ttl, ...rest } = item;
return rest as unknown as CheckoutSession;
}
}