@01.software/init 0.9.2 → 0.10.1

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 (114) hide show
  1. package/dist/ai-docs.d.ts +13 -0
  2. package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
  3. package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
  4. package/dist/chunk-4LHYICUL.js.map +1 -0
  5. package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
  6. package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
  7. package/dist/chunk-STM4DKVZ.js +183 -0
  8. package/dist/chunk-STM4DKVZ.js.map +1 -0
  9. package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
  10. package/dist/chunk-WDWJ73KP.js.map +1 -0
  11. package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
  12. package/dist/create-app-templates/ecommerce/CHANGELOG.md +48 -0
  13. package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
  14. package/dist/create-app-templates/ecommerce/README.md +154 -0
  15. package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
  16. package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
  17. package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
  18. package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
  19. package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
  20. package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
  21. package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
  22. package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
  23. package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
  24. package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
  25. package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
  26. package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
  27. package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
  28. package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
  29. package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
  30. package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
  31. package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
  32. package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
  33. package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
  34. package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
  35. package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
  36. package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
  37. package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
  38. package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +129 -0
  39. package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +307 -0
  40. package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
  41. package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
  42. package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
  43. package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
  44. package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
  45. package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
  46. package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
  47. package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
  48. package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
  49. package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
  50. package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
  51. package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
  52. package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
  53. package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
  54. package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
  55. package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
  56. package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +135 -0
  57. package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
  58. package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
  59. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
  60. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
  61. package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +86 -0
  62. package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +73 -0
  63. package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +6 -0
  64. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +346 -0
  65. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
  66. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +930 -0
  67. package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
  68. package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
  69. package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
  70. package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
  71. package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +208 -0
  72. package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
  73. package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
  74. package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
  75. package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
  76. package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
  77. package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
  78. package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
  79. package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
  80. package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
  81. package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
  82. package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
  83. package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
  84. package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
  85. package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
  86. package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
  87. package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
  88. package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
  89. package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
  90. package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
  91. package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
  92. package/dist/create-app-templates/ecommerce/package.json +33 -0
  93. package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
  94. package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
  95. package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +401 -0
  96. package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1632 -0
  97. package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
  98. package/dist/create-app-templates/registry.json +66 -0
  99. package/dist/create-app.d.ts +40 -0
  100. package/dist/create-app.js +652 -0
  101. package/dist/create-app.js.map +1 -0
  102. package/dist/detect-Bjxp9wcS.d.ts +13 -0
  103. package/dist/file-ops.d.ts +21 -0
  104. package/dist/file-ops.js +1 -1
  105. package/dist/index.d.ts +2 -0
  106. package/dist/index.js +4 -3
  107. package/dist/index.js.map +1 -1
  108. package/dist/init.d.ts +40 -0
  109. package/dist/init.js +4 -3
  110. package/dist/templates.d.ts +27 -0
  111. package/dist/templates.js +1 -1
  112. package/package.json +18 -3
  113. package/dist/chunk-ENQSB4OF.js.map +0 -1
  114. package/dist/chunk-UA7WNT2F.js.map +0 -1
