Files
xo-api.vending-machine/src/services/order-payment-service.ts
2026-05-23 10:40:21 +02:00

253 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
};
}