@unifiedcommerce/core 0.2.0 → 0.2.2
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/package.json +2 -1
- package/src/adapters/console-email.ts +43 -0
- package/src/auth/access.ts +187 -0
- package/src/auth/auth-schema.ts +139 -0
- package/src/auth/middleware.ts +161 -0
- package/src/auth/org.ts +41 -0
- package/src/auth/permissions.ts +28 -0
- package/src/auth/setup.ts +171 -0
- package/src/auth/system-actor.ts +19 -0
- package/src/auth/types.ts +10 -0
- package/src/config/defaults.ts +82 -0
- package/src/config/define-config.ts +53 -0
- package/src/config/types.ts +301 -0
- package/src/generated/plugin-capabilities.d.ts +20 -0
- package/src/generated/plugin-manifest.ts +23 -0
- package/src/generated/plugin-repositories.d.ts +20 -0
- package/src/hooks/checkout-completion.ts +262 -0
- package/src/hooks/checkout.ts +677 -0
- package/src/hooks/order-emails.ts +62 -0
- package/src/index.ts +215 -0
- package/src/interfaces/mcp/agent-prompt.ts +174 -0
- package/src/interfaces/mcp/context-enrichment.ts +177 -0
- package/src/interfaces/mcp/server.ts +47 -0
- package/src/interfaces/mcp/tool-builder.ts +261 -0
- package/src/interfaces/mcp/tools/analytics.ts +76 -0
- package/src/interfaces/mcp/tools/cart.ts +57 -0
- package/src/interfaces/mcp/tools/catalog.ts +299 -0
- package/src/interfaces/mcp/tools/index.ts +22 -0
- package/src/interfaces/mcp/tools/inventory.ts +161 -0
- package/src/interfaces/mcp/tools/orders.ts +104 -0
- package/src/interfaces/mcp/tools/pricing.ts +94 -0
- package/src/interfaces/mcp/tools/promotions.ts +106 -0
- package/src/interfaces/mcp/tools/registry.ts +101 -0
- package/src/interfaces/mcp/tools/search.ts +42 -0
- package/src/interfaces/mcp/tools/webhooks.ts +48 -0
- package/src/interfaces/mcp/transport.ts +128 -0
- package/src/interfaces/rest/customer-portal.ts +299 -0
- package/src/interfaces/rest/index.ts +74 -0
- package/src/interfaces/rest/router.ts +333 -0
- package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
- package/src/interfaces/rest/routes/audit.ts +50 -0
- package/src/interfaces/rest/routes/carts.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +493 -0
- package/src/interfaces/rest/routes/checkout.ts +284 -0
- package/src/interfaces/rest/routes/inventory.ts +70 -0
- package/src/interfaces/rest/routes/media.ts +86 -0
- package/src/interfaces/rest/routes/orders.ts +78 -0
- package/src/interfaces/rest/routes/payments.ts +60 -0
- package/src/interfaces/rest/routes/pricing.ts +57 -0
- package/src/interfaces/rest/routes/promotions.ts +93 -0
- package/src/interfaces/rest/routes/search.ts +71 -0
- package/src/interfaces/rest/routes/webhooks.ts +46 -0
- package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
- package/src/interfaces/rest/schemas/audit.ts +46 -0
- package/src/interfaces/rest/schemas/carts.ts +125 -0
- package/src/interfaces/rest/schemas/catalog.ts +450 -0
- package/src/interfaces/rest/schemas/checkout.ts +66 -0
- package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
- package/src/interfaces/rest/schemas/inventory.ts +138 -0
- package/src/interfaces/rest/schemas/media.ts +75 -0
- package/src/interfaces/rest/schemas/orders.ts +104 -0
- package/src/interfaces/rest/schemas/pricing.ts +80 -0
- package/src/interfaces/rest/schemas/promotions.ts +110 -0
- package/src/interfaces/rest/schemas/responses.ts +85 -0
- package/src/interfaces/rest/schemas/search.ts +58 -0
- package/src/interfaces/rest/schemas/shared.ts +62 -0
- package/src/interfaces/rest/schemas/webhooks.ts +68 -0
- package/src/interfaces/rest/utils.ts +104 -0
- package/src/interfaces/rest/webhook-router.ts +50 -0
- package/src/kernel/compensation/executor.ts +61 -0
- package/src/kernel/compensation/types.ts +26 -0
- package/src/kernel/database/adapter.ts +21 -0
- package/src/kernel/database/drizzle-db.ts +56 -0
- package/src/kernel/database/migrate.ts +76 -0
- package/src/kernel/database/plugin-types.ts +34 -0
- package/src/kernel/database/schema.ts +49 -0
- package/src/kernel/database/scoped-db.ts +68 -0
- package/src/kernel/database/tx-context.ts +46 -0
- package/src/kernel/error-mapper.ts +15 -0
- package/src/kernel/errors.ts +89 -0
- package/src/kernel/factory/repository-factory.ts +244 -0
- package/src/kernel/hooks/create-context.ts +43 -0
- package/src/kernel/hooks/executor.ts +88 -0
- package/src/kernel/hooks/registry.ts +74 -0
- package/src/kernel/hooks/types.ts +52 -0
- package/src/kernel/http-error.ts +44 -0
- package/src/kernel/jobs/adapter.ts +36 -0
- package/src/kernel/jobs/drizzle-adapter.ts +58 -0
- package/src/kernel/jobs/runner.ts +153 -0
- package/src/kernel/jobs/schema.ts +46 -0
- package/src/kernel/jobs/types.ts +30 -0
- package/src/kernel/local-api.ts +187 -0
- package/src/kernel/plugin/manifest.ts +271 -0
- package/src/kernel/query/executor.ts +184 -0
- package/src/kernel/query/registry.ts +46 -0
- package/src/kernel/result.ts +33 -0
- package/src/kernel/schema/extra-columns.ts +37 -0
- package/src/kernel/service-registry.ts +76 -0
- package/src/kernel/service-timing.ts +89 -0
- package/src/kernel/state-machine/machine.ts +101 -0
- package/src/modules/analytics/drizzle-adapter.ts +426 -0
- package/src/modules/analytics/hooks.ts +11 -0
- package/src/modules/analytics/models.ts +125 -0
- package/src/modules/analytics/repository/index.ts +6 -0
- package/src/modules/analytics/service.ts +245 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/audit/hooks.ts +78 -0
- package/src/modules/audit/schema.ts +33 -0
- package/src/modules/audit/service.ts +151 -0
- package/src/modules/cart/access.ts +27 -0
- package/src/modules/cart/matcher.ts +26 -0
- package/src/modules/cart/repository/index.ts +234 -0
- package/src/modules/cart/schema.ts +42 -0
- package/src/modules/cart/schemas.ts +38 -0
- package/src/modules/cart/service.ts +541 -0
- package/src/modules/catalog/repository/index.ts +772 -0
- package/src/modules/catalog/schema.ts +203 -0
- package/src/modules/catalog/schemas.ts +104 -0
- package/src/modules/catalog/service.ts +1544 -0
- package/src/modules/customers/repository/index.ts +327 -0
- package/src/modules/customers/schema.ts +64 -0
- package/src/modules/customers/service.ts +171 -0
- package/src/modules/fulfillment/repository/index.ts +426 -0
- package/src/modules/fulfillment/schema.ts +101 -0
- package/src/modules/fulfillment/service.ts +555 -0
- package/src/modules/fulfillment/types.ts +59 -0
- package/src/modules/inventory/repository/index.ts +509 -0
- package/src/modules/inventory/schema.ts +94 -0
- package/src/modules/inventory/schemas.ts +38 -0
- package/src/modules/inventory/service.ts +490 -0
- package/src/modules/media/adapter.ts +17 -0
- package/src/modules/media/repository/index.ts +274 -0
- package/src/modules/media/schema.ts +41 -0
- package/src/modules/media/service.ts +151 -0
- package/src/modules/orders/repository/index.ts +287 -0
- package/src/modules/orders/schema.ts +66 -0
- package/src/modules/orders/service.ts +619 -0
- package/src/modules/orders/stale-order-cleanup.ts +76 -0
- package/src/modules/organization/service.ts +191 -0
- package/src/modules/payments/adapter.ts +47 -0
- package/src/modules/payments/repository/index.ts +6 -0
- package/src/modules/payments/service.ts +107 -0
- package/src/modules/pricing/repository/index.ts +291 -0
- package/src/modules/pricing/schema.ts +71 -0
- package/src/modules/pricing/schemas.ts +38 -0
- package/src/modules/pricing/service.ts +494 -0
- package/src/modules/promotions/repository/index.ts +325 -0
- package/src/modules/promotions/schema.ts +62 -0
- package/src/modules/promotions/schemas.ts +38 -0
- package/src/modules/promotions/service.ts +598 -0
- package/src/modules/search/adapter.ts +57 -0
- package/src/modules/search/hooks.ts +12 -0
- package/src/modules/search/repository/index.ts +6 -0
- package/src/modules/search/service.ts +315 -0
- package/src/modules/shipping/calculator.ts +188 -0
- package/src/modules/shipping/repository/index.ts +6 -0
- package/src/modules/shipping/service.ts +51 -0
- package/src/modules/tax/adapter.ts +60 -0
- package/src/modules/tax/repository/index.ts +6 -0
- package/src/modules/tax/service.ts +53 -0
- package/src/modules/webhooks/hook.ts +34 -0
- package/src/modules/webhooks/repository/index.ts +278 -0
- package/src/modules/webhooks/schema.ts +56 -0
- package/src/modules/webhooks/service.ts +117 -0
- package/src/modules/webhooks/signing.ts +6 -0
- package/src/modules/webhooks/ssrf-guard.ts +71 -0
- package/src/modules/webhooks/tasks.ts +52 -0
- package/src/modules/webhooks/worker.ts +134 -0
- package/src/runtime/commerce.ts +145 -0
- package/src/runtime/kernel.ts +426 -0
- package/src/runtime/logger.ts +36 -0
- package/src/runtime/server.ts +355 -0
- package/src/runtime/shutdown.ts +43 -0
- package/src/test-utils/create-pglite-adapter.ts +129 -0
- package/src/test-utils/create-plugin-test-app.ts +128 -0
- package/src/test-utils/create-repository-test-harness.ts +16 -0
- package/src/test-utils/create-test-config.ts +190 -0
- package/src/test-utils/create-test-kernel.ts +7 -0
- package/src/test-utils/create-test-plugin-context.ts +75 -0
- package/src/test-utils/rest-api-test-utils.ts +265 -0
- package/src/test-utils/test-actors.ts +62 -0
- package/src/test-utils/typed-hooks.ts +54 -0
- package/src/types/commerce-types.ts +34 -0
- package/src/utils/id.ts +3 -0
- package/src/utils/logger.ts +18 -0
- package/src/utils/pagination.ts +22 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
+
import { checkoutRoute } from "../schemas/checkout.js";
|
|
4
|
+
import {
|
|
5
|
+
applyPromotionCodes,
|
|
6
|
+
authorizePayment,
|
|
7
|
+
calculateShipping,
|
|
8
|
+
calculateTax,
|
|
9
|
+
checkInventoryAvailability,
|
|
10
|
+
completeCheckout,
|
|
11
|
+
recordAnalyticsEvent,
|
|
12
|
+
resolveCurrentPrices,
|
|
13
|
+
validateCartNotEmpty,
|
|
14
|
+
validatePaymentMethod,
|
|
15
|
+
type CheckoutData,
|
|
16
|
+
type OrderResult,
|
|
17
|
+
} from "../../../hooks/checkout.js";
|
|
18
|
+
import { runAfterHooks, runBeforeHooks } from "../../../kernel/hooks/executor.js";
|
|
19
|
+
import { createHookContext } from "../../../kernel/hooks/create-context.js";
|
|
20
|
+
import type { AfterHook, BeforeHook, ServiceContainer } from "../../../kernel/hooks/types.js";
|
|
21
|
+
import type { PluginDb } from "../../../kernel/database/plugin-types.js";
|
|
22
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
|
|
23
|
+
import { isCommerceError } from "../../../kernel/errors.js";
|
|
24
|
+
import { makeId } from "../../../utils/id.js";
|
|
25
|
+
import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
|
|
26
|
+
|
|
27
|
+
export function checkoutRoutes(kernel: Kernel) {
|
|
28
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
29
|
+
|
|
30
|
+
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
31
|
+
// returns union responses (201 | 400 | 422). The route definition documents the
|
|
32
|
+
// contract; the defaultHook handles Zod validation; the handler returns dynamic status.
|
|
33
|
+
router.openapi(checkoutRoute, async (c) => {
|
|
34
|
+
const body = c.req.valid("json");
|
|
35
|
+
|
|
36
|
+
const actor = c.get("actor");
|
|
37
|
+
const checkoutData: CheckoutData = {
|
|
38
|
+
checkoutId: makeId(),
|
|
39
|
+
cartId: body.cartId,
|
|
40
|
+
currency: body.currency ?? "USD",
|
|
41
|
+
paymentMethodId: body.paymentMethodId,
|
|
42
|
+
lineItems: [],
|
|
43
|
+
subtotal: 0,
|
|
44
|
+
discountTotal: 0,
|
|
45
|
+
taxTotal: 0,
|
|
46
|
+
shippingTotal: 0,
|
|
47
|
+
total: 0,
|
|
48
|
+
...(body.customerId !== undefined ? { customerId: body.customerId } : {}),
|
|
49
|
+
...(body.customerGroupIds !== undefined
|
|
50
|
+
? { customerGroupIds: body.customerGroupIds }
|
|
51
|
+
: {}),
|
|
52
|
+
...(body.promotionCodes !== undefined
|
|
53
|
+
? { promotionCodes: body.promotionCodes }
|
|
54
|
+
: {}),
|
|
55
|
+
...(body.shippingAddress != null
|
|
56
|
+
? {
|
|
57
|
+
shippingAddress: {
|
|
58
|
+
line1: body.shippingAddress.line1,
|
|
59
|
+
city: body.shippingAddress.city,
|
|
60
|
+
postalCode: body.shippingAddress.postalCode,
|
|
61
|
+
country: body.shippingAddress.country,
|
|
62
|
+
...(body.shippingAddress.line2 != null ? { line2: body.shippingAddress.line2 } : {}),
|
|
63
|
+
...(body.shippingAddress.state != null ? { state: body.shippingAddress.state } : {}),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
: {}),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ── Phase 1: Validate & Calculate (inside DB transaction — fast SQL only) ──
|
|
70
|
+
const validationHooks: BeforeHook<CheckoutData>[] = [
|
|
71
|
+
validateCartNotEmpty,
|
|
72
|
+
resolveCurrentPrices,
|
|
73
|
+
checkInventoryAvailability,
|
|
74
|
+
applyPromotionCodes,
|
|
75
|
+
calculateTax,
|
|
76
|
+
calculateShipping,
|
|
77
|
+
...(kernel.hooks.resolve("checkout.beforePayment") as BeforeHook<CheckoutData>[]),
|
|
78
|
+
validatePaymentMethod,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// ── Phase 2: Payment Authorization (outside transaction — external API call) ──
|
|
82
|
+
const paymentHooks: BeforeHook<CheckoutData>[] = [
|
|
83
|
+
authorizePayment,
|
|
84
|
+
...(kernel.hooks.resolve("checkout.beforeCreate") as BeforeHook<CheckoutData>[]),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const afterHooks: AfterHook<OrderResult>[] = [
|
|
88
|
+
completeCheckout,
|
|
89
|
+
recordAnalyticsEvent,
|
|
90
|
+
...(kernel.hooks.resolve("checkout.afterCreate") as AfterHook<OrderResult>[]),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const context = createHookContext({
|
|
94
|
+
actor,
|
|
95
|
+
logger: kernel.logger,
|
|
96
|
+
services: kernel.services as ServiceContainer,
|
|
97
|
+
context: { moduleName: "checkout" },
|
|
98
|
+
origin: "rest",
|
|
99
|
+
kernel: { database: { db: kernel.database.db as PluginDb } },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Phase 1: DB transaction for validation — releases connection immediately after
|
|
104
|
+
const validated = await kernel.database.transaction(async (_tx) => {
|
|
105
|
+
context.tx = _tx;
|
|
106
|
+
return runBeforeHooks(
|
|
107
|
+
validationHooks,
|
|
108
|
+
checkoutData,
|
|
109
|
+
"create",
|
|
110
|
+
context,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Phase 2: Payment authorization — NO DB connection held while calling Stripe/etc.
|
|
115
|
+
// If Stripe takes 5s, the DB connection pool is not affected.
|
|
116
|
+
context.tx = null;
|
|
117
|
+
const processed = await runBeforeHooks(
|
|
118
|
+
paymentHooks,
|
|
119
|
+
validated,
|
|
120
|
+
"create",
|
|
121
|
+
context,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Resolve customer profile UUID from customerId (may be a profile UUID or a Better Auth user_id)
|
|
125
|
+
let customerUuid: string | undefined = undefined;
|
|
126
|
+
if (processed.customerId) {
|
|
127
|
+
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
128
|
+
if (uuidRe.test(processed.customerId)) {
|
|
129
|
+
// Looks like a profile UUID — try direct lookup (no auto-create)
|
|
130
|
+
const byIdResult = await kernel.services.customers.getById(
|
|
131
|
+
processed.customerId,
|
|
132
|
+
actor,
|
|
133
|
+
);
|
|
134
|
+
if (byIdResult.ok) {
|
|
135
|
+
customerUuid = byIdResult.value.id;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!customerUuid) {
|
|
139
|
+
// Fall back to user_id lookup (auto-creates customer profile if needed)
|
|
140
|
+
const byUserIdResult = await kernel.services.customers.getByUserId(
|
|
141
|
+
processed.customerId,
|
|
142
|
+
actor,
|
|
143
|
+
);
|
|
144
|
+
if (byUserIdResult.ok) {
|
|
145
|
+
customerUuid = byUserIdResult.value.id;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// If both lookups fail, we still allow guest checkout (customerUuid remains undefined)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const orderPayload = {
|
|
152
|
+
currency: processed.currency,
|
|
153
|
+
subtotal: processed.subtotal,
|
|
154
|
+
taxTotal: processed.taxTotal,
|
|
155
|
+
shippingTotal: processed.shippingTotal,
|
|
156
|
+
discountTotal: processed.discountTotal,
|
|
157
|
+
grandTotal: processed.total,
|
|
158
|
+
paymentIntentId: processed.paymentIntentId,
|
|
159
|
+
paymentMethodId: processed.paymentMethodId,
|
|
160
|
+
metadata: {
|
|
161
|
+
// H2 fix: Merge hook-injected metadata (e.g., BNPL fee) before core fields
|
|
162
|
+
...(typeof processed.metadata === "object" && processed.metadata !== null
|
|
163
|
+
? processed.metadata
|
|
164
|
+
: {}),
|
|
165
|
+
cartId: processed.cartId,
|
|
166
|
+
paymentIntentId: processed.paymentIntentId,
|
|
167
|
+
checkoutId: processed.checkoutId,
|
|
168
|
+
promotionCodes: processed.promotionCodes,
|
|
169
|
+
appliedPromotions: processed.appliedPromotions,
|
|
170
|
+
shippingAddress: processed.shippingAddress,
|
|
171
|
+
},
|
|
172
|
+
lineItems: processed.lineItems.map((lineItem) => {
|
|
173
|
+
const payload = {
|
|
174
|
+
entityId: lineItem.entityId,
|
|
175
|
+
entityType: lineItem.entityType ?? "product",
|
|
176
|
+
title: lineItem.title ?? lineItem.entityId,
|
|
177
|
+
quantity: lineItem.quantity,
|
|
178
|
+
unitPrice: lineItem.resolvedUnitPrice ?? 0,
|
|
179
|
+
totalPrice: lineItem.resolvedTotal ?? 0,
|
|
180
|
+
};
|
|
181
|
+
return lineItem.variantId !== undefined
|
|
182
|
+
? { ...payload, variantId: lineItem.variantId }
|
|
183
|
+
: payload;
|
|
184
|
+
}),
|
|
185
|
+
...(customerUuid !== undefined
|
|
186
|
+
? { customerId: customerUuid }
|
|
187
|
+
: {}),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const order = await kernel.services.orders.create(orderPayload, actor);
|
|
191
|
+
|
|
192
|
+
if (!order.ok) {
|
|
193
|
+
return c.json(
|
|
194
|
+
mapErrorToResponse(order.error),
|
|
195
|
+
mapErrorToStatus(order.error),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (order.ok && (processed.appliedPromotions?.length ?? 0) > 0) {
|
|
200
|
+
await kernel.services.promotions.recordUsage({
|
|
201
|
+
promotions: processed.appliedPromotions ?? [],
|
|
202
|
+
orderId: order.value.id,
|
|
203
|
+
...(customerUuid !== undefined
|
|
204
|
+
? { customerId: customerUuid }
|
|
205
|
+
: {}),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (order.ok) {
|
|
210
|
+
await kernel.services.tax.reportTransaction({
|
|
211
|
+
transactionId: order.value.id,
|
|
212
|
+
transactionDate: new Date(),
|
|
213
|
+
currency: processed.currency,
|
|
214
|
+
amount:
|
|
215
|
+
processed.subtotal -
|
|
216
|
+
processed.discountTotal +
|
|
217
|
+
processed.shippingTotal,
|
|
218
|
+
shipping: processed.shippingTotal,
|
|
219
|
+
salesTax: processed.taxTotal,
|
|
220
|
+
lineItems: processed.lineItems.map((lineItem, index) => ({
|
|
221
|
+
id: lineItem.id ?? `${order.value.id}-${index + 1}`,
|
|
222
|
+
entityId: lineItem.entityId,
|
|
223
|
+
description: lineItem.title ?? lineItem.entityId,
|
|
224
|
+
quantity: lineItem.quantity,
|
|
225
|
+
unitPrice: lineItem.resolvedUnitPrice ?? 0,
|
|
226
|
+
...(lineItem.discountAmount !== undefined
|
|
227
|
+
? { discount: lineItem.discountAmount }
|
|
228
|
+
: {}),
|
|
229
|
+
})),
|
|
230
|
+
...(customerUuid !== undefined
|
|
231
|
+
? { customerId: customerUuid }
|
|
232
|
+
: {}),
|
|
233
|
+
...(processed.shippingAddress !== undefined
|
|
234
|
+
? { toAddress: processed.shippingAddress }
|
|
235
|
+
: {}),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Stash paymentMethodId for completeCheckout compensation chain
|
|
240
|
+
context.context.paymentMethodId = processed.paymentMethodId;
|
|
241
|
+
|
|
242
|
+
const afterReport = await runAfterHooks(
|
|
243
|
+
afterHooks,
|
|
244
|
+
null,
|
|
245
|
+
order.value,
|
|
246
|
+
"create",
|
|
247
|
+
context,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
await kernel.services.cart.markAsCheckedOut(body.cartId, actor);
|
|
251
|
+
|
|
252
|
+
return c.json(
|
|
253
|
+
{
|
|
254
|
+
data: {
|
|
255
|
+
...order.value,
|
|
256
|
+
// Stripe Elements requires clientSecret to collect card details on the frontend
|
|
257
|
+
...(processed.paymentClientSecret
|
|
258
|
+
? { paymentClientSecret: processed.paymentClientSecret }
|
|
259
|
+
: {}),
|
|
260
|
+
},
|
|
261
|
+
meta: afterReport.hasErrors
|
|
262
|
+
? { hookErrors: afterReport.errors }
|
|
263
|
+
: undefined,
|
|
264
|
+
},
|
|
265
|
+
201,
|
|
266
|
+
);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const message = isCommerceError(error)
|
|
269
|
+
? error.message
|
|
270
|
+
: "Checkout failed.";
|
|
271
|
+
return c.json(
|
|
272
|
+
{
|
|
273
|
+
error: {
|
|
274
|
+
code: "CHECKOUT_FAILED",
|
|
275
|
+
message,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
422,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return router;
|
|
284
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
+
import {
|
|
4
|
+
inventoryAdjustRoute,
|
|
5
|
+
inventoryReserveRoute,
|
|
6
|
+
inventoryReleaseRoute,
|
|
7
|
+
createWarehouseRoute,
|
|
8
|
+
inventoryCheckRoute,
|
|
9
|
+
listWarehousesRoute,
|
|
10
|
+
} from "../schemas/inventory.js";
|
|
11
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
|
|
12
|
+
|
|
13
|
+
export function inventoryRoutes(kernel: Kernel) {
|
|
14
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
15
|
+
|
|
16
|
+
// @ts-expect-error -- openapi handler union return type
|
|
17
|
+
router.openapi(inventoryCheckRoute, async (c) => {
|
|
18
|
+
const entityIds = (c.req.query("entityIds") ?? "")
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((item) => item.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
|
|
23
|
+
const result = await kernel.services.inventory.checkMultiple(entityIds);
|
|
24
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
25
|
+
return c.json({ data: result.value });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// @ts-expect-error -- openapi handler union return type
|
|
29
|
+
router.openapi(inventoryAdjustRoute, async (c) => {
|
|
30
|
+
const body = c.req.valid("json");
|
|
31
|
+
const result = await kernel.services.inventory.adjust(body, c.get("actor"));
|
|
32
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
33
|
+
return c.json({ data: result.value });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// @ts-expect-error -- openapi handler union return type
|
|
37
|
+
router.openapi(inventoryReserveRoute, async (c) => {
|
|
38
|
+
const body = c.req.valid("json");
|
|
39
|
+
const result = await kernel.services.inventory.reserve(body, c.get("actor"));
|
|
40
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
41
|
+
return c.json({ data: { reserved: true } });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// @ts-expect-error -- openapi handler union return type
|
|
45
|
+
router.openapi(inventoryReleaseRoute, async (c) => {
|
|
46
|
+
const body = c.req.valid("json");
|
|
47
|
+
const result = await kernel.services.inventory.release(body, c.get("actor"));
|
|
48
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
49
|
+
return c.json({ data: { released: true } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// @ts-expect-error -- openapi handler union return type
|
|
53
|
+
router.openapi(createWarehouseRoute, async (c) => {
|
|
54
|
+
const body = c.req.valid("json") as Parameters<typeof kernel.services.inventory.createWarehouse>[0];
|
|
55
|
+
const actor = c.get("actor");
|
|
56
|
+
const result = await kernel.services.inventory.createWarehouse(body, actor);
|
|
57
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
58
|
+
return c.json({ data: result.value }, 201);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// @ts-expect-error -- openapi handler union return type
|
|
62
|
+
router.openapi(listWarehousesRoute, async (c) => {
|
|
63
|
+
const actor = c.get("actor");
|
|
64
|
+
const result = await kernel.services.inventory.listWarehouses(actor);
|
|
65
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
66
|
+
return c.json({ data: result.value });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return router;
|
|
70
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
+
import type { AttachMediaInput } from "../../../modules/media/service.js";
|
|
4
|
+
import { attachMediaRoute, getMediaRoute, deleteMediaRoute } from "../schemas/media.js";
|
|
5
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus, requirePerm } from "../utils.js";
|
|
6
|
+
|
|
7
|
+
export function mediaRoutes(kernel: Kernel) {
|
|
8
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
9
|
+
|
|
10
|
+
// Upload requires authentication + media:write permission
|
|
11
|
+
router.use("/upload", requirePerm("media:write"));
|
|
12
|
+
|
|
13
|
+
router.post("/upload", async (c) => {
|
|
14
|
+
const body = await c.req.parseBody();
|
|
15
|
+
const file = body.file as File;
|
|
16
|
+
|
|
17
|
+
if (!file) {
|
|
18
|
+
return c.json({ error: { code: "VALIDATION_FAILED", message: "file is required" } }, 422);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const buffer = await file.arrayBuffer();
|
|
22
|
+
const actor = c.get("actor");
|
|
23
|
+
const result = await kernel.services.media.upload({
|
|
24
|
+
filename: file.name,
|
|
25
|
+
contentType: file.type,
|
|
26
|
+
data: buffer,
|
|
27
|
+
alt: String(body.alt ?? ""),
|
|
28
|
+
}, actor);
|
|
29
|
+
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return c.json({ data: result.value }, 201);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
router.openapi(getMediaRoute, async (c) => {
|
|
38
|
+
const signed = c.req.query("signed") === "true";
|
|
39
|
+
|
|
40
|
+
// Signed URLs require authentication
|
|
41
|
+
if (signed && !c.get("actor")) {
|
|
42
|
+
return c.json(
|
|
43
|
+
{ error: { code: "UNAUTHORIZED", message: "Authentication required for signed URLs." } },
|
|
44
|
+
401,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = signed
|
|
49
|
+
? await kernel.services.media.getSignedUrl(c.req.param("id"))
|
|
50
|
+
: await kernel.services.media.getUrl(c.req.param("id"));
|
|
51
|
+
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return c.redirect(result.value, 302);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// @ts-expect-error -- openapi handler union return type
|
|
60
|
+
router.openapi(deleteMediaRoute, async (c) => {
|
|
61
|
+
const actor = c.get("actor");
|
|
62
|
+
if (!actor || (!actor.permissions.includes("media:write") && !actor.permissions.includes("*:*"))) {
|
|
63
|
+
return c.json({ error: { code: "FORBIDDEN", message: "media:write permission required." } }, 403);
|
|
64
|
+
}
|
|
65
|
+
const result = await kernel.services.media.delete(c.req.param("id"));
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
68
|
+
}
|
|
69
|
+
return c.json({ data: { deleted: true } });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Attach requires media:write
|
|
73
|
+
router.use("/attach", requirePerm("media:write"));
|
|
74
|
+
|
|
75
|
+
// @ts-expect-error -- openapi handler union return type
|
|
76
|
+
router.openapi(attachMediaRoute, async (c) => {
|
|
77
|
+
const body = c.req.valid("json") as AttachMediaInput;
|
|
78
|
+
const result = await kernel.services.media.attachToEntity(body);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
81
|
+
}
|
|
82
|
+
return c.json({ data: { attached: true } }, 201);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return router;
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
+
import { changeOrderStatusRoute, listOrdersRoute, getOrderRoute, getOrderFulfillmentsRoute } from "../schemas/orders.js";
|
|
4
|
+
import { type AppEnv, isUUID, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
|
|
5
|
+
|
|
6
|
+
export function orderRoutes(kernel: Kernel) {
|
|
7
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
8
|
+
|
|
9
|
+
// @ts-expect-error -- openapi handler union return type
|
|
10
|
+
router.openapi(listOrdersRoute, async (c) => {
|
|
11
|
+
const pagination = parsePagination(c.req.query());
|
|
12
|
+
const status = c.req.query("status");
|
|
13
|
+
const result = await kernel.services.orders.list(
|
|
14
|
+
{
|
|
15
|
+
page: pagination.page,
|
|
16
|
+
limit: pagination.limit,
|
|
17
|
+
...(status !== undefined ? { status } : {}),
|
|
18
|
+
},
|
|
19
|
+
c.get("actor"),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
23
|
+
return c.json({
|
|
24
|
+
data: result.value.items,
|
|
25
|
+
meta: {
|
|
26
|
+
pagination: result.value.pagination,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// @ts-expect-error -- openapi handler union return type
|
|
32
|
+
router.openapi(getOrderRoute, async (c) => {
|
|
33
|
+
const idOrNumber = c.req.param("idOrNumber");
|
|
34
|
+
const result = isUUID(idOrNumber)
|
|
35
|
+
? await kernel.services.orders.getById(idOrNumber, c.get("actor"))
|
|
36
|
+
: await kernel.services.orders.getByNumber(idOrNumber, c.get("actor"));
|
|
37
|
+
|
|
38
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
39
|
+
return c.json({ data: result.value });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
43
|
+
// returns union responses (200 | 400 | 404). The route definition documents the
|
|
44
|
+
// contract; the handler returns dynamic status.
|
|
45
|
+
router.openapi(changeOrderStatusRoute, async (c) => {
|
|
46
|
+
const body = c.req.valid("json");
|
|
47
|
+
const result = await kernel.services.orders.changeStatus(
|
|
48
|
+
{
|
|
49
|
+
orderId: c.req.param("id"),
|
|
50
|
+
newStatus: body.status,
|
|
51
|
+
...(body.reason !== undefined ? { reason: body.reason } : {}),
|
|
52
|
+
},
|
|
53
|
+
c.get("actor"),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
57
|
+
return c.json({ data: result.value });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// @ts-expect-error -- openapi handler union return type
|
|
61
|
+
router.openapi(getOrderFulfillmentsRoute, async (c) => {
|
|
62
|
+
const orderId = c.req.param("id");
|
|
63
|
+
const actor = c.get("actor");
|
|
64
|
+
|
|
65
|
+
// Verify the order exists and the actor has access before returning fulfillments
|
|
66
|
+
const orderResult = isUUID(orderId)
|
|
67
|
+
? await kernel.services.orders.getById(orderId, actor)
|
|
68
|
+
: await kernel.services.orders.getByNumber(orderId, actor);
|
|
69
|
+
|
|
70
|
+
if (!orderResult.ok) return c.json(mapErrorToResponse(orderResult.error), mapErrorToStatus(orderResult.error));
|
|
71
|
+
|
|
72
|
+
const result = await kernel.services.fulfillment.getByOrderId(orderResult.value.id);
|
|
73
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
74
|
+
return c.json({ data: result.value });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return router;
|
|
78
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
3
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
4
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
|
|
5
|
+
import { processedWebhookEvents } from "../../../modules/webhooks/schema.js";
|
|
6
|
+
|
|
7
|
+
type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
|
|
8
|
+
|
|
9
|
+
export function paymentRoutes(kernel: Kernel) {
|
|
10
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
11
|
+
|
|
12
|
+
router.post("/webhook", async (c) => {
|
|
13
|
+
const result = await kernel.services.payments.verifyWebhook(c.req.raw);
|
|
14
|
+
if (!result.ok) {
|
|
15
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const event = result.value;
|
|
19
|
+
const db = kernel.database.db as Db;
|
|
20
|
+
|
|
21
|
+
// Atomic idempotency: INSERT ... ON CONFLICT DO NOTHING ... RETURNING
|
|
22
|
+
// If the row already exists, RETURNING yields zero rows → duplicate.
|
|
23
|
+
// If the row is new, RETURNING yields one row → process the event.
|
|
24
|
+
// No TOCTOU race: the UNIQUE constraint on event_id is the single source of truth.
|
|
25
|
+
const [inserted] = await db
|
|
26
|
+
.insert(processedWebhookEvents)
|
|
27
|
+
.values({
|
|
28
|
+
eventId: event.id,
|
|
29
|
+
provider: "stripe",
|
|
30
|
+
eventType: event.type,
|
|
31
|
+
})
|
|
32
|
+
.onConflictDoNothing()
|
|
33
|
+
.returning({ id: processedWebhookEvents.id });
|
|
34
|
+
|
|
35
|
+
if (!inserted) {
|
|
36
|
+
// Row already existed — this is a duplicate delivery
|
|
37
|
+
return c.json({ data: { received: true, duplicate: true } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Process the event (first time only)
|
|
41
|
+
if (event.type === "payment_intent.succeeded") {
|
|
42
|
+
const data = event.data as Record<string, unknown> | undefined;
|
|
43
|
+
const metadata = data?.metadata as Record<string, unknown> | undefined;
|
|
44
|
+
if (typeof metadata?.orderId === "string") {
|
|
45
|
+
await kernel.services.orders.changeStatus(
|
|
46
|
+
{
|
|
47
|
+
orderId: metadata.orderId,
|
|
48
|
+
newStatus: "confirmed",
|
|
49
|
+
reason: "stripe_webhook_payment_intent_succeeded",
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return c.json({ data: { received: true } });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return router;
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
+
import { setBasePriceRoute, createModifierRoute, listPricesRoute } from "../schemas/pricing.js";
|
|
4
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus, requirePerm } from "../utils.js";
|
|
5
|
+
|
|
6
|
+
export function pricingRoutes(kernel: Kernel) {
|
|
7
|
+
const router = new OpenAPIHono<AppEnv>();
|
|
8
|
+
|
|
9
|
+
router.use("/prices", requirePerm("pricing:manage"));
|
|
10
|
+
|
|
11
|
+
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
12
|
+
// returns union responses (201 | 400 | 422). The route definition documents the
|
|
13
|
+
// contract; the handler returns dynamic status.
|
|
14
|
+
router.openapi(setBasePriceRoute, async (c) => {
|
|
15
|
+
const actor = c.get("actor");
|
|
16
|
+
const result = await kernel.services.pricing.setBasePrice(c.req.valid("json"), actor);
|
|
17
|
+
if (!result.ok) {
|
|
18
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
19
|
+
}
|
|
20
|
+
return c.json({ data: result.value }, 201);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// @ts-expect-error -- openapi handler union return type
|
|
24
|
+
router.openapi(listPricesRoute, async (c) => {
|
|
25
|
+
const entityId = c.req.query("entityId");
|
|
26
|
+
const variantId = c.req.query("variantId");
|
|
27
|
+
const currency = c.req.query("currency");
|
|
28
|
+
const customerGroupId = c.req.query("customerGroupId");
|
|
29
|
+
|
|
30
|
+
const result = await kernel.services.pricing.listPrices({
|
|
31
|
+
...(entityId !== undefined ? { entityId } : {}),
|
|
32
|
+
...(variantId !== undefined ? { variantId } : {}),
|
|
33
|
+
...(currency !== undefined ? { currency } : {}),
|
|
34
|
+
...(customerGroupId !== undefined ? { customerGroupId } : {}),
|
|
35
|
+
});
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
38
|
+
}
|
|
39
|
+
return c.json({ data: result.value });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
router.use("/modifiers", requirePerm("pricing:manage"));
|
|
43
|
+
|
|
44
|
+
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
45
|
+
// returns union responses (201 | 400 | 422). The route definition documents the
|
|
46
|
+
// contract; the handler returns dynamic status.
|
|
47
|
+
router.openapi(createModifierRoute, async (c) => {
|
|
48
|
+
const actor = c.get("actor");
|
|
49
|
+
const result = await kernel.services.pricing.createModifier(c.req.valid("json"), actor);
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
52
|
+
}
|
|
53
|
+
return c.json({ data: result.value }, 201);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return router;
|
|
57
|
+
}
|