Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 161 additions & 175 deletions README.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions docs/sllr-mcp-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,31 @@ ordering still works unless the server sets `SLLR_REQUIRE_BUYER_AUTH=true`.
- `solana_pay`: prepare Solana Pay URL with a unique reference.
- `helio` / `moonpay`: open checkout handoff and verify webhook.

6. Check order status.
6. Check order status from the buyer-scoped surface.

```text
GET /orders/{orderId}
GET /buyer/orders
Authorization: Bearer ***
```

Each order includes a derived `tracking` snapshot with `queuePosition`,
`ordersAhead`, live estimated wait, promised ready time, and last update. Use
this buyer-scoped endpoint for public clients; `GET /orders/{orderId}` is also
buyer-gated when `SLLR_REQUIRE_BUYER_AUTH=true`.

The Raposa demo page short-polls this buyer-owned feed every two seconds,
always updates its in-page status, and can emit an optional browser
notification after the user grants permission. It stops polling after a
rejected order or canonical receipt. Merchant boards poll the protected
merchant order feed with merchant authorization; do not expose that feed to
public buyer agents.

MCP `list_orders` is merchant-only and requires `verificationToken` containing
the operator verifier secret or a merchant-scoped token. MCP
`check_order_status` is available to the owning buyer session; other callers
must provide the same merchant authorization. Do not use either tool as an
anonymous public status feed.

7. Record merchant fulfillment, then issue receipt memory.

