Let AI go to work
This commit is contained in:
252
src/services/order-payment-service.ts
Normal file
252
src/services/order-payment-service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user