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,380 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import {
3
+ MerchantConfig,
4
+ CheckoutSession,
5
+ Order,
6
+ LineItem,
7
+ Buyer,
8
+ Fulfillment,
9
+ CheckoutMessage,
10
+ UCP_VERSION,
11
+ Item,
12
+ OrderConfirmation,
13
+ } from './schema';
14
+
15
+ // In-memory stores
16
+ const checkoutSessions: Map<string, CheckoutSession> = new Map();
17
+ const orders: Map<string, Order> = new Map();
18
+
19
+ export class UCPServer {
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
+ // Generate UCP profile for /.well-known/ucp
29
+ getProfile() {
30
+ const paymentHandlers: Record<string, { id: string; version: string; config?: Record<string, unknown> }[]> = {};
31
+ for (const handler of this.config.payment_handlers) {
32
+ paymentHandlers[handler.namespace] = [
33
+ {
34
+ id: handler.id,
35
+ version: UCP_VERSION,
36
+ config: handler.config,
37
+ },
38
+ ];
39
+ }
40
+
41
+ return {
42
+ ucp: {
43
+ version: UCP_VERSION,
44
+ services: {
45
+ 'dev.ucp.shopping': [
46
+ {
47
+ version: UCP_VERSION,
48
+ spec: 'https://ucp.dev/specification/overview',
49
+ transport: 'rest',
50
+ endpoint: `${this.config.domain}/ucp/v1`,
51
+ schema: 'https://ucp.dev/services/shopping/rest.openapi.json',
52
+ },
53
+ ],
54
+ },
55
+ capabilities: {
56
+ 'dev.ucp.shopping.checkout': [
57
+ {
58
+ version: UCP_VERSION,
59
+ spec: 'https://ucp.dev/specification/checkout',
60
+ schema: 'https://ucp.dev/schemas/shopping/checkout.json',
61
+ },
62
+ ],
63
+ 'dev.ucp.shopping.fulfillment': [
64
+ {
65
+ version: UCP_VERSION,
66
+ spec: 'https://ucp.dev/specification/fulfillment',
67
+ schema: 'https://ucp.dev/schemas/shopping/fulfillment.json',
68
+ extends: 'dev.ucp.shopping.checkout',
69
+ },
70
+ ],
71
+ 'dev.ucp.shopping.order': [
72
+ {
73
+ version: UCP_VERSION,
74
+ spec: 'https://ucp.dev/specification/order',
75
+ schema: 'https://ucp.dev/schemas/shopping/order.json',
76
+ },
77
+ ],
78
+ },
79
+ payment_handlers: paymentHandlers,
80
+ },
81
+ };
82
+ }
83
+
84
+ // Calculate totals for line items
85
+ private calculateLineItemTotals(lineItems: LineItem[]): { type: string; amount: number }[] {
86
+ const subtotal = lineItems.reduce((sum, li) => sum + li.item.price * li.quantity, 0);
87
+ const tax = Math.round(subtotal * this.config.tax_rate);
88
+ return [
89
+ { type: 'subtotal', amount: subtotal },
90
+ { type: 'tax', amount: tax },
91
+ { type: 'total', amount: subtotal + tax },
92
+ ];
93
+ }
94
+
95
+ // Validate checkout and generate messages
96
+ private validateCheckout(session: Partial<CheckoutSession>): CheckoutMessage[] {
97
+ const messages: CheckoutMessage[] = [];
98
+
99
+ if (!session.buyer?.email) {
100
+ messages.push({
101
+ type: 'error',
102
+ code: 'missing',
103
+ path: '$.buyer.email',
104
+ content: 'Buyer email is required',
105
+ severity: 'recoverable',
106
+ });
107
+ }
108
+
109
+ if (!session.fulfillment?.methods?.[0]?.selected_destination_id) {
110
+ messages.push({
111
+ type: 'error',
112
+ code: 'missing',
113
+ path: '$.fulfillment.methods[0].selected_destination_id',
114
+ content: 'Shipping address is required',
115
+ severity: 'recoverable',
116
+ });
117
+ }
118
+
119
+ return messages;
120
+ }
121
+
122
+ // Create checkout session
123
+ createCheckout(data: { line_items: { id?: string; item: { id: string; title?: string; price?: number }; quantity: number }[] }): CheckoutSession {
124
+ const id = `chk_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
125
+
126
+ // Resolve items from catalog or use provided
127
+ const lineItems: LineItem[] = data.line_items.map((li, idx) => {
128
+ const catalogItem = this.itemsMap.get(li.item.id);
129
+ const item: Item = catalogItem || {
130
+ id: li.item.id,
131
+ title: li.item.title || 'Unknown Item',
132
+ price: li.item.price || 0,
133
+ };
134
+
135
+ const subtotal = item.price * li.quantity;
136
+ return {
137
+ id: li.id || `li_${idx + 1}`,
138
+ item,
139
+ quantity: li.quantity,
140
+ totals: [
141
+ { type: 'subtotal', amount: subtotal },
142
+ { type: 'total', amount: subtotal },
143
+ ],
144
+ };
145
+ });
146
+
147
+ const totals = this.calculateLineItemTotals(lineItems);
148
+ const messages = this.validateCheckout({ line_items: lineItems });
149
+
150
+ const paymentHandlers: Record<string, { id: string; version: string; config?: Record<string, unknown> }[]> = {};
151
+ for (const handler of this.config.payment_handlers) {
152
+ paymentHandlers[handler.namespace] = [{ id: handler.id, version: UCP_VERSION, config: handler.config }];
153
+ }
154
+
155
+ // Build links
156
+ const links: { type: string; url: string }[] = [];
157
+ if (this.config.terms_url) {
158
+ links.push({ type: 'terms_of_service', url: this.config.terms_url });
159
+ }
160
+ if (this.config.privacy_url) {
161
+ links.push({ type: 'privacy_policy', url: this.config.privacy_url });
162
+ }
163
+
164
+ // Set expiry (1 hour from now)
165
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
166
+
167
+ const session: CheckoutSession = {
168
+ ucp: {
169
+ version: UCP_VERSION,
170
+ capabilities: {
171
+ 'dev.ucp.shopping.checkout': [{ version: UCP_VERSION }],
172
+ },
173
+ payment_handlers: paymentHandlers,
174
+ },
175
+ id,
176
+ status: messages.length > 0 ? 'incomplete' : 'ready_for_complete',
177
+ messages: messages.length > 0 ? messages : undefined,
178
+ currency: this.config.currency,
179
+ line_items: lineItems,
180
+ totals,
181
+ links,
182
+ continue_url: `${this.config.domain}/checkout/${id}`,
183
+ expires_at: expiresAt,
184
+ };
185
+
186
+ checkoutSessions.set(id, session);
187
+ return session;
188
+ }
189
+
190
+ // Get checkout session
191
+ getCheckout(id: string): CheckoutSession | undefined {
192
+ return checkoutSessions.get(id);
193
+ }
194
+
195
+ // Update checkout session
196
+ updateCheckout(id: string, data: {
197
+ buyer?: Buyer;
198
+ line_items?: { id?: string; item: { id: string; title?: string; price?: number }; quantity: number }[];
199
+ fulfillment?: Fulfillment;
200
+ }): CheckoutSession | undefined {
201
+ const session = checkoutSessions.get(id);
202
+ if (!session) return undefined;
203
+
204
+ // Update buyer
205
+ if (data.buyer) {
206
+ session.buyer = { ...session.buyer, ...data.buyer };
207
+ }
208
+
209
+ // Update line items
210
+ if (data.line_items) {
211
+ session.line_items = data.line_items.map((li, idx) => {
212
+ const catalogItem = this.itemsMap.get(li.item.id);
213
+ const item: Item = catalogItem || {
214
+ id: li.item.id,
215
+ title: li.item.title || 'Unknown Item',
216
+ price: li.item.price || 0,
217
+ };
218
+ const subtotal = item.price * li.quantity;
219
+ return {
220
+ id: li.id || `li_${idx + 1}`,
221
+ item,
222
+ quantity: li.quantity,
223
+ totals: [
224
+ { type: 'subtotal', amount: subtotal },
225
+ { type: 'total', amount: subtotal },
226
+ ],
227
+ };
228
+ });
229
+ }
230
+
231
+ // Update fulfillment with shipping options
232
+ if (data.fulfillment) {
233
+ const methods = data.fulfillment.methods.map((method, mIdx) => {
234
+ const destinations = method.destinations.map((dest, dIdx) => ({
235
+ ...dest,
236
+ id: dest.id || `dest_${dIdx + 1}`,
237
+ }));
238
+
239
+ const groups = method.groups?.length
240
+ ? method.groups
241
+ : [
242
+ {
243
+ id: `group_${mIdx + 1}`,
244
+ line_item_ids: session.line_items.map((li) => li.id),
245
+ selected_option_id: this.config.shipping_options[0]?.id,
246
+ options: this.config.shipping_options.map((opt) => ({
247
+ id: opt.id,
248
+ title: opt.title,
249
+ description: opt.description || opt.estimated_days,
250
+ totals: [{ type: 'total', amount: opt.price }],
251
+ })),
252
+ },
253
+ ];
254
+
255
+ return {
256
+ id: method.id || `method_${mIdx + 1}`,
257
+ type: method.type,
258
+ line_item_ids: method.line_item_ids || session.line_items.map((li) => li.id),
259
+ selected_destination_id: method.selected_destination_id || destinations[0]?.id,
260
+ destinations,
261
+ groups,
262
+ };
263
+ });
264
+
265
+ session.fulfillment = { methods };
266
+ }
267
+
268
+ // Recalculate totals including shipping
269
+ let shippingTotal = 0;
270
+ if (session.fulfillment) {
271
+ for (const method of session.fulfillment.methods) {
272
+ for (const group of method.groups) {
273
+ const selectedOption = group.options.find((o) => o.id === group.selected_option_id);
274
+ if (selectedOption) {
275
+ const total = selectedOption.totals.find((t) => t.type === 'total');
276
+ shippingTotal += total?.amount || 0;
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ const subtotal = session.line_items.reduce((sum, li) => sum + li.item.price * li.quantity, 0);
283
+ const tax = Math.round(subtotal * this.config.tax_rate);
284
+ session.totals = [
285
+ { type: 'subtotal', amount: subtotal },
286
+ { type: 'shipping', amount: shippingTotal },
287
+ { type: 'tax', amount: tax },
288
+ { type: 'total', amount: subtotal + shippingTotal + tax },
289
+ ];
290
+
291
+ // Revalidate
292
+ const newMessages = this.validateCheckout(session);
293
+ session.messages = newMessages.length > 0 ? newMessages : undefined;
294
+ session.status = newMessages.length > 0 ? 'incomplete' : 'ready_for_complete';
295
+
296
+ checkoutSessions.set(id, session);
297
+ return session;
298
+ }
299
+
300
+ // Complete checkout (create order)
301
+ async completeCheckout(id: string): Promise<CheckoutSession | { error: string }> {
302
+ const session = checkoutSessions.get(id);
303
+ if (!session) return { error: 'Checkout session not found' };
304
+ if (session.status !== 'ready_for_complete') {
305
+ return { error: 'Checkout is not ready for completion', messages: session.messages };
306
+ }
307
+
308
+ const orderId = `order_${uuidv4().replace(/-/g, '').slice(0, 12)}`;
309
+
310
+ const order: Order = {
311
+ ucp: {
312
+ version: UCP_VERSION,
313
+ capabilities: {
314
+ 'dev.ucp.shopping.order': [{ version: UCP_VERSION }],
315
+ },
316
+ },
317
+ id: orderId,
318
+ checkout_id: id,
319
+ permalink_url: `${this.config.domain}/orders/${orderId}`,
320
+ line_items: session.line_items,
321
+ buyer: session.buyer,
322
+ totals: session.totals,
323
+ fulfillment: session.fulfillment
324
+ ? {
325
+ expectations: session.fulfillment.methods.map((m, idx) => ({
326
+ id: `exp_${idx + 1}`,
327
+ line_items: m.line_item_ids.map((liId) => {
328
+ const li = session.line_items.find((l) => l.id === liId);
329
+ return { id: liId, quantity: li?.quantity || 1 };
330
+ }),
331
+ method_type: m.type,
332
+ destination: m.destinations.find((d) => d.id === m.selected_destination_id),
333
+ description: 'Arrives in 5-7 business days',
334
+ fulfillable_on: 'now',
335
+ })),
336
+ events: [],
337
+ }
338
+ : undefined,
339
+ created_at: new Date().toISOString(),
340
+ };
341
+
342
+ orders.set(orderId, order);
343
+
344
+ // Update session to completed state with order confirmation
345
+ session.status = 'completed';
346
+ session.order = {
347
+ id: orderId,
348
+ permalink_url: order.permalink_url,
349
+ };
350
+ delete session.continue_url;
351
+ delete session.messages;
352
+ checkoutSessions.set(id, session);
353
+
354
+ return session;
355
+ }
356
+
357
+ // Cancel checkout
358
+ cancelCheckout(id: string): CheckoutSession | undefined {
359
+ const session = checkoutSessions.get(id);
360
+ if (!session) return undefined;
361
+ session.status = 'canceled';
362
+ delete session.continue_url;
363
+ checkoutSessions.set(id, session);
364
+ return session;
365
+ }
366
+
367
+ // Get order
368
+ getOrder(id: string): Order | undefined {
369
+ return orders.get(id);
370
+ }
371
+
372
+ // List orders
373
+ listOrders(): Order[] {
374
+ return Array.from(orders.values());
375
+ }
376
+
377
+ getConfig(): MerchantConfig {
378
+ return this.config;
379
+ }
380
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }