rspace-online/modules/rcart/mod.ts

2161 lines
78 KiB
TypeScript

/**
* Cart module — cosmolocal print-on-demand shop.
*
* Ported from /opt/apps/rcart/ (Express → Hono).
* Handles catalog (artifact listings), orders, fulfillment resolution.
* Integrates with provider-registry for provider matching and flow-service for revenue splits.
*
* Storage: Automerge documents via SyncServer (no PostgreSQL).
*/
import * as Automerge from "@automerge/automerge";
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
paymentRequestSchema, groupBuySchema,
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
paymentRequestDocId, groupBuyDocId,
type CatalogDoc, type CatalogEntry,
type OrderDoc, type OrderMeta,
type ShoppingCartDoc, type ShoppingCartIndexDoc,
type PaymentRequestDoc, type PaymentRequestMeta,
type GroupBuyDoc,
type CartItem, type CartStatus,
} from './schemas';
import { extractProductFromUrl } from './extract';
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
import QRCode from 'qrcode';
import { createTransport, type Transporter } from "nodemailer";
let _syncServer: SyncServer | null = null;
// ── SMTP transport (lazy init) ──
let _smtpTransport: Transporter | null = null;
function getSmtpTransport(): Transporter | null {
if (_smtpTransport) return _smtpTransport;
if (!process.env.SMTP_PASS) return null;
_smtpTransport = createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
port: Number(process.env.SMTP_PORT) || 587,
secure: Number(process.env.SMTP_PORT) === 465,
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
});
return _smtpTransport;
}
const routes = new Hono();
// Provider registry URL (for fulfillment resolution)
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";
function getProviderUrl(): string {
// In unified mode, providers module is co-located — call its routes directly via internal URL
// In standalone mode, use PROVIDER_REGISTRY_URL env
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
}
// ── Subscription interval helpers ──
const INTERVAL_MS: Record<string, number> = {
weekly: 7 * 24 * 60 * 60 * 1000,
biweekly: 14 * 24 * 60 * 60 * 1000,
monthly: 30 * 24 * 60 * 60 * 1000,
quarterly: 91 * 24 * 60 * 60 * 1000,
yearly: 365 * 24 * 60 * 60 * 1000,
};
function computeNextDueDate(fromMs: number, interval: string): number {
return fromMs + (INTERVAL_MS[interval] || INTERVAL_MS.monthly);
}
// ── Subscription reminder scheduler ──
let _reminderTimer: ReturnType<typeof setInterval> | null = null;
function startSubscriptionScheduler() {
if (_reminderTimer) return;
// Check every hour for due subscriptions
_reminderTimer = setInterval(() => checkDueSubscriptions(), 60 * 60 * 1000);
// Also run once on startup (after 30s delay for init)
setTimeout(() => checkDueSubscriptions(), 30_000);
console.log('[rcart] Subscription reminder scheduler started (hourly)');
}
async function checkDueSubscriptions() {
if (!_syncServer) return;
const now = Date.now();
const transport = getSmtpTransport();
if (!transport) return;
// Scan all payment request docs for due subscriptions
const allDocIds = _syncServer.listDocs?.() || [];
for (const docId of allDocIds) {
if (!docId.includes(':payment:')) continue;
try {
const doc = _syncServer.getDoc<PaymentRequestDoc>(docId);
if (!doc) continue;
const p = doc.payment;
// Only process active subscriptions with a due date in the past
if (p.status !== 'pending') continue;
if (!p.interval || !p.nextDueAt) continue;
if (p.nextDueAt > now) continue;
if (!p.subscriberEmail) continue;
// Don't send more than once per interval — check if last payment was recent
const lastPayment = p.paymentHistory?.length > 0
? p.paymentHistory[p.paymentHistory.length - 1]
: null;
const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;
// Send reminder email
const space = doc.meta.spaceSlug || 'demo';
const host = 'rspace.online';
const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
const senderName = p.creatorUsername || 'Someone';
const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`;
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${p.interval} payment is due</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
Your recurring payment of <strong>${displayAmount}</strong> to <strong>${senderName}</strong>${p.description ? ` for "${p.description}"` : ''} is due.
</p>
<p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${p.paymentCount + 1}${p.maxPayments > 0 ? ` of ${p.maxPayments}` : ''}</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
Pay Now
</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
try {
await transport.sendMail({
from: `"${senderName} via rSpace" <${fromAddr}>`,
to: p.subscriberEmail,
subject: `Payment reminder: ${displayAmount} due to ${senderName}`,
html,
});
console.log(`[rcart] Sent subscription reminder for ${p.id} to ${p.subscriberEmail}`);
} catch (err) {
console.warn(`[rcart] Failed to send reminder for ${p.id}:`, err);
}
} catch {
// Skip docs that fail to parse
}
}
}
// ── Automerge helpers ──
/** Lazily create (or retrieve) the catalog doc for a space. */
function ensureCatalogDoc(space: string): Automerge.Doc<CatalogDoc> {
const docId = catalogDocId(space);
let doc = _syncServer!.getDoc<CatalogDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CatalogDoc>(), 'init catalog', (d) => {
const init = catalogSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** Get all order docs for a space by scanning known doc IDs. */
function getSpaceOrderDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> {
const prefix = `${space}:cart:orders:`;
const results: Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<OrderDoc>(id);
if (doc) results.push({ docId: id, doc });
}
}
return results;
}
// ── CATALOG ROUTES ──
// POST /api/catalog/ingest — Add artifact to catalog
routes.post("/api/catalog/ingest", async (c) => {
const space = c.req.param("space") || "demo";
const artifact = await c.req.json();
if (!artifact.id || !artifact.schema_version || !artifact.type) {
return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
}
if (artifact.type !== "print-ready") {
return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
}
if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
}
const doc = ensureCatalogDoc(space);
// Check for duplicate artifact_id
for (const [, entry] of Object.entries(doc.items)) {
if (entry.artifactId === artifact.id) {
return c.json({ error: "Artifact already listed", catalog_entry_id: entry.id }, 409);
}
}
const entryId = crypto.randomUUID();
const now = Date.now();
const docId = catalogDocId(space);
_syncServer!.changeDoc<CatalogDoc>(docId, 'ingest catalog entry', (d) => {
d.items[entryId] = {
id: entryId,
artifactId: artifact.id,
artifact: artifact,
title: artifact.payload?.title || "Untitled",
productType: artifact.spec?.product_type || null,
requiredCapabilities: artifact.spec?.required_capabilities || [],
substrates: artifact.spec?.substrates || [],
creatorId: artifact.creator?.id || null,
sourceSpace: artifact.source_space || space,
tags: artifact.payload?.tags || [],
status: "active",
createdAt: now,
updatedAt: now,
};
});
return c.json({
id: entryId,
artifact_id: artifact.id,
title: artifact.payload?.title || "Untitled",
product_type: artifact.spec?.product_type || null,
status: "active",
created_at: new Date(now).toISOString(),
}, 201);
});
// GET /api/catalog — Browse catalog
routes.get("/api/catalog", async (c) => {
const space = c.req.param("space") || "demo";
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
const doc = ensureCatalogDoc(space);
let entries = Object.values(doc.items);
// Apply filters
entries = entries.filter((e) => e.status === "active");
if (product_type) entries = entries.filter((e) => e.productType === product_type);
if (capability) {
const caps = capability.split(",");
entries = entries.filter((e) => caps.some((cap) => e.requiredCapabilities.includes(cap)));
}
if (tag) entries = entries.filter((e) => e.tags.includes(tag));
if (source_space) entries = entries.filter((e) => e.sourceSpace === source_space);
if (q) {
const lower = q.toLowerCase();
entries = entries.filter((e) => e.title.toLowerCase().includes(lower));
}
// Sort by createdAt descending
entries.sort((a, b) => b.createdAt - a.createdAt);
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const total = entries.length;
const paged = entries.slice(offsetNum, offsetNum + limitNum);
// Map to response shape matching the original SQL response
const result = paged.map((e) => {
const art = e.artifact as Record<string, any> | undefined;
return {
id: e.id,
artifact_id: e.artifactId,
title: e.title,
product_type: e.productType,
required_capabilities: e.requiredCapabilities,
tags: e.tags,
source_space: e.sourceSpace,
description: art?.payload?.description || null,
pricing: art?.pricing || null,
dimensions: art?.spec?.dimensions || null,
status: e.status,
created_at: new Date(e.createdAt).toISOString(),
};
});
return c.json({ entries: result, total, limit: limitNum, offset: offsetNum });
});
// GET /api/catalog/:id — Single catalog entry
routes.get("/api/catalog/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const doc = ensureCatalogDoc(space);
// Look up by entry id or artifact id
let entry: CatalogEntry | undefined;
if (doc.items[id]) {
entry = doc.items[id];
} else {
entry = Object.values(doc.items).find((e) => e.artifactId === id);
}
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
return c.json({
id: entry.id,
artifact: entry.artifact,
status: entry.status,
created_at: new Date(entry.createdAt).toISOString(),
updated_at: new Date(entry.updatedAt).toISOString(),
});
});
// PATCH /api/catalog/:id — Update listing status
routes.patch("/api/catalog/:id", async (c) => {
const space = c.req.param("space") || "demo";
const { status } = await c.req.json();
const valid = ["active", "paused", "sold_out", "removed"];
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
const doc = ensureCatalogDoc(space);
const entryId = c.req.param("id");
if (!doc.items[entryId]) return c.json({ error: "Catalog entry not found" }, 404);
const docId = catalogDocId(space);
_syncServer!.changeDoc<CatalogDoc>(docId, `update catalog status → ${status}`, (d) => {
d.items[entryId].status = status;
d.items[entryId].updatedAt = Date.now();
});
return c.json({ id: entryId, status });
});
// ── ORDER ROUTES ──
// POST /api/orders — Create an order
routes.post("/api/orders", async (c) => {
const space = c.req.param("space") || "demo";
// Optional auth — set buyer_did from claims if authenticated
const token = extractToken(c.req.raw.headers);
let buyerDid: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
}
const body = await c.req.json();
const {
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
provider_id, provider_name, provider_distance_km,
quantity = 1, production_cost, creator_payout, community_payout,
total_price, currency = "USD", payment_method = "manual",
payment_tx, payment_network,
} = body;
if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);
// Look up catalog entry
const catalogDoc = ensureCatalogDoc(space);
const lookupId = catalog_entry_id || artifact_id;
let entry: CatalogEntry | undefined;
if (catalogDoc.items[lookupId]) {
entry = catalogDoc.items[lookupId];
} else {
entry = Object.values(catalogDoc.items).find((e) => e.artifactId === lookupId || e.id === lookupId);
}
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
// x402 detection
const x402Header = c.req.header("x-payment");
const effectiveMethod = x402Header ? "x402" : payment_method;
const initialStatus = x402Header ? "paid" : "pending";
const orderId = crypto.randomUUID();
const now = Date.now();
// Create order doc
const oDocId = orderDocId(space, orderId);
let orderDoc = Automerge.change(Automerge.init<OrderDoc>(), 'create order', (d) => {
const init = orderSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.order.id = orderId;
d.order.catalogEntryId = entry!.id;
d.order.artifactId = entry!.artifactId;
d.order.buyerId = buyerDid || buyer_id || null;
d.order.buyerLocation = buyer_location ? JSON.stringify(buyer_location) : null;
d.order.buyerContact = buyer_contact ? JSON.stringify(buyer_contact) : null;
d.order.providerId = provider_id;
d.order.providerName = provider_name || null;
d.order.providerDistanceKm = provider_distance_km || null;
d.order.quantity = quantity;
d.order.productionCost = production_cost || null;
d.order.creatorPayout = creator_payout || null;
d.order.communityPayout = community_payout || null;
d.order.totalPrice = total_price;
d.order.currency = currency;
d.order.status = initialStatus;
d.order.paymentMethod = effectiveMethod;
d.order.paymentTx = payment_tx || null;
d.order.paymentNetwork = payment_network || null;
d.order.createdAt = now;
d.order.updatedAt = now;
if (initialStatus === "paid") d.order.paidAt = now;
});
_syncServer!.setDoc(oDocId, orderDoc);
const order = orderDoc.order;
if (initialStatus === "paid") {
depositOrderRevenue(total_price, orderId);
}
// Return response matching original shape
return c.json(orderToResponse(order, entry), 201);
});
// GET /api/orders — List orders
routes.get("/api/orders", async (c) => {
const space = c.req.param("space") || "demo";
// Optional auth — filter by buyer if authenticated
const token = extractToken(c.req.raw.headers);
let authedBuyer: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
}
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
const orderDocs = getSpaceOrderDocs(space);
// Build enriched order list with catalog info
const catalogDoc = ensureCatalogDoc(space);
let orders = orderDocs.map(({ doc }) => {
const o = doc.order;
const catEntry = catalogDoc.items[o.catalogEntryId];
const resp = orderToResponse(o);
resp.artifact_title = catEntry?.title || null;
resp.product_type = catEntry?.productType || null;
return resp;
});
// Apply filters
if (status) orders = orders.filter((o) => o.status === status);
if (provider_id) orders = orders.filter((o) => o.provider_id === provider_id);
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
if (effectiveBuyerId) orders = orders.filter((o) => o.buyer_id === effectiveBuyerId);
// Sort by created_at descending
orders.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const paged = orders.slice(offsetNum, offsetNum + limitNum);
return c.json({ orders: paged });
});
// GET /api/orders/:id — Single order
routes.get("/api/orders/:id", async (c) => {
const space = c.req.param("space") || "demo";
const orderId = c.req.param("id");
const oDocId = orderDocId(space, orderId);
const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
if (!doc) return c.json({ error: "Order not found" }, 404);
const catalogDoc = ensureCatalogDoc(space);
const catEntry = catalogDoc.items[doc.order.catalogEntryId];
const resp = orderToResponse(doc.order);
resp.artifact_envelope = catEntry?.artifact || null;
resp.artifact_title = catEntry?.title || null;
return c.json(resp);
});
// PATCH /api/orders/:id/status — Update order status
routes.patch("/api/orders/:id/status", async (c) => {
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const { status, payment_tx, payment_network } = body;
const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
const orderId = c.req.param("id");
const oDocId = orderDocId(space, orderId);
const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
if (!doc) return c.json({ error: "Order not found" }, 404);
const now = Date.now();
const updated = _syncServer!.changeDoc<OrderDoc>(oDocId, `order status → ${status}`, (d) => {
d.order.status = status;
d.order.updatedAt = now;
if (status === "paid") d.order.paidAt = now;
if (status === "accepted") d.order.acceptedAt = now;
if (status === "completed") d.order.completedAt = now;
if (status === "paid" && payment_tx) {
d.order.paymentTx = payment_tx;
d.order.paymentNetwork = payment_network || null;
}
});
if (!updated) return c.json({ error: "Order not found" }, 404);
if (status === "paid" && updated.order.totalPrice) {
depositOrderRevenue(updated.order.totalPrice, orderId);
}
return c.json(orderToResponse(updated.order));
});
// ── Response helpers ──
interface OrderResponse {
id: string;
catalog_entry_id: string;
artifact_id: string;
buyer_id: string | null;
buyer_location: unknown;
buyer_contact: unknown;
provider_id: string | null;
provider_name: string | null;
provider_distance_km: number | null;
quantity: number;
production_cost: number | null;
creator_payout: number | null;
community_payout: number | null;
total_price: number | null;
currency: string;
status: string;
payment_method: string | null;
payment_tx: string | null;
payment_network: string | null;
created_at: string;
paid_at: string | null;
accepted_at: string | null;
completed_at: string | null;
updated_at: string;
artifact_title?: string | null;
product_type?: string | null;
artifact_envelope?: unknown;
}
/** Convert an OrderMeta to the flat response shape matching the original SQL rows. */
function orderToResponse(o: OrderMeta, catEntry?: CatalogEntry): OrderResponse {
return {
id: o.id,
catalog_entry_id: o.catalogEntryId,
artifact_id: o.artifactId,
buyer_id: o.buyerId,
buyer_location: o.buyerLocation ? tryParse(o.buyerLocation) : null,
buyer_contact: o.buyerContact ? tryParse(o.buyerContact) : null,
provider_id: o.providerId,
provider_name: o.providerName,
provider_distance_km: o.providerDistanceKm,
quantity: o.quantity,
production_cost: o.productionCost,
creator_payout: o.creatorPayout,
community_payout: o.communityPayout,
total_price: o.totalPrice,
currency: o.currency,
status: o.status,
payment_method: o.paymentMethod,
payment_tx: o.paymentTx,
payment_network: o.paymentNetwork,
created_at: new Date(o.createdAt).toISOString(),
paid_at: o.paidAt ? new Date(o.paidAt).toISOString() : null,
accepted_at: o.acceptedAt ? new Date(o.acceptedAt).toISOString() : null,
completed_at: o.completedAt ? new Date(o.completedAt).toISOString() : null,
updated_at: new Date(o.updatedAt).toISOString(),
...(catEntry ? { artifact_title: catEntry.title, product_type: catEntry.productType } : {}),
};
}
function tryParse(s: string): unknown {
try { return JSON.parse(s); } catch { return s; }
}
// ── FULFILLMENT ROUTES ──
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
interface ProviderMatch {
id: string; name: string; distance_km: number;
location: { city: string; region: string; country: string };
turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
wallet: string;
}
function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
const spec = artifact.spec as Record<string, unknown> | undefined;
const capabilities = (spec?.required_capabilities as string[]) || [];
const pages = (spec?.pages as number) || 1;
const breakdown: { label: string; amount: number }[] = [];
for (const cap of capabilities) {
const capPricing = provider.pricing?.[cap];
if (!capPricing) continue;
const basePrice = capPricing.base_price || 0;
let perUnit = capPricing.per_unit || 0;
const unitLabel = capPricing.per_unit_label || "per unit";
if (capPricing.volume_breaks) {
for (const vb of capPricing.volume_breaks) {
if (quantity >= vb.min_qty) perUnit = vb.per_unit;
}
}
let units = quantity;
if (unitLabel.includes("page")) units = pages * quantity;
if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
}
if (breakdown.length === 0) {
breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
}
return breakdown;
}
// POST /api/fulfill/resolve — Find fulfillment options
routes.post("/api/fulfill/resolve", async (c) => {
const space = c.req.param("space") || "demo";
const body = await c.req.json();
const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;
if (!buyer_location?.lat || !buyer_location?.lng) {
return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
}
if (!artifact_id && !catalog_entry_id) {
return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
}
const catalogDoc = ensureCatalogDoc(space);
const lookupId = artifact_id || catalog_entry_id;
// Find entry by id or artifact_id, must be active
let entry: CatalogEntry | undefined;
if (catalogDoc.items[lookupId] && catalogDoc.items[lookupId].status === "active") {
entry = catalogDoc.items[lookupId];
} else {
entry = Object.values(catalogDoc.items).find(
(e) => (e.artifactId === lookupId || e.id === lookupId) && e.status === "active"
);
}
if (!entry) return c.json({ error: "Artifact not found in catalog" }, 404);
const artifact = entry.artifact as Record<string, any>;
const capabilities = artifact.spec?.required_capabilities || [];
const substrates = artifact.spec?.substrates || [];
if (capabilities.length === 0) {
return c.json({ error: "Artifact has no required_capabilities" }, 400);
}
// Query provider registry (internal module or external service)
const providerUrl = getProviderUrl();
const params = new URLSearchParams({
capabilities: capabilities.join(","),
lat: String(buyer_location.lat),
lng: String(buyer_location.lng),
});
if (substrates.length > 0) params.set("substrates", substrates.join(","));
let providers: ProviderMatch[];
try {
const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
const data = await resp.json() as { matches?: ProviderMatch[] };
providers = data.matches || [];
} catch (err) {
return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
}
if (providers.length === 0) {
return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
}
const options = providers.map((provider) => {
const costBreakdown = composeCost(artifact, provider, quantity);
const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
const pricing = artifact.pricing || {};
const creatorPct = (pricing.creator_share_pct || 30) / 100;
const communityPct = (pricing.community_share_pct || 0) / 100;
const markupMultiplier = 1 / (1 - creatorPct - communityPct);
const totalPrice = productionCost * markupMultiplier;
const creatorPayout = totalPrice * creatorPct;
const communityPayout = totalPrice * communityPct;
return {
provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
production_cost: round2(productionCost),
creator_payout: round2(creatorPayout),
community_payout: round2(communityPayout),
total_price: round2(totalPrice),
currency: "USD",
turnaround_days: provider.turnaround?.standard_days || 5,
cost_breakdown: costBreakdown,
};
});
options.sort((a, b) => a.total_price - b.total_price);
return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
});
// ── SHOPPING CART helpers ──
/** Lazily create (or retrieve) the shopping cart index doc for a space. */
function ensureShoppingCartIndex(space: string): Automerge.Doc<ShoppingCartIndexDoc> {
const docId = shoppingCartIndexDocId(space);
let doc = _syncServer!.getDoc<ShoppingCartIndexDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ShoppingCartIndexDoc>(), 'init shopping cart index', (d) => {
const init = shoppingCartIndexSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** Recompute index entry from a shopping cart doc. */
function reindexCart(space: string, cartId: string) {
const cartDocId = shoppingCartDocId(space, cartId);
const cartDoc = _syncServer!.getDoc<ShoppingCartDoc>(cartDocId);
if (!cartDoc) return;
const indexDocId = shoppingCartIndexDocId(space);
ensureShoppingCartIndex(space);
const items = cartDoc.items ? Object.values(cartDoc.items) : [];
const totalAmount = items.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0);
_syncServer!.changeDoc<ShoppingCartIndexDoc>(indexDocId, 'reindex cart', (d) => {
d.carts[cartId] = {
name: cartDoc.cart.name,
status: cartDoc.cart.status as CartStatus,
itemCount: items.length,
totalAmount: Math.round(totalAmount * 100) / 100,
fundedAmount: cartDoc.cart.fundedAmount,
currency: cartDoc.cart.currency,
createdAt: cartDoc.cart.createdAt,
updatedAt: Date.now(),
};
});
}
// ── SHOPPING CART ROUTES ──
// POST /api/extract — Extract product from URL
routes.post("/api/extract", async (c) => {
const { url } = await c.req.json();
if (!url) return c.json({ error: "Required: url" }, 400);
try {
const product = await extractProductFromUrl(url);
return c.json(product);
} catch (err) {
return c.json({
error: "Failed to extract product",
detail: err instanceof Error ? err.message : String(err),
}, 502);
}
});
// GET /api/shopping-carts — List carts from index
routes.get("/api/shopping-carts", async (c) => {
const space = c.req.param("space") || "demo";
const indexDoc = ensureShoppingCartIndex(space);
const carts = Object.entries(indexDoc.carts || {}).map(([id, entry]) => ({
id,
...entry,
createdAt: new Date(entry.createdAt).toISOString(),
updatedAt: new Date(entry.updatedAt).toISOString(),
}));
carts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return c.json({ carts });
});
// POST /api/shopping-carts — Create cart
routes.post("/api/shopping-carts", async (c) => {
const space = c.req.param("space") || "demo";
const { name, description = "", targetAmount = 0, currency = "USD" } = await c.req.json();
if (!name) return c.json({ error: "Required: name" }, 400);
const cartId = crypto.randomUUID();
const now = Date.now();
const docId = shoppingCartDocId(space, cartId);
const cartDoc = Automerge.change(Automerge.init<ShoppingCartDoc>(), 'create shopping cart', (d) => {
const init = shoppingCartSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.cart.id = cartId;
d.cart.name = name;
d.cart.description = description;
d.cart.status = 'OPEN';
d.cart.targetAmount = targetAmount;
d.cart.fundedAmount = 0;
d.cart.currency = currency;
d.cart.createdAt = now;
d.cart.updatedAt = now;
});
_syncServer!.setDoc(docId, cartDoc);
reindexCart(space, cartId);
return c.json({ id: cartId, name, status: 'OPEN', created_at: new Date(now).toISOString() }, 201);
});
// GET /api/shopping-carts/:cartId — Full cart with items + contributions
routes.get("/api/shopping-carts/:cartId", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
const items = Object.entries(doc.items || {}).map(([id, item]) => ({
id, ...item, addedAt: new Date(item.addedAt).toISOString(),
}));
const contributions = Object.entries(doc.contributions || {}).map(([id, contrib]) => ({
id, ...contrib,
createdAt: new Date(contrib.createdAt).toISOString(),
updatedAt: new Date(contrib.updatedAt).toISOString(),
}));
return c.json({
id: doc.cart.id,
name: doc.cart.name,
description: doc.cart.description,
status: doc.cart.status,
targetAmount: doc.cart.targetAmount,
fundedAmount: doc.cart.fundedAmount,
currency: doc.cart.currency,
createdAt: new Date(doc.cart.createdAt).toISOString(),
updatedAt: new Date(doc.cart.updatedAt).toISOString(),
items,
contributions,
events: doc.events || [],
});
});
// PUT /api/shopping-carts/:cartId — Update cart
routes.put("/api/shopping-carts/:cartId", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
const body = await c.req.json();
const { name, description, status } = body;
const validStatuses: CartStatus[] = ['OPEN', 'FUNDING', 'FUNDED', 'CHECKING_OUT', 'ORDERED', 'CLOSED'];
if (status && !validStatuses.includes(status)) {
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
}
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'update cart', (d) => {
if (name !== undefined) d.cart.name = name;
if (description !== undefined) d.cart.description = description;
if (status) d.cart.status = status;
d.cart.updatedAt = Date.now();
});
reindexCart(space, cartId);
return c.json({ id: cartId, status: status || doc.cart.status });
});
// DELETE /api/shopping-carts/:cartId — Delete cart + remove from index
routes.delete("/api/shopping-carts/:cartId", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
// Remove from index
const indexDocId = shoppingCartIndexDocId(space);
ensureShoppingCartIndex(space);
_syncServer!.changeDoc<ShoppingCartIndexDoc>(indexDocId, 'remove cart from index', (d) => {
delete d.carts[cartId];
});
// Note: Automerge docs aren't truly deleted, but removing from index effectively hides it
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'close cart', (d) => {
d.cart.status = 'CLOSED';
d.cart.updatedAt = Date.now();
});
return c.json({ deleted: true, id: cartId });
});
// POST /api/shopping-carts/:cartId/items — Add item
routes.post("/api/shopping-carts/:cartId/items", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
const body = await c.req.json();
const { url, product: preExtracted } = body;
let productData: any;
if (preExtracted) {
// Extension sent pre-extracted product data
productData = preExtracted;
productData.sourceUrl = url || preExtracted.sourceUrl;
} else if (url) {
// Extract from URL server-side
try {
productData = await extractProductFromUrl(url);
} catch (err) {
return c.json({ error: "Failed to extract product", detail: err instanceof Error ? err.message : String(err) }, 502);
}
} else {
return c.json({ error: "Required: url or product" }, 400);
}
const itemId = crypto.randomUUID();
const now = Date.now();
const domain = productData.vendor?.domain || productData.sourceUrl ? (() => {
try { return new URL(productData.sourceUrl).hostname.replace(/^www\./, ''); } catch { return 'unknown'; }
})() : 'unknown';
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'add item to cart', (d) => {
d.items[itemId] = {
name: productData.name || 'Unknown Product',
price: productData.price ?? null,
currency: productData.currency || 'USD',
quantity: body.quantity || 1,
sourceUrl: productData.sourceUrl || url || '',
imageUrl: productData.imageUrl || null,
description: productData.description || null,
vendor: {
name: productData.vendor?.name || domain,
domain: productData.vendor?.domain || domain,
platform: productData.vendor?.platform || null,
},
addedBy: null,
addedAt: now,
sku: productData.sku || null,
};
d.cart.updatedAt = now;
d.events.push({
type: 'item_added',
actor: 'anonymous',
detail: `Added ${productData.name || 'item'}`,
timestamp: now,
});
});
reindexCart(space, cartId);
return c.json({
id: itemId,
name: productData.name,
price: productData.price,
sourceUrl: productData.sourceUrl || url,
imageUrl: productData.imageUrl,
}, 201);
});
// PUT /api/shopping-carts/:cartId/items/:itemId — Update item quantity
routes.put("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const itemId = c.req.param("itemId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404);
const { quantity } = await c.req.json();
if (typeof quantity !== 'number' || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'update item quantity', (d) => {
d.items[itemId].quantity = quantity;
d.cart.updatedAt = Date.now();
});
reindexCart(space, cartId);
return c.json({ id: itemId, quantity });
});
// DELETE /api/shopping-carts/:cartId/items/:itemId — Remove item
routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const itemId = c.req.param("itemId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404);
const itemName = doc.items[itemId].name;
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'remove item from cart', (d) => {
delete d.items[itemId];
d.cart.updatedAt = Date.now();
d.events.push({
type: 'item_removed',
actor: 'anonymous',
detail: `Removed ${itemName}`,
timestamp: Date.now(),
});
});
reindexCart(space, cartId);
return c.json({ deleted: true, id: itemId });
});
// POST /api/shopping-carts/:cartId/contribute — Add contribution
routes.post("/api/shopping-carts/:cartId/contribute", async (c) => {
const space = c.req.param("space") || "demo";
const cartId = c.req.param("cartId");
const docId = shoppingCartDocId(space, cartId);
const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
if (!doc) return c.json({ error: "Cart not found" }, 404);
const { amount, username = "Anonymous", paymentMethod = "MANUAL" } = await c.req.json();
if (typeof amount !== 'number' || amount <= 0) return c.json({ error: "amount must be > 0" }, 400);
const contribId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'add contribution', (d) => {
d.contributions[contribId] = {
userId: null,
username,
amount,
currency: d.cart.currency,
paymentMethod,
status: 'confirmed',
txHash: null,
createdAt: now,
updatedAt: now,
};
d.cart.fundedAmount = Math.round((d.cart.fundedAmount + amount) * 100) / 100;
d.cart.updatedAt = now;
d.events.push({
type: 'contribution',
actor: username,
detail: `Contributed $${amount.toFixed(2)}`,
timestamp: now,
});
});
reindexCart(space, cartId);
return c.json({ id: contribId, amount, fundedAmount: doc.cart.fundedAmount + amount }, 201);
});
// ── Extension shortcut routes ──
// POST /api/cart/quick-add — Simplified endpoint for extension
routes.post("/api/cart/quick-add", async (c) => {
const space = c.req.param("space") || "demo";
const { url, product, space: targetSpace } = await c.req.json();
if (!url) return c.json({ error: "Required: url" }, 400);
const effectiveSpace = targetSpace || space;
// Find or create a default OPEN cart
const indexDoc = ensureShoppingCartIndex(effectiveSpace);
let activeCartId: string | null = null;
for (const [id, entry] of Object.entries(indexDoc.carts || {})) {
if (entry.status === 'OPEN') {
activeCartId = id;
break;
}
}
if (!activeCartId) {
// Create a default cart
activeCartId = crypto.randomUUID();
const now = Date.now();
const docId = shoppingCartDocId(effectiveSpace, activeCartId);
const cartDoc = Automerge.change(Automerge.init<ShoppingCartDoc>(), 'create default cart', (d) => {
const init = shoppingCartSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = effectiveSpace;
d.cart.id = activeCartId!;
d.cart.name = 'My Cart';
d.cart.description = 'Default shopping cart';
d.cart.status = 'OPEN';
d.cart.createdAt = now;
d.cart.updatedAt = now;
});
_syncServer!.setDoc(docId, cartDoc);
reindexCart(effectiveSpace, activeCartId);
}
// Extract product data
let productData: any;
if (product) {
productData = product;
productData.sourceUrl = url;
} else {
try {
productData = await extractProductFromUrl(url);
} catch {
productData = { name: url, sourceUrl: url };
}
}
const itemId = crypto.randomUUID();
const now = Date.now();
const docId = shoppingCartDocId(effectiveSpace, activeCartId);
const domain = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'unknown'; } })();
_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'quick-add item', (d) => {
d.items[itemId] = {
name: productData.name || 'Unknown Product',
price: productData.price ?? null,
currency: productData.currency || 'USD',
quantity: 1,
sourceUrl: url,
imageUrl: productData.imageUrl || null,
description: productData.description || null,
vendor: {
name: productData.vendor?.name || domain,
domain: productData.vendor?.domain || domain,
platform: productData.vendor?.platform || null,
},
addedBy: null,
addedAt: now,
sku: productData.sku || null,
};
d.cart.updatedAt = now;
});
reindexCart(effectiveSpace, activeCartId);
return c.json({
success: true,
data: {
name: productData.name || url,
cartId: activeCartId,
itemId,
},
}, 201);
});
// GET /api/cart/summary — Badge count for extension popup
routes.get("/api/cart/summary", async (c) => {
const space = c.req.param("space") || "demo";
const indexDoc = ensureShoppingCartIndex(space);
let totalItems = 0;
let totalAmount = 0;
const vendorGroups: Array<{ vendor: { name: string; domain: string }; items: Array<{ name: string; price: number; quantity: number }>; subtotal: number }> = [];
for (const [cartId, entry] of Object.entries(indexDoc.carts || {})) {
if (entry.status === 'OPEN' || entry.status === 'FUNDING') {
totalItems += entry.itemCount;
totalAmount += entry.totalAmount;
// Get full cart doc for vendor grouping
const cartDocId = shoppingCartDocId(space, cartId);
const cartDoc = _syncServer!.getDoc<ShoppingCartDoc>(cartDocId);
if (cartDoc) {
const byVendor: Record<string, typeof vendorGroups[0]> = {};
for (const item of Object.values(cartDoc.items || {})) {
const key = item.vendor.domain;
if (!byVendor[key]) {
byVendor[key] = { vendor: { name: item.vendor.name, domain: item.vendor.domain }, items: [], subtotal: 0 };
}
byVendor[key].items.push({ name: item.name, price: item.price || 0, quantity: item.quantity });
byVendor[key].subtotal += (item.price || 0) * item.quantity;
}
vendorGroups.push(...Object.values(byVendor));
}
}
}
return c.json({
success: true,
data: {
totalItems,
totalAmount: Math.round(totalAmount * 100) / 100,
currency: 'USD',
vendorGroups,
},
});
});
// ── PAYMENT REQUEST helpers ──
/** Get all payment request docs for a space. */
function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> {
const prefix = `${space}:cart:payments:`;
const results: Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<PaymentRequestDoc>(id);
if (doc) results.push({ docId: id, doc });
}
}
return results;
}
// USDC contract addresses
const USDC_ADDRESSES: Record<number, string> = {
8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base
84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia
1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum
};
// ── PAYMENT REQUEST ROUTES ──
// POST /api/payments — Create payment request (auth required)
routes.post("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const {
description, amount, amountEditable = false,
token: payToken = 'USDC',
chainId = 8453, recipientAddress,
fiatAmount = null, fiatCurrency = 'USD',
expiresIn = 0, // seconds, 0 = no expiry
paymentType = 'single',
maxPayments = 0,
enabledMethods = { card: true, wallet: true, encryptid: true },
interval = null,
} = body;
if (!description || !recipientAddress) {
return c.json({ error: "Required: description, recipientAddress" }, 400);
}
if (!amountEditable && !amount) {
return c.json({ error: "Required: amount (or set amountEditable: true)" }, 400);
}
const paymentId = crypto.randomUUID();
const now = Date.now();
const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : 0;
const docId = paymentRequestDocId(space, paymentId);
const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create payment request', (d) => {
const init = paymentRequestSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.payment.id = paymentId;
d.payment.description = description;
d.payment.amount = amount ? String(amount) : '0';
d.payment.amountEditable = !!amountEditable;
d.payment.token = payToken;
d.payment.chainId = chainId;
d.payment.recipientAddress = recipientAddress;
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
d.payment.fiatCurrency = fiatCurrency;
d.payment.creatorDid = claims.sub;
d.payment.creatorUsername = claims.username || '';
d.payment.status = 'pending';
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
d.payment.paymentCount = 0;
d.payment.enabledMethods = {
card: enabledMethods.card !== false,
wallet: enabledMethods.wallet !== false,
encryptid: enabledMethods.encryptid !== false,
};
// Subscription interval
const validIntervals = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
if (interval && validIntervals.includes(interval)) {
d.payment.interval = interval;
}
d.payment.createdAt = now;
d.payment.updatedAt = now;
d.payment.expiresAt = expiresAt;
});
_syncServer!.setDoc(docId, payDoc);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
return c.json({
id: paymentId,
description,
amount: String(amount),
token: payToken,
chainId,
recipientAddress,
status: 'pending',
payUrl,
qrUrl: `https://${host}/${space}/rcart/api/payments/${paymentId}/qr`,
created_at: new Date(now).toISOString(),
}, 201);
});
// GET /api/payments — List my payment requests (auth required)
routes.get("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const paymentDocs = getSpacePaymentDocs(space);
const payments = paymentDocs
.map(({ doc }) => doc.payment)
.filter((p) => p.creatorDid === claims.sub)
.sort((a, b) => b.createdAt - a.createdAt)
.map((p) => ({
id: p.id,
description: p.description,
amount: p.amount,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
created_at: new Date(p.createdAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
}));
return c.json({ payments });
});
// GET /api/payments/:id — Get payment details (public)
routes.get("/api/payments/:id", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
// Check expiry
if (p.expiresAt > 0 && Date.now() > p.expiresAt && p.status === 'pending') {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'expire payment', (d) => {
d.payment.status = 'expired';
d.payment.updatedAt = Date.now();
});
return c.json({
...paymentToResponse(p),
status: 'expired',
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
}
// Check inventory fill
if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'fill payment', (d) => {
d.payment.status = 'filled';
d.payment.updatedAt = Date.now();
});
return c.json({
...paymentToResponse(p),
status: 'filled',
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
}
return c.json({
...paymentToResponse(p),
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
});
// PATCH /api/payments/:id/status — Update payment status
routes.patch("/api/payments/:id/status", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const body = await c.req.json();
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType, payerEmail } = body;
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
if (status && !validStatuses.includes(status)) {
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
}
// Check if inventory is full before allowing payment
const p = doc.payment;
if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) {
return c.json({ error: "This payment request has reached its limit" }, 400);
}
const now = Date.now();
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
if (status) d.payment.status = status;
if (txHash) d.payment.txHash = txHash;
if (paymentMethod) d.payment.paymentMethod = paymentMethod;
if (payerIdentity) d.payment.payerIdentity = payerIdentity;
if (transakOrderId) d.payment.transakOrderId = transakOrderId;
// Allow amount update only if payment is editable and still pending
if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
d.payment.amount = String(amount);
}
// Store subscriber email for reminder notifications
if (payerEmail && !d.payment.subscriberEmail) {
d.payment.subscriberEmail = payerEmail;
}
d.payment.updatedAt = now;
if (status === 'paid') {
d.payment.paidAt = now;
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
// Record payment in history
if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
(d.payment.paymentHistory as any).push({
txHash: txHash || null,
transakOrderId: transakOrderId || null,
paymentMethod: paymentMethod || null,
payerIdentity: payerIdentity || null,
payerEmail: payerEmail || null,
amount: d.payment.amount,
paidAt: now,
});
// Determine if this payment should stay open for more payments
const effectiveType = d.payment.paymentType === 'payer_choice'
? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
: d.payment.paymentType;
const isRecurring = effectiveType === 'subscription' || d.payment.maxPayments > 1;
if (isRecurring) {
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
d.payment.status = 'filled';
} else {
// Keep accepting payments — reset status after recording
d.payment.status = 'pending';
// Compute next due date based on interval
if (d.payment.interval) {
d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
}
}
}
}
});
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
// Fire-and-forget payment success email
if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) {
const host = c.req.header("host") || "rspace.online";
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
.catch((err) => console.error('[rcart] payment email failed:', err));
}
return c.json(paymentToResponse(updated!.payment));
});
// GET /api/payments/:id/qr — QR code SVG
routes.get("/api/payments/:id/qr", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
});
// POST /api/payments/:id/transak-session — Get Transak widget URL (public)
routes.post("/api/payments/:id/transak-session", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);
const { email, amount: overrideAmount } = await c.req.json();
if (!email) return c.json({ error: "Required: email" }, 400);
const transakApiKey = getTransakApiKey();
if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
const host = new URL(c.req.url).hostname;
// Use override amount for editable-amount payments, otherwise use preset amount
const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;
const widgetParams: Record<string, string> = {
apiKey: transakApiKey,
referrerDomain: extractRootDomain(host),
cryptoCurrencyCode: p.token,
network: networkMap[p.chainId] || 'base',
defaultCryptoCurrency: p.token,
walletAddress: p.recipientAddress,
disableWalletAddressForm: 'true',
cryptoAmount: effectiveAmount,
partnerOrderId: `pay-${paymentId}`,
email,
themeColor: '6366f1',
hideMenu: 'true',
};
// Pre-fill fiat amount/currency so user sees the total immediately
if (p.fiatAmount) {
widgetParams.fiatAmount = p.fiatAmount;
widgetParams.defaultFiatAmount = p.fiatAmount;
}
if (p.fiatCurrency) {
widgetParams.fiatCurrency = p.fiatCurrency;
widgetParams.defaultFiatCurrency = p.fiatCurrency;
}
const widgetUrl = createTransakWidgetUrl(widgetParams);
return c.json({ widgetUrl });
});
// POST /api/payments/:id/share-email — Email payment link to recipients
routes.post("/api/payments/:id/share-email", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
const { emails } = await c.req.json();
if (!Array.isArray(emails) || emails.length === 0) return c.json({ error: "Required: emails array" }, 400);
if (emails.length > 50) return c.json({ error: "Maximum 50 recipients per request" }, 400);
const transport = getSmtpTransport();
if (!transport) return c.json({ error: "Email not configured" }, 503);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
const senderName = p.creatorUsername || 'Someone';
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online";
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Request</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">from ${senderName}</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
<strong>${senderName}</strong> has sent you a payment request${p.description ? ` for <strong>${p.description}</strong>` : ''}.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px">
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Amount</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right;font-weight:600">${displayAmount}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Network</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${chainName}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0">Wallet</td>
<td style="color:#fff;font-size:13px;padding:8px 0;text-align:right;font-family:monospace">${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
Pay Now
</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
let sent = 0;
const validEmails = emails.filter((e: string) => typeof e === 'string' && e.includes('@'));
for (const email of validEmails) {
try {
await transport.sendMail({
from: `"${senderName} via rSpace" <${fromAddr}>`,
to: email.trim(),
subject: `Payment request from ${senderName}${p.description ? `: ${p.description}` : ''}`,
html,
});
sent++;
} catch (err) {
console.warn(`[rcart] Failed to send payment email to ${email}:`, err);
}
}
return c.json({ sent, total: validEmails.length });
});
// ── Payment success email ──
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const CHAIN_EXPLORERS: Record<number, string> = {
8453: 'https://basescan.org/tx/',
84532: 'https://sepolia.basescan.org/tx/',
1: 'https://etherscan.io/tx/',
};
async function sendPaymentSuccessEmail(
email: string,
p: PaymentRequestMeta,
host: string,
space: string,
) {
const transport = getSmtpTransport();
if (!transport) {
console.warn('[rcart] SMTP not configured — skipping payment email');
return;
}
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
const explorer = CHAIN_EXPLORERS[p.chainId];
const txLink = explorer && p.txHash
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
const rflowsUrl = `https://${host}/${space}/rflows`;
const dashboardUrl = `https://${host}/${space}/rcart`;
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#10003;</div>
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">Payment Received</h1>
</td></tr>
<!-- Body -->
<tr><td style="background:#1a1a24;padding:28px 24px">
<!-- Details table -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Method</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${p.paymentMethod || 'N/A'}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
</table>
<!-- What happens next -->
<div style="background:#12121a;border:1px solid #2a2a3a;border-radius:8px;padding:20px;margin-bottom:24px">
<h2 style="margin:0 0 8px;color:#f0f0f5;font-size:16px;font-weight:600">What happens next</h2>
<p style="margin:0 0 16px;color:#9ca3af;font-size:14px;line-height:1.6">
Your contribution flows into a funding flow that distributes resources across the project.
Track how funds are allocated in real time.
</p>
<a href="${rflowsUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View rFlows</a>
</div>
<!-- Resources -->
<div style="margin-bottom:8px">
<h2 style="margin:0 0 10px;color:#f0f0f5;font-size:16px;font-weight:600">Resources</h2>
<p style="margin:0 0 6px;font-size:14px;line-height:1.6">
<a href="https://psilo-cyber.net/ics" style="color:#67e8f9;text-decoration:none">Interplanetary Coordination System</a>
</p>
<p style="margin:0;color:#6b7280;font-size:13px;line-height:1.6">
Endosymbiotic Finance (coming soon)
</p>
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
<p style="margin:0;color:#6b7280;font-size:12px">
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
const text = [
`Payment Received`,
``,
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
`Network: ${chainName}`,
`Method: ${p.paymentMethod || 'N/A'}`,
`Transaction: ${p.txHash || 'N/A'}`,
`Date: ${paidDate}`,
``,
`What happens next:`,
`Your contribution flows into a funding flow that distributes resources across the project.`,
`View rFlows: ${rflowsUrl}`,
``,
`Resources:`,
`Interplanetary Coordination System: https://psilo-cyber.net/ics`,
`Endosymbiotic Finance (coming soon)`,
``,
`Sent by rSpace — ${dashboardUrl}`,
].join('\n');
await transport.sendMail({
from: 'rSpace <noreply@rspace.online>',
to: email,
subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`,
html,
text,
});
}
function paymentToResponse(p: PaymentRequestMeta) {
return {
id: p.id,
description: p.description,
amount: p.amount,
amountEditable: p.amountEditable,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
payerIdentity: p.payerIdentity,
transakOrderId: p.transakOrderId,
paymentType: p.paymentType || 'single',
maxPayments: p.maxPayments || 0,
paymentCount: p.paymentCount || 0,
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
creatorUsername: p.creatorUsername || '',
interval: p.interval || null,
nextDueAt: p.nextDueAt ? new Date(p.nextDueAt).toISOString() : null,
paymentHistory: (p.paymentHistory || []).map(h => ({
txHash: h.txHash,
paymentMethod: h.paymentMethod,
amount: h.amount,
paidAt: new Date(h.paidAt).toISOString(),
})),
created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
expires_at: p.expiresAt ? new Date(p.expiresAt).toISOString() : null,
};
}
// ── GROUP BUY ROUTES ──
// POST /api/group-buys — Create a group buy from a catalog entry
routes.post("/api/group-buys", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { catalogEntryId, tiers, description, closesInDays } = body;
if (!catalogEntryId || !tiers?.length) return c.json({ error: "catalogEntryId and tiers[] required" }, 400);
const catDoc = _syncServer!.getDoc<CatalogDoc>(catalogDocId(space));
const entry = catDoc?.items?.[catalogEntryId];
if (!entry) return c.json({ error: "Catalog entry not found" }, 404);
const buyId = crypto.randomUUID();
const now = Date.now();
const docId = groupBuyDocId(space, buyId);
const doc = Automerge.change(Automerge.init<GroupBuyDoc>(), 'create group buy', (d) => {
const init = groupBuySchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.meta.createdAt = now;
d.buy.id = buyId;
d.buy.catalogEntryId = catalogEntryId;
d.buy.artifactId = entry.artifactId;
d.buy.title = entry.title;
d.buy.productType = entry.productType;
d.buy.imageUrl = (entry as any).imageUrl || null;
d.buy.description = description || '';
d.buy.status = 'OPEN';
d.buy.totalPledged = 0;
d.buy.createdBy = claims.sub || null;
d.buy.createdAt = now;
d.buy.updatedAt = now;
d.buy.closesAt = closesInDays ? now + closesInDays * 86400000 : now + 30 * 86400000;
for (const tier of tiers) {
d.buy.tiers.push({ min_qty: tier.min_qty, per_unit: tier.per_unit, currency: tier.currency || 'USD' });
}
});
_syncServer!.setDoc(docId, doc);
const host = c.req.header('host') || 'rspace.online';
const shareUrl = `https://${host}/${space}/rcart/group-buy/${buyId}`;
return c.json({ id: buyId, shareUrl }, 201);
});
// GET /api/group-buys — List open group buys for a space
routes.get("/api/group-buys", (c) => {
const space = c.req.param("space") || "demo";
const prefix = `${space}:cart:group-buys:`;
const buys: any[] = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<GroupBuyDoc>(id);
if (doc?.buy) {
buys.push({
id: doc.buy.id,
title: doc.buy.title,
productType: doc.buy.productType,
imageUrl: doc.buy.imageUrl,
status: doc.buy.status,
totalPledged: doc.buy.totalPledged,
tiers: doc.buy.tiers,
closesAt: new Date(doc.buy.closesAt).toISOString(),
createdAt: new Date(doc.buy.createdAt).toISOString(),
});
}
}
}
return c.json({ groupBuys: buys });
});
// GET /api/group-buys/:id — Get group buy details
routes.get("/api/group-buys/:id", (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
const docId = groupBuyDocId(space, buyId);
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
if (!doc) return c.json({ error: "Group buy not found" }, 404);
const pledges = Object.values(doc.pledges || {}).map(p => ({
id: p.id,
displayName: p.displayName,
quantity: p.quantity,
pledgedAt: new Date(p.pledgedAt).toISOString(),
}));
const currentTier = [...doc.buy.tiers].reverse().find(t => doc.buy.totalPledged >= t.min_qty) || doc.buy.tiers[0] || null;
return c.json({
id: doc.buy.id,
catalogEntryId: doc.buy.catalogEntryId,
title: doc.buy.title,
productType: doc.buy.productType,
imageUrl: doc.buy.imageUrl,
description: doc.buy.description,
tiers: doc.buy.tiers,
status: doc.buy.status,
totalPledged: doc.buy.totalPledged,
currentTier,
pledges,
closesAt: new Date(doc.buy.closesAt).toISOString(),
createdAt: new Date(doc.buy.createdAt).toISOString(),
updatedAt: new Date(doc.buy.updatedAt).toISOString(),
});
});
// POST /api/group-buys/:id/pledge — Add a pledge
routes.post("/api/group-buys/:id/pledge", async (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
const docId = groupBuyDocId(space, buyId);
const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
if (!doc) return c.json({ error: "Group buy not found" }, 404);
if (doc.buy.status !== 'OPEN') return c.json({ error: "Group buy is no longer open" }, 400);
const body = await c.req.json();
const { quantity, displayName } = body;
if (!quantity || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);
let buyerId: string | null = null;
const token = extractToken(c.req.raw.headers);
if (token) {
try { const claims = await verifyEncryptIDToken(token); buyerId = claims.sub || null; } catch { /* public pledge */ }
}
const pledgeId = crypto.randomUUID();
const now = Date.now();
_syncServer!.changeDoc<GroupBuyDoc>(docId, 'add pledge', (d) => {
d.pledges[pledgeId] = {
id: pledgeId,
buyerId,
displayName: displayName || 'Anonymous',
quantity,
pledgedAt: now,
};
d.buy.totalPledged += quantity;
d.buy.updatedAt = now;
});
const updated = _syncServer!.getDoc<GroupBuyDoc>(docId)!;
const currentTier = [...updated.buy.tiers].reverse().find(t => updated.buy.totalPledged >= t.min_qty) || updated.buy.tiers[0] || null;
return c.json({
pledgeId,
totalPledged: updated.buy.totalPledged,
currentTier,
}, 201);
});
// ── Page route: group buy page ──
routes.get("/group-buy/:id", (c) => {
const space = c.req.param("space") || "demo";
const buyId = c.req.param("id");
return c.html(renderShell({
title: `Group Buy | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-group-buy-page space="${space}" buy-id="${buyId}"></folk-group-buy-page>`,
scripts: `<script type="module" src="/modules/rcart/folk-group-buy-page.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: payments dashboard ──
routes.get("/payments", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Payments | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payments-dashboard space="${space}"></folk-payments-dashboard>`,
scripts: `<script type="module" src="/modules/rcart/folk-payments-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Request Payment | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-request space="${space}"></folk-payment-request>`,
scripts: `<script type="module" src="/modules/rcart/folk-payment-request.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: payment page ──
routes.get("/pay/:id", (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
return c.html(renderShell({
title: `Payment | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-page space="${space}" payment-id="${paymentId}"></folk-payment-page>`,
scripts: `<script type="module" src="/modules/rcart/folk-payment-page.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page routes: shop views (subnav tab links) ──
function renderShop(space: string, view?: string) {
const viewAttr = view ? ` initial-view="${view}"` : "";
return renderShell({
title: `${space} — Shop | rSpace`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
});
}
routes.get("/carts", (c) => c.html(renderShop(c.req.param("space") || "demo", "carts")));
routes.get("/catalog", (c) => c.html(renderShop(c.req.param("space") || "demo", "catalog")));
routes.get("/orders", (c) => c.html(renderShop(c.req.param("space") || "demo", "orders")));
routes.get("/group-buys", (c) => c.html(renderShop(c.req.param("space") || "demo", "group-buys")));
routes.get("/subscriptions", (c) => c.html(renderShop(c.req.param("space") || "demo", "subscriptions")));
routes.get("/", (c) => c.html(renderShop(c.req.param("space") || "demo")));
// ── Seed template data ──
function seedTemplateCart(space: string) {
if (!_syncServer) return;
const doc = ensureCatalogDoc(space);
if (Object.keys(doc.items).length > 0) return;
const docId = catalogDocId(space);
const now = Date.now();
const items: Array<{ title: string; type: string; caps: string[]; subs: string[]; tags: string[] }> = [
{
title: 'Commons Community Sticker Pack', type: 'sticker',
caps: ['laser-print'], subs: ['vinyl-matte'],
tags: ['merch', 'stickers'],
},
{
title: 'Cosmolocal Network Poster (A2)', type: 'poster',
caps: ['risograph'], subs: ['paper-heavyweight'],
tags: ['merch', 'poster', 'cosmolocal'],
},
{
title: 'rSpace Contributor Tee', type: 'apparel',
caps: ['screen-print'], subs: ['cotton-organic'],
tags: ['merch', 'apparel'],
},
];
_syncServer.changeDoc<CatalogDoc>(docId, 'seed template catalog', (d) => {
for (const item of items) {
const id = crypto.randomUUID();
d.items[id] = {
id, artifactId: crypto.randomUUID(), artifact: null,
title: item.title, productType: item.type,
requiredCapabilities: item.caps, substrates: item.subs,
creatorId: 'did:demo:seed', sourceSpace: space,
tags: item.tags, status: 'active',
createdAt: now, updatedAt: now,
};
}
});
console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`);
}
export const cartModule: RSpaceModule = {
id: "rcart",
name: "rCart",
icon: "🛒",
description: "Group shopping & cosmolocal print-on-demand shop",
publicWrite: true,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [
{ pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init },
{ pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init },
{ pattern: '{space}:cart:shopping:{cartId}', description: 'Shopping cart', init: shoppingCartSchema.init },
{ pattern: '{space}:cart:shopping-index', description: 'Shopping cart index', init: shoppingCartIndexSchema.init },
{ pattern: '{space}:cart:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init },
],
routes,
standaloneDomain: "rcart.online",
landingPage: renderLanding,
seedTemplate: seedTemplateCart,
async onInit(ctx) {
_syncServer = ctx.syncServer;
startSubscriptionScheduler();
},
feeds: [
{
id: "orders",
name: "Orders",
kind: "economic",
description: "Order stream with pricing, fulfillment status, and revenue splits",
filterable: true,
},
{
id: "catalog",
name: "Catalog",
kind: "data",
description: "Active catalog listings with product details and pricing",
filterable: true,
},
{
id: "shopping",
name: "Shopping Carts",
kind: "data",
description: "Group shopping carts with pooled items and contributions",
filterable: true,
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" },
{ path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" },
{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
{ path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" },
{ path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" },
{ path: "subscriptions", name: "Subscriptions", icon: "🔄", description: "Recurring subscription orders" },
],
};