Let AI go to work

This commit is contained in:
2026-05-23 10:33:29 +02:00
parent 7890669eda
commit adc758dfa5
20 changed files with 2200 additions and 257 deletions

View File

@@ -0,0 +1,252 @@
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<string, OrderInvitationTracker>;
};
/**
* 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<string, OrderInvitationTracker>;
}): Promise<OrderPaymentService> {
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<CreateOrderResult> {
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),
};
}