@01.software/init 0.9.1 → 0.10.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/dist/ai-docs.d.ts +13 -0
- package/dist/ai-docs.js +1 -1
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-TBGKXE3Q.js → chunk-NJ4X7VNK.js} +5 -5
- package/dist/chunk-NJ4X7VNK.js.map +1 -0
- package/dist/{chunk-5K2CB2Y5.js → chunk-Q6MSORYN.js} +14 -37
- package/dist/chunk-Q6MSORYN.js.map +1 -0
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-2IGKOSK7.js → chunk-WDWJ73KP.js} +41 -215
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +30 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +139 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
- package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
- package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
- package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
- package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
- package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
- package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
- package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
- package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
- package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
- package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +119 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +267 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
- package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
- package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
- package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
- package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
- package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
- package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
- package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
- package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
- package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
- package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
- package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
- package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
- package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
- package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +76 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +63 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +3 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +336 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +913 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +206 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
- package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
- package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
- package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
- package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
- package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
- package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
- package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
- package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
- package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
- package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
- package/dist/create-app-templates/ecommerce/package.json +33 -0
- package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
- package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
- package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +392 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1537 -0
- package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
- package/dist/create-app-templates/registry.json +66 -0
- package/dist/create-app.d.ts +40 -0
- package/dist/create-app.js +652 -0
- package/dist/create-app.js.map +1 -0
- package/dist/detect-Bjxp9wcS.d.ts +13 -0
- package/dist/file-ops.d.ts +21 -0
- package/dist/file-ops.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +5 -4
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +31 -15
- package/dist/chunk-2IGKOSK7.js.map +0 -1
- package/dist/chunk-5K2CB2Y5.js.map +0 -1
- package/dist/chunk-TBGKXE3Q.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
import '../../server-only-guard.ts'
|
|
2
|
+
import { normalizeCartLines } from '../../cart/normalize.ts'
|
|
3
|
+
import type { CartProvider, CommerceProvider } from '../provider.ts'
|
|
4
|
+
import type { CheckoutCommerceProvider } from '../../checkout/types.ts'
|
|
5
|
+
import type {
|
|
6
|
+
CartItemView,
|
|
7
|
+
CartView,
|
|
8
|
+
CommerceImage,
|
|
9
|
+
CreatePendingOrderResult,
|
|
10
|
+
Order,
|
|
11
|
+
ShippingPolicy,
|
|
12
|
+
} from '../types.ts'
|
|
13
|
+
import {
|
|
14
|
+
mapSoftwareProductDetailResult,
|
|
15
|
+
mapSoftwareVariant,
|
|
16
|
+
type SoftwareProductDetailResultLike,
|
|
17
|
+
} from './software-mappers.ts'
|
|
18
|
+
|
|
19
|
+
type SoftwareProductListDoc = {
|
|
20
|
+
id: string | number
|
|
21
|
+
slug?: string | null
|
|
22
|
+
title?: string | null
|
|
23
|
+
description?: string | null
|
|
24
|
+
status?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SoftwareVariantDoc = Parameters<typeof mapSoftwareVariant>[0] & {
|
|
28
|
+
product?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type SoftwareCommerceConfig = {
|
|
32
|
+
publishableKey: string
|
|
33
|
+
secretKey: string
|
|
34
|
+
apiUrl?: string
|
|
35
|
+
shippingPolicy: ShippingPolicy
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type SoftwareEnv = NodeJS.ProcessEnv | Record<string, string | undefined>
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// SDK response shapes (narrowed). The real SDK types are richer; we narrow to
|
|
42
|
+
// the fields the storefront consumes and keep relation refs tolerant of
|
|
43
|
+
// id-only (depth 0) or populated objects.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
type CartItemDoc = {
|
|
46
|
+
id?: string | number
|
|
47
|
+
product?: unknown
|
|
48
|
+
variant?: unknown
|
|
49
|
+
quantity?: number | null
|
|
50
|
+
unitPrice?: number | null
|
|
51
|
+
discountedTotalPrice?: number | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CartDoc = {
|
|
55
|
+
id?: string | number
|
|
56
|
+
currency?: string | null
|
|
57
|
+
subtotalAmount?: number | null
|
|
58
|
+
shippingAmount?: number | null
|
|
59
|
+
discountAmount?: number | null
|
|
60
|
+
totalAmount?: number | null
|
|
61
|
+
discountCode?: string | null
|
|
62
|
+
items?: CartItemDoc[] | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type OrderItemDoc = {
|
|
66
|
+
product?: unknown
|
|
67
|
+
variant?: unknown
|
|
68
|
+
productTitle?: string | null
|
|
69
|
+
variantTitle?: string | null
|
|
70
|
+
quantity?: number | null
|
|
71
|
+
unitPrice?: number | null
|
|
72
|
+
totalPrice?: number | null
|
|
73
|
+
discountedTotalPrice?: number | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type TransactionDoc = {
|
|
77
|
+
pgPaymentId?: string | null
|
|
78
|
+
pgProvider?: string | null
|
|
79
|
+
status?: string | null
|
|
80
|
+
amount?: number | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type OrderDoc = {
|
|
84
|
+
id?: string | number
|
|
85
|
+
orderNumber?: string | null
|
|
86
|
+
financialStatus?: string | null
|
|
87
|
+
displayFinancialStatus?: string | null
|
|
88
|
+
displayStatus?: string | null
|
|
89
|
+
customerSnapshot?: {
|
|
90
|
+
name?: string | null
|
|
91
|
+
email?: string | null
|
|
92
|
+
phone?: string | null
|
|
93
|
+
} | null
|
|
94
|
+
shippingAddress?: {
|
|
95
|
+
recipientName?: string | null
|
|
96
|
+
phone?: string | null
|
|
97
|
+
postalCode?: string | null
|
|
98
|
+
address?: string | null
|
|
99
|
+
detailAddress?: string | null
|
|
100
|
+
deliveryMessage?: string | null
|
|
101
|
+
} | null
|
|
102
|
+
subtotalAmount?: number | null
|
|
103
|
+
shippingAmount?: number | null
|
|
104
|
+
totalAmount?: number | null
|
|
105
|
+
items?: OrderItemDoc[] | { docs?: OrderItemDoc[] | null } | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type OrderWithTransaction = {
|
|
109
|
+
order: OrderDoc
|
|
110
|
+
transaction: TransactionDoc | null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type CustomerSnapshotInput = { name?: string; email: string; phone?: string }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The slice of the `@01.software/sdk` server client this adapter depends on.
|
|
117
|
+
* Declaring it explicitly is the SDK boundary: the real client is cast to it
|
|
118
|
+
* once in {@link createSoftwareCommerceProvider}, and tests can inject a fake to
|
|
119
|
+
* exercise the checkout → confirm round-trip without a live Console.
|
|
120
|
+
*/
|
|
121
|
+
export type SoftwareSdkClientLike = {
|
|
122
|
+
collections: {
|
|
123
|
+
from: (slug: string) => {
|
|
124
|
+
find: (options: {
|
|
125
|
+
limit?: number
|
|
126
|
+
depth?: number
|
|
127
|
+
where?: unknown
|
|
128
|
+
joins?: boolean
|
|
129
|
+
}) => Promise<{ docs: unknown[]; totalDocs?: number }>
|
|
130
|
+
update: (id: string, data: Record<string, unknown>) => Promise<unknown>
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
commerce: {
|
|
134
|
+
product: {
|
|
135
|
+
detail: (params: { slug: string }) => Promise<unknown>
|
|
136
|
+
stockCheck: (params: {
|
|
137
|
+
items: { variantId: string; quantity: number }[]
|
|
138
|
+
}) => Promise<{
|
|
139
|
+
allAvailable?: boolean
|
|
140
|
+
results: {
|
|
141
|
+
variantId: string | number
|
|
142
|
+
requestedQuantity: number
|
|
143
|
+
isUnlimited: boolean
|
|
144
|
+
availableStock: number | null
|
|
145
|
+
available: boolean
|
|
146
|
+
status: string
|
|
147
|
+
}[]
|
|
148
|
+
}>
|
|
149
|
+
}
|
|
150
|
+
cart: {
|
|
151
|
+
create: () => Promise<{ cartToken?: string }>
|
|
152
|
+
get: (cartToken: string) => Promise<CartDoc>
|
|
153
|
+
addItem: (params: {
|
|
154
|
+
cartToken: string
|
|
155
|
+
product: string
|
|
156
|
+
variant: string
|
|
157
|
+
option?: string
|
|
158
|
+
quantity: number
|
|
159
|
+
}) => Promise<unknown>
|
|
160
|
+
updateItem: (params: {
|
|
161
|
+
cartToken: string
|
|
162
|
+
cartItemId: string
|
|
163
|
+
quantity: number
|
|
164
|
+
}) => Promise<unknown>
|
|
165
|
+
removeItem: (params: {
|
|
166
|
+
cartToken: string
|
|
167
|
+
cartItemId: string
|
|
168
|
+
}) => Promise<unknown>
|
|
169
|
+
clear: (params: { cartToken: string }) => Promise<unknown>
|
|
170
|
+
}
|
|
171
|
+
orders: {
|
|
172
|
+
checkout: (params: {
|
|
173
|
+
cartId: string
|
|
174
|
+
orderNumber: string
|
|
175
|
+
customerSnapshot: CustomerSnapshotInput
|
|
176
|
+
idempotencyKey?: string
|
|
177
|
+
}) => Promise<OrderDoc>
|
|
178
|
+
confirmPaymentReturningOrder: (params: {
|
|
179
|
+
orderNumber?: string
|
|
180
|
+
pgPaymentId: string
|
|
181
|
+
pgProvider: string
|
|
182
|
+
amount: number
|
|
183
|
+
currency?: string
|
|
184
|
+
providerEventId?: string
|
|
185
|
+
confirmationSource?: string
|
|
186
|
+
idempotencyKey?: string
|
|
187
|
+
}) => Promise<OrderWithTransaction>
|
|
188
|
+
getByPaymentId: (params: {
|
|
189
|
+
pgProvider: string
|
|
190
|
+
pgPaymentId: string
|
|
191
|
+
}) => Promise<OrderWithTransaction>
|
|
192
|
+
updateTransaction: (params: {
|
|
193
|
+
pgPaymentId: string
|
|
194
|
+
status: string
|
|
195
|
+
}) => Promise<unknown>
|
|
196
|
+
cancelOrder: (params: {
|
|
197
|
+
orderNumber: string
|
|
198
|
+
reasonCode?: string
|
|
199
|
+
reasonDetail?: string
|
|
200
|
+
idempotencyKey?: string
|
|
201
|
+
}) => Promise<unknown>
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function createSoftwareCommerceProvider(): CommerceProvider {
|
|
207
|
+
const config = getSoftwareCommerceConfig(process.env)
|
|
208
|
+
const clientPromise = createSoftwareSdkClient(config)
|
|
209
|
+
return buildSoftwareCommerceProvider(
|
|
210
|
+
// The real server client is a structural superset of the slice we use.
|
|
211
|
+
clientPromise as unknown as Promise<SoftwareSdkClientLike>,
|
|
212
|
+
config.shippingPolicy,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function buildSoftwareCommerceProvider(
|
|
217
|
+
clientPromise: Promise<SoftwareSdkClientLike>,
|
|
218
|
+
shippingPolicy: ShippingPolicy,
|
|
219
|
+
): CommerceProvider {
|
|
220
|
+
const provider: CommerceProvider = {
|
|
221
|
+
async listProducts(input) {
|
|
222
|
+
const client = await clientPromise
|
|
223
|
+
const limit = input?.limit ?? 24
|
|
224
|
+
const productResponse = await client.collections.from('products').find({
|
|
225
|
+
limit,
|
|
226
|
+
depth: 1,
|
|
227
|
+
where: { status: { equals: 'published' } },
|
|
228
|
+
joins: false,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const productDetails = await Promise.allSettled(
|
|
232
|
+
(productResponse.docs as SoftwareProductListDoc[]).flatMap((product) =>
|
|
233
|
+
product.slug
|
|
234
|
+
? [client.commerce.product.detail({ slug: product.slug })]
|
|
235
|
+
: [],
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const products = productDetails.flatMap((detail) =>
|
|
240
|
+
detail.status === 'fulfilled'
|
|
241
|
+
? wrapOptional(
|
|
242
|
+
mapSoftwareProductDetailResult(
|
|
243
|
+
detail.value as unknown as SoftwareProductDetailResultLike,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
: [],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
products,
|
|
251
|
+
total: productResponse.totalDocs ?? products.length,
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async getProductBySlug(slug) {
|
|
256
|
+
const client = await clientPromise
|
|
257
|
+
const result = await client.commerce.product.detail({ slug })
|
|
258
|
+
const detail = mapSoftwareProductDetailResult(
|
|
259
|
+
result as unknown as SoftwareProductDetailResultLike,
|
|
260
|
+
)
|
|
261
|
+
return detail?.product.status === 'published' ? detail : null
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
async getVariantsByIds(ids) {
|
|
265
|
+
const client = await clientPromise
|
|
266
|
+
const normalizedIds = [...new Set(ids.map(String).filter(Boolean))]
|
|
267
|
+
if (normalizedIds.length === 0) return []
|
|
268
|
+
|
|
269
|
+
const response = await client.collections.from('product-variants').find({
|
|
270
|
+
limit: normalizedIds.length,
|
|
271
|
+
depth: 2,
|
|
272
|
+
where: { id: { in: normalizedIds } },
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
return (response.docs as SoftwareVariantDoc[]).map((variant) =>
|
|
276
|
+
mapSoftwareVariant(variant, getRelationId(variant.product) ?? ''),
|
|
277
|
+
)
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async getShippingPolicy() {
|
|
281
|
+
return shippingPolicy
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async checkStock(items) {
|
|
285
|
+
const client = await clientPromise
|
|
286
|
+
const lines = normalizeCartLines(items)
|
|
287
|
+
if (lines.length === 0) {
|
|
288
|
+
return { ok: true, lines: [] }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const stock = await client.commerce.product.stockCheck({
|
|
292
|
+
items: lines.map((line) => ({
|
|
293
|
+
variantId: line.variantId,
|
|
294
|
+
quantity: line.quantity,
|
|
295
|
+
})),
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
ok: Boolean(stock.allAvailable),
|
|
300
|
+
lines: stock.results.map((line) => ({
|
|
301
|
+
variantId: String(line.variantId),
|
|
302
|
+
quantity: line.requestedQuantity,
|
|
303
|
+
availableQuantity: line.isUnlimited ? null : line.availableStock,
|
|
304
|
+
ok: Boolean(line.available),
|
|
305
|
+
reason:
|
|
306
|
+
line.status === 'not_found'
|
|
307
|
+
? 'variant_not_found'
|
|
308
|
+
: line.available
|
|
309
|
+
? undefined
|
|
310
|
+
: 'insufficient_stock',
|
|
311
|
+
})),
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async createCart() {
|
|
316
|
+
const client = await clientPromise
|
|
317
|
+
const created = await client.commerce.cart.create()
|
|
318
|
+
if (!created.cartToken) {
|
|
319
|
+
throw new Error('Cart create did not return a cartToken')
|
|
320
|
+
}
|
|
321
|
+
return { cartToken: created.cartToken }
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
async getCart(cartToken) {
|
|
325
|
+
const client = await clientPromise
|
|
326
|
+
try {
|
|
327
|
+
const cart = await client.commerce.cart.get(cartToken)
|
|
328
|
+
return enrichCart(cart)
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// A missing/expired/completed cart resolves to "no active cart" so the
|
|
331
|
+
// storefront can mint a fresh one rather than surfacing a 404.
|
|
332
|
+
if (isNotFound(error)) return null
|
|
333
|
+
throw error
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async addCartItem({ cartToken, item }) {
|
|
338
|
+
const client = await clientPromise
|
|
339
|
+
await client.commerce.cart.addItem({
|
|
340
|
+
cartToken,
|
|
341
|
+
product: item.productId,
|
|
342
|
+
variant: item.variantId,
|
|
343
|
+
...(item.optionId ? { option: item.optionId } : {}),
|
|
344
|
+
quantity: item.quantity,
|
|
345
|
+
})
|
|
346
|
+
return getCartOrThrow(provider, cartToken)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
async updateCartItem({ cartToken, cartItemId, quantity }) {
|
|
350
|
+
const client = await clientPromise
|
|
351
|
+
await client.commerce.cart.updateItem({ cartToken, cartItemId, quantity })
|
|
352
|
+
return getCartOrThrow(provider, cartToken)
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
async removeCartItem({ cartToken, cartItemId }) {
|
|
356
|
+
const client = await clientPromise
|
|
357
|
+
await client.commerce.cart.removeItem({ cartToken, cartItemId })
|
|
358
|
+
return getCartOrThrow(provider, cartToken)
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
async clearCart({ cartToken }) {
|
|
362
|
+
const client = await clientPromise
|
|
363
|
+
await client.commerce.cart.clear({ cartToken })
|
|
364
|
+
return getCartOrThrow(provider, cartToken)
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async checkoutCart(input) {
|
|
368
|
+
const client = await clientPromise
|
|
369
|
+
const cart = await client.commerce.cart.get(input.cartToken)
|
|
370
|
+
const cartId = cart.id != null ? String(cart.id) : ''
|
|
371
|
+
const lines = cart.items ?? []
|
|
372
|
+
if (!cartId || lines.length === 0) {
|
|
373
|
+
throw new Error('Cart is empty')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The order snapshots `shippingAddress` from the cart, and checkout only
|
|
377
|
+
// accepts `customerSnapshot` — so persist the buyer-entered address on the
|
|
378
|
+
// cart first. These fields are caller-writable (only the amount fields are
|
|
379
|
+
// read-only). Shipping was already computed by the cart on each item
|
|
380
|
+
// mutation; the base fee does not depend on the address for the default
|
|
381
|
+
// policy (postalCode-driven zone surcharge recompute is owned by the
|
|
382
|
+
// Console cart shipping-authority work, not the template).
|
|
383
|
+
await client.collections.from('carts').update(cartId, {
|
|
384
|
+
email: input.customerSnapshot.email,
|
|
385
|
+
shippingAddress: {
|
|
386
|
+
recipientName: input.shippingAddress.recipientName,
|
|
387
|
+
phone: input.shippingAddress.phone,
|
|
388
|
+
postalCode: input.shippingAddress.postalCode,
|
|
389
|
+
address: input.shippingAddress.address,
|
|
390
|
+
detailAddress: input.shippingAddress.detailAddress,
|
|
391
|
+
deliveryMessage: input.shippingAddress.deliveryMessage ?? '',
|
|
392
|
+
},
|
|
393
|
+
...(input.shippingAddress.deliveryMessage
|
|
394
|
+
? { customerNote: input.shippingAddress.deliveryMessage }
|
|
395
|
+
: {}),
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const orderNumber = generateOrderNumber()
|
|
399
|
+
// pgPaymentId is minted template-side AFTER checkout (the design's "mint
|
|
400
|
+
// after" rule): passing it to checkout would route into the Toss-only
|
|
401
|
+
// Console-owned-capture path. Checkout creates an OPEN Checkout only (no
|
|
402
|
+
// Order / Transaction yet); placement resolves it by `orderNumber`
|
|
403
|
+
// (→ `checkoutToken`) at confirm. The durable orderNumber↔pgPaymentId
|
|
404
|
+
// mapping is the Console Checkout/Transaction row after confirm — there is
|
|
405
|
+
// no local index.
|
|
406
|
+
const paymentId = `pay_${crypto.randomUUID()}`
|
|
407
|
+
|
|
408
|
+
const placed = await client.commerce.orders.checkout({
|
|
409
|
+
cartId,
|
|
410
|
+
orderNumber,
|
|
411
|
+
customerSnapshot: {
|
|
412
|
+
name: input.customerSnapshot.name,
|
|
413
|
+
email: input.customerSnapshot.email,
|
|
414
|
+
phone: input.customerSnapshot.phone,
|
|
415
|
+
},
|
|
416
|
+
idempotencyKey: `checkout-${orderNumber}`,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const order = mapOrder(placed, {
|
|
420
|
+
fallbackOrderNumber: orderNumber,
|
|
421
|
+
contact: {
|
|
422
|
+
customerSnapshot: input.customerSnapshot,
|
|
423
|
+
shippingAddress: input.shippingAddress,
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const amount = placed.totalAmount ?? cart.totalAmount ?? order.totalAmount
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
order,
|
|
431
|
+
paymentId,
|
|
432
|
+
paymentName: buildPaymentName(
|
|
433
|
+
order.items.map((item) => item.productTitle),
|
|
434
|
+
),
|
|
435
|
+
amount,
|
|
436
|
+
currency: cart.currency ?? shippingPolicy.currency,
|
|
437
|
+
} satisfies CreatePendingOrderResult
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
async getOrderByPaymentId({ paymentId, provider: pgProvider }) {
|
|
441
|
+
const client = await clientPromise
|
|
442
|
+
try {
|
|
443
|
+
const result = await client.commerce.orders.getByPaymentId({
|
|
444
|
+
pgProvider,
|
|
445
|
+
pgPaymentId: paymentId,
|
|
446
|
+
})
|
|
447
|
+
return mapOrder(result.order, {
|
|
448
|
+
transaction: result.transaction,
|
|
449
|
+
fallbackPaymentId: paymentId,
|
|
450
|
+
fallbackProvider: pgProvider,
|
|
451
|
+
})
|
|
452
|
+
} catch (error) {
|
|
453
|
+
// Before placement (confirm) there is no payment Transaction yet, so a
|
|
454
|
+
// by-payment lookup legitimately 404s. Callers treat null as "not
|
|
455
|
+
// placed yet".
|
|
456
|
+
if (isNotFound(error)) return null
|
|
457
|
+
throw error
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
async confirmOrderPayment(input) {
|
|
462
|
+
const client = await clientPromise
|
|
463
|
+
const result = await client.commerce.orders.confirmPaymentReturningOrder({
|
|
464
|
+
orderNumber: input.orderNumber,
|
|
465
|
+
pgPaymentId: input.paymentId,
|
|
466
|
+
pgProvider: input.provider,
|
|
467
|
+
amount: input.amount,
|
|
468
|
+
currency: shippingPolicy.currency,
|
|
469
|
+
providerEventId: input.providerEventId,
|
|
470
|
+
confirmationSource: input.providerEventId
|
|
471
|
+
? 'provider_webhook'
|
|
472
|
+
: 'provider_lookup',
|
|
473
|
+
idempotencyKey: input.providerEventId ?? input.paymentId,
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
return mapOrder(result.order, {
|
|
477
|
+
transaction: result.transaction,
|
|
478
|
+
fallbackPaymentId: input.paymentId,
|
|
479
|
+
fallbackProvider: input.provider,
|
|
480
|
+
})
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
async markPaymentFailed(input) {
|
|
484
|
+
const client = await clientPromise
|
|
485
|
+
// The payment Transaction only exists once a checkout is placed. A failed
|
|
486
|
+
// PG attempt before placement leaves the Checkout to expire via the
|
|
487
|
+
// Console cron, so annotation is best-effort.
|
|
488
|
+
try {
|
|
489
|
+
await client.commerce.orders.updateTransaction({
|
|
490
|
+
pgPaymentId: input.paymentId,
|
|
491
|
+
status: 'failed',
|
|
492
|
+
})
|
|
493
|
+
} catch (error) {
|
|
494
|
+
if (!isNotFound(error)) throw error
|
|
495
|
+
}
|
|
496
|
+
return provider.getOrderByPaymentId({
|
|
497
|
+
paymentId: input.paymentId,
|
|
498
|
+
provider: input.provider,
|
|
499
|
+
})
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
async cancelPendingOrder(input) {
|
|
503
|
+
// Pre-placement there is no Order to cancel — the abandoned Checkout
|
|
504
|
+
// expires server-side. Only a placed order can be canceled.
|
|
505
|
+
const existing = await provider.getOrderByPaymentId({
|
|
506
|
+
paymentId: input.paymentId,
|
|
507
|
+
provider: input.provider,
|
|
508
|
+
})
|
|
509
|
+
if (!existing) return null
|
|
510
|
+
|
|
511
|
+
const client = await clientPromise
|
|
512
|
+
await client.commerce.orders.cancelOrder({
|
|
513
|
+
orderNumber: existing.orderNumber,
|
|
514
|
+
reasonCode: 'other',
|
|
515
|
+
reasonDetail: 'Payment canceled before completion',
|
|
516
|
+
idempotencyKey: input.providerEventId ?? input.paymentId,
|
|
517
|
+
})
|
|
518
|
+
return { ...existing, displayStatus: 'canceled' }
|
|
519
|
+
},
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Map a Console cart (id-only relation refs at depth 0) into the storefront
|
|
524
|
+
* `CartView`, enriching each line with catalog title/image. Products are
|
|
525
|
+
* fetched only for the cart's distinct product ids in a single query — no
|
|
526
|
+
* full-catalog scan and no per-product `detail` fan-out.
|
|
527
|
+
*/
|
|
528
|
+
async function enrichCart(cart: CartDoc): Promise<CartView> {
|
|
529
|
+
const client = await clientPromise
|
|
530
|
+
const rawItems = cart.items ?? []
|
|
531
|
+
const variantIds = rawItems
|
|
532
|
+
.map((item) => getRelationId(item.variant))
|
|
533
|
+
.filter((id): id is string => Boolean(id))
|
|
534
|
+
|
|
535
|
+
const variants =
|
|
536
|
+
variantIds.length > 0 ? await provider.getVariantsByIds(variantIds) : []
|
|
537
|
+
const variantById = new Map(
|
|
538
|
+
variants.map((variant) => [variant.id, variant]),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
const productIds = [
|
|
542
|
+
...new Set(variants.map((variant) => variant.productId).filter(Boolean)),
|
|
543
|
+
]
|
|
544
|
+
const productById =
|
|
545
|
+
productIds.length > 0
|
|
546
|
+
? await fetchProductSummaries(client, productIds)
|
|
547
|
+
: new Map<string, ProductSummary>()
|
|
548
|
+
|
|
549
|
+
const items: CartItemView[] = rawItems.flatMap((item) => {
|
|
550
|
+
const cartItemId = item.id != null ? String(item.id) : ''
|
|
551
|
+
const variantId = getRelationId(item.variant) ?? ''
|
|
552
|
+
if (!cartItemId || !variantId) return []
|
|
553
|
+
|
|
554
|
+
const variant = variantById.get(variantId)
|
|
555
|
+
const productId = variant?.productId ?? getRelationId(item.product) ?? ''
|
|
556
|
+
const summary = productById.get(productId)
|
|
557
|
+
const quantity = item.quantity ?? 0
|
|
558
|
+
const unitAmount = item.unitPrice ?? variant?.price ?? 0
|
|
559
|
+
const lineAmount = item.discountedTotalPrice ?? unitAmount * quantity
|
|
560
|
+
|
|
561
|
+
return [
|
|
562
|
+
{
|
|
563
|
+
cartItemId,
|
|
564
|
+
productId,
|
|
565
|
+
variantId,
|
|
566
|
+
quantity,
|
|
567
|
+
unitAmount,
|
|
568
|
+
lineAmount,
|
|
569
|
+
productTitle: summary?.title ?? 'Product',
|
|
570
|
+
variantTitle: variant?.title,
|
|
571
|
+
image: variant?.images[0] ?? summary?.image ?? null,
|
|
572
|
+
},
|
|
573
|
+
]
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
id: cart.id != null ? String(cart.id) : '',
|
|
578
|
+
currency: cart.currency ?? shippingPolicy.currency,
|
|
579
|
+
subtotalAmount: cart.subtotalAmount ?? 0,
|
|
580
|
+
shippingAmount: cart.shippingAmount ?? 0,
|
|
581
|
+
discountAmount: cart.discountAmount ?? 0,
|
|
582
|
+
totalAmount: cart.totalAmount ?? 0,
|
|
583
|
+
...(cart.discountCode ? { discountCode: cart.discountCode } : {}),
|
|
584
|
+
items,
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return provider
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function createSoftwareSdkClient(config: SoftwareCommerceConfig) {
|
|
592
|
+
const { createServerClient } = await import('@01.software/sdk/server')
|
|
593
|
+
return createServerClient({
|
|
594
|
+
publishableKey: config.publishableKey,
|
|
595
|
+
secretKey: config.secretKey,
|
|
596
|
+
apiUrl: config.apiUrl,
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Per-request **customer-JWT-scoped** commerce client (browser SDK, no secret
|
|
602
|
+
* key — the JWT is the only credential, so cart writes bind to and re-verify the
|
|
603
|
+
* customer). This is the same client family as `lib/customer/client.server.ts`;
|
|
604
|
+
* here we consume its `commerce`/`collections` surface for cart ops.
|
|
605
|
+
*/
|
|
606
|
+
async function createSoftwareCustomerCommerceClient(
|
|
607
|
+
config: SoftwareCommerceConfig,
|
|
608
|
+
token: string,
|
|
609
|
+
) {
|
|
610
|
+
const { createClient } = await import('@01.software/sdk')
|
|
611
|
+
return createClient({
|
|
612
|
+
publishableKey: config.publishableKey,
|
|
613
|
+
apiUrl: config.apiUrl,
|
|
614
|
+
customer: { persist: false, token },
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Build the shared provider around a customer-JWT client. Cart reads/writes and
|
|
620
|
+
* checkout run as the customer (publishable key + JWT, no secret key), so the
|
|
621
|
+
* Console can bind new carts to the customer and reject checkout for carts owned
|
|
622
|
+
* by another customer.
|
|
623
|
+
*/
|
|
624
|
+
function createSoftwareCustomerProvider(token: string): CommerceProvider {
|
|
625
|
+
const config = getSoftwareCommerceConfig(process.env)
|
|
626
|
+
const clientPromise = createSoftwareCustomerCommerceClient(config, token)
|
|
627
|
+
return buildSoftwareCommerceProvider(
|
|
628
|
+
clientPromise as unknown as Promise<SoftwareSdkClientLike>,
|
|
629
|
+
config.shippingPolicy,
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A {@link CartProvider} whose cart reads/writes go through the customer's JWT
|
|
635
|
+
* client. Logged-in `createCart()` therefore mints a **customer-bound** cart
|
|
636
|
+
* (the JWT auto-binds `customer`), never a guest cart.
|
|
637
|
+
*/
|
|
638
|
+
export function createSoftwareCustomerCartProvider(token: string): CartProvider {
|
|
639
|
+
return createSoftwareCustomerProvider(token)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Checkout-only customer provider for route handlers. Exposing only
|
|
644
|
+
* `checkoutCart` prevents unrelated payment lifecycle operations from being
|
|
645
|
+
* accidentally run through the customer client.
|
|
646
|
+
*/
|
|
647
|
+
export function createSoftwareCustomerCheckoutProvider(
|
|
648
|
+
token: string,
|
|
649
|
+
): CheckoutCommerceProvider {
|
|
650
|
+
return createSoftwareCustomerProvider(token)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function getSoftwareCommerceConfig(
|
|
654
|
+
env: SoftwareEnv,
|
|
655
|
+
): SoftwareCommerceConfig {
|
|
656
|
+
const publishableKey = env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY?.trim()
|
|
657
|
+
const secretKey = env.SOFTWARE_SECRET_KEY?.trim()
|
|
658
|
+
if (!publishableKey) {
|
|
659
|
+
throw new Error(
|
|
660
|
+
'01.software commerce requires NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY.',
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
if (!secretKey) {
|
|
664
|
+
throw new Error('01.software commerce requires SOFTWARE_SECRET_KEY.')
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const apiUrl =
|
|
668
|
+
env.SOFTWARE_API_URL?.trim() || env.NEXT_PUBLIC_SOFTWARE_API_URL?.trim()
|
|
669
|
+
const baseAmount = readMoneyEnv(env.SOFTWARE_SHIPPING_AMOUNT, 0)
|
|
670
|
+
const freeAboveAmount = readOptionalMoneyEnv(
|
|
671
|
+
env.SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
publishableKey,
|
|
676
|
+
secretKey,
|
|
677
|
+
apiUrl: apiUrl || undefined,
|
|
678
|
+
shippingPolicy: {
|
|
679
|
+
currency: 'KRW',
|
|
680
|
+
baseAmount,
|
|
681
|
+
...(freeAboveAmount == null ? {} : { freeAboveAmount }),
|
|
682
|
+
},
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function getCartOrThrow(
|
|
687
|
+
provider: CommerceProvider,
|
|
688
|
+
cartToken: string,
|
|
689
|
+
): Promise<CartView> {
|
|
690
|
+
const cart = await provider.getCart(cartToken)
|
|
691
|
+
if (!cart) throw new Error('Cart not found')
|
|
692
|
+
return cart
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
type SoftwareMediaRef =
|
|
696
|
+
| string
|
|
697
|
+
| number
|
|
698
|
+
| {
|
|
699
|
+
url?: string | null
|
|
700
|
+
alt?: string | null
|
|
701
|
+
width?: number | null
|
|
702
|
+
height?: number | null
|
|
703
|
+
}
|
|
704
|
+
| null
|
|
705
|
+
|
|
706
|
+
type SoftwareProductSummaryDoc = {
|
|
707
|
+
id?: string | number
|
|
708
|
+
title?: string | null
|
|
709
|
+
thumbnail?: SoftwareMediaRef
|
|
710
|
+
images?: SoftwareMediaRef[] | null
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
type ProductSummary = { title: string; image: CommerceImage | null }
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Fetch title + representative image for exactly the cart's distinct products
|
|
717
|
+
* in one tenant-scoped query (depth 1 populates the media relations). Avoids
|
|
718
|
+
* the full-catalog `listProducts` scan and its per-product `detail` fan-out.
|
|
719
|
+
*/
|
|
720
|
+
async function fetchProductSummaries(
|
|
721
|
+
client: SoftwareSdkClientLike,
|
|
722
|
+
productIds: string[],
|
|
723
|
+
): Promise<Map<string, ProductSummary>> {
|
|
724
|
+
const response = await client.collections.from('products').find({
|
|
725
|
+
limit: productIds.length,
|
|
726
|
+
depth: 1,
|
|
727
|
+
where: { id: { in: productIds } },
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
const summaries = new Map<string, ProductSummary>()
|
|
731
|
+
for (const doc of response.docs as SoftwareProductSummaryDoc[]) {
|
|
732
|
+
const id = doc.id != null ? String(doc.id) : ''
|
|
733
|
+
if (!id) continue
|
|
734
|
+
summaries.set(id, {
|
|
735
|
+
title: doc.title ?? 'Product',
|
|
736
|
+
image: toImage(doc.thumbnail) ?? toImage(doc.images?.[0]),
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
return summaries
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function toImage(media: SoftwareMediaRef | undefined): CommerceImage | null {
|
|
743
|
+
if (!media || typeof media !== 'object' || !media.url) return null
|
|
744
|
+
return {
|
|
745
|
+
url: media.url,
|
|
746
|
+
alt: media.alt ?? undefined,
|
|
747
|
+
width: media.width ?? undefined,
|
|
748
|
+
height: media.height ?? undefined,
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function mapOrder(
|
|
753
|
+
order: OrderDoc,
|
|
754
|
+
opts: {
|
|
755
|
+
transaction?: TransactionDoc | null
|
|
756
|
+
fallbackPaymentId?: string
|
|
757
|
+
fallbackProvider?: string
|
|
758
|
+
fallbackOrderNumber?: string
|
|
759
|
+
contact?: {
|
|
760
|
+
customerSnapshot: Order['customerSnapshot']
|
|
761
|
+
shippingAddress: Order['shippingAddress']
|
|
762
|
+
}
|
|
763
|
+
} = {},
|
|
764
|
+
): Order {
|
|
765
|
+
const rawItems = Array.isArray(order.items)
|
|
766
|
+
? order.items
|
|
767
|
+
: (order.items?.docs ?? [])
|
|
768
|
+
|
|
769
|
+
const transactions: Order['transactions'] = opts.transaction
|
|
770
|
+
? [
|
|
771
|
+
{
|
|
772
|
+
paymentId:
|
|
773
|
+
opts.transaction.pgPaymentId ?? opts.fallbackPaymentId ?? '',
|
|
774
|
+
provider:
|
|
775
|
+
opts.transaction.pgProvider ?? opts.fallbackProvider ?? 'unknown',
|
|
776
|
+
status: normalizeTransactionStatus(opts.transaction.status),
|
|
777
|
+
amount: opts.transaction.amount ?? order.totalAmount ?? 0,
|
|
778
|
+
},
|
|
779
|
+
]
|
|
780
|
+
: []
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
id: order.id != null ? String(order.id) : '',
|
|
784
|
+
orderNumber: order.orderNumber ?? opts.fallbackOrderNumber ?? '',
|
|
785
|
+
displayStatus: deriveDisplayStatus(order),
|
|
786
|
+
items: rawItems.map((item) => ({
|
|
787
|
+
variantId: getRelationId(item.variant) ?? '',
|
|
788
|
+
productTitle: item.productTitle ?? 'Product',
|
|
789
|
+
variantTitle: item.variantTitle ?? undefined,
|
|
790
|
+
quantity: item.quantity ?? 0,
|
|
791
|
+
unitAmount: item.unitPrice ?? 0,
|
|
792
|
+
lineAmount: item.totalPrice ?? item.discountedTotalPrice ?? 0,
|
|
793
|
+
})),
|
|
794
|
+
customerSnapshot: {
|
|
795
|
+
name:
|
|
796
|
+
order.customerSnapshot?.name ??
|
|
797
|
+
opts.contact?.customerSnapshot.name ??
|
|
798
|
+
'',
|
|
799
|
+
email:
|
|
800
|
+
order.customerSnapshot?.email ??
|
|
801
|
+
opts.contact?.customerSnapshot.email ??
|
|
802
|
+
'',
|
|
803
|
+
phone:
|
|
804
|
+
order.customerSnapshot?.phone ??
|
|
805
|
+
opts.contact?.customerSnapshot.phone ??
|
|
806
|
+
'',
|
|
807
|
+
},
|
|
808
|
+
shippingAddress: {
|
|
809
|
+
recipientName:
|
|
810
|
+
order.shippingAddress?.recipientName ??
|
|
811
|
+
opts.contact?.shippingAddress.recipientName ??
|
|
812
|
+
'',
|
|
813
|
+
phone:
|
|
814
|
+
order.shippingAddress?.phone ??
|
|
815
|
+
opts.contact?.shippingAddress.phone ??
|
|
816
|
+
'',
|
|
817
|
+
postalCode:
|
|
818
|
+
order.shippingAddress?.postalCode ??
|
|
819
|
+
opts.contact?.shippingAddress.postalCode ??
|
|
820
|
+
'',
|
|
821
|
+
address:
|
|
822
|
+
order.shippingAddress?.address ??
|
|
823
|
+
opts.contact?.shippingAddress.address ??
|
|
824
|
+
'',
|
|
825
|
+
detailAddress:
|
|
826
|
+
order.shippingAddress?.detailAddress ??
|
|
827
|
+
opts.contact?.shippingAddress.detailAddress ??
|
|
828
|
+
'',
|
|
829
|
+
deliveryMessage:
|
|
830
|
+
order.shippingAddress?.deliveryMessage ??
|
|
831
|
+
opts.contact?.shippingAddress.deliveryMessage ??
|
|
832
|
+
'',
|
|
833
|
+
},
|
|
834
|
+
subtotalAmount: order.subtotalAmount ?? 0,
|
|
835
|
+
shippingAmount: order.shippingAmount ?? 0,
|
|
836
|
+
totalAmount: order.totalAmount ?? 0,
|
|
837
|
+
transactions,
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function deriveDisplayStatus(order: OrderDoc): Order['displayStatus'] {
|
|
842
|
+
const financial = order.displayFinancialStatus ?? order.financialStatus
|
|
843
|
+
if (financial === 'paid' || financial === 'partially_refunded') return 'paid'
|
|
844
|
+
if (
|
|
845
|
+
financial === 'canceled' ||
|
|
846
|
+
financial === 'voided' ||
|
|
847
|
+
financial === 'refunded'
|
|
848
|
+
) {
|
|
849
|
+
return 'canceled'
|
|
850
|
+
}
|
|
851
|
+
if (order.displayStatus === 'paid') return 'paid'
|
|
852
|
+
if (order.displayStatus === 'canceled') return 'canceled'
|
|
853
|
+
return 'pending'
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function normalizeTransactionStatus(
|
|
857
|
+
status: string | null | undefined,
|
|
858
|
+
): Order['transactions'][number]['status'] {
|
|
859
|
+
if (status === 'paid') return 'paid'
|
|
860
|
+
if (status === 'failed') return 'failed'
|
|
861
|
+
// `refunded`/`canceled` both read as canceled on the storefront transaction.
|
|
862
|
+
if (status === 'canceled' || status === 'refunded') return 'canceled'
|
|
863
|
+
return 'pending'
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isNotFound(error: unknown): boolean {
|
|
867
|
+
if (!error || typeof error !== 'object') return false
|
|
868
|
+
const status = (error as { status?: number }).status
|
|
869
|
+
const name = (error as { name?: string }).name
|
|
870
|
+
return status === 404 || name === 'NotFoundError'
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function readMoneyEnv(value: string | undefined, fallback: number): number {
|
|
874
|
+
if (value == null || value.trim() === '') return fallback
|
|
875
|
+
const parsed = Number(value)
|
|
876
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
877
|
+
throw new Error(`Expected a non-negative number, received ${value}.`)
|
|
878
|
+
}
|
|
879
|
+
return Math.round(parsed)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function readOptionalMoneyEnv(value: string | undefined): number | undefined {
|
|
883
|
+
if (value == null || value.trim() === '') return undefined
|
|
884
|
+
return readMoneyEnv(value, 0)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function getRelationId(value: unknown): string | null {
|
|
888
|
+
if (value == null) return null
|
|
889
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
890
|
+
return String(value)
|
|
891
|
+
if (typeof value === 'object' && 'id' in value) {
|
|
892
|
+
const id = (value as { id?: unknown }).id
|
|
893
|
+
if (typeof id === 'string' || typeof id === 'number') return String(id)
|
|
894
|
+
}
|
|
895
|
+
return null
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function buildPaymentName(productTitles: string[]): string {
|
|
899
|
+
const [first, ...rest] = productTitles
|
|
900
|
+
if (!first) return 'Order'
|
|
901
|
+
return rest.length === 0 ? first : `${first} and ${rest.length} more`
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function wrapOptional<T>(value: T | null | undefined): T[] {
|
|
905
|
+
return value == null ? [] : [value]
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function generateOrderNumber(): string {
|
|
909
|
+
return `ORD-${Date.now().toString(36).toUpperCase()}-${crypto
|
|
910
|
+
.randomUUID()
|
|
911
|
+
.slice(0, 8)
|
|
912
|
+
.toUpperCase()}`
|
|
913
|
+
}
|