253 lines
6.6 KiB
TypeScript
253 lines
6.6 KiB
TypeScript
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),
|
||
};
|
||
}
|