```text
Expand Down
11 changes: 6 additions & 5 deletions src/core/merchantApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders } from "./orders.js";
import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders, withLiveOrderTrackingBatch } from "./orders.js";
import { quoteOrder } from "./quote.js";
import { allMerchantProfiles, merchantForId } from "../merchants/profiles.js";
import { recurringSuggestion } from "./recurring.js";
Expand Down Expand Up @@ -415,12 +415,13 @@ export async function createMerchantOrder(merchantId: string, payload: Record<st

export async function listMerchantOrders(merchantId: string, status?: string | null) {
requireMerchant(merchantId);
const orders = await listOrders({
merchantId,
status: status as never || undefined,
});
return {
product: "SLL-R merchant orders",
orders: await listOrders({
merchantId,
status: status as never || undefined,
}),
orders: await withLiveOrderTrackingBatch(orders),
};
}

Expand Down
78 changes: 74 additions & 4 deletions src/core/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ async function allOrders(): Promise<SellerOrder[]> {
export async function listOrdersForBuyer(buyerId: string, limit?: number): Promise<SellerOrder[]> {
const ids = await sllrStore().indexMembers(buyerOrdersIndex(buyerId));
const boundedIds = limit === undefined ? ids : ids.slice(-Math.max(1, Math.min(limit, 500)));
return (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
const orders = (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
return withLiveOrderTrackingBatch(orders);
}

function addMinutes(date: Date, minutes: number) {
Expand All @@ -109,22 +110,26 @@ async function activePickupOrders(merchantId: string, productionClass: string) {
// Scheduled pickups already occupy their target capacity window and must
// not inflate the queue for an order placed right now.
&& order.promise.scheduledPickup !== true
&& !["rejected", "claimed", "fulfilled", "receipt_issued"].includes(order.status)
&& !["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status)
));
}

// Queue-aware pickup wait for an item RIGHT NOW — the single ETA formula shared
// by quotes and order promises, so a quote can never show "~7 min" while the
// created order silently computes 52 (the trust bug the pilot audit caught).
// null for non-pickup items.
export async function estimatedPickupWaitMinutes(merchantId: string, item: CatalogItem, quantity = 1): Promise<number | null> {
export async function estimatedPickupWaitMinutes(
merchantId: string,
item: CatalogItem,
quantity = 1,
now = new Date(),
): Promise<number | null> {
if (!item.fulfillment.includes("pickup")) return null;
const productionClass = productionClassFor(item);
const activeAhead = (await activePickupOrders(merchantId, productionClass)).length;
const capacity = CAPACITY_BY_PRODUCTION_CLASS[productionClass];
const prepMinutes = Math.max(item.prepMinutes || 5, 1);
const queueWait = prepMinutes + Math.floor(activeAhead / capacity) * CAPACITY_WINDOW_MINUTES;
const now = new Date();
const desiredReadyAt = addMinutes(now, prepMinutes);
for (let offset = 0; offset < 32; offset += 1) {
const probeAt = addMinutes(desiredReadyAt, offset * CAPACITY_WINDOW_MINUTES);
Expand All @@ -137,6 +142,71 @@ export async function estimatedPickupWaitMinutes(merchantId: string, item: Catal
return Math.max(queueWait, prepMinutes + 32 * CAPACITY_WINDOW_MINUTES);
}

function queueKey(order: SellerOrder) {
return `${order.merchantId}:${order.promise.productionClass}`;
}

function trackingSnapshot(order: SellerOrder, queue: SellerOrder[]) {
const isPickup = order.promise.productionClass !== "shipping";
const terminal = ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status);
if (!isPickup) {
return {
...order,
tracking: {
live: true,
status: order.status,
receiptState: order.receipt ? "issued" : "not_issued",
queuePosition: null,
ordersAhead: null,
estimatedWaitMinutes: null,
promisedReadyAt: order.promise.promisedReadyAt,
updatedAt: order.updatedAt,
},
};
}

const index = terminal ? -1 : queue.findIndex((candidate) => candidate.id === order.id);
const promised = order.promise.promisedReadyAt ? new Date(order.promise.promisedReadyAt).getTime() : Number.NaN;
const remaining = terminal
? 0
: Number.isFinite(promised)
? Math.max(0, Math.ceil((promised - Date.now()) / 60_000))
: order.promise.estimatedWaitMinutes;

return {
...order,
tracking: {
live: true,
status: order.status,
receiptState: order.receipt ? "issued" : "not_issued",
queuePosition: index >= 0 ? index + 1 : null,
ordersAhead: index >= 0 ? index : 0,
estimatedWaitMinutes: remaining,
promisedReadyAt: order.promise.promisedReadyAt,
updatedAt: order.updatedAt,
},
};
}

export async function withLiveOrderTrackingBatch(orders: SellerOrder[]) {
const queues = new Map<string, SellerOrder[]>();
for (const candidate of await allOrders()) {
if (
candidate.promise.productionClass === "shipping"
|| candidate.promise.scheduledPickup === true
|| ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(candidate.status)
) continue;
const key = queueKey(candidate);
const queue = queues.get(key) || [];
queue.push(candidate);
queues.set(key, queue);
}
for (const queue of queues.values()) {
queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
return orders.map((order) => trackingSnapshot(order, queues.get(queueKey(order)) || []));
}

async function pickupPromise(merchant: MerchantProfile, item: CatalogItem, input: OrderRequest, now: Date, quantity: number): Promise<SellerOrder["promise"]> {
if (!item.fulfillment.includes("pickup")) {
return {
Expand Down
23 changes: 18 additions & 5 deletions src/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,29 +634,42 @@ const tools: ToolDefinition[] = [
},
{
name: "list_orders",
description: "Agent POS feed: list SLL-R orders for a merchant, optionally filtered by status (e.g. pending_payment, accepted, ready).",
description: "Authorized Agent POS feed: list SLL-R orders for a merchant, optionally filtered by status (e.g. pending_payment, accepted, ready). Requires the merchant verifier secret or merchant-scoped token.",
inputSchema: {
type: "object",
required: ["merchantId"],
properties: {
merchantId: quoteProperties.merchantId,
status: { type: "string", description: "Optional order status filter, for example pending_payment or ready." },
verificationToken: { type: "string", description: "Merchant verifier secret or merchant-scoped token." },
demo: { type: "boolean", description: "Local demo only, accepted only when no verifier secret is configured." },
},
},
handler: (args) => listMerchantOrders(requireString(args, "merchantId"), typeof args.status === "string" ? args.status : null),
handler: async (args) => {
const merchantId = requireString(args, "merchantId");
await requireMerchantAuth({}, args, merchantId);
return listMerchantOrders(merchantId, typeof args.status === "string" ? args.status : null);
},
},
{
name: "check_order_status",
description: "Read current order state, payment status, fulfillment state, pickup promise, and receipt handoff.",
description: "Read current order state, payment status, fulfillment state, pickup promise, and receipt handoff. Buyer-bound orders require the matching buyer session; merchant access requires the verifier secret or merchant-scoped token.",
inputSchema: {
type: "object",
required: ["orderId"],
properties: { orderId: { type: "string", description: "SLL-R order id, for example ord_..." } },
properties: {
orderId: { type: "string", description: "SLL-R order id, for example ord_..." },
verificationToken: { type: "string", description: "Merchant verifier secret or merchant-scoped token when the caller is not the owning buyer." },
demo: { type: "boolean", description: "Local demo only, accepted only when no verifier secret is configured." },
},
},
handler: async (args) => {
handler: async (args, _origin, buyerId) => {
const orderId = requireString(args, "orderId");
const order = await getOrder(orderId);
if (!order) throw Object.assign(new Error(`Unknown order: ${orderId}`), { status: 404 });
if (!order.buyerId || order.buyerId !== buyerId) {
await requireMerchantAuth({}, args, order.merchantId);
}
return { product: "SLL-R merchant terminal", order };
},
},
Expand Down
Loading
Loading