@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.
Files changed (119) hide show
  1. package/dist/ai-docs.d.ts +13 -0
  2. package/dist/ai-docs.js +1 -1
  3. package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
  4. package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
  5. package/dist/chunk-4LHYICUL.js.map +1 -0
  6. package/dist/{chunk-TBGKXE3Q.js → chunk-NJ4X7VNK.js} +5 -5
  7. package/dist/chunk-NJ4X7VNK.js.map +1 -0
  8. package/dist/{chunk-5K2CB2Y5.js → chunk-Q6MSORYN.js} +14 -37
  9. package/dist/chunk-Q6MSORYN.js.map +1 -0
  10. package/dist/chunk-STM4DKVZ.js +183 -0
  11. package/dist/chunk-STM4DKVZ.js.map +1 -0
  12. package/dist/{chunk-2IGKOSK7.js → chunk-WDWJ73KP.js} +41 -215
  13. package/dist/chunk-WDWJ73KP.js.map +1 -0
  14. package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
  15. package/dist/create-app-templates/ecommerce/CHANGELOG.md +30 -0
  16. package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
  17. package/dist/create-app-templates/ecommerce/README.md +139 -0
  18. package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
  19. package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
  20. package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
  21. package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
  22. package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
  23. package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
  24. package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
  25. package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
  26. package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
  27. package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
  28. package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
  29. package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
  30. package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
  31. package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
  32. package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
  33. package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
  34. package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
  35. package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
  36. package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
  37. package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
  38. package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
  39. package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
  40. package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
  41. package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +119 -0
  42. package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +267 -0
  43. package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
  44. package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
  45. package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
  46. package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
  47. package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
  48. package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
  49. package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
  50. package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
  51. package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
  52. package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
  53. package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
  54. package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
  55. package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
  56. package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
  57. package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
  58. package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
  59. package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +96 -0
  60. package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
  61. package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
  62. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
  63. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
  64. package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +76 -0
  65. package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +63 -0
  66. package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +3 -0
  67. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +336 -0
  68. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
  69. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +913 -0
  70. package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
  71. package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
  72. package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
  73. package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
  74. package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +206 -0
  75. package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
  76. package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
  77. package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
  78. package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
  79. package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
  80. package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
  81. package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
  82. package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
  83. package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
  84. package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
  85. package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
  86. package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
  87. package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
  88. package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
  89. package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
  90. package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
  91. package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
  92. package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
  93. package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
  94. package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
  95. package/dist/create-app-templates/ecommerce/package.json +33 -0
  96. package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
  97. package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
  98. package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +392 -0
  99. package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1537 -0
  100. package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
  101. package/dist/create-app-templates/registry.json +66 -0
  102. package/dist/create-app.d.ts +40 -0
  103. package/dist/create-app.js +652 -0
  104. package/dist/create-app.js.map +1 -0
  105. package/dist/detect-Bjxp9wcS.d.ts +13 -0
  106. package/dist/file-ops.d.ts +21 -0
  107. package/dist/file-ops.js +1 -1
  108. package/dist/index.d.ts +2 -0
  109. package/dist/index.js +6 -5
  110. package/dist/index.js.map +1 -1
  111. package/dist/init.d.ts +40 -0
  112. package/dist/init.js +5 -4
  113. package/dist/templates.d.ts +27 -0
  114. package/dist/templates.js +1 -1
  115. package/package.json +31 -15
  116. package/dist/chunk-2IGKOSK7.js.map +0 -1
  117. package/dist/chunk-5K2CB2Y5.js.map +0 -1
  118. package/dist/chunk-TBGKXE3Q.js.map +0 -1
  119. 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
+ }