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.
- package/.env.example +23 -0
- package/Dockerfile +35 -0
- package/README.md +320 -0
- package/dist/__tests__/api.test.d.ts +1 -0
- package/dist/__tests__/api.test.js +121 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +207 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +22 -0
- package/dist/db.d.ts +4 -0
- package/dist/db.js +129 -0
- package/dist/express-app.d.ts +5 -0
- package/dist/express-app.js +318 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +15 -0
- package/dist/paypal.d.ts +20 -0
- package/dist/paypal.js +111 -0
- package/dist/schema.d.ts +266 -0
- package/dist/schema.js +98 -0
- package/dist/stripe.d.ts +14 -0
- package/dist/stripe.js +103 -0
- package/dist/ucp-server-db.d.ts +72 -0
- package/dist/ucp-server-db.js +364 -0
- package/dist/ucp-server.d.ts +77 -0
- package/dist/ucp-server.js +331 -0
- package/merchant-config.json +64 -0
- package/package.json +53 -0
- package/src/__tests__/api.test.ts +129 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +27 -0
- package/src/db.ts +94 -0
- package/src/express-app.ts +344 -0
- package/src/index.ts +4 -0
- package/src/logger.ts +12 -0
- package/src/paypal.ts +120 -0
- package/src/schema.ts +244 -0
- package/src/stripe.ts +106 -0
- package/src/ucp-server-db.ts +412 -0
- package/src/ucp-server.ts +380 -0
- package/tsconfig.json +17 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { MerchantConfigSchema, MerchantConfig } from './schema';
|
|
7
|
+
import { createExpressApp } from './express-app';
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('ucp-server')
|
|
13
|
+
.description('Generate and run a UCP-compliant server for merchants')
|
|
14
|
+
.version('1.0.0');
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command('init')
|
|
18
|
+
.description('Create a sample merchant configuration file')
|
|
19
|
+
.option('-o, --output <file>', 'Output file path', 'merchant-config.json')
|
|
20
|
+
.action((options) => {
|
|
21
|
+
const sampleConfig: MerchantConfig = {
|
|
22
|
+
name: 'My Store',
|
|
23
|
+
domain: 'http://localhost:3000',
|
|
24
|
+
currency: 'USD',
|
|
25
|
+
terms_url: 'https://example.com/terms',
|
|
26
|
+
privacy_url: 'https://example.com/privacy',
|
|
27
|
+
tax_rate: 0.08,
|
|
28
|
+
port: 3000,
|
|
29
|
+
items: [
|
|
30
|
+
{
|
|
31
|
+
id: 'item_001',
|
|
32
|
+
title: 'Classic T-Shirt',
|
|
33
|
+
description: 'A comfortable cotton t-shirt',
|
|
34
|
+
price: 2500, // $25.00 in cents
|
|
35
|
+
sku: 'TSH-001',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'item_002',
|
|
39
|
+
title: 'Premium Hoodie',
|
|
40
|
+
description: 'Warm and stylish hoodie',
|
|
41
|
+
price: 5999, // $59.99 in cents
|
|
42
|
+
sku: 'HOO-001',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
shipping_options: [
|
|
46
|
+
{
|
|
47
|
+
id: 'standard',
|
|
48
|
+
title: 'Standard Shipping',
|
|
49
|
+
description: 'Arrives in 5-7 business days',
|
|
50
|
+
price: 500, // $5.00
|
|
51
|
+
estimated_days: '5-7 business days',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'express',
|
|
55
|
+
title: 'Express Shipping',
|
|
56
|
+
description: 'Arrives in 2-3 business days',
|
|
57
|
+
price: 1500, // $15.00
|
|
58
|
+
estimated_days: '2-3 business days',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'overnight',
|
|
62
|
+
title: 'Overnight Shipping',
|
|
63
|
+
description: 'Arrives next business day',
|
|
64
|
+
price: 2999, // $29.99
|
|
65
|
+
estimated_days: '1 business day',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
payment_handlers: [
|
|
69
|
+
{
|
|
70
|
+
namespace: 'com.stripe',
|
|
71
|
+
id: 'stripe_handler',
|
|
72
|
+
config: {
|
|
73
|
+
publishable_key: 'pk_test_...',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
namespace: 'com.paypal',
|
|
78
|
+
id: 'paypal_handler',
|
|
79
|
+
config: {
|
|
80
|
+
client_id: 'your_paypal_client_id',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(options.output, JSON.stringify(sampleConfig, null, 2));
|
|
87
|
+
console.log(`✅ Created sample config at: ${options.output}`);
|
|
88
|
+
console.log('\n📝 Edit this file to configure your products, shipping, and payment handlers.');
|
|
89
|
+
console.log(`\n🚀 Run: npx ts-node src/cli.ts serve ${options.output}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
program
|
|
93
|
+
.command('serve')
|
|
94
|
+
.description('Start the UCP server from a configuration file')
|
|
95
|
+
.argument('<config>', 'Path to merchant configuration JSON file')
|
|
96
|
+
.option('-p, --port <number>', 'Port to run on (overrides config)')
|
|
97
|
+
.option('--no-db', 'Use in-memory storage instead of SQLite')
|
|
98
|
+
.action((configPath, options) => {
|
|
99
|
+
try {
|
|
100
|
+
const configFile = path.resolve(configPath);
|
|
101
|
+
if (!fs.existsSync(configFile)) {
|
|
102
|
+
console.error(`❌ Config file not found: ${configFile}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rawConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
107
|
+
const config = MerchantConfigSchema.parse(rawConfig);
|
|
108
|
+
|
|
109
|
+
if (options.port) {
|
|
110
|
+
config.port = parseInt(options.port, 10);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const useDb = options.db !== false;
|
|
114
|
+
const app = createExpressApp(config, { useDb });
|
|
115
|
+
const port = config.port;
|
|
116
|
+
const storageType = useDb ? 'SQLite' : 'In-memory';
|
|
117
|
+
|
|
118
|
+
app.listen(port, () => {
|
|
119
|
+
console.log(`
|
|
120
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
121
|
+
║ 🛒 UCP Server Running ║
|
|
122
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
123
|
+
║ Merchant: ${config.name.padEnd(49)}║
|
|
124
|
+
║ Domain: ${config.domain.padEnd(49)}║
|
|
125
|
+
║ Port: ${String(port).padEnd(49)}║
|
|
126
|
+
║ Storage: ${storageType.padEnd(49)}║
|
|
127
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
128
|
+
║ Endpoints: ║
|
|
129
|
+
║ • GET /.well-known/ucp - UCP Profile ║
|
|
130
|
+
║ • POST /ucp/v1/checkout-sessions - Create Checkout ║
|
|
131
|
+
║ • GET /ucp/v1/checkout-sessions/:id - Get Checkout ║
|
|
132
|
+
║ • PUT /ucp/v1/checkout-sessions/:id - Update Checkout ║
|
|
133
|
+
║ • POST /ucp/v1/checkout-sessions/:id/complete - Complete ║
|
|
134
|
+
║ • POST /ucp/v1/checkout-sessions/:id/cancel - Cancel ║
|
|
135
|
+
║ • GET /ucp/v1/orders - List Orders ║
|
|
136
|
+
║ • GET /ucp/v1/orders/:id - Get Order ║
|
|
137
|
+
║ • GET /ucp/v1/items - Product Catalog ║
|
|
138
|
+
║ • GET /health - Health Check ║
|
|
139
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
140
|
+
|
|
141
|
+
🔗 UCP Profile: http://localhost:${port}/.well-known/ucp
|
|
142
|
+
📦 Products: ${config.items.length} items loaded
|
|
143
|
+
🚚 Shipping: ${config.shipping_options.length} options available
|
|
144
|
+
💳 Payment Handlers: ${config.payment_handlers.length} configured
|
|
145
|
+
💾 Storage: ${storageType}${useDb ? ' (./data/ucp.db)' : ''}
|
|
146
|
+
`);
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error('❌ Error:', error);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
program
|
|
155
|
+
.command('validate')
|
|
156
|
+
.description('Validate a merchant configuration file')
|
|
157
|
+
.argument('<config>', 'Path to merchant configuration JSON file')
|
|
158
|
+
.action((configPath) => {
|
|
159
|
+
try {
|
|
160
|
+
const configFile = path.resolve(configPath);
|
|
161
|
+
if (!fs.existsSync(configFile)) {
|
|
162
|
+
console.error(`❌ Config file not found: ${configFile}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const rawConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
167
|
+
const config = MerchantConfigSchema.parse(rawConfig);
|
|
168
|
+
|
|
169
|
+
console.log('✅ Configuration is valid!');
|
|
170
|
+
console.log(`\n📊 Summary:`);
|
|
171
|
+
console.log(` • Name: ${config.name}`);
|
|
172
|
+
console.log(` • Domain: ${config.domain}`);
|
|
173
|
+
console.log(` • Currency: ${config.currency}`);
|
|
174
|
+
console.log(` • Tax Rate: ${(config.tax_rate * 100).toFixed(2)}%`);
|
|
175
|
+
console.log(` • Products: ${config.items.length}`);
|
|
176
|
+
console.log(` • Shipping Options: ${config.shipping_options.length}`);
|
|
177
|
+
console.log(` • Payment Handlers: ${config.payment_handlers.length}`);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('❌ Validation failed:', error);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
program.parse();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
export const config = {
|
|
4
|
+
// Server
|
|
5
|
+
port: parseInt(process.env.PORT || '3000', 10),
|
|
6
|
+
nodeEnv: process.env.NODE_ENV || 'development',
|
|
7
|
+
|
|
8
|
+
// Database
|
|
9
|
+
databasePath: process.env.DATABASE_PATH || './data/ucp.db',
|
|
10
|
+
|
|
11
|
+
// Logging
|
|
12
|
+
logLevel: process.env.LOG_LEVEL || 'info',
|
|
13
|
+
|
|
14
|
+
// UCP
|
|
15
|
+
domain: process.env.UCP_DOMAIN || 'http://localhost:3000',
|
|
16
|
+
|
|
17
|
+
// Stripe
|
|
18
|
+
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
|
19
|
+
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
|
20
|
+
|
|
21
|
+
// PayPal
|
|
22
|
+
paypalClientId: process.env.PAYPAL_CLIENT_ID || '',
|
|
23
|
+
paypalClientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
|
24
|
+
paypalMode: (process.env.PAYPAL_MODE || 'sandbox') as 'sandbox' | 'live',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type Config = typeof config;
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Database, { Database as DatabaseType } from 'better-sqlite3';
|
|
2
|
+
import { config } from './config';
|
|
3
|
+
import { logger } from './logger';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
|
|
7
|
+
// Ensure data directory exists
|
|
8
|
+
const dataDir = path.dirname(config.databasePath);
|
|
9
|
+
if (!fs.existsSync(dataDir)) {
|
|
10
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const db: DatabaseType = new Database(config.databasePath);
|
|
14
|
+
|
|
15
|
+
// Enable WAL mode for better concurrent access
|
|
16
|
+
db.pragma('journal_mode = WAL');
|
|
17
|
+
|
|
18
|
+
// Initialize schema
|
|
19
|
+
const schema = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS checkouts (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
status TEXT NOT NULL,
|
|
23
|
+
currency TEXT NOT NULL,
|
|
24
|
+
buyer_json TEXT,
|
|
25
|
+
fulfillment_json TEXT,
|
|
26
|
+
payment_json TEXT,
|
|
27
|
+
totals_json TEXT NOT NULL,
|
|
28
|
+
links_json TEXT NOT NULL,
|
|
29
|
+
messages_json TEXT,
|
|
30
|
+
continue_url TEXT,
|
|
31
|
+
expires_at TEXT,
|
|
32
|
+
order_json TEXT,
|
|
33
|
+
payment_intent_id TEXT,
|
|
34
|
+
paypal_order_id TEXT,
|
|
35
|
+
payment_provider TEXT,
|
|
36
|
+
payment_status TEXT DEFAULT 'none',
|
|
37
|
+
created_at TEXT NOT NULL,
|
|
38
|
+
updated_at TEXT NOT NULL
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS line_items (
|
|
42
|
+
id TEXT NOT NULL,
|
|
43
|
+
checkout_id TEXT NOT NULL,
|
|
44
|
+
item_json TEXT NOT NULL,
|
|
45
|
+
quantity INTEGER NOT NULL,
|
|
46
|
+
totals_json TEXT,
|
|
47
|
+
PRIMARY KEY (id, checkout_id),
|
|
48
|
+
FOREIGN KEY (checkout_id) REFERENCES checkouts(id) ON DELETE CASCADE
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
checkout_id TEXT NOT NULL,
|
|
54
|
+
permalink_url TEXT,
|
|
55
|
+
buyer_json TEXT,
|
|
56
|
+
line_items_json TEXT NOT NULL,
|
|
57
|
+
totals_json TEXT NOT NULL,
|
|
58
|
+
fulfillment_json TEXT,
|
|
59
|
+
payment_intent_id TEXT,
|
|
60
|
+
paypal_order_id TEXT,
|
|
61
|
+
payment_provider TEXT,
|
|
62
|
+
payment_status TEXT DEFAULT 'pending',
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
FOREIGN KEY (checkout_id) REFERENCES checkouts(id)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS inventory (
|
|
68
|
+
item_id TEXT PRIMARY KEY,
|
|
69
|
+
stock INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
reserved INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
updated_at TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_line_items_checkout ON line_items(checkout_id);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_orders_checkout ON orders(checkout_id);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_checkouts_status ON checkouts(status);
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
db.exec(schema);
|
|
80
|
+
logger.info({ path: config.databasePath }, 'Database initialized');
|
|
81
|
+
|
|
82
|
+
// Helper functions
|
|
83
|
+
export function getDb(): Database.Database {
|
|
84
|
+
return db;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isDbHealthy(): boolean {
|
|
88
|
+
try {
|
|
89
|
+
db.prepare('SELECT 1').get();
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import express, { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import rateLimit from 'express-rate-limit';
|
|
3
|
+
import { UCPServer } from './ucp-server';
|
|
4
|
+
import { UCPServerDB } from './ucp-server-db';
|
|
5
|
+
import { MerchantConfig, UCP_VERSION, CreateCheckoutRequestSchema, UpdateCheckoutRequestSchema } from './schema';
|
|
6
|
+
import { isDbHealthy, db } from './db';
|
|
7
|
+
import { logger } from './logger';
|
|
8
|
+
import { constructWebhookEvent, isStripeConfigured } from './stripe';
|
|
9
|
+
import { getPayPalOrder, capturePayPalOrder } from './paypal';
|
|
10
|
+
|
|
11
|
+
type UCPServerType = UCPServer | UCPServerDB;
|
|
12
|
+
|
|
13
|
+
// Rate limiters
|
|
14
|
+
const apiLimiter = rateLimit({
|
|
15
|
+
windowMs: 60 * 1000, // 1 minute
|
|
16
|
+
max: 100, // 100 requests per minute
|
|
17
|
+
standardHeaders: true,
|
|
18
|
+
legacyHeaders: false,
|
|
19
|
+
message: { error: 'Too many requests, please try again later' },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const checkoutLimiter = rateLimit({
|
|
23
|
+
windowMs: 60 * 1000,
|
|
24
|
+
max: 20, // 20 checkout creates per minute
|
|
25
|
+
standardHeaders: true,
|
|
26
|
+
legacyHeaders: false,
|
|
27
|
+
message: { error: 'Too many checkout requests' },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function createExpressApp(config: MerchantConfig, options?: { useDb?: boolean }): express.Application {
|
|
31
|
+
const app = express();
|
|
32
|
+
const ucpServer: UCPServerType = options?.useDb
|
|
33
|
+
? new UCPServerDB(config)
|
|
34
|
+
: new UCPServer(config);
|
|
35
|
+
const useDb = options?.useDb ?? false;
|
|
36
|
+
|
|
37
|
+
// Apply rate limiting to API routes
|
|
38
|
+
app.use('/ucp/v1/', apiLimiter);
|
|
39
|
+
|
|
40
|
+
// Stripe webhook needs raw body - must come before json parser
|
|
41
|
+
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
|
|
42
|
+
const signature = req.headers['stripe-signature'] as string;
|
|
43
|
+
if (!signature) {
|
|
44
|
+
res.status(400).json({ error: 'Missing stripe-signature header' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const event = constructWebhookEvent(req.body, signature);
|
|
49
|
+
if (!event) {
|
|
50
|
+
res.status(400).json({ error: 'Invalid webhook signature' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info({ eventType: event.type, eventId: event.id }, 'Stripe webhook received');
|
|
55
|
+
|
|
56
|
+
switch (event.type) {
|
|
57
|
+
case 'payment_intent.succeeded': {
|
|
58
|
+
const paymentIntent = event.data.object as { id: string; metadata?: { checkout_id?: string; order_id?: string } };
|
|
59
|
+
const checkoutId = paymentIntent.metadata?.checkout_id;
|
|
60
|
+
const orderId = paymentIntent.metadata?.order_id;
|
|
61
|
+
|
|
62
|
+
if (checkoutId && useDb) {
|
|
63
|
+
db.prepare(`UPDATE checkouts SET payment_status = 'succeeded', updated_at = ? WHERE id = ?`)
|
|
64
|
+
.run(new Date().toISOString(), checkoutId);
|
|
65
|
+
if (orderId) {
|
|
66
|
+
db.prepare(`UPDATE orders SET payment_status = 'succeeded' WHERE id = ?`).run(orderId);
|
|
67
|
+
}
|
|
68
|
+
logger.info({ checkoutId, orderId, paymentIntentId: paymentIntent.id }, 'Payment succeeded');
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'payment_intent.payment_failed': {
|
|
73
|
+
const paymentIntent = event.data.object as { id: string; metadata?: { checkout_id?: string; order_id?: string }; last_payment_error?: { message?: string } };
|
|
74
|
+
const checkoutId = paymentIntent.metadata?.checkout_id;
|
|
75
|
+
const orderId = paymentIntent.metadata?.order_id;
|
|
76
|
+
|
|
77
|
+
if (checkoutId && useDb) {
|
|
78
|
+
db.prepare(`UPDATE checkouts SET payment_status = 'failed', updated_at = ? WHERE id = ?`)
|
|
79
|
+
.run(new Date().toISOString(), checkoutId);
|
|
80
|
+
if (orderId) {
|
|
81
|
+
db.prepare(`UPDATE orders SET payment_status = 'failed' WHERE id = ?`).run(orderId);
|
|
82
|
+
}
|
|
83
|
+
logger.error({
|
|
84
|
+
checkoutId, orderId, paymentIntentId: paymentIntent.id,
|
|
85
|
+
error: paymentIntent.last_payment_error?.message
|
|
86
|
+
}, 'Payment failed');
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
res.json({ received: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// PayPal webhook - capture completed orders
|
|
96
|
+
app.post('/webhooks/paypal', express.json(), async (req: Request, res: Response) => {
|
|
97
|
+
const eventType = req.body.event_type;
|
|
98
|
+
const resource = req.body.resource;
|
|
99
|
+
|
|
100
|
+
logger.info({ eventType, resourceId: resource?.id }, 'PayPal webhook received');
|
|
101
|
+
|
|
102
|
+
if (eventType === 'CHECKOUT.ORDER.APPROVED' && resource?.id) {
|
|
103
|
+
try {
|
|
104
|
+
// Capture the payment
|
|
105
|
+
const capturedOrder = await capturePayPalOrder(resource.id);
|
|
106
|
+
if (capturedOrder && capturedOrder.status === 'COMPLETED') {
|
|
107
|
+
// Get the checkout ID from the order
|
|
108
|
+
const orderDetails = await getPayPalOrder(resource.id);
|
|
109
|
+
const checkoutId = orderDetails?.customId;
|
|
110
|
+
|
|
111
|
+
if (checkoutId && useDb) {
|
|
112
|
+
const now = new Date().toISOString();
|
|
113
|
+
db.prepare(`UPDATE checkouts SET payment_status = 'succeeded', updated_at = ? WHERE paypal_order_id = ?`)
|
|
114
|
+
.run(now, resource.id);
|
|
115
|
+
db.prepare(`UPDATE orders SET payment_status = 'succeeded' WHERE paypal_order_id = ?`)
|
|
116
|
+
.run(resource.id);
|
|
117
|
+
logger.info({ checkoutId, paypalOrderId: resource.id }, 'PayPal payment captured and succeeded');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error({ error, paypalOrderId: resource.id }, 'Failed to capture PayPal order');
|
|
122
|
+
}
|
|
123
|
+
} else if (eventType === 'PAYMENT.CAPTURE.DENIED' && resource?.id) {
|
|
124
|
+
if (useDb) {
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
db.prepare(`UPDATE checkouts SET payment_status = 'failed', updated_at = ? WHERE paypal_order_id = ?`)
|
|
127
|
+
.run(now, resource.supplementary_data?.related_ids?.order_id);
|
|
128
|
+
db.prepare(`UPDATE orders SET payment_status = 'failed' WHERE paypal_order_id = ?`)
|
|
129
|
+
.run(resource.supplementary_data?.related_ids?.order_id);
|
|
130
|
+
logger.error({ paypalOrderId: resource.id }, 'PayPal payment denied');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.json({ received: true });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.use(express.json());
|
|
138
|
+
|
|
139
|
+
// Request logging middleware
|
|
140
|
+
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
res.on('finish', () => {
|
|
143
|
+
logger.info({
|
|
144
|
+
method: req.method,
|
|
145
|
+
path: req.path,
|
|
146
|
+
status: res.statusCode,
|
|
147
|
+
duration: Date.now() - start,
|
|
148
|
+
}, 'Request completed');
|
|
149
|
+
});
|
|
150
|
+
next();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// CORS middleware
|
|
154
|
+
app.use((_req: Request, res: Response, next: NextFunction) => {
|
|
155
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
156
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
157
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, UCP-Agent, Authorization');
|
|
158
|
+
next();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// UCP Discovery endpoint
|
|
162
|
+
app.get('/.well-known/ucp', (_req: Request, res: Response) => {
|
|
163
|
+
res.json(ucpServer.getProfile());
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Create Checkout Session
|
|
167
|
+
app.post('/ucp/v1/checkout-sessions', checkoutLimiter, (req: Request, res: Response) => {
|
|
168
|
+
const parsed = CreateCheckoutRequestSchema.safeParse(req.body);
|
|
169
|
+
if (!parsed.success) {
|
|
170
|
+
res.status(400).json({ error: 'Validation failed', details: parsed.error.issues });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const session = ucpServer.createCheckout(parsed.data);
|
|
175
|
+
res.status(201).json(session);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
res.status(400).json({ error: 'Invalid request', details: String(error) });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Get Checkout Session
|
|
182
|
+
app.get('/ucp/v1/checkout-sessions/:id', (req: Request, res: Response) => {
|
|
183
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
184
|
+
const session = ucpServer.getCheckout(id);
|
|
185
|
+
if (!session) {
|
|
186
|
+
res.status(404).json({ error: 'Checkout session not found' });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
res.json(session);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Update Checkout Session
|
|
193
|
+
app.put('/ucp/v1/checkout-sessions/:id', (req: Request, res: Response) => {
|
|
194
|
+
const parsed = UpdateCheckoutRequestSchema.safeParse(req.body);
|
|
195
|
+
if (!parsed.success) {
|
|
196
|
+
res.status(400).json({ error: 'Validation failed', details: parsed.error.issues });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
201
|
+
const session = ucpServer.updateCheckout(id, parsed.data);
|
|
202
|
+
if (!session) {
|
|
203
|
+
res.status(404).json({ error: 'Checkout session not found' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.json(session);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
res.status(400).json({ error: 'Invalid request', details: String(error) });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Complete Checkout
|
|
213
|
+
app.post('/ucp/v1/checkout-sessions/:id/complete', async (req: Request, res: Response) => {
|
|
214
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
215
|
+
try {
|
|
216
|
+
const result = await ucpServer.completeCheckout(id);
|
|
217
|
+
if ('error' in result) {
|
|
218
|
+
res.status(400).json(result);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
res.status(201).json(result);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
logger.error({ error, checkoutId: id }, 'Failed to complete checkout');
|
|
224
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Cancel Checkout
|
|
229
|
+
app.post('/ucp/v1/checkout-sessions/:id/cancel', (req: Request, res: Response) => {
|
|
230
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
231
|
+
const session = ucpServer.cancelCheckout(id);
|
|
232
|
+
if (!session) {
|
|
233
|
+
res.status(404).json({ error: 'Checkout session not found' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
res.json(session);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Get Order
|
|
240
|
+
app.get('/ucp/v1/orders/:id', (req: Request, res: Response) => {
|
|
241
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
242
|
+
const order = ucpServer.getOrder(id);
|
|
243
|
+
if (!order) {
|
|
244
|
+
res.status(404).json({ error: 'Order not found' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.json(order);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// List Orders
|
|
251
|
+
app.get('/ucp/v1/orders', (_req: Request, res: Response) => {
|
|
252
|
+
res.json(ucpServer.listOrders());
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Product catalog (convenience endpoint)
|
|
256
|
+
app.get('/ucp/v1/items', (_req: Request, res: Response) => {
|
|
257
|
+
res.json(ucpServer.getConfig().items);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Admin stats endpoint
|
|
261
|
+
app.get('/admin/stats', (_req: Request, res: Response) => {
|
|
262
|
+
if (!useDb) {
|
|
263
|
+
res.status(501).json({ error: 'Stats require database mode' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const checkoutStats = db.prepare(`
|
|
268
|
+
SELECT status, COUNT(*) as count FROM checkouts GROUP BY status
|
|
269
|
+
`).all() as { status: string; count: number }[];
|
|
270
|
+
|
|
271
|
+
const orderStats = db.prepare(`
|
|
272
|
+
SELECT payment_status, payment_provider, COUNT(*) as count
|
|
273
|
+
FROM orders GROUP BY payment_status, payment_provider
|
|
274
|
+
`).all() as { payment_status: string; payment_provider: string; count: number }[];
|
|
275
|
+
|
|
276
|
+
const revenueResult = db.prepare(`
|
|
277
|
+
SELECT SUM(json_extract(totals_json, '$[?(@.type=="total")].amount')) as total
|
|
278
|
+
FROM orders WHERE payment_status = 'succeeded'
|
|
279
|
+
`).get() as { total: number | null };
|
|
280
|
+
|
|
281
|
+
const todayOrders = db.prepare(`
|
|
282
|
+
SELECT COUNT(*) as count FROM orders
|
|
283
|
+
WHERE date(created_at) = date('now')
|
|
284
|
+
`).get() as { count: number };
|
|
285
|
+
|
|
286
|
+
res.json({
|
|
287
|
+
checkouts: Object.fromEntries(checkoutStats.map(s => [s.status, s.count])),
|
|
288
|
+
orders: {
|
|
289
|
+
by_payment_status: Object.fromEntries(orderStats.map(s => [`${s.payment_provider || 'none'}_${s.payment_status}`, s.count])),
|
|
290
|
+
today: todayOrders.count,
|
|
291
|
+
},
|
|
292
|
+
revenue: {
|
|
293
|
+
total_succeeded: revenueResult.total || 0,
|
|
294
|
+
},
|
|
295
|
+
timestamp: new Date().toISOString(),
|
|
296
|
+
});
|
|
297
|
+
} catch (error) {
|
|
298
|
+
logger.error({ error }, 'Failed to get admin stats');
|
|
299
|
+
res.status(500).json({ error: 'Failed to get stats' });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Admin dashboard HTML
|
|
304
|
+
app.get('/admin', (_req: Request, res: Response) => {
|
|
305
|
+
res.send(`<!DOCTYPE html>
|
|
306
|
+
<html><head><title>UCP Admin</title>
|
|
307
|
+
<style>
|
|
308
|
+
body { font-family: system-ui; max-width: 800px; margin: 40px auto; padding: 20px; }
|
|
309
|
+
.card { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 10px 0; }
|
|
310
|
+
.stat { font-size: 2em; font-weight: bold; color: #333; }
|
|
311
|
+
.label { color: #666; font-size: 0.9em; }
|
|
312
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
|
|
313
|
+
h1 { color: #333; }
|
|
314
|
+
</style>
|
|
315
|
+
</head><body>
|
|
316
|
+
<h1>🛒 UCP Server Admin</h1>
|
|
317
|
+
<div class="grid" id="stats">Loading...</div>
|
|
318
|
+
<script>
|
|
319
|
+
fetch('/admin/stats').then(r=>r.json()).then(d=>{
|
|
320
|
+
document.getElementById('stats').innerHTML = \`
|
|
321
|
+
<div class="card"><div class="stat">\${Object.values(d.checkouts).reduce((a,b)=>a+b,0)}</div><div class="label">Total Checkouts</div></div>
|
|
322
|
+
<div class="card"><div class="stat">\${d.checkouts.completed||0}</div><div class="label">Completed</div></div>
|
|
323
|
+
<div class="card"><div class="stat">\${d.orders.today||0}</div><div class="label">Orders Today</div></div>
|
|
324
|
+
<div class="card"><div class="stat">$\${(d.revenue.total_succeeded/100).toFixed(2)}</div><div class="label">Revenue</div></div>
|
|
325
|
+
\`;
|
|
326
|
+
}).catch(e=>document.getElementById('stats').innerHTML='Error loading stats');
|
|
327
|
+
</script>
|
|
328
|
+
</body></html>`);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Health check
|
|
332
|
+
app.get('/health', (_req: Request, res: Response) => {
|
|
333
|
+
const dbHealthy = isDbHealthy();
|
|
334
|
+
const status = dbHealthy ? 'ok' : 'degraded';
|
|
335
|
+
res.status(dbHealthy ? 200 : 503).json({
|
|
336
|
+
status,
|
|
337
|
+
ucp_version: UCP_VERSION,
|
|
338
|
+
database: dbHealthy ? 'connected' : 'disconnected',
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return app;
|
|
344
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { MerchantConfigSchema, ItemSchema, ShippingOptionSchema, PaymentHandlerSchema } from './schema';
|
|
2
|
+
export type { MerchantConfig, Item, ShippingOption, PaymentHandler, CheckoutSession, Order } from './schema';
|
|
3
|
+
export { UCPServer } from './ucp-server';
|
|
4
|
+
export { createExpressApp } from './express-app';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import { config } from './config';
|
|
3
|
+
|
|
4
|
+
export const logger = pino({
|
|
5
|
+
level: config.logLevel,
|
|
6
|
+
transport: config.nodeEnv === 'development'
|
|
7
|
+
? { target: 'pino-pretty', options: { colorize: true } }
|
|
8
|
+
: undefined,
|
|
9
|
+
base: { service: 'ucp-server' },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type Logger = typeof logger;
|