import type { Debugger } from "debug"; import type { Engine } from "@xo-cash/engine"; import { serializeInvitation } from "@xo-cash/engine"; import { vendingMachineTemplate } from "../templates/vending-machine.js"; import type { Config } from "./config.js"; import type { Database } from "./database/database.js"; import type { ItemsTable } from "./database/tables.js"; import { InvitationSyncClient } from "./invitation-sync-client.js"; import { OrderInvitationTracker } from "./order-invitation-tracker.js"; export type CreateOrderLineItem = { id: string; name: string; quantity: number; price: number; }; export type CreateOrderResult = { order: { id: string; status: string; total_price: number; total_quantity: number; items: Array<{ id: string; quantity: number }>; invitation_identifier: string | null; created_at: number; updated_at: number; }; invitation: string; syncServerUrl: string; receipt: { summary: string; lineItems: CreateOrderLineItem[]; }; }; export type OrderPaymentServiceDeps = { engine: Engine; database: Database; config: Config; syncClient: InvitationSyncClient; debug: Debugger; templateIdentifier: string; trackers: Map; }; /** * Handles XO Engine invitation creation and sync for vending orders. */ export class OrderPaymentService { constructor(private readonly deps: OrderPaymentServiceDeps) {} static async create(deps: { engine: Engine; database: Database; config: Config; debug: Debugger; trackers: Map; }): Promise { const { templateIdentifier } = await deps.engine.importTemplate( vendingMachineTemplate, ); await deps.engine.setDefaultLockingParameters( templateIdentifier, "purchaseOutput", "merchant", ); const syncClient = new InvitationSyncClient(deps.config.syncServer.url); return new OrderPaymentService({ ...deps, syncClient, templateIdentifier, }); } async createOrder( itemsInput: Array<{ id: string; quantity: number }>, ): Promise { const dbItems = await this.deps.database.db .selectFrom("items") .selectAll() .where( "id", "in", itemsInput.map((item) => item.id), ) .execute(); if (dbItems.length !== itemsInput.length) { throw new OrderPaymentError("Items not found", 404); } const lineItems: CreateOrderLineItem[] = []; let totalPrice = 0; let totalQuantity = 0; for (const input of itemsInput) { const item = dbItems.find((row) => row.id === input.id); if (!item) { throw new OrderPaymentError("Items not found", 404); } if (item.quantity < input.quantity) { throw new OrderPaymentError(`Insufficient stock for ${item.name}`, 400); } lineItems.push({ id: item.id, name: item.name, quantity: input.quantity, price: item.price, }); totalPrice += item.price * input.quantity; totalQuantity += input.quantity; } const receiptSummary = lineItems .map((item) => `${item.quantity}× ${item.name}`) .join(", "); const lineItemsJson = JSON.stringify( lineItems.map((item) => ({ id: item.id, name: item.name, quantity: item.quantity, price: item.price, })), ); const orderRow = await this.deps.database.db .insertInto("orders") .values({ status: "pending", total_price: totalPrice, total_quantity: totalQuantity, items: JSON.stringify( lineItems.map((item) => ({ id: item.id, quantity: item.quantity })), ), invitation_identifier: null, }) .returningAll() .executeTakeFirstOrThrow(); let invitation = await this.deps.engine.createInvitation({ templateIdentifier: this.deps.templateIdentifier, actionIdentifier: "purchaseItems", }); invitation = await this.deps.engine.appendInvitation( invitation.invitationIdentifier, { variables: [ { variableIdentifier: "totalSatoshis", roleIdentifier: "merchant", value: totalPrice, }, { variableIdentifier: "orderId", roleIdentifier: "merchant", value: orderRow.id, }, { variableIdentifier: "merchantName", roleIdentifier: "merchant", value: this.deps.config.merchant.name, }, { variableIdentifier: "receiptSummary", roleIdentifier: "merchant", value: receiptSummary, }, { variableIdentifier: "lineItemsJson", roleIdentifier: "merchant", value: lineItemsJson, }, ], }, ); invitation = await this.deps.engine.appendInvitation( invitation.invitationIdentifier, { outputs: [{ outputIdentifier: "purchaseOutput" }], }, ); const updatedOrder = await this.deps.database.db .updateTable("orders") .set({ invitation_identifier: invitation.invitationIdentifier }) .where("id", "=", orderRow.id) .returningAll() .executeTakeFirstOrThrow(); const tracker = await OrderInvitationTracker.start({ syncClient: this.deps.syncClient, engine: this.deps.engine, database: this.deps.database, orderId: updatedOrder.id, invitation, debug: this.deps.debug, }); this.deps.trackers.set(updatedOrder.id, tracker); return { order: formatOrder(updatedOrder), invitation: serializeInvitation(invitation), syncServerUrl: this.deps.syncClient.url, receipt: { summary: receiptSummary, lineItems, }, }; } } export class OrderPaymentError extends Error { constructor( message: string, public readonly statusCode: number, ) { super(message); this.name = "OrderPaymentError"; } } function formatOrder(order: { id: string; status: string; total_price: number; total_quantity: number; items: string; invitation_identifier: string | null; created_at: unknown; updated_at: unknown; }) { return { id: order.id, status: order.status, total_price: order.total_price, total_quantity: order.total_quantity, items: JSON.parse(order.items) as Array<{ id: string; quantity: number }>, invitation_identifier: order.invitation_identifier, created_at: Number(order.created_at), updated_at: Number(order.updated_at), }; }