ucpify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { db } from './db';
3
+ import { logger } from './logger';
4
+ import { createPaymentIntent, isStripeConfigured } from './stripe';
5
+ import { createPayPalOrder, isPayPalConfigured } from './paypal';
6
+ import {
7
+ MerchantConfig,
8
+ CheckoutSession,
9
+ Order,
10
+ LineItem,
11
+ Buyer,
12
+ Fulfillment,
13
+ CheckoutMessage,
14
+ UCP_VERSION,
15
+ Item,
16
+ OrderConfirmation,
17
+ } from './schema';
18
+
19
+ export class UCPServerDB {
20
+ private config: MerchantConfig;
21
+ private itemsMap: Map<string, Item>;
22
+
23
+ constructor(config: MerchantConfig) {
24
+ this.config = config;
25
+ this.itemsMap = new Map(config.items.map((item) => [item.id, item]));
26
+ }
27
+
28
+ getProfile() {
29
+ const paymentHandlers: Record<string, { id: string; version: string; config?: Record<string, unknown> }[]> = {};
30
+ for (const handler of this.config.payment_handlers) {
31
+ paymentHandlers[handler.namespace] = [
32
+ { id: handler.id, version: UCP_VERSION, config: handler.config },
33
+ ];
34
+ }
35
+
36
+ return {
37
+ ucp: {
38
+ version: UCP_VERSION,
39
+ services: {
40
+ 'dev.ucp.shopping': [
41
+ {
42
+ version: UCP_VERSION,
43
+ spec: 'https://ucp.dev/specification/overview',
44
+ transport: 'rest',
45
+ endpoint: `${this.config.domain}/ucp/v1`,
46
+ schema: 'https://ucp.dev/services/shopping/rest.openapi.json',
47
+ },
48
+ ],
49
+ },
50
+ capabilities: {
51
+ 'dev.ucp.shopping.checkout': [
52
+ { version: UCP_VERSION, spec: 'https://ucp.dev/specification/checkout', schema: 'https://ucp.dev/schemas/shopping/checkout.json' },
53
+ ],
54
+ 'dev.ucp.shopping.fulfillment': [
55
+ { version: UCP_VERSION, spec: 'https://ucp.dev/specification/fulfillment', schema: 'https://ucp.dev/schemas/shopping/fulfillment.json', extends: 'dev.ucp.shopping.checkout' },
56
+ ],
57
+ 'dev.ucp.shopping.order': [
58
+ { version: UCP_VERSION, spec: 'https://ucp.dev/specification/order', schema: 'https://ucp.dev/schemas/shopping/order.json' },
59
+ ],
60
+ },
61
+ payment_handlers: paymentHandlers,
62
+ },
63
+ };
64
+ }
65
+
66
+ private buildLinks(): { type: string; url: string }[] {
67
+ const links: { type: string; url: string }[] = [];
68
+ if (this.config.terms_url) links.push({ type: 'terms_of_service', url: this.config.terms_url });
69
+ if (this.config.privacy_url) links.push({ type: 'privacy_policy', url: this.config.privacy_url });
70
+ return links;
71
+ }
72
+
73
+ private buildPaymentHandlers(): Record<string, { id: string; version: string; config?: Record<string, unknown> }[]> {
74
+ const paymentHandlers: Record<string, { id: string; version: string; config?: Record<string, unknown> }[]> = {};
75
+ for (const handler of this.config.payment_handlers) {
76
+ paymentHandlers[handler.namespace] = [{ id: handler.id, version: UCP_VERSION, config: handler.config }];
77
+ }
78
+ return paymentHandlers;
79
+ }
80
+
81
+ private validateCheckout(session: { buyer?: Buyer; fulfillment?: Fulfillment }): CheckoutMessage[] {
82
+ const messages: CheckoutMessage[] = [];
83
+ if (!session.buyer?.email) {
84
+ messages.push({ type: 'error', code: 'missing', path: '$.buyer.email', content: 'Buyer email is required', severity: 'recoverable' });
85
+ }
86
+ if (!session.fulfillment?.methods?.[0]?.selected_destination_id) {
87
+ messages.push({ type: 'error', code: 'missing', path: '$.fulfillment.methods[0].selected_destination_id', content: 'Shipping address is required', severity: 'recoverable' });
88
+ }
89
+ return messages;
90
+ }
91
+
92
+ private calculateTotals(lineItems: LineItem[], shippingTotal = 0): { type: string; amount: number }[] {
93
+ const subtotal = lineItems.reduce((sum, li) => sum + li.item.price * li.quantity, 0);
94
+ const tax = Math.round(subtotal * this.config.tax_rate);
95
+ const totals = [
96
+ { type: 'subtotal', amount: subtotal },
97
+ { type: 'tax', amount: tax },
98
+ { type: 'total', amount: subtotal + shippingTotal + tax },
99
+ ];
100
+ if (shippingTotal > 0) {
101
+ totals.splice(1, 0, { type: 'shipping', amount: shippingTotal });
102
+ }
103
+ return totals;
104
+ }
105
+
106
+ private sessionFromRow(row: any): CheckoutSession | undefined {
107
+ if (!row) return undefined;
108
+
109
+ const lineItems = db.prepare('SELECT * FROM line_items WHERE checkout_id = ?').all(row.id) as any[];
110
+
111
+ return {
112
+ ucp: {
113
+ version: UCP_VERSION,
114
+ capabilities: { 'dev.ucp.shopping.checkout': [{ version: UCP_VERSION }] },
115
+ payment_handlers: this.buildPaymentHandlers(),
116
+ },
117
+ id: row.id,
118
+ status: row.status,
119
+ currency: row.currency,
120
+ buyer: row.buyer_json ? JSON.parse(row.buyer_json) : undefined,
121
+ line_items: lineItems.map((li) => ({
122
+ id: li.id,
123
+ item: JSON.parse(li.item_json),
124
+ quantity: li.quantity,
125
+ totals: li.totals_json ? JSON.parse(li.totals_json) : undefined,
126
+ })),
127
+ totals: JSON.parse(row.totals_json),
128
+ links: JSON.parse(row.links_json),
129
+ fulfillment: row.fulfillment_json ? JSON.parse(row.fulfillment_json) : undefined,
130
+ payment: row.payment_json ? JSON.parse(row.payment_json) : undefined,
131
+ messages: row.messages_json ? JSON.parse(row.messages_json) : undefined,
132
+ continue_url: row.continue_url || undefined,
133
+ expires_at: row.expires_at || undefined,
134
+ order: row.order_json ? JSON.parse(row.order_json) : undefined,
135
+ };
136
+ }
137
+
138
+ createCheckout(data: { line_items: { id?: string; item: { id: string; title?: string; price?: number }; quantity: number }[] }): CheckoutSession {
139
+ const id = `chk_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
140
+ const now = new Date().toISOString();
141
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
142
+
143
+ const lineItems: LineItem[] = data.line_items.map((li, idx) => {
144
+ const catalogItem = this.itemsMap.get(li.item.id);
145
+ const item: Item = catalogItem || { id: li.item.id, title: li.item.title || 'Unknown Item', price: li.item.price || 0 };
146
+ const subtotal = item.price * li.quantity;
147
+ return {
148
+ id: li.id || `li_${idx + 1}`,
149
+ item,
150
+ quantity: li.quantity,
151
+ totals: [{ type: 'subtotal', amount: subtotal }, { type: 'total', amount: subtotal }],
152
+ };
153
+ });
154
+
155
+ const totals = this.calculateTotals(lineItems);
156
+ const links = this.buildLinks();
157
+ const messages = this.validateCheckout({});
158
+ const status = messages.length > 0 ? 'incomplete' : 'ready_for_complete';
159
+ const continueUrl = `${this.config.domain}/checkout/${id}`;
160
+
161
+ const insertCheckout = db.prepare(`
162
+ INSERT INTO checkouts (id, status, currency, totals_json, links_json, messages_json, continue_url, expires_at, created_at, updated_at)
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
+ `);
165
+
166
+ const insertLineItem = db.prepare(`
167
+ INSERT INTO line_items (id, checkout_id, item_json, quantity, totals_json)
168
+ VALUES (?, ?, ?, ?, ?)
169
+ `);
170
+
171
+ db.transaction(() => {
172
+ insertCheckout.run(id, status, this.config.currency, JSON.stringify(totals), JSON.stringify(links),
173
+ messages.length > 0 ? JSON.stringify(messages) : null, continueUrl, expiresAt, now, now);
174
+ for (const li of lineItems) {
175
+ insertLineItem.run(li.id, id, JSON.stringify(li.item), li.quantity, JSON.stringify(li.totals));
176
+ }
177
+ })();
178
+
179
+ logger.info({ checkoutId: id, lineItems: lineItems.length }, 'Checkout created');
180
+ return this.getCheckout(id)!;
181
+ }
182
+
183
+ getCheckout(id: string): CheckoutSession | undefined {
184
+ const row = db.prepare('SELECT * FROM checkouts WHERE id = ?').get(id);
185
+ return this.sessionFromRow(row);
186
+ }
187
+
188
+ updateCheckout(id: string, data: { buyer?: Buyer; line_items?: any[]; fulfillment?: Fulfillment }): CheckoutSession | undefined {
189
+ const session = this.getCheckout(id);
190
+ if (!session) return undefined;
191
+
192
+ const now = new Date().toISOString();
193
+ let buyer = session.buyer;
194
+ let lineItems = session.line_items;
195
+ let fulfillment = session.fulfillment;
196
+
197
+ if (data.buyer) {
198
+ buyer = { ...buyer, ...data.buyer };
199
+ }
200
+
201
+ if (data.line_items) {
202
+ lineItems = data.line_items.map((li, idx) => {
203
+ const catalogItem = this.itemsMap.get(li.item.id);
204
+ const item: Item = catalogItem || { id: li.item.id, title: li.item.title || 'Unknown Item', price: li.item.price || 0 };
205
+ const subtotal = item.price * li.quantity;
206
+ return {
207
+ id: li.id || `li_${idx + 1}`,
208
+ item,
209
+ quantity: li.quantity,
210
+ totals: [{ type: 'subtotal', amount: subtotal }, { type: 'total', amount: subtotal }],
211
+ };
212
+ });
213
+ }
214
+
215
+ if (data.fulfillment) {
216
+ const methods = data.fulfillment.methods.map((method, mIdx) => {
217
+ const destinations = method.destinations.map((dest, dIdx) => ({ ...dest, id: dest.id || `dest_${dIdx + 1}` }));
218
+ const groups = method.groups?.length ? method.groups : [{
219
+ id: `group_${mIdx + 1}`,
220
+ line_item_ids: lineItems.map((li) => li.id),
221
+ selected_option_id: this.config.shipping_options[0]?.id,
222
+ options: this.config.shipping_options.map((opt) => ({
223
+ id: opt.id, title: opt.title, description: opt.description || opt.estimated_days,
224
+ totals: [{ type: 'total', amount: opt.price }],
225
+ })),
226
+ }];
227
+ return {
228
+ id: method.id || `method_${mIdx + 1}`,
229
+ type: method.type,
230
+ line_item_ids: method.line_item_ids || lineItems.map((li) => li.id),
231
+ selected_destination_id: method.selected_destination_id || destinations[0]?.id,
232
+ destinations,
233
+ groups,
234
+ };
235
+ });
236
+ fulfillment = { methods };
237
+ }
238
+
239
+ // Calculate shipping total
240
+ let shippingTotal = 0;
241
+ if (fulfillment) {
242
+ for (const method of fulfillment.methods) {
243
+ for (const group of method.groups) {
244
+ const selectedOption = group.options.find((o) => o.id === group.selected_option_id);
245
+ if (selectedOption) {
246
+ const total = selectedOption.totals.find((t) => t.type === 'total');
247
+ shippingTotal += total?.amount || 0;
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ const totals = this.calculateTotals(lineItems, shippingTotal);
254
+ const messages = this.validateCheckout({ buyer, fulfillment });
255
+ const status = messages.length > 0 ? 'incomplete' : 'ready_for_complete';
256
+
257
+ db.transaction(() => {
258
+ db.prepare(`
259
+ UPDATE checkouts SET status = ?, buyer_json = ?, fulfillment_json = ?, totals_json = ?, messages_json = ?, updated_at = ?
260
+ WHERE id = ?
261
+ `).run(status, buyer ? JSON.stringify(buyer) : null, fulfillment ? JSON.stringify(fulfillment) : null,
262
+ JSON.stringify(totals), messages.length > 0 ? JSON.stringify(messages) : null, now, id);
263
+
264
+ db.prepare('DELETE FROM line_items WHERE checkout_id = ?').run(id);
265
+ const insertLineItem = db.prepare('INSERT INTO line_items (id, checkout_id, item_json, quantity, totals_json) VALUES (?, ?, ?, ?, ?)');
266
+ for (const li of lineItems) {
267
+ insertLineItem.run(li.id, id, JSON.stringify(li.item), li.quantity, JSON.stringify(li.totals));
268
+ }
269
+ })();
270
+
271
+ logger.info({ checkoutId: id, status }, 'Checkout updated');
272
+ return this.getCheckout(id);
273
+ }
274
+
275
+ async completeCheckout(id: string): Promise<CheckoutSession | { error: string }> {
276
+ const session = this.getCheckout(id);
277
+ if (!session) return { error: 'Checkout session not found' };
278
+ if (session.status !== 'ready_for_complete') {
279
+ return { error: 'Checkout is not ready for completion', messages: session.messages } as any;
280
+ }
281
+
282
+ const orderId = `order_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
283
+ const now = new Date().toISOString();
284
+
285
+ // Get total amount for payment
286
+ const totalAmount = session.totals.find(t => t.type === 'total')?.amount || 0;
287
+
288
+ // Payment processing - try Stripe first, then PayPal
289
+ let paymentIntentId: string | null = null;
290
+ let paypalOrderId: string | null = null;
291
+ let paymentProvider: string | null = null;
292
+ let paymentStatus = 'none';
293
+
294
+ if (totalAmount > 0) {
295
+ // Try Stripe first
296
+ if (isStripeConfigured()) {
297
+ try {
298
+ const paymentIntent = await createPaymentIntent({
299
+ amount: totalAmount,
300
+ currency: session.currency,
301
+ checkoutId: id,
302
+ customerEmail: session.buyer?.email,
303
+ metadata: { order_id: orderId },
304
+ });
305
+ if (paymentIntent) {
306
+ paymentIntentId = paymentIntent.id;
307
+ paymentProvider = 'stripe';
308
+ paymentStatus = 'pending';
309
+ logger.info({ checkoutId: id, paymentIntentId, amount: totalAmount }, 'Stripe payment intent created');
310
+ }
311
+ } catch (error) {
312
+ logger.error({ error, checkoutId: id }, 'Failed to create Stripe payment intent');
313
+ return { error: 'Payment processing failed' };
314
+ }
315
+ }
316
+ // Fall back to PayPal if Stripe not configured
317
+ else if (isPayPalConfigured()) {
318
+ try {
319
+ const ppOrder = await createPayPalOrder({
320
+ amount: totalAmount,
321
+ currency: session.currency,
322
+ checkoutId: id,
323
+ description: `Order ${orderId}`,
324
+ });
325
+ if (ppOrder) {
326
+ paypalOrderId = ppOrder.id;
327
+ paymentProvider = 'paypal';
328
+ paymentStatus = 'pending';
329
+ logger.info({ checkoutId: id, paypalOrderId, amount: totalAmount }, 'PayPal order created');
330
+ }
331
+ } catch (error) {
332
+ logger.error({ error, checkoutId: id }, 'Failed to create PayPal order');
333
+ return { error: 'Payment processing failed' };
334
+ }
335
+ }
336
+ }
337
+
338
+ const orderFulfillment = session.fulfillment ? {
339
+ expectations: session.fulfillment.methods.map((m, idx) => ({
340
+ id: `exp_${idx + 1}`,
341
+ line_items: m.line_item_ids.map((liId) => {
342
+ const li = session.line_items.find((l) => l.id === liId);
343
+ return { id: liId, quantity: li?.quantity || 1 };
344
+ }),
345
+ method_type: m.type,
346
+ destination: m.destinations.find((d) => d.id === m.selected_destination_id),
347
+ description: 'Arrives in 5-7 business days',
348
+ fulfillable_on: 'now',
349
+ })),
350
+ events: [],
351
+ } : undefined;
352
+
353
+ const orderConfirmation: OrderConfirmation = {
354
+ id: orderId,
355
+ permalink_url: `${this.config.domain}/orders/${orderId}`,
356
+ };
357
+
358
+ db.transaction(() => {
359
+ db.prepare(`
360
+ INSERT INTO orders (id, checkout_id, permalink_url, buyer_json, line_items_json, totals_json, fulfillment_json, payment_intent_id, paypal_order_id, payment_provider, payment_status, created_at)
361
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
362
+ `).run(orderId, id, orderConfirmation.permalink_url, session.buyer ? JSON.stringify(session.buyer) : null,
363
+ JSON.stringify(session.line_items), JSON.stringify(session.totals),
364
+ orderFulfillment ? JSON.stringify(orderFulfillment) : null, paymentIntentId, paypalOrderId, paymentProvider, paymentStatus, now);
365
+
366
+ db.prepare(`
367
+ UPDATE checkouts SET status = 'completed', order_json = ?, continue_url = NULL, messages_json = NULL, payment_intent_id = ?, paypal_order_id = ?, payment_provider = ?, payment_status = ?, updated_at = ?
368
+ WHERE id = ?
369
+ `).run(JSON.stringify(orderConfirmation), paymentIntentId, paypalOrderId, paymentProvider, paymentStatus, now, id);
370
+ })();
371
+
372
+ logger.info({ checkoutId: id, orderId, paymentProvider, paymentIntentId, paypalOrderId }, 'Checkout completed, order created');
373
+ return this.getCheckout(id)!;
374
+ }
375
+
376
+ cancelCheckout(id: string): CheckoutSession | undefined {
377
+ const session = this.getCheckout(id);
378
+ if (!session) return undefined;
379
+
380
+ db.prepare(`UPDATE checkouts SET status = 'canceled', continue_url = NULL, updated_at = ? WHERE id = ?`)
381
+ .run(new Date().toISOString(), id);
382
+
383
+ logger.info({ checkoutId: id }, 'Checkout canceled');
384
+ return this.getCheckout(id);
385
+ }
386
+
387
+ getOrder(id: string): Order | undefined {
388
+ const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(id) as any;
389
+ if (!row) return undefined;
390
+
391
+ return {
392
+ ucp: { version: UCP_VERSION, capabilities: { 'dev.ucp.shopping.order': [{ version: UCP_VERSION }] } },
393
+ id: row.id,
394
+ checkout_id: row.checkout_id,
395
+ permalink_url: row.permalink_url,
396
+ line_items: JSON.parse(row.line_items_json),
397
+ buyer: row.buyer_json ? JSON.parse(row.buyer_json) : undefined,
398
+ totals: JSON.parse(row.totals_json),
399
+ fulfillment: row.fulfillment_json ? JSON.parse(row.fulfillment_json) : undefined,
400
+ created_at: row.created_at,
401
+ };
402
+ }
403
+
404
+ listOrders(): Order[] {
405
+ const rows = db.prepare('SELECT * FROM orders ORDER BY created_at DESC').all() as any[];
406
+ return rows.map((row) => this.getOrder(row.id)!);
407
+ }
408
+
409
+ getConfig(): MerchantConfig {
410
+ return this.config;
411
+ }
412
+ }