@porulle/core 0.10.4 → 0.10.6
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/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +1 -0
- package/dist/config/types.d.ts +8 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout.d.ts +5 -0
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +6 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +27 -1
- package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/checkout.js +8 -1
- package/dist/interfaces/rest/schemas/compensation-failures.d.ts +1 -1
- package/dist/interfaces/rest/schemas/promotions.d.ts +2 -2
- package/dist/kernel/hooks/executor.d.ts.map +1 -1
- package/dist/kernel/hooks/executor.js +18 -1
- package/dist/modules/cart/repository/index.d.ts +2 -0
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +10 -0
- package/dist/modules/cart/service.d.ts +5 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +7 -0
- package/dist/modules/payments/adapter.d.ts +4 -0
- package/dist/modules/payments/adapter.d.ts.map +1 -1
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +9 -1
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +13 -13
- package/src/auth/setup.ts +1 -0
- package/src/config/types.ts +8 -0
- package/src/hooks/checkout.ts +13 -0
- package/src/interfaces/rest/routes/checkout.ts +29 -1
- package/src/interfaces/rest/schemas/checkout.ts +8 -1
- package/src/kernel/hooks/executor.ts +19 -1
- package/src/modules/cart/repository/index.ts +14 -0
- package/src/modules/cart/service.ts +11 -0
- package/src/modules/payments/adapter.ts +4 -0
- package/src/runtime/server.ts +10 -2
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
8
9
|
"bun": "./src/index.ts",
|
|
9
10
|
"import": "./dist/index.js",
|
|
10
|
-
"require": "./dist/index.js"
|
|
11
|
-
"types": "./src/index.ts"
|
|
11
|
+
"require": "./dist/index.js"
|
|
12
12
|
},
|
|
13
13
|
"./schema": {
|
|
14
|
+
"types": "./src/kernel/database/schema.ts",
|
|
14
15
|
"bun": "./src/kernel/database/schema.ts",
|
|
15
16
|
"import": "./dist/kernel/database/schema.js",
|
|
16
|
-
"require": "./dist/kernel/database/schema.js"
|
|
17
|
-
"types": "./src/kernel/database/schema.ts"
|
|
17
|
+
"require": "./dist/kernel/database/schema.js"
|
|
18
18
|
},
|
|
19
19
|
"./auth-schema": {
|
|
20
|
+
"types": "./src/auth/auth-schema.ts",
|
|
20
21
|
"bun": "./src/auth/auth-schema.ts",
|
|
21
22
|
"import": "./dist/auth/auth-schema.js",
|
|
22
|
-
"require": "./dist/auth/auth-schema.js"
|
|
23
|
-
"types": "./src/auth/auth-schema.ts"
|
|
23
|
+
"require": "./dist/auth/auth-schema.js"
|
|
24
24
|
},
|
|
25
25
|
"./testing": {
|
|
26
|
+
"types": "./src/testing.ts",
|
|
26
27
|
"bun": "./src/testing.ts",
|
|
27
28
|
"import": "./dist/testing.js",
|
|
28
|
-
"require": "./dist/testing.js"
|
|
29
|
-
"types": "./src/testing.ts"
|
|
29
|
+
"require": "./dist/testing.js"
|
|
30
30
|
},
|
|
31
31
|
"./drizzle": {
|
|
32
|
+
"types": "./src/drizzle.ts",
|
|
32
33
|
"bun": "./src/drizzle.ts",
|
|
33
34
|
"import": "./dist/drizzle.js",
|
|
34
|
-
"require": "./dist/drizzle.js"
|
|
35
|
-
"types": "./src/drizzle.ts"
|
|
35
|
+
"require": "./dist/drizzle.js"
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"eslint": "^9.39.1",
|
|
57
57
|
"typescript": "5.9.2",
|
|
58
58
|
"vitest": "^3.2.4",
|
|
59
|
-
"@porulle/
|
|
60
|
-
"@porulle/
|
|
59
|
+
"@porulle/eslint-config": "0.1.0",
|
|
60
|
+
"@porulle/typescript-config": "0.1.0"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"access": "public"
|
package/src/auth/setup.ts
CHANGED
|
@@ -122,6 +122,7 @@ export function createAuth(
|
|
|
122
122
|
|
|
123
123
|
try {
|
|
124
124
|
const auth = betterAuth({
|
|
125
|
+
...(config.auth?.baseURL ? { baseURL: config.auth.baseURL } : {}),
|
|
125
126
|
database: drizzleAdapter(db.db as unknown as Record<string, unknown>, {
|
|
126
127
|
provider: resolveAuthDbProvider(db.provider),
|
|
127
128
|
schema: authSchema,
|
package/src/config/types.ts
CHANGED
|
@@ -123,6 +123,14 @@ export interface ApiKeyScopeDefinition {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export interface AuthConfig {
|
|
126
|
+
/**
|
|
127
|
+
* Canonical public origin used by Better Auth when constructing callback
|
|
128
|
+
* URLs and validating requests. Set this explicitly in deployed runtimes
|
|
129
|
+
* instead of relying on request-header inference.
|
|
130
|
+
*
|
|
131
|
+
* @example "https://commerce.example.com"
|
|
132
|
+
*/
|
|
133
|
+
baseURL?: string;
|
|
126
134
|
/**
|
|
127
135
|
* The default organization ID for single-store deployments.
|
|
128
136
|
* Created by the seed script via `auth.api.createOrganization()`.
|
package/src/hooks/checkout.ts
CHANGED
|
@@ -61,7 +61,12 @@ export interface CheckoutData {
|
|
|
61
61
|
customerId?: string;
|
|
62
62
|
customerGroupIds?: string[];
|
|
63
63
|
currency: string;
|
|
64
|
+
/** Porulle payment adapter/provider id, e.g. `stripe`. */
|
|
64
65
|
paymentMethodId: string;
|
|
66
|
+
/** Provider-issued token for the actual payment instrument. */
|
|
67
|
+
paymentMethodToken?: string;
|
|
68
|
+
/** Stable retry key shared by order creation and payment authorization. */
|
|
69
|
+
idempotencyKey?: string;
|
|
65
70
|
lineItems: CheckoutLineItem[];
|
|
66
71
|
subtotal: number;
|
|
67
72
|
discountTotal: number;
|
|
@@ -517,6 +522,8 @@ export const authorizePayment: BeforeHook<CheckoutData> = async ({
|
|
|
517
522
|
amount: number;
|
|
518
523
|
currency: string;
|
|
519
524
|
paymentMethodId: string;
|
|
525
|
+
paymentMethodToken?: string;
|
|
526
|
+
idempotencyKey?: string;
|
|
520
527
|
customerId?: string;
|
|
521
528
|
metadata: Record<string, unknown>;
|
|
522
529
|
}): Promise<{
|
|
@@ -529,6 +536,12 @@ export const authorizePayment: BeforeHook<CheckoutData> = async ({
|
|
|
529
536
|
amount: data.total,
|
|
530
537
|
currency: data.currency,
|
|
531
538
|
paymentMethodId: data.paymentMethodId,
|
|
539
|
+
...(data.paymentMethodToken !== undefined
|
|
540
|
+
? { paymentMethodToken: data.paymentMethodToken }
|
|
541
|
+
: {}),
|
|
542
|
+
...(data.idempotencyKey !== undefined
|
|
543
|
+
? { idempotencyKey: data.idempotencyKey }
|
|
544
|
+
: {}),
|
|
532
545
|
metadata: {
|
|
533
546
|
checkoutId: data.checkoutId,
|
|
534
547
|
cartId: data.cartId,
|
|
@@ -131,6 +131,12 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
131
131
|
cartId: body.cartId,
|
|
132
132
|
currency: body.currency ?? "USD",
|
|
133
133
|
paymentMethodId: body.paymentMethodId,
|
|
134
|
+
...(body.paymentMethodToken !== undefined
|
|
135
|
+
? { paymentMethodToken: body.paymentMethodToken }
|
|
136
|
+
: {}),
|
|
137
|
+
...(body.idempotencyKey !== undefined
|
|
138
|
+
? { idempotencyKey: body.idempotencyKey }
|
|
139
|
+
: {}),
|
|
134
140
|
lineItems: [],
|
|
135
141
|
subtotal: 0,
|
|
136
142
|
discountTotal: 0,
|
|
@@ -194,6 +200,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
194
200
|
database: { db: kernel.database.db as PluginDb },
|
|
195
201
|
});
|
|
196
202
|
|
|
203
|
+
let pipelineStage = "validate-and-calculate";
|
|
197
204
|
try {
|
|
198
205
|
// Phase 1: DB transaction for validation — releases connection immediately after
|
|
199
206
|
const validated = await kernel.database.transaction(async (_tx) => {
|
|
@@ -208,6 +215,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
208
215
|
|
|
209
216
|
// Phase 2: Payment authorization — NO DB connection held while calling Stripe/etc.
|
|
210
217
|
// If Stripe takes 5s, the DB connection pool is not affected.
|
|
218
|
+
pipelineStage = "authorize-payment";
|
|
211
219
|
context.tx = null;
|
|
212
220
|
const processed = await runBeforeHooks(
|
|
213
221
|
paymentHooks,
|
|
@@ -219,6 +227,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
219
227
|
// SEC-07: resolve the order's customer server-side. A self-service actor
|
|
220
228
|
// can only attribute the order to its own profile; a client-supplied
|
|
221
229
|
// foreign customerId is ignored unless the actor is staff.
|
|
230
|
+
pipelineStage = "resolve-customer";
|
|
222
231
|
const customerUuid = await resolveCheckoutCustomerUuid(
|
|
223
232
|
kernel.services.customers,
|
|
224
233
|
actor,
|
|
@@ -275,6 +284,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
275
284
|
// Checkout is a trusted, already-server-priced pipeline (resolveCurrentPrices
|
|
276
285
|
// + promotions/tax) and reserves stock in its own after-hooks — so it hands
|
|
277
286
|
// the order primitive precomputed totals rather than re-deriving them.
|
|
287
|
+
pipelineStage = "create-order";
|
|
278
288
|
const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
|
|
279
289
|
trustedPricing: true,
|
|
280
290
|
});
|
|
@@ -287,6 +297,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
if (order.ok && (processed.appliedPromotions?.length ?? 0) > 0) {
|
|
300
|
+
pipelineStage = "record-promotion-usage";
|
|
290
301
|
await kernel.services.promotions.recordUsage({
|
|
291
302
|
promotions: processed.appliedPromotions ?? [],
|
|
292
303
|
organizationId: order.value.organizationId,
|
|
@@ -298,6 +309,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
298
309
|
}
|
|
299
310
|
|
|
300
311
|
if (order.ok) {
|
|
312
|
+
pipelineStage = "report-tax-transaction";
|
|
301
313
|
await kernel.services.tax.reportTransaction({
|
|
302
314
|
transactionId: order.value.id,
|
|
303
315
|
transactionDate: new Date(),
|
|
@@ -330,6 +342,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
330
342
|
// Stash paymentMethodId for completeCheckout compensation chain
|
|
331
343
|
context.context.paymentMethodId = processed.paymentMethodId;
|
|
332
344
|
|
|
345
|
+
pipelineStage = "complete-checkout";
|
|
333
346
|
const afterReport = await runAfterHooks(
|
|
334
347
|
afterHooks,
|
|
335
348
|
null,
|
|
@@ -338,6 +351,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
338
351
|
context,
|
|
339
352
|
);
|
|
340
353
|
|
|
354
|
+
pipelineStage = "mark-cart-checked-out";
|
|
341
355
|
await kernel.services.cart.markAsCheckedOut(body.cartId, actor);
|
|
342
356
|
|
|
343
357
|
return c.json(
|
|
@@ -356,8 +370,22 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
356
370
|
201,
|
|
357
371
|
);
|
|
358
372
|
} catch (error) {
|
|
373
|
+
// Validation, payment authorization, or compensated completion may fail
|
|
374
|
+
// after the cart's atomic active -> checking_out claim. Release only
|
|
375
|
+
// that transient state so the shopper can correct the problem and retry.
|
|
376
|
+
await kernel.services.cart.releaseCheckoutClaim(body.cartId).catch(
|
|
377
|
+
(releaseError: unknown) => {
|
|
378
|
+
console.error("[checkout] Failed to release cart claim:", {
|
|
379
|
+
cartId: body.cartId,
|
|
380
|
+
error: releaseError instanceof Error ? releaseError.message : String(releaseError),
|
|
381
|
+
});
|
|
382
|
+
},
|
|
383
|
+
);
|
|
359
384
|
// Always log the real error — hidden errors in checkout are unacceptable
|
|
360
|
-
const
|
|
385
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
386
|
+
const realMessage = rawMessage === "[object ErrorEvent]"
|
|
387
|
+
? `Checkout stage "${pipelineStage}" failed: ${rawMessage}`
|
|
388
|
+
: rawMessage;
|
|
361
389
|
const realStack = error instanceof Error ? error.stack : undefined;
|
|
362
390
|
console.error("[checkout] Pipeline failed:", { message: realMessage, stack: realStack, code: (error as Record<string, unknown>)?.code });
|
|
363
391
|
|
|
@@ -5,7 +5,14 @@ import { ErrorSchema, errorResponses } from "./shared.js";
|
|
|
5
5
|
|
|
6
6
|
export const CheckoutBodySchema = z.object({
|
|
7
7
|
cartId: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
8
|
-
paymentMethodId: z.string().openapi({
|
|
8
|
+
paymentMethodId: z.string().openapi({
|
|
9
|
+
example: "stripe",
|
|
10
|
+
description: "Registered Porulle payment adapter/provider id.",
|
|
11
|
+
}),
|
|
12
|
+
paymentMethodToken: z.string().min(3).max(255).optional().openapi({
|
|
13
|
+
example: "pm_card_visa",
|
|
14
|
+
description: "Provider-issued token for the shopper's payment instrument. This is not the Porulle adapter id.",
|
|
15
|
+
}),
|
|
9
16
|
idempotencyKey: z.string().min(8).max(255).optional().openapi({
|
|
10
17
|
example: "checkout-8f14e45f-1738312200",
|
|
11
18
|
description: "Client-supplied retry key. A re-submitted checkout with the same key returns the already-created order without re-authorizing payment (safe offline-queue replay).",
|
|
@@ -34,6 +34,24 @@ function withTimeout<T>(promiseOrValue: Promise<T> | T, timeoutMs: number, hookN
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function actionableHookError(error: unknown, hookName: string): Error {
|
|
38
|
+
if (error instanceof Error && error.message && error.message !== "[object ErrorEvent]") {
|
|
39
|
+
return error;
|
|
40
|
+
}
|
|
41
|
+
const candidate = error as { error?: unknown; cause?: unknown; message?: unknown } | null;
|
|
42
|
+
const nested = candidate?.error ?? candidate?.cause;
|
|
43
|
+
const nestedMessage = nested instanceof Error
|
|
44
|
+
? nested.message
|
|
45
|
+
: nested && typeof nested === "object" && typeof (nested as { message?: unknown }).message === "string"
|
|
46
|
+
? String((nested as { message: string }).message)
|
|
47
|
+
: undefined;
|
|
48
|
+
const directMessage = typeof candidate?.message === "string" && candidate.message !== "[object ErrorEvent]"
|
|
49
|
+
? candidate.message
|
|
50
|
+
: undefined;
|
|
51
|
+
const message = nestedMessage || directMessage || String(error);
|
|
52
|
+
return new Error(`Before-hook "${hookName}" failed: ${message}`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
export async function runBeforeHooks<T>(
|
|
38
56
|
hooks: BeforeHook<T>[],
|
|
39
57
|
data: T,
|
|
@@ -54,7 +72,7 @@ export async function runBeforeHooks<T>(
|
|
|
54
72
|
error: error instanceof Error ? error.message : String(error),
|
|
55
73
|
requestId: context.requestId,
|
|
56
74
|
});
|
|
57
|
-
throw error; //
|
|
75
|
+
throw actionableHookError(error, hookName); // Before hooks MUST succeed with actionable context.
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
return current;
|
|
@@ -148,6 +148,20 @@ export class CartRepository {
|
|
|
148
148
|
return rows[0];
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/** Release only an in-progress checkout claim; terminal cart states are untouched. */
|
|
152
|
+
async releaseCheckoutClaim(
|
|
153
|
+
id: string,
|
|
154
|
+
ctx?: TxContext,
|
|
155
|
+
): Promise<Cart | undefined> {
|
|
156
|
+
const db = this.getDb(ctx);
|
|
157
|
+
const rows = await db
|
|
158
|
+
.update(carts)
|
|
159
|
+
.set({ status: "active", updatedAt: new Date() })
|
|
160
|
+
.where(and(eq(carts.id, id), eq(carts.status, "checking_out")))
|
|
161
|
+
.returning();
|
|
162
|
+
return rows[0];
|
|
163
|
+
}
|
|
164
|
+
|
|
151
165
|
async delete(id: string, ctx?: TxContext): Promise<boolean> {
|
|
152
166
|
const db = this.getDb(ctx);
|
|
153
167
|
const result = await db.delete(carts).where(eq(carts.id, id)).returning();
|
|
@@ -599,6 +599,17 @@ export class CartService {
|
|
|
599
599
|
return Ok(claimed);
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Atomically returns a failed checkout claim to `active`. A checked-out,
|
|
604
|
+
* merged, abandoned, or already-active cart is left untouched.
|
|
605
|
+
*/
|
|
606
|
+
async releaseCheckoutClaim(
|
|
607
|
+
cartId: string,
|
|
608
|
+
ctx?: TxContext,
|
|
609
|
+
): Promise<Result<Cart | null>> {
|
|
610
|
+
return Ok((await this.repo.releaseCheckoutClaim(cartId, ctx)) ?? null);
|
|
611
|
+
}
|
|
612
|
+
|
|
602
613
|
/**
|
|
603
614
|
* Creates an anonymous guest cart with a secret token for access control.
|
|
604
615
|
* The secret must be stored client-side (cookie/local storage) and sent
|
|
@@ -32,6 +32,10 @@ export interface CreatePaymentIntentParams {
|
|
|
32
32
|
orderId: string;
|
|
33
33
|
customerId?: string;
|
|
34
34
|
metadata?: Record<string, string>;
|
|
35
|
+
/** Provider token for the shopper-selected payment instrument. */
|
|
36
|
+
paymentMethodToken?: string;
|
|
37
|
+
/** Stable key forwarded to the provider so retries cannot create a second intent. */
|
|
38
|
+
idempotencyKey?: string;
|
|
35
39
|
terminalId?: string;
|
|
36
40
|
}
|
|
37
41
|
|
package/src/runtime/server.ts
CHANGED
|
@@ -39,8 +39,16 @@ type ServerEnv = {
|
|
|
39
39
|
* Exposed for direct unit testing — see test/server-edge-runtime.test.ts.
|
|
40
40
|
*/
|
|
41
41
|
export function isNodeRuntime(): boolean {
|
|
42
|
+
const workerUserAgent = typeof navigator !== "undefined"
|
|
43
|
+
? navigator.userAgent
|
|
44
|
+
: undefined;
|
|
45
|
+
const hasWorkersSocketPair = "WebSocketPair" in globalThis;
|
|
46
|
+
if (workerUserAgent === "Cloudflare-Workers" || hasWorkersSocketPair) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
42
49
|
return (
|
|
43
50
|
typeof process !== "undefined" &&
|
|
51
|
+
typeof process.versions?.node === "string" &&
|
|
44
52
|
typeof process.on === "function" &&
|
|
45
53
|
typeof process.exit === "function"
|
|
46
54
|
);
|
|
@@ -150,8 +158,8 @@ export async function createServer(config: CommerceConfig) {
|
|
|
150
158
|
const ct = c.res.headers.get("content-type");
|
|
151
159
|
if (ct?.includes("json")) {
|
|
152
160
|
try {
|
|
153
|
-
const body = await c.res.clone().json();
|
|
154
|
-
if (body
|
|
161
|
+
const body = await c.res.clone().json() as { error?: { name?: string } };
|
|
162
|
+
if (body.error?.name === "ZodError") {
|
|
155
163
|
c.res = new Response(
|
|
156
164
|
JSON.stringify({ error: { code: "VALIDATION_FAILED", message: "Invalid input." } }),
|
|
157
165
|
{ status: 422, headers: { "content-type": "application/json" } },
|