@@ -0,0 +1,1632 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHmac } from 'node:crypto'
3
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ import { normalizeCartLines } from '../lib/cart/normalize.ts'
8
+ import { getCheckoutErrorStatus } from '../lib/checkout/checkout-errors.ts'
9
+ import { parseCheckoutPayload } from '../lib/checkout/parse-checkout-payload.ts'
10
+ import { startCheckout } from '../lib/checkout/start-checkout.ts'
11
+ import {
12
+ mapSoftwareProductDetail,
13
+ mapSoftwareProductDetailResult,
14
+ } from '../lib/commerce/adapters/software-mappers.ts'
15
+ import {
16
+ buildSoftwareCommerceProvider,
17
+ getSoftwareCommerceConfig,
18
+ type SoftwareSdkClientLike,
19
+ } from '../lib/commerce/adapters/software.ts'
20
+ import { summarizeProductAvailability } from '../lib/commerce/product-summary.ts'
21
+ import { resolveVariantFromSelection } from '../lib/commerce/variant-selection.ts'
22
+ import { createMockPaymentProvider } from '../lib/payment/adapters/mock.ts'
23
+ // scaffold:provider:portone:start
24
+ import {
25
+ createPortOnePaymentProviderForConfig,
26
+ getPortOneConfig,
27
+ } from '../lib/payment/adapters/portone.ts'
28
+ // scaffold:provider:portone:end
29
+ // scaffold:provider:tosspayments:start
30
+ import {
31
+ createTossPaymentsProvider,
32
+ getTossPaymentsConfig,
33
+ } from '../lib/payment/adapters/tosspayments.ts'
34
+ // scaffold:provider:tosspayments:end
35
+ import {
36
+ AmountMismatchError,
37
+ assertAmountEquals,
38
+ } from '../lib/payment/amount-gate.ts'
39
+ import { syncOrderPayment } from '../lib/payment/sync-order-payment.ts'
40
+ import type { CommerceProvider } from '../lib/commerce/provider.ts'
41
+ import type { PaymentProvider } from '../lib/payment/provider.ts'
42
+ import type {
43
+ Order,
44
+ ProductDetail,
45
+ ProductVariant,
46
+ } from '../lib/commerce/types.ts'
47
+
48
+ const variantA: ProductVariant = {
49
+ id: 'var-shirt-red-s',
50
+ productId: 'prod-shirt',
51
+ title: 'Red / Small',
52
+ sku: 'SHIRT-RED-S',
53
+ price: 32000,
54
+ stock: 8,
55
+ reservedStock: 2,
56
+ isUnlimited: false,
57
+ optionValues: [
58
+ {
59
+ optionId: 'opt-color',
60
+ valueId: 'val-red',
61
+ valueSlug: 'red',
62
+ value: 'Red',
63
+ },
64
+ {
65
+ optionId: 'opt-size',
66
+ valueId: 'val-small',
67
+ valueSlug: 'small',
68
+ value: 'Small',
69
+ },
70
+ ],
71
+ images: [],
72
+ }
73
+
74
+ const variantB: ProductVariant = {
75
+ ...variantA,
76
+ id: 'var-shirt-blue-s',
77
+ title: 'Blue / Small',
78
+ optionValues: [
79
+ {
80
+ optionId: 'opt-color',
81
+ valueId: 'val-blue',
82
+ valueSlug: 'blue',
83
+ value: 'Blue',
84
+ },
85
+ {
86
+ optionId: 'opt-size',
87
+ valueId: 'val-small',
88
+ valueSlug: 'small',
89
+ value: 'Small',
90
+ },
91
+ ],
92
+ }
93
+
94
+ const productDetail: ProductDetail = {
95
+ product: {
96
+ id: 'prod-shirt',
97
+ slug: 'daily-shirt',
98
+ title: 'Daily Shirt',
99
+ images: [],
100
+ status: 'published',
101
+ },
102
+ variants: [variantA, variantB],
103
+ options: [
104
+ {
105
+ id: 'opt-color',
106
+ slug: 'color',
107
+ title: 'Color',
108
+ values: [
109
+ { id: 'val-red', slug: 'red', value: 'Red' },
110
+ { id: 'val-blue', slug: 'blue', value: 'Blue' },
111
+ ],
112
+ },
113
+ {
114
+ id: 'opt-size',
115
+ slug: 'size',
116
+ title: 'Size',
117
+ values: [{ id: 'val-small', slug: 'small', value: 'Small' }],
118
+ },
119
+ ],
120
+ }
121
+
122
+ const mockPaymentStorePath = join(process.cwd(), '.mock-payments.json')
123
+
124
+ test('normalizes persisted cart lines into unique positive integer quantities', () => {
125
+ assert.deepEqual(
126
+ normalizeCartLines([
127
+ { variantId: ' var-shirt-red-s ', quantity: 1 },
128
+ { variantId: 'var-shirt-red-s', quantity: 2.8 },
129
+ { variantId: '', quantity: 4 },
130
+ { variantId: 'var-shirt-blue-s', quantity: -1 },
131
+ { variantId: 'var-shirt-blue-s', quantity: 100 },
132
+ ]),
133
+ [
134
+ { variantId: 'var-shirt-red-s', quantity: 3 },
135
+ { variantId: 'var-shirt-blue-s', quantity: 99 },
136
+ ],
137
+ )
138
+ })
139
+
140
+ test('resolves a variant only when every product option has a selected value', () => {
141
+ assert.equal(
142
+ resolveVariantFromSelection(productDetail, {
143
+ 'opt-color': 'val-blue',
144
+ 'opt-size': 'val-small',
145
+ })?.id,
146
+ 'var-shirt-blue-s',
147
+ )
148
+
149
+ assert.equal(
150
+ resolveVariantFromSelection(productDetail, {
151
+ 'opt-color': 'val-blue',
152
+ }),
153
+ null,
154
+ )
155
+ })
156
+
157
+ test('summarizes products with no variants as unavailable', () => {
158
+ assert.deepEqual(
159
+ summarizeProductAvailability({
160
+ ...productDetail,
161
+ variants: [],
162
+ }),
163
+ {
164
+ hasVariants: false,
165
+ available: false,
166
+ priceLabel: 'Unavailable',
167
+ },
168
+ )
169
+ })
170
+
171
+ test('syncOrderPayment places a verified-paid payment through confirmOrderPayment', async () => {
172
+ // Pre-placement there is no order; a verified-paid PG payment promotes the
173
+ // open checkout to a paid order with the provider-re-fetched amount.
174
+ const commerce = makeCommerceProvider(null)
175
+ const payment = makePaymentProvider({
176
+ paymentId: 'pay_1',
177
+ status: 'paid',
178
+ amount: 99000,
179
+ })
180
+
181
+ const result = await syncOrderPayment({
182
+ paymentId: 'pay_1',
183
+ orderNumber: 'ORD-1',
184
+ commerceProvider: commerce,
185
+ paymentProvider: payment,
186
+ })
187
+
188
+ assert.equal(result.status, 'paid')
189
+ assert.equal(commerce.confirmed, true)
190
+ assert.equal(commerce.confirmedAmount, 99000)
191
+ })
192
+
193
+ test('syncOrderPayment is idempotent once the order is already placed', async () => {
194
+ const order = makePendingOrder({ totalAmount: 99000 })
195
+ const commerce = makeCommerceProvider({ ...order, displayStatus: 'paid' })
196
+ const payment = makePaymentProvider({
197
+ paymentId: 'pay_1',
198
+ status: 'paid',
199
+ amount: 99000,
200
+ })
201
+
202
+ const result = await syncOrderPayment({
203
+ paymentId: 'pay_1',
204
+ orderNumber: 'ORD-1',
205
+ commerceProvider: commerce,
206
+ paymentProvider: payment,
207
+ })
208
+
209
+ assert.equal(result.status, 'paid')
210
+ assert.equal(commerce.confirmed, false)
211
+ })
212
+
213
+ test('syncOrderPayment reports pending while the provider payment is pending', async () => {
214
+ const commerce = makeCommerceProvider(null)
215
+ const payment = makePaymentProvider({
216
+ paymentId: 'pay_1',
217
+ status: 'pending',
218
+ amount: 99000,
219
+ })
220
+
221
+ const result = await syncOrderPayment({
222
+ paymentId: 'pay_1',
223
+ orderNumber: 'ORD-1',
224
+ commerceProvider: commerce,
225
+ paymentProvider: payment,
226
+ })
227
+
228
+ assert.equal(result.status, 'pending')
229
+ assert.equal(commerce.confirmed, false)
230
+ })
231
+
232
+ test('parseCheckoutPayload rejects invalid customer and shipping fields', () => {
233
+ assert.throws(
234
+ () =>
235
+ parseCheckoutPayload({
236
+ lines: [{ variantId: 'var-shirt-red-s', quantity: 1 }],
237
+ customerSnapshot: { name: 'Ada', email: 'not-an-email', phone: '010' },
238
+ shippingAddress: {
239
+ recipientName: 'Ada',
240
+ phone: '010',
241
+ postalCode: '04524',
242
+ address: 'Seoul',
243
+ detailAddress: '101',
244
+ },
245
+ }),
246
+ /email/,
247
+ )
248
+ })
249
+
250
+ test('parseCheckoutPayload accepts missing shipping fields when the cart requires no shipping', () => {
251
+ assert.deepEqual(
252
+ parseCheckoutPayload(
253
+ {
254
+ customerSnapshot: {
255
+ name: 'Ada',
256
+ email: 'ada@example.test',
257
+ phone: '010',
258
+ },
259
+ },
260
+ { requiresShipping: false },
261
+ ),
262
+ {
263
+ customerSnapshot: {
264
+ name: 'Ada',
265
+ email: 'ada@example.test',
266
+ phone: '010',
267
+ },
268
+ },
269
+ )
270
+ })
271
+
272
+ test('parseCheckoutPayload still requires shipping fields by default', () => {
273
+ assert.throws(
274
+ () =>
275
+ parseCheckoutPayload({
276
+ customerSnapshot: {
277
+ name: 'Ada',
278
+ email: 'ada@example.test',
279
+ phone: '010',
280
+ },
281
+ }),
282
+ /shippingAddress/,
283
+ )
284
+ })
285
+
286
+ test('startCheckout converts the server cart then requests payment with the cart total', async () => {
287
+ const order = makePendingOrder({ totalAmount: 99000 })
288
+ let requestedAmount = 0
289
+ let checkoutCartToken: string | null = null
290
+ const commerce: CommerceProvider = {
291
+ ...makeCommerceProvider(null),
292
+ async checkoutCart(input) {
293
+ checkoutCartToken = input.cartToken
294
+ return {
295
+ order,
296
+ paymentId: 'pay_1',
297
+ paymentName: 'Server cart order',
298
+ amount: 99000,
299
+ currency: 'KRW',
300
+ }
301
+ },
302
+ }
303
+ const payment: PaymentProvider = {
304
+ ...makePaymentProvider(null),
305
+ provider: 'tosspayments',
306
+ async requestPayment(input) {
307
+ requestedAmount = input.amount
308
+ return {
309
+ ok: true,
310
+ paymentId: input.paymentId,
311
+ redirectUrl: '/checkout/success?paymentId=pay_1&orderNumber=ORD-1',
312
+ }
313
+ },
314
+ }
315
+
316
+ const result = await startCheckout({
317
+ cartToken: 'tok_1',
318
+ payload: {
319
+ customerSnapshot: order.customerSnapshot,
320
+ shippingAddress: order.shippingAddress,
321
+ },
322
+ commerceProvider: commerce,
323
+ paymentProvider: payment,
324
+ })
325
+
326
+ assert.equal(checkoutCartToken, 'tok_1')
327
+ assert.equal(requestedAmount, 99000)
328
+ assert.equal(
329
+ result.redirectUrl,
330
+ '/checkout/success?paymentId=pay_1&orderNumber=ORD-1',
331
+ )
332
+ })
333
+
334
+ test('startCheckout surfaces a failed payment request', async () => {
335
+ const order = makePendingOrder({ totalAmount: 99000 })
336
+ const commerce: CommerceProvider = {
337
+ ...makeCommerceProvider(null),
338
+ async checkoutCart() {
339
+ return {
340
+ order,
341
+ paymentId: 'pay_1',
342
+ paymentName: 'Server cart order',
343
+ amount: 99000,
344
+ currency: 'KRW',
345
+ }
346
+ },
347
+ }
348
+ const payment: PaymentProvider = {
349
+ ...makePaymentProvider(null),
350
+ provider: 'tosspayments',
351
+ async requestPayment() {
352
+ return { ok: false, paymentId: 'pay_1', reason: 'failed' }
353
+ },
354
+ }
355
+
356
+ await assert.rejects(
357
+ startCheckout({
358
+ cartToken: 'tok_1',
359
+ payload: {
360
+ customerSnapshot: order.customerSnapshot,
361
+ shippingAddress: order.shippingAddress,
362
+ },
363
+ commerceProvider: commerce,
364
+ paymentProvider: payment,
365
+ }),
366
+ /failed/,
367
+ )
368
+ })
369
+
370
+ test('checkout error status preserves SDK authorization failures', () => {
371
+ assert.equal(
372
+ getCheckoutErrorStatus(
373
+ Object.assign(new Error('Forbidden'), { status: 403 }),
374
+ ),
375
+ 403,
376
+ )
377
+ })
378
+
379
+ test('checkout error status maps payload validation failures to bad request', () => {
380
+ assert.equal(
381
+ getCheckoutErrorStatus(new Error('valid email is required')),
382
+ 400,
383
+ )
384
+ })
385
+
386
+ test('mock payment provider stores payments independently from mock commerce orders', async () => {
387
+ clearMockPaymentStore()
388
+ const payment = createMockPaymentProvider()
389
+
390
+ await payment.requestPayment({
391
+ paymentId: 'pay_independent',
392
+ orderNumber: 'ORD-INDEPENDENT',
393
+ orderName: 'Independent order',
394
+ amount: 12345,
395
+ currency: 'KRW',
396
+ customer: {
397
+ name: 'Ada',
398
+ email: 'ada@example.com',
399
+ phone: '010-0000-0000',
400
+ },
401
+ })
402
+
403
+ assert.deepEqual(await payment.getPayment('pay_independent'), {
404
+ paymentId: 'pay_independent',
405
+ status: 'paid',
406
+ amount: 12345,
407
+ provider: 'mock',
408
+ orderNumber: 'ORD-INDEPENDENT',
409
+ })
410
+ clearMockPaymentStore()
411
+ })
412
+
413
+ test('README documents the server-cart architecture, not removed local stores', async () => {
414
+ const readFile = await import('node:fs/promises')
415
+ const readme = await readFile.readFile(
416
+ new URL('../README.md', import.meta.url),
417
+ 'utf8',
418
+ )
419
+
420
+ assert.match(readme, /Server-authoritative cart/i)
421
+ assert.match(readme, /commerce\.cart\.\*/)
422
+ assert.match(readme, /HttpOnly cart cookie/i)
423
+ assert.match(readme, /cartToken/)
424
+ assert.match(
425
+ readme,
426
+ /client\s+JavaScript receives rendered cart views but never owns the authoritative cart\s+or token/i,
427
+ )
428
+ assert.match(
429
+ readme,
430
+ /no cart contents are persisted in localStorage/i,
431
+ )
432
+ assert.match(readme, /orders\.checkout\(\{ cartId \}\)/)
433
+ assert.match(readme, /orders\.getByPaymentId/i)
434
+ assert.match(
435
+ readme,
436
+ /mock adapter emulates the server cart and checkout flow in memory/i,
437
+ )
438
+ assert.match(readme, /zero-backend demos/i)
439
+ assert.match(readme, /\/api\/checkout\/payment-return/)
440
+ assert.match(readme, /tampered redirect amount/i)
441
+ assert.match(readme, /PG-verified amount/i)
442
+ assert.match(readme, /Success-page\s+reconciliation/i)
443
+ assert.match(readme, /orders\.confirmPayment/i)
444
+ const removedLocalCartText = new RegExp(
445
+ ['Local persisted cart', ' with Zustand'].join(' state'),
446
+ 'i',
447
+ )
448
+ const removedMockOrderIndex = new RegExp(
449
+ ['\\.mock', 'orders\\.json'].join('-'),
450
+ 'i',
451
+ )
452
+ const removedSoftwareOrderIndex = new RegExp(
453
+ ['\\.software', 'orders\\.json'].join('-'),
454
+ 'i',
455
+ )
456
+ const removedLegacyCheckoutCall = new RegExp(
457
+ ['orders', 'create'].join('\\.'),
458
+ 'i',
459
+ )
460
+
461
+ assert.doesNotMatch(readme, removedLocalCartText)
462
+ assert.doesNotMatch(readme, removedMockOrderIndex)
463
+ assert.doesNotMatch(readme, removedSoftwareOrderIndex)
464
+ assert.doesNotMatch(readme, removedLegacyCheckoutCall)
465
+ })
466
+
467
+ test('software adapter maps SDK product detail responses into storefront product detail', () => {
468
+ const detail = mapSoftwareProductDetail({
469
+ product: {
470
+ id: 42,
471
+ slug: 'linen-bag',
472
+ title: 'Linen Bag',
473
+ description: 'Everyday carry',
474
+ status: 'published',
475
+ },
476
+ images: [{ url: '/media/bag.jpg', alt: 'Bag', width: 800, height: 1000 }],
477
+ options: [
478
+ {
479
+ id: 'color',
480
+ slug: 'color',
481
+ title: 'Color',
482
+ values: [{ id: 'natural', slug: 'natural', value: 'Natural' }],
483
+ },
484
+ ],
485
+ variants: [
486
+ {
487
+ id: 100,
488
+ product: 42,
489
+ displayName: 'Natural',
490
+ price: 49000,
491
+ stock: 5,
492
+ reservedStock: 1,
493
+ isUnlimited: false,
494
+ optionValues: [
495
+ {
496
+ optionId: 'color',
497
+ value: { id: 'natural', slug: 'natural', value: 'Natural' },
498
+ },
499
+ ],
500
+ images: [{ url: '/media/bag-natural.jpg', alt: 'Natural bag' }],
501
+ },
502
+ ],
503
+ })
504
+
505
+ assert.equal(detail.product.id, '42')
506
+ assert.equal(detail.product.thumbnail?.url, '/media/bag.jpg')
507
+ assert.equal(detail.variants[0]?.id, '100')
508
+ assert.equal(detail.variants[0]?.price, 49000)
509
+ assert.equal(detail.variants[0]?.optionValues[0]?.valueSlug, 'natural')
510
+ })
511
+
512
+ test('software adapter unwraps SDK product detail result before mapping', () => {
513
+ const detail = mapSoftwareProductDetailResult({
514
+ found: true,
515
+ product: {
516
+ product: {
517
+ id: 'prod-1',
518
+ slug: 'daily-mug',
519
+ title: 'Daily Mug',
520
+ status: 'published',
521
+ },
522
+ variants: [],
523
+ options: [],
524
+ images: [],
525
+ },
526
+ })
527
+
528
+ assert.equal(detail?.product.slug, 'daily-mug')
529
+ assert.equal(
530
+ mapSoftwareProductDetailResult({ found: false, reason: 'not_found' }),
531
+ null,
532
+ )
533
+ })
534
+
535
+ test('software adapter requires SDK publishable and secret keys', () => {
536
+ assert.throws(
537
+ () =>
538
+ getSoftwareCommerceConfig({
539
+ NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY: 'pk_test',
540
+ }),
541
+ /SOFTWARE_SECRET_KEY/,
542
+ )
543
+
544
+ assert.deepEqual(
545
+ getSoftwareCommerceConfig({
546
+ NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY: 'pk_test',
547
+ SOFTWARE_SECRET_KEY: 'sk_test',
548
+ SOFTWARE_API_URL: 'https://console.example.test',
549
+ SOFTWARE_SHIPPING_AMOUNT: '2500',
550
+ SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT: '50000',
551
+ }),
552
+ {
553
+ publishableKey: 'pk_test',
554
+ secretKey: 'sk_test',
555
+ apiUrl: 'https://console.example.test',
556
+ shippingPolicy: {
557
+ currency: 'KRW',
558
+ baseAmount: 2500,
559
+ freeAboveAmount: 50000,
560
+ },
561
+ },
562
+ )
563
+ })
564
+
565
+ // scaffold:provider:tosspayments:start
566
+ test('tosspayments adapter requires secret and client keys', () => {
567
+ assert.throws(
568
+ () =>
569
+ getTossPaymentsConfig({
570
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
571
+ }),
572
+ /NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY/,
573
+ )
574
+
575
+ assert.deepEqual(
576
+ getTossPaymentsConfig({
577
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
578
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
579
+ NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY: 'customer_1',
580
+ TOSSPAYMENTS_API_BASE_URL: 'https://api.example.test/v1/',
581
+ }),
582
+ {
583
+ secretKey: 'test_sk',
584
+ clientKey: 'test_ck',
585
+ customerKey: 'customer_1',
586
+ apiBaseUrl: 'https://api.example.test/v1',
587
+ webhookSecret: undefined,
588
+ // Default-allow only outside production (NODE_ENV unset here).
589
+ allowUnsignedWebhooks: true,
590
+ },
591
+ )
592
+
593
+ // Production without a webhook secret (and without an explicit opt-in) fails
594
+ // closed — parity with PortOne's signed-webhook enforcement (#1544 / I6).
595
+ assert.throws(
596
+ () =>
597
+ getTossPaymentsConfig({
598
+ NODE_ENV: 'production',
599
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
600
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
601
+ }),
602
+ /TOSSPAYMENTS_WEBHOOK_SECRET/,
603
+ )
604
+
605
+ assert.equal(
606
+ getTossPaymentsConfig({
607
+ NODE_ENV: 'production',
608
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
609
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
610
+ TOSSPAYMENTS_ALLOW_UNSIGNED_WEBHOOKS: 'true',
611
+ }).allowUnsignedWebhooks,
612
+ true,
613
+ )
614
+ })
615
+
616
+ test('tosspayments adapter confirms payment through official REST API', async () => {
617
+ const originalFetch = globalThis.fetch
618
+ const previousEnv = {
619
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
620
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
621
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
622
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
623
+ }
624
+
625
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
626
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
627
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
628
+
629
+ let requestCount = 0
630
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
631
+ requestCount += 1
632
+ assert.equal(String(input), 'https://api.example.test/v1/payments/confirm')
633
+ assert.equal(init?.method, 'POST')
634
+ assert.equal(
635
+ new Headers(init?.headers).get('Authorization'),
636
+ `Basic ${Buffer.from('test_sk:').toString('base64')}`,
637
+ )
638
+ assert.equal(
639
+ new Headers(init?.headers).get('Content-Type'),
640
+ 'application/json',
641
+ )
642
+ assert.deepEqual(JSON.parse(String(init?.body)), {
643
+ paymentKey: 'payment_key_1',
644
+ orderId: 'pay_1',
645
+ amount: 99000,
646
+ })
647
+
648
+ return new Response(
649
+ JSON.stringify({
650
+ paymentKey: 'payment_key_1',
651
+ orderId: 'pay_1',
652
+ status: 'DONE',
653
+ totalAmount: 99000,
654
+ }),
655
+ {
656
+ status: 200,
657
+ headers: { 'Content-Type': 'application/json' },
658
+ },
659
+ )
660
+ }
661
+
662
+ try {
663
+ const payment = createTossPaymentsProvider()
664
+ assert.deepEqual(
665
+ await payment.confirmPayment({
666
+ paymentId: 'pay_1',
667
+ providerPaymentId: 'payment_key_1',
668
+ amount: 99000,
669
+ }),
670
+ {
671
+ paymentId: 'pay_1',
672
+ status: 'paid',
673
+ amount: 99000,
674
+ provider: 'tosspayments',
675
+ },
676
+ )
677
+ assert.equal(requestCount, 1)
678
+ } finally {
679
+ globalThis.fetch = originalFetch
680
+ restoreEnv(previousEnv)
681
+ }
682
+ })
683
+
684
+ test('tosspayments adapter looks up payment status through official REST API', async () => {
685
+ const originalFetch = globalThis.fetch
686
+ const previousEnv = {
687
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
688
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
689
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
690
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
691
+ }
692
+
693
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
694
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
695
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
696
+
697
+ let requestCount = 0
698
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
699
+ requestCount += 1
700
+ assert.equal(
701
+ String(input),
702
+ 'https://api.example.test/v1/payments/orders/pay_1',
703
+ )
704
+ assert.equal(
705
+ new Headers(init?.headers).get('Authorization'),
706
+ `Basic ${Buffer.from('test_sk:').toString('base64')}`,
707
+ )
708
+
709
+ return new Response(
710
+ JSON.stringify({
711
+ paymentKey: 'payment_key_1',
712
+ orderId: 'pay_1',
713
+ status: 'DONE',
714
+ totalAmount: 99000,
715
+ }),
716
+ {
717
+ status: 200,
718
+ headers: { 'Content-Type': 'application/json' },
719
+ },
720
+ )
721
+ }
722
+
723
+ try {
724
+ const payment = createTossPaymentsProvider()
725
+ assert.deepEqual(await payment.getPayment('pay_1'), {
726
+ paymentId: 'pay_1',
727
+ status: 'paid',
728
+ amount: 99000,
729
+ provider: 'tosspayments',
730
+ })
731
+ assert.equal(requestCount, 1)
732
+ } finally {
733
+ globalThis.fetch = originalFetch
734
+ restoreEnv(previousEnv)
735
+ }
736
+ })
737
+
738
+ test('tosspayments adapter maps partial cancellation as canceled', async () => {
739
+ const originalFetch = globalThis.fetch
740
+ const previousEnv = {
741
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
742
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
743
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
744
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
745
+ }
746
+
747
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
748
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
749
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
750
+
751
+ globalThis.fetch = async () =>
752
+ new Response(
753
+ JSON.stringify({
754
+ orderId: 'pay_1',
755
+ status: 'PARTIAL_CANCELED',
756
+ totalAmount: 99000,
757
+ }),
758
+ {
759
+ status: 200,
760
+ headers: { 'Content-Type': 'application/json' },
761
+ },
762
+ )
763
+
764
+ try {
765
+ const payment = createTossPaymentsProvider()
766
+ assert.deepEqual(await payment.getPayment('pay_1'), {
767
+ paymentId: 'pay_1',
768
+ status: 'canceled',
769
+ amount: 99000,
770
+ provider: 'tosspayments',
771
+ })
772
+ } finally {
773
+ globalThis.fetch = originalFetch
774
+ restoreEnv(previousEnv)
775
+ }
776
+ })
777
+
778
+ test('tosspayments webhook event id falls back to payment-specific keys', async () => {
779
+ const originalFetch = globalThis.fetch
780
+ const previousEnv = {
781
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
782
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
783
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
784
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
785
+ }
786
+
787
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
788
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
789
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
790
+
791
+ // verifyWebhook now performs an authoritative server re-fetch (verify-then-
792
+ // server-refetch parity with PortOne, #1544 / I6), so a lookup must resolve.
793
+ const lookups: string[] = []
794
+ globalThis.fetch = async (input: RequestInfo | URL) => {
795
+ lookups.push(String(input))
796
+ return new Response(
797
+ JSON.stringify({ orderId: 'pay_1', status: 'DONE', totalAmount: 99000 }),
798
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
799
+ )
800
+ }
801
+
802
+ try {
803
+ const payment = createTossPaymentsProvider()
804
+ assert.deepEqual(
805
+ await payment.verifyWebhook(
806
+ new Request('https://example.test/webhook', {
807
+ method: 'POST',
808
+ body: JSON.stringify({
809
+ eventType: 'PAYMENT_STATUS_CHANGED',
810
+ data: {
811
+ orderId: 'pay_1',
812
+ paymentKey: 'payment_key_1',
813
+ },
814
+ }),
815
+ }),
816
+ ),
817
+ {
818
+ type: 'payment.updated',
819
+ paymentId: 'pay_1',
820
+ eventId: 'payment_key_1',
821
+ },
822
+ )
823
+ // The webhook trusted a server re-fetch, not the request body.
824
+ assert.deepEqual(lookups, [
825
+ 'https://api.example.test/v1/payments/orders/pay_1',
826
+ ])
827
+
828
+ assert.deepEqual(
829
+ await payment.verifyWebhook(
830
+ new Request('https://example.test/webhook', {
831
+ method: 'POST',
832
+ body: JSON.stringify({
833
+ eventType: 'PAYMENT_STATUS_CHANGED',
834
+ data: {
835
+ orderId: 'pay_2',
836
+ },
837
+ }),
838
+ }),
839
+ ),
840
+ {
841
+ type: 'payment.updated',
842
+ paymentId: 'pay_2',
843
+ eventId: 'PAYMENT_STATUS_CHANGED:pay_2',
844
+ },
845
+ )
846
+ } finally {
847
+ globalThis.fetch = originalFetch
848
+ restoreEnv(previousEnv)
849
+ }
850
+ })
851
+
852
+ test('tosspayments webhook rejects an unsigned delivery outside development', () => {
853
+ const previousEnv = {
854
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
855
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
856
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
857
+ TOSSPAYMENTS_WEBHOOK_SECRET: process.env.TOSSPAYMENTS_WEBHOOK_SECRET,
858
+ NODE_ENV: process.env.NODE_ENV,
859
+ }
860
+
861
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
862
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
863
+ delete process.env.TOSSPAYMENTS_WEBHOOK_SECRET
864
+ ;(process.env as Record<string, string | undefined>).NODE_ENV = 'production'
865
+
866
+ try {
867
+ assert.throws(
868
+ () => createTossPaymentsProvider(),
869
+ /TOSSPAYMENTS_WEBHOOK_SECRET/,
870
+ )
871
+ } finally {
872
+ restoreEnv(previousEnv)
873
+ }
874
+ })
875
+
876
+ test('tosspayments webhook verifies a configured HMAC signature', async () => {
877
+ const originalFetch = globalThis.fetch
878
+ const previousEnv = {
879
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
880
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
881
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
882
+ TOSSPAYMENTS_WEBHOOK_SECRET: process.env.TOSSPAYMENTS_WEBHOOK_SECRET,
883
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
884
+ NODE_ENV: process.env.NODE_ENV,
885
+ }
886
+
887
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
888
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
889
+ process.env.TOSSPAYMENTS_WEBHOOK_SECRET = 'whsec_test'
890
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
891
+ ;(process.env as Record<string, string | undefined>).NODE_ENV = 'production'
892
+
893
+ globalThis.fetch = async () =>
894
+ new Response(
895
+ JSON.stringify({ orderId: 'pay_1', status: 'DONE', totalAmount: 99000 }),
896
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
897
+ )
898
+
899
+ const rawBody = JSON.stringify({
900
+ eventType: 'PAYMENT_STATUS_CHANGED',
901
+ data: { orderId: 'pay_1', paymentKey: 'payment_key_1' },
902
+ })
903
+ const timestamp = String(Math.floor(Date.now() / 1000))
904
+ const signature = createHmac('sha256', 'whsec_test')
905
+ .update(`${timestamp}.${rawBody}`)
906
+ .digest('base64')
907
+
908
+ try {
909
+ const payment = createTossPaymentsProvider()
910
+
911
+ // A valid signature is accepted.
912
+ assert.deepEqual(
913
+ await payment.verifyWebhook(
914
+ new Request('https://example.test/webhook', {
915
+ method: 'POST',
916
+ headers: {
917
+ 'Toss-Signature': signature,
918
+ 'Toss-Timestamp': timestamp,
919
+ },
920
+ body: rawBody,
921
+ }),
922
+ ),
923
+ {
924
+ type: 'payment.updated',
925
+ paymentId: 'pay_1',
926
+ eventId: 'payment_key_1',
927
+ },
928
+ )
929
+
930
+ // A tampered signature is rejected before any re-fetch.
931
+ await assert.rejects(
932
+ payment.verifyWebhook(
933
+ new Request('https://example.test/webhook', {
934
+ method: 'POST',
935
+ headers: {
936
+ 'Toss-Signature': 'not-the-signature',
937
+ 'Toss-Timestamp': timestamp,
938
+ },
939
+ body: rawBody,
940
+ }),
941
+ ),
942
+ /signature/,
943
+ )
944
+ } finally {
945
+ globalThis.fetch = originalFetch
946
+ restoreEnv(previousEnv)
947
+ }
948
+ })
949
+
950
+ // scaffold:provider:tosspayments:end
951
+ // scaffold:provider:portone:start
952
+ test('portone adapter requires API secret and browser SDK public keys', () => {
953
+ assert.throws(
954
+ () => getPortOneConfig({ PORTONE_API_SECRET: 'secret' }),
955
+ /NEXT_PUBLIC_PORTONE_STORE_ID/,
956
+ )
957
+
958
+ assert.deepEqual(
959
+ getPortOneConfig({
960
+ PORTONE_API_SECRET: 'secret',
961
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
962
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
963
+ NEXT_PUBLIC_PORTONE_PAY_METHOD: 'CARD',
964
+ PORTONE_WEBHOOK_SECRET: 'webhook_secret',
965
+ }),
966
+ {
967
+ apiSecret: 'secret',
968
+ storeId: 'store_test',
969
+ channelKey: 'channel_test',
970
+ payMethod: 'CARD',
971
+ webhookSecret: 'webhook_secret',
972
+ allowUnsignedWebhooks: true,
973
+ },
974
+ )
975
+
976
+ assert.throws(
977
+ () =>
978
+ getPortOneConfig({
979
+ NODE_ENV: 'production',
980
+ PORTONE_API_SECRET: 'secret',
981
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
982
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
983
+ }),
984
+ /PORTONE_WEBHOOK_SECRET/,
985
+ )
986
+
987
+ assert.equal(
988
+ getPortOneConfig({
989
+ NODE_ENV: 'production',
990
+ PORTONE_API_SECRET: 'secret',
991
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
992
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
993
+ PORTONE_ALLOW_UNSIGNED_WEBHOOKS: 'true',
994
+ }).allowUnsignedWebhooks,
995
+ true,
996
+ )
997
+ })
998
+
999
+ test('portone adapter returns browser SDK payload and maps server SDK payments', async () => {
1000
+ const payment = createPortOnePaymentProviderForConfig(
1001
+ {
1002
+ apiSecret: 'secret',
1003
+ storeId: 'store_test',
1004
+ channelKey: 'channel_test',
1005
+ payMethod: 'CARD',
1006
+ allowUnsignedWebhooks: true,
1007
+ },
1008
+ {
1009
+ async getPayment(options) {
1010
+ assert.deepEqual(options, {
1011
+ paymentId: 'pay_1',
1012
+ storeId: 'store_test',
1013
+ })
1014
+ return {
1015
+ id: 'pay_1',
1016
+ status: 'PAID',
1017
+ amount: {
1018
+ total: 99000,
1019
+ taxFree: 0,
1020
+ discount: 0,
1021
+ paid: 99000,
1022
+ cancelled: 0,
1023
+ cancelledTaxFree: 0,
1024
+ },
1025
+ } as never
1026
+ },
1027
+ },
1028
+ )
1029
+
1030
+ assert.deepEqual(
1031
+ await payment.requestPayment({
1032
+ paymentId: 'pay_1',
1033
+ orderNumber: 'ORD-1',
1034
+ orderName: 'Daily Shirt',
1035
+ amount: 99000,
1036
+ currency: 'KRW',
1037
+ customer: {
1038
+ name: 'Ada',
1039
+ email: 'ada@example.com',
1040
+ phone: '01012345678',
1041
+ },
1042
+ }),
1043
+ {
1044
+ ok: true,
1045
+ paymentId: 'pay_1',
1046
+ clientPayment: {
1047
+ provider: 'portone',
1048
+ storeId: 'store_test',
1049
+ channelKey: 'channel_test',
1050
+ payMethod: 'CARD',
1051
+ paymentId: 'pay_1',
1052
+ orderNumber: 'ORD-1',
1053
+ orderName: 'Daily Shirt',
1054
+ amount: 99000,
1055
+ currency: 'KRW',
1056
+ customer: {
1057
+ name: 'Ada',
1058
+ email: 'ada@example.com',
1059
+ phone: '01012345678',
1060
+ },
1061
+ },
1062
+ },
1063
+ )
1064
+
1065
+ assert.deepEqual(await payment.getPayment('pay_1'), {
1066
+ paymentId: 'pay_1',
1067
+ status: 'paid',
1068
+ amount: 99000,
1069
+ provider: 'portone',
1070
+ })
1071
+ })
1072
+
1073
+ test('portone adapter recovers orderNumber from payment customData', async () => {
1074
+ const payment = createPortOnePaymentProviderForConfig(
1075
+ {
1076
+ apiSecret: 'secret',
1077
+ storeId: 'store_test',
1078
+ channelKey: 'channel_test',
1079
+ payMethod: 'CARD',
1080
+ allowUnsignedWebhooks: true,
1081
+ },
1082
+ {
1083
+ async getPayment() {
1084
+ return {
1085
+ id: 'pay_1',
1086
+ status: 'PAID',
1087
+ amount: { total: 99000, paid: 99000 },
1088
+ // PortOne serializes customData to a JSON string.
1089
+ customData: JSON.stringify({ orderNumber: 'ORD-1' }),
1090
+ } as never
1091
+ },
1092
+ },
1093
+ )
1094
+
1095
+ assert.deepEqual(await payment.getPayment('pay_1'), {
1096
+ paymentId: 'pay_1',
1097
+ status: 'paid',
1098
+ amount: 99000,
1099
+ provider: 'portone',
1100
+ orderNumber: 'ORD-1',
1101
+ })
1102
+ })
1103
+
1104
+ // scaffold:provider:portone:end
1105
+ // scaffold:provider:tosspayments:start
1106
+ function restoreEnv(values: Record<string, string | undefined>): void {
1107
+ for (const [key, value] of Object.entries(values)) {
1108
+ if (value === undefined) {
1109
+ delete process.env[key]
1110
+ } else {
1111
+ process.env[key] = value
1112
+ }
1113
+ }
1114
+ }
1115
+ // scaffold:provider:tosspayments:end
1116
+
1117
+ test('software adapter threads the SDK checkout → confirm round-trip', async () => {
1118
+ const { client, calls, orderNumbers } = makeFakeSdkClient()
1119
+ const provider = buildSoftwareCommerceProvider(Promise.resolve(client), {
1120
+ currency: 'KRW',
1121
+ baseAmount: 3000,
1122
+ })
1123
+
1124
+ // Before placement, a by-payment lookup 404s and resolves to null.
1125
+ assert.equal(
1126
+ await provider.getOrderByPaymentId({
1127
+ paymentId: 'pay_x',
1128
+ provider: 'mock',
1129
+ }),
1130
+ null,
1131
+ )
1132
+
1133
+ // Checkout converts the server cart into a pending order and mints the
1134
+ // pgPaymentId template-side.
1135
+ const pending = await provider.checkoutCart({
1136
+ cartToken: 'tok_1',
1137
+ customerSnapshot: { name: 'Ada', email: 'ada@example.com', phone: '010' },
1138
+ shippingAddress: {
1139
+ recipientName: 'Ada',
1140
+ phone: '010',
1141
+ postalCode: '04524',
1142
+ address: 'Seoul',
1143
+ detailAddress: '101',
1144
+ deliveryMessage: '',
1145
+ },
1146
+ })
1147
+ const orderNumber = pending.order.orderNumber
1148
+ assert.equal(pending.order.displayStatus, 'pending')
1149
+ assert.equal(pending.amount, 53000)
1150
+ assert.match(pending.paymentId, /^pay_/)
1151
+ // The buyer address is passed to checkout with the cart capability token so
1152
+ // Console can persist context and price shipping atomically.
1153
+ assert.ok(calls.includes(`checkout:cart_1:tok_1:04524:${orderNumber}`))
1154
+ assert.equal(orderNumbers[0], orderNumber)
1155
+
1156
+ // Confirm with the minted payment id + provider-verified amount → placed paid
1157
+ // order.
1158
+ const paid = await provider.confirmOrderPayment({
1159
+ paymentId: pending.paymentId,
1160
+ orderNumber,
1161
+ provider: 'mock',
1162
+ amount: pending.amount,
1163
+ })
1164
+ assert.equal(paid.displayStatus, 'paid')
1165
+ assert.equal(paid.transactions[0]?.status, 'paid')
1166
+ assert.equal(paid.transactions[0]?.amount, 53000)
1167
+ assert.ok(calls.includes(`confirm:${orderNumber}:${pending.paymentId}:53000`))
1168
+
1169
+ // The placed order is now resolvable by PG payment id (idempotency key).
1170
+ const resolved = await provider.getOrderByPaymentId({
1171
+ paymentId: pending.paymentId,
1172
+ provider: 'mock',
1173
+ })
1174
+ assert.equal(resolved?.displayStatus, 'paid')
1175
+ assert.equal(resolved?.orderNumber, orderNumber)
1176
+ })
1177
+
1178
+ function makeFakeSdkClient(): {
1179
+ client: SoftwareSdkClientLike
1180
+ calls: string[]
1181
+ orderNumbers: string[]
1182
+ } {
1183
+ const calls: string[] = []
1184
+ const orderNumbers: string[] = []
1185
+ const placed = new Map<
1186
+ string,
1187
+ { order: Record<string, unknown>; transaction: Record<string, unknown> }
1188
+ >()
1189
+
1190
+ const cart = {
1191
+ id: 'cart_1',
1192
+ currency: 'KRW',
1193
+ subtotalAmount: 50000,
1194
+ shippingAmount: 3000,
1195
+ totalAmount: 53000,
1196
+ items: [
1197
+ {
1198
+ id: 'ci_1',
1199
+ product: 'prod-1',
1200
+ variant: 'var-1',
1201
+ quantity: 1,
1202
+ unitPrice: 50000,
1203
+ discountedTotalPrice: 50000,
1204
+ },
1205
+ ],
1206
+ }
1207
+
1208
+ const client: SoftwareSdkClientLike = {
1209
+ collections: {
1210
+ from: (slug) => ({
1211
+ async find() {
1212
+ return { docs: [] }
1213
+ },
1214
+ }),
1215
+ },
1216
+ commerce: {
1217
+ product: {
1218
+ async detail() {
1219
+ return { found: false }
1220
+ },
1221
+ async stockCheck() {
1222
+ return { allAvailable: true, results: [] }
1223
+ },
1224
+ },
1225
+ cart: {
1226
+ async create() {
1227
+ return { cartToken: 'tok_1' }
1228
+ },
1229
+ async get() {
1230
+ return cart
1231
+ },
1232
+ async addItem() {
1233
+ return {}
1234
+ },
1235
+ async updateItem() {
1236
+ return {}
1237
+ },
1238
+ async removeItem() {
1239
+ return {}
1240
+ },
1241
+ async clear() {
1242
+ return {}
1243
+ },
1244
+ },
1245
+ orders: {
1246
+ async checkout(params) {
1247
+ calls.push(
1248
+ `checkout:${params.cartId}:${params.cartToken}:${params.shippingAddress?.postalCode}:${params.orderNumber}`,
1249
+ )
1250
+ orderNumbers.push(params.orderNumber)
1251
+ return {
1252
+ id: 'ord_pending',
1253
+ orderNumber: params.orderNumber,
1254
+ displayFinancialStatus: 'pending',
1255
+ totalAmount: cart.totalAmount,
1256
+ subtotalAmount: cart.subtotalAmount,
1257
+ shippingAmount: cart.shippingAmount,
1258
+ customerSnapshot: params.customerSnapshot,
1259
+ items: [
1260
+ {
1261
+ variant: 'var-1',
1262
+ productTitle: 'Daily Shirt',
1263
+ quantity: 1,
1264
+ unitPrice: 50000,
1265
+ totalPrice: 50000,
1266
+ },
1267
+ ],
1268
+ }
1269
+ },
1270
+ async confirmPaymentReturningOrder(params) {
1271
+ calls.push(
1272
+ `confirm:${params.orderNumber}:${params.pgPaymentId}:${params.amount}`,
1273
+ )
1274
+ const result = {
1275
+ order: {
1276
+ id: 'ord_1',
1277
+ orderNumber: params.orderNumber,
1278
+ displayFinancialStatus: 'paid',
1279
+ totalAmount: params.amount,
1280
+ items: [],
1281
+ },
1282
+ transaction: {
1283
+ pgPaymentId: params.pgPaymentId,
1284
+ pgProvider: params.pgProvider,
1285
+ status: 'paid',
1286
+ amount: params.amount,
1287
+ },
1288
+ }
1289
+ placed.set(params.pgPaymentId, result)
1290
+ return result
1291
+ },
1292
+ async getByPaymentId(params) {
1293
+ const found = placed.get(params.pgPaymentId)
1294
+ if (!found) {
1295
+ const error = new Error('not found') as Error & { status: number }
1296
+ error.status = 404
1297
+ throw error
1298
+ }
1299
+ return found
1300
+ },
1301
+ async updateTransaction() {
1302
+ return {}
1303
+ },
1304
+ async cancelOrder() {
1305
+ return {}
1306
+ },
1307
+ },
1308
+ },
1309
+ }
1310
+
1311
+ return { client, calls, orderNumbers }
1312
+ }
1313
+
1314
+ function makePendingOrder(input: { totalAmount: number }): Order {
1315
+ return {
1316
+ id: 'order_1',
1317
+ orderNumber: 'ORD-1',
1318
+ displayStatus: 'pending',
1319
+ items: [],
1320
+ customerSnapshot: {
1321
+ name: 'Ada',
1322
+ email: 'ada@example.com',
1323
+ phone: '010-0000-0000',
1324
+ },
1325
+ shippingAddress: {
1326
+ recipientName: 'Ada',
1327
+ phone: '010-0000-0000',
1328
+ postalCode: '04524',
1329
+ address: 'Seoul',
1330
+ detailAddress: '101',
1331
+ deliveryMessage: '',
1332
+ },
1333
+ subtotalAmount: input.totalAmount,
1334
+ shippingAmount: 0,
1335
+ totalAmount: input.totalAmount,
1336
+ transactions: [
1337
+ {
1338
+ paymentId: 'pay_1',
1339
+ provider: 'mock',
1340
+ status: 'pending',
1341
+ amount: input.totalAmount,
1342
+ },
1343
+ ],
1344
+ }
1345
+ }
1346
+
1347
+ type FakeCommerceProvider = CommerceProvider & {
1348
+ confirmed: boolean
1349
+ confirmedAmount: number | null
1350
+ }
1351
+
1352
+ function makeCommerceProvider(order: Order | null): FakeCommerceProvider {
1353
+ const emptyCart = {
1354
+ id: 'cart_1',
1355
+ currency: 'KRW',
1356
+ subtotalAmount: 0,
1357
+ shippingAmount: 0,
1358
+ discountAmount: 0,
1359
+ totalAmount: 0,
1360
+ items: [],
1361
+ }
1362
+ return {
1363
+ confirmed: false,
1364
+ confirmedAmount: null,
1365
+ async listProducts() {
1366
+ return { products: [], total: 0 }
1367
+ },
1368
+ async getProductBySlug() {
1369
+ return null
1370
+ },
1371
+ async getVariantsByIds() {
1372
+ return []
1373
+ },
1374
+ async getShippingPolicy() {
1375
+ return { currency: 'KRW', baseAmount: 0 }
1376
+ },
1377
+ async checkStock() {
1378
+ return { ok: true, lines: [] }
1379
+ },
1380
+ async createCart() {
1381
+ return { cartToken: 'tok_1' }
1382
+ },
1383
+ async getCart() {
1384
+ return emptyCart
1385
+ },
1386
+ async addCartItem() {
1387
+ return emptyCart
1388
+ },
1389
+ async updateCartItem() {
1390
+ return emptyCart
1391
+ },
1392
+ async removeCartItem() {
1393
+ return emptyCart
1394
+ },
1395
+ async clearCart() {
1396
+ return emptyCart
1397
+ },
1398
+ async checkoutCart() {
1399
+ throw new Error('not used')
1400
+ },
1401
+ async getOrderByPaymentId({ paymentId }) {
1402
+ return order && paymentId === 'pay_1' ? order : null
1403
+ },
1404
+ async confirmOrderPayment(input) {
1405
+ this.confirmed = true
1406
+ this.confirmedAmount = input.amount
1407
+ return {
1408
+ ...(order ?? makePendingOrder({ totalAmount: input.amount })),
1409
+ displayStatus: 'paid',
1410
+ }
1411
+ },
1412
+ async markPaymentFailed() {
1413
+ return order
1414
+ },
1415
+ async cancelPendingOrder() {
1416
+ return order ? { ...order, displayStatus: 'canceled' } : null
1417
+ },
1418
+ }
1419
+ }
1420
+
1421
+ function makePaymentProvider(
1422
+ payment: Awaited<ReturnType<PaymentProvider['getPayment']>>,
1423
+ ): PaymentProvider {
1424
+ return {
1425
+ provider: payment?.provider ?? 'mock',
1426
+ async requestPayment() {
1427
+ return { ok: true, paymentId: payment?.paymentId ?? 'pay_1' }
1428
+ },
1429
+ async getPayment() {
1430
+ return payment
1431
+ },
1432
+ async confirmPayment() {
1433
+ if (!payment) throw new Error('missing payment')
1434
+ return payment
1435
+ },
1436
+ async verifyWebhook() {
1437
+ return {
1438
+ type: 'payment.updated',
1439
+ paymentId: payment?.paymentId ?? 'pay_1',
1440
+ eventId: 'evt_1',
1441
+ }
1442
+ },
1443
+ }
1444
+ }
1445
+
1446
+ function clearMockPaymentStore(): void {
1447
+ if (existsSync(mockPaymentStorePath)) {
1448
+ rmSync(mockPaymentStorePath)
1449
+ }
1450
+ }
1451
+
1452
+ // ── #1544 / I6: payment lifecycle hardening ──
1453
+
1454
+ test('assertAmountEquals passes when amounts (and currencies) match', () => {
1455
+ assert.doesNotThrow(() =>
1456
+ assertAmountEquals({ context: 'unit', expected: 99000, actual: 99000 }),
1457
+ )
1458
+ assert.doesNotThrow(() =>
1459
+ assertAmountEquals({
1460
+ context: 'unit',
1461
+ expected: 99000,
1462
+ actual: 99000,
1463
+ expectedCurrency: 'KRW',
1464
+ actualCurrency: 'KRW',
1465
+ }),
1466
+ )
1467
+ })
1468
+
1469
+ test('assertAmountEquals throws AmountMismatchError on any mismatch', () => {
1470
+ assert.throws(
1471
+ () =>
1472
+ assertAmountEquals({ context: 'unit', expected: 99000, actual: 9000 }),
1473
+ (error: unknown) =>
1474
+ error instanceof AmountMismatchError && error.code === 'amount_mismatch',
1475
+ )
1476
+ // Non-finite fails closed.
1477
+ assert.throws(
1478
+ () =>
1479
+ assertAmountEquals({
1480
+ context: 'unit',
1481
+ expected: 99000,
1482
+ actual: Number.NaN,
1483
+ }),
1484
+ AmountMismatchError,
1485
+ )
1486
+ // Currency mismatch even when the numeric amount matches.
1487
+ assert.throws(
1488
+ () =>
1489
+ assertAmountEquals({
1490
+ context: 'unit',
1491
+ expected: 99000,
1492
+ actual: 99000,
1493
+ expectedCurrency: 'KRW',
1494
+ actualCurrency: 'USD',
1495
+ }),
1496
+ AmountMismatchError,
1497
+ )
1498
+ })
1499
+
1500
+ test('syncOrderPayment recovers the orderNumber from the PG payment on a webhook with none', async () => {
1501
+ const commerce = makeCommerceProvider(null)
1502
+ const payment = makePaymentProvider({
1503
+ paymentId: 'pay_1',
1504
+ status: 'paid',
1505
+ amount: 99000,
1506
+ provider: 'mock',
1507
+ orderNumber: 'ORD-1',
1508
+ })
1509
+
1510
+ const result = await syncOrderPayment({
1511
+ paymentId: 'pay_1',
1512
+ providerEventId: 'evt_1',
1513
+ // No orderNumber: the webhook path recovers it from the PG re-fetch.
1514
+ commerceProvider: commerce,
1515
+ paymentProvider: payment,
1516
+ })
1517
+
1518
+ assert.equal(result.status, 'paid')
1519
+ assert.equal(commerce.confirmed, true)
1520
+ assert.equal(commerce.confirmedAmount, 99000)
1521
+ })
1522
+
1523
+ test('syncOrderPayment amount gate rejects a PG amount that differs from the placed order', async () => {
1524
+ const order = makePendingOrder({ totalAmount: 50000 })
1525
+ const commerce = makeCommerceProvider(order)
1526
+ const payment = makePaymentProvider({
1527
+ paymentId: 'pay_1',
1528
+ status: 'paid',
1529
+ amount: 99000,
1530
+ provider: 'mock',
1531
+ })
1532
+
1533
+ await assert.rejects(
1534
+ syncOrderPayment({
1535
+ paymentId: 'pay_1',
1536
+ orderNumber: 'ORD-1',
1537
+ commerceProvider: commerce,
1538
+ paymentProvider: payment,
1539
+ }),
1540
+ AmountMismatchError,
1541
+ )
1542
+ assert.equal(commerce.confirmed, false)
1543
+ })
1544
+
1545
+ test('server-only guards are present on runtime-bearing modules', () => {
1546
+ const guarded = [
1547
+ 'lib/payment/provider.server.ts',
1548
+ 'lib/payment/sync-order-payment.ts',
1549
+ 'lib/payment/adapters/mock.ts',
1550
+ 'lib/commerce/provider.server.ts',
1551
+ 'lib/commerce/adapters/software.ts',
1552
+ 'lib/cart/select-provider.ts',
1553
+ 'lib/checkout/start-checkout.ts',
1554
+ // scaffold:customer:start
1555
+ 'lib/customer/session.ts',
1556
+ 'lib/customer/client.server.ts',
1557
+ 'lib/customer/auth-actions.ts',
1558
+ 'lib/customer/current-customer.ts',
1559
+ // scaffold:customer:end
1560
+ // scaffold:provider:portone:start
1561
+ 'lib/payment/adapters/portone.ts',
1562
+ // scaffold:provider:portone:end
1563
+ // scaffold:provider:tosspayments:start
1564
+ 'lib/payment/adapters/tosspayments.ts',
1565
+ // scaffold:provider:tosspayments:end
1566
+ ]
1567
+ for (const rel of guarded) {
1568
+ const source = readFileSync(join(process.cwd(), rel), 'utf8')
1569
+ assert.match(
1570
+ source,
1571
+ /server-only-guard/,
1572
+ `${rel} must import the server-only guard`,
1573
+ )
1574
+ }
1575
+ })
1576
+
1577
+ // Cross-PG parity: each adapter is checked against the SAME security contract,
1578
+ // so a fix can never land on one fork only. The registry entries are guarded by
1579
+ // scaffold markers, so a scaffolded single-provider app keeps exactly one entry
1580
+ // and still asserts the shared contract.
1581
+ type ParityProviderCase = {
1582
+ id: string
1583
+ // Minimal valid creds WITHOUT a webhook secret, for the signed-webhook gate.
1584
+ credsWithoutWebhookSecret: Record<string, string | undefined>
1585
+ configFromEnv: (env: Record<string, string | undefined>) => {
1586
+ allowUnsignedWebhooks: boolean
1587
+ }
1588
+ }
1589
+
1590
+ const PARITY_PROVIDERS: ParityProviderCase[] = [
1591
+ // scaffold:provider:portone:start
1592
+ {
1593
+ id: 'portone',
1594
+ credsWithoutWebhookSecret: {
1595
+ PORTONE_API_SECRET: 'secret',
1596
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
1597
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
1598
+ },
1599
+ configFromEnv: getPortOneConfig,
1600
+ },
1601
+ // scaffold:provider:portone:end
1602
+ // scaffold:provider:tosspayments:start
1603
+ {
1604
+ id: 'tosspayments',
1605
+ credsWithoutWebhookSecret: {
1606
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
1607
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
1608
+ },
1609
+ configFromEnv: getTossPaymentsConfig,
1610
+ },
1611
+ // scaffold:provider:tosspayments:end
1612
+ ]
1613
+
1614
+ for (const pg of PARITY_PROVIDERS) {
1615
+ test(`cross-PG parity: ${pg.id} enforces signed webhooks outside development`, () => {
1616
+ // Default-allow only inside local development.
1617
+ assert.equal(
1618
+ pg.configFromEnv({ ...pg.credsWithoutWebhookSecret })
1619
+ .allowUnsignedWebhooks,
1620
+ true,
1621
+ )
1622
+ // No default-allow-unsigned outside development.
1623
+ assert.throws(
1624
+ () =>
1625
+ pg.configFromEnv({
1626
+ ...pg.credsWithoutWebhookSecret,
1627
+ NODE_ENV: 'production',
1628
+ }),
1629
+ /WEBHOOK_SECRET/,
1630
+ )
1631
+ })
1632
+ }