@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,1537 @@
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('startCheckout converts the server cart then requests payment with the cart total', async () => {
251
+ const order = makePendingOrder({ totalAmount: 99000 })
252
+ let requestedAmount = 0
253
+ let checkoutCartToken: string | null = null
254
+ const commerce: CommerceProvider = {
255
+ ...makeCommerceProvider(null),
256
+ async checkoutCart(input) {
257
+ checkoutCartToken = input.cartToken
258
+ return {
259
+ order,
260
+ paymentId: 'pay_1',
261
+ paymentName: 'Server cart order',
262
+ amount: 99000,
263
+ currency: 'KRW',
264
+ }
265
+ },
266
+ }
267
+ const payment: PaymentProvider = {
268
+ ...makePaymentProvider(null),
269
+ provider: 'tosspayments',
270
+ async requestPayment(input) {
271
+ requestedAmount = input.amount
272
+ return {
273
+ ok: true,
274
+ paymentId: input.paymentId,
275
+ redirectUrl: '/checkout/success?paymentId=pay_1&orderNumber=ORD-1',
276
+ }
277
+ },
278
+ }
279
+
280
+ const result = await startCheckout({
281
+ cartToken: 'tok_1',
282
+ payload: {
283
+ customerSnapshot: order.customerSnapshot,
284
+ shippingAddress: order.shippingAddress,
285
+ },
286
+ commerceProvider: commerce,
287
+ paymentProvider: payment,
288
+ })
289
+
290
+ assert.equal(checkoutCartToken, 'tok_1')
291
+ assert.equal(requestedAmount, 99000)
292
+ assert.equal(
293
+ result.redirectUrl,
294
+ '/checkout/success?paymentId=pay_1&orderNumber=ORD-1',
295
+ )
296
+ })
297
+
298
+ test('startCheckout surfaces a failed payment request', async () => {
299
+ const order = makePendingOrder({ totalAmount: 99000 })
300
+ const commerce: CommerceProvider = {
301
+ ...makeCommerceProvider(null),
302
+ async checkoutCart() {
303
+ return {
304
+ order,
305
+ paymentId: 'pay_1',
306
+ paymentName: 'Server cart order',
307
+ amount: 99000,
308
+ currency: 'KRW',
309
+ }
310
+ },
311
+ }
312
+ const payment: PaymentProvider = {
313
+ ...makePaymentProvider(null),
314
+ provider: 'tosspayments',
315
+ async requestPayment() {
316
+ return { ok: false, paymentId: 'pay_1', reason: 'failed' }
317
+ },
318
+ }
319
+
320
+ await assert.rejects(
321
+ startCheckout({
322
+ cartToken: 'tok_1',
323
+ payload: {
324
+ customerSnapshot: order.customerSnapshot,
325
+ shippingAddress: order.shippingAddress,
326
+ },
327
+ commerceProvider: commerce,
328
+ paymentProvider: payment,
329
+ }),
330
+ /failed/,
331
+ )
332
+ })
333
+
334
+ test('checkout error status preserves SDK authorization failures', () => {
335
+ assert.equal(
336
+ getCheckoutErrorStatus(Object.assign(new Error('Forbidden'), { status: 403 })),
337
+ 403,
338
+ )
339
+ })
340
+
341
+ test('checkout error status maps payload validation failures to bad request', () => {
342
+ assert.equal(getCheckoutErrorStatus(new Error('valid email is required')), 400)
343
+ })
344
+
345
+ test('mock payment provider stores payments independently from mock commerce orders', async () => {
346
+ clearMockPaymentStore()
347
+ const payment = createMockPaymentProvider()
348
+
349
+ await payment.requestPayment({
350
+ paymentId: 'pay_independent',
351
+ orderNumber: 'ORD-INDEPENDENT',
352
+ orderName: 'Independent order',
353
+ amount: 12345,
354
+ currency: 'KRW',
355
+ customer: {
356
+ name: 'Ada',
357
+ email: 'ada@example.com',
358
+ phone: '010-0000-0000',
359
+ },
360
+ })
361
+
362
+ assert.deepEqual(await payment.getPayment('pay_independent'), {
363
+ paymentId: 'pay_independent',
364
+ status: 'paid',
365
+ amount: 12345,
366
+ provider: 'mock',
367
+ orderNumber: 'ORD-INDEPENDENT',
368
+ })
369
+ clearMockPaymentStore()
370
+ })
371
+
372
+ test('software adapter maps SDK product detail responses into storefront product detail', () => {
373
+ const detail = mapSoftwareProductDetail({
374
+ product: {
375
+ id: 42,
376
+ slug: 'linen-bag',
377
+ title: 'Linen Bag',
378
+ description: 'Everyday carry',
379
+ status: 'published',
380
+ },
381
+ images: [{ url: '/media/bag.jpg', alt: 'Bag', width: 800, height: 1000 }],
382
+ options: [
383
+ {
384
+ id: 'color',
385
+ slug: 'color',
386
+ title: 'Color',
387
+ values: [{ id: 'natural', slug: 'natural', value: 'Natural' }],
388
+ },
389
+ ],
390
+ variants: [
391
+ {
392
+ id: 100,
393
+ product: 42,
394
+ displayName: 'Natural',
395
+ price: 49000,
396
+ stock: 5,
397
+ reservedStock: 1,
398
+ isUnlimited: false,
399
+ optionValues: [
400
+ {
401
+ optionId: 'color',
402
+ value: { id: 'natural', slug: 'natural', value: 'Natural' },
403
+ },
404
+ ],
405
+ images: [{ url: '/media/bag-natural.jpg', alt: 'Natural bag' }],
406
+ },
407
+ ],
408
+ })
409
+
410
+ assert.equal(detail.product.id, '42')
411
+ assert.equal(detail.product.thumbnail?.url, '/media/bag.jpg')
412
+ assert.equal(detail.variants[0]?.id, '100')
413
+ assert.equal(detail.variants[0]?.price, 49000)
414
+ assert.equal(detail.variants[0]?.optionValues[0]?.valueSlug, 'natural')
415
+ })
416
+
417
+ test('software adapter unwraps SDK product detail result before mapping', () => {
418
+ const detail = mapSoftwareProductDetailResult({
419
+ found: true,
420
+ product: {
421
+ product: {
422
+ id: 'prod-1',
423
+ slug: 'daily-mug',
424
+ title: 'Daily Mug',
425
+ status: 'published',
426
+ },
427
+ variants: [],
428
+ options: [],
429
+ images: [],
430
+ },
431
+ })
432
+
433
+ assert.equal(detail?.product.slug, 'daily-mug')
434
+ assert.equal(
435
+ mapSoftwareProductDetailResult({ found: false, reason: 'not_found' }),
436
+ null,
437
+ )
438
+ })
439
+
440
+ test('software adapter requires SDK publishable and secret keys', () => {
441
+ assert.throws(
442
+ () =>
443
+ getSoftwareCommerceConfig({
444
+ NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY: 'pk_test',
445
+ }),
446
+ /SOFTWARE_SECRET_KEY/,
447
+ )
448
+
449
+ assert.deepEqual(
450
+ getSoftwareCommerceConfig({
451
+ NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY: 'pk_test',
452
+ SOFTWARE_SECRET_KEY: 'sk_test',
453
+ SOFTWARE_API_URL: 'https://console.example.test',
454
+ SOFTWARE_SHIPPING_AMOUNT: '2500',
455
+ SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT: '50000',
456
+ }),
457
+ {
458
+ publishableKey: 'pk_test',
459
+ secretKey: 'sk_test',
460
+ apiUrl: 'https://console.example.test',
461
+ shippingPolicy: {
462
+ currency: 'KRW',
463
+ baseAmount: 2500,
464
+ freeAboveAmount: 50000,
465
+ },
466
+ },
467
+ )
468
+ })
469
+
470
+ // scaffold:provider:tosspayments:start
471
+ test('tosspayments adapter requires secret and client keys', () => {
472
+ assert.throws(
473
+ () =>
474
+ getTossPaymentsConfig({
475
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
476
+ }),
477
+ /NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY/,
478
+ )
479
+
480
+ assert.deepEqual(
481
+ getTossPaymentsConfig({
482
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
483
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
484
+ NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY: 'customer_1',
485
+ TOSSPAYMENTS_API_BASE_URL: 'https://api.example.test/v1/',
486
+ }),
487
+ {
488
+ secretKey: 'test_sk',
489
+ clientKey: 'test_ck',
490
+ customerKey: 'customer_1',
491
+ apiBaseUrl: 'https://api.example.test/v1',
492
+ webhookSecret: undefined,
493
+ // Default-allow only outside production (NODE_ENV unset here).
494
+ allowUnsignedWebhooks: true,
495
+ },
496
+ )
497
+
498
+ // Production without a webhook secret (and without an explicit opt-in) fails
499
+ // closed — parity with PortOne's signed-webhook enforcement (#1544 / I6).
500
+ assert.throws(
501
+ () =>
502
+ getTossPaymentsConfig({
503
+ NODE_ENV: 'production',
504
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
505
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
506
+ }),
507
+ /TOSSPAYMENTS_WEBHOOK_SECRET/,
508
+ )
509
+
510
+ assert.equal(
511
+ getTossPaymentsConfig({
512
+ NODE_ENV: 'production',
513
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
514
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
515
+ TOSSPAYMENTS_ALLOW_UNSIGNED_WEBHOOKS: 'true',
516
+ }).allowUnsignedWebhooks,
517
+ true,
518
+ )
519
+ })
520
+
521
+ test('tosspayments adapter confirms payment through official REST API', async () => {
522
+ const originalFetch = globalThis.fetch
523
+ const previousEnv = {
524
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
525
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
526
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
527
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
528
+ }
529
+
530
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
531
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
532
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
533
+
534
+ let requestCount = 0
535
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
536
+ requestCount += 1
537
+ assert.equal(String(input), 'https://api.example.test/v1/payments/confirm')
538
+ assert.equal(init?.method, 'POST')
539
+ assert.equal(
540
+ new Headers(init?.headers).get('Authorization'),
541
+ `Basic ${Buffer.from('test_sk:').toString('base64')}`,
542
+ )
543
+ assert.equal(
544
+ new Headers(init?.headers).get('Content-Type'),
545
+ 'application/json',
546
+ )
547
+ assert.deepEqual(JSON.parse(String(init?.body)), {
548
+ paymentKey: 'payment_key_1',
549
+ orderId: 'pay_1',
550
+ amount: 99000,
551
+ })
552
+
553
+ return new Response(
554
+ JSON.stringify({
555
+ paymentKey: 'payment_key_1',
556
+ orderId: 'pay_1',
557
+ status: 'DONE',
558
+ totalAmount: 99000,
559
+ }),
560
+ {
561
+ status: 200,
562
+ headers: { 'Content-Type': 'application/json' },
563
+ },
564
+ )
565
+ }
566
+
567
+ try {
568
+ const payment = createTossPaymentsProvider()
569
+ assert.deepEqual(
570
+ await payment.confirmPayment({
571
+ paymentId: 'pay_1',
572
+ providerPaymentId: 'payment_key_1',
573
+ amount: 99000,
574
+ }),
575
+ {
576
+ paymentId: 'pay_1',
577
+ status: 'paid',
578
+ amount: 99000,
579
+ provider: 'tosspayments',
580
+ },
581
+ )
582
+ assert.equal(requestCount, 1)
583
+ } finally {
584
+ globalThis.fetch = originalFetch
585
+ restoreEnv(previousEnv)
586
+ }
587
+ })
588
+
589
+ test('tosspayments adapter looks up payment status through official REST API', async () => {
590
+ const originalFetch = globalThis.fetch
591
+ const previousEnv = {
592
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
593
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
594
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
595
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
596
+ }
597
+
598
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
599
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
600
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
601
+
602
+ let requestCount = 0
603
+ globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
604
+ requestCount += 1
605
+ assert.equal(
606
+ String(input),
607
+ 'https://api.example.test/v1/payments/orders/pay_1',
608
+ )
609
+ assert.equal(
610
+ new Headers(init?.headers).get('Authorization'),
611
+ `Basic ${Buffer.from('test_sk:').toString('base64')}`,
612
+ )
613
+
614
+ return new Response(
615
+ JSON.stringify({
616
+ paymentKey: 'payment_key_1',
617
+ orderId: 'pay_1',
618
+ status: 'DONE',
619
+ totalAmount: 99000,
620
+ }),
621
+ {
622
+ status: 200,
623
+ headers: { 'Content-Type': 'application/json' },
624
+ },
625
+ )
626
+ }
627
+
628
+ try {
629
+ const payment = createTossPaymentsProvider()
630
+ assert.deepEqual(await payment.getPayment('pay_1'), {
631
+ paymentId: 'pay_1',
632
+ status: 'paid',
633
+ amount: 99000,
634
+ provider: 'tosspayments',
635
+ })
636
+ assert.equal(requestCount, 1)
637
+ } finally {
638
+ globalThis.fetch = originalFetch
639
+ restoreEnv(previousEnv)
640
+ }
641
+ })
642
+
643
+ test('tosspayments adapter maps partial cancellation as canceled', async () => {
644
+ const originalFetch = globalThis.fetch
645
+ const previousEnv = {
646
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
647
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
648
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
649
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
650
+ }
651
+
652
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
653
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
654
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
655
+
656
+ globalThis.fetch = async () =>
657
+ new Response(
658
+ JSON.stringify({
659
+ orderId: 'pay_1',
660
+ status: 'PARTIAL_CANCELED',
661
+ totalAmount: 99000,
662
+ }),
663
+ {
664
+ status: 200,
665
+ headers: { 'Content-Type': 'application/json' },
666
+ },
667
+ )
668
+
669
+ try {
670
+ const payment = createTossPaymentsProvider()
671
+ assert.deepEqual(await payment.getPayment('pay_1'), {
672
+ paymentId: 'pay_1',
673
+ status: 'canceled',
674
+ amount: 99000,
675
+ provider: 'tosspayments',
676
+ })
677
+ } finally {
678
+ globalThis.fetch = originalFetch
679
+ restoreEnv(previousEnv)
680
+ }
681
+ })
682
+
683
+ test('tosspayments webhook event id falls back to payment-specific keys', async () => {
684
+ const originalFetch = globalThis.fetch
685
+ const previousEnv = {
686
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
687
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
688
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
689
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
690
+ }
691
+
692
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
693
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
694
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
695
+
696
+ // verifyWebhook now performs an authoritative server re-fetch (verify-then-
697
+ // server-refetch parity with PortOne, #1544 / I6), so a lookup must resolve.
698
+ const lookups: string[] = []
699
+ globalThis.fetch = async (input: RequestInfo | URL) => {
700
+ lookups.push(String(input))
701
+ return new Response(
702
+ JSON.stringify({ orderId: 'pay_1', status: 'DONE', totalAmount: 99000 }),
703
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
704
+ )
705
+ }
706
+
707
+ try {
708
+ const payment = createTossPaymentsProvider()
709
+ assert.deepEqual(
710
+ await payment.verifyWebhook(
711
+ new Request('https://example.test/webhook', {
712
+ method: 'POST',
713
+ body: JSON.stringify({
714
+ eventType: 'PAYMENT_STATUS_CHANGED',
715
+ data: {
716
+ orderId: 'pay_1',
717
+ paymentKey: 'payment_key_1',
718
+ },
719
+ }),
720
+ }),
721
+ ),
722
+ {
723
+ type: 'payment.updated',
724
+ paymentId: 'pay_1',
725
+ eventId: 'payment_key_1',
726
+ },
727
+ )
728
+ // The webhook trusted a server re-fetch, not the request body.
729
+ assert.deepEqual(lookups, [
730
+ 'https://api.example.test/v1/payments/orders/pay_1',
731
+ ])
732
+
733
+ assert.deepEqual(
734
+ await payment.verifyWebhook(
735
+ new Request('https://example.test/webhook', {
736
+ method: 'POST',
737
+ body: JSON.stringify({
738
+ eventType: 'PAYMENT_STATUS_CHANGED',
739
+ data: {
740
+ orderId: 'pay_2',
741
+ },
742
+ }),
743
+ }),
744
+ ),
745
+ {
746
+ type: 'payment.updated',
747
+ paymentId: 'pay_2',
748
+ eventId: 'PAYMENT_STATUS_CHANGED:pay_2',
749
+ },
750
+ )
751
+ } finally {
752
+ globalThis.fetch = originalFetch
753
+ restoreEnv(previousEnv)
754
+ }
755
+ })
756
+
757
+ test('tosspayments webhook rejects an unsigned delivery outside development', () => {
758
+ const previousEnv = {
759
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
760
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
761
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
762
+ TOSSPAYMENTS_WEBHOOK_SECRET: process.env.TOSSPAYMENTS_WEBHOOK_SECRET,
763
+ NODE_ENV: process.env.NODE_ENV,
764
+ }
765
+
766
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
767
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
768
+ delete process.env.TOSSPAYMENTS_WEBHOOK_SECRET
769
+ ;(process.env as Record<string, string | undefined>).NODE_ENV = 'production'
770
+
771
+ try {
772
+ assert.throws(
773
+ () => createTossPaymentsProvider(),
774
+ /TOSSPAYMENTS_WEBHOOK_SECRET/,
775
+ )
776
+ } finally {
777
+ restoreEnv(previousEnv)
778
+ }
779
+ })
780
+
781
+ test('tosspayments webhook verifies a configured HMAC signature', async () => {
782
+ const originalFetch = globalThis.fetch
783
+ const previousEnv = {
784
+ TOSSPAYMENTS_SECRET_KEY: process.env.TOSSPAYMENTS_SECRET_KEY,
785
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY:
786
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY,
787
+ TOSSPAYMENTS_WEBHOOK_SECRET: process.env.TOSSPAYMENTS_WEBHOOK_SECRET,
788
+ TOSSPAYMENTS_API_BASE_URL: process.env.TOSSPAYMENTS_API_BASE_URL,
789
+ NODE_ENV: process.env.NODE_ENV,
790
+ }
791
+
792
+ process.env.TOSSPAYMENTS_SECRET_KEY = 'test_sk'
793
+ process.env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY = 'test_ck'
794
+ process.env.TOSSPAYMENTS_WEBHOOK_SECRET = 'whsec_test'
795
+ process.env.TOSSPAYMENTS_API_BASE_URL = 'https://api.example.test/v1'
796
+ ;(process.env as Record<string, string | undefined>).NODE_ENV = 'production'
797
+
798
+ globalThis.fetch = async () =>
799
+ new Response(
800
+ JSON.stringify({ orderId: 'pay_1', status: 'DONE', totalAmount: 99000 }),
801
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
802
+ )
803
+
804
+ const rawBody = JSON.stringify({
805
+ eventType: 'PAYMENT_STATUS_CHANGED',
806
+ data: { orderId: 'pay_1', paymentKey: 'payment_key_1' },
807
+ })
808
+ const timestamp = String(Math.floor(Date.now() / 1000))
809
+ const signature = createHmac('sha256', 'whsec_test')
810
+ .update(`${timestamp}.${rawBody}`)
811
+ .digest('base64')
812
+
813
+ try {
814
+ const payment = createTossPaymentsProvider()
815
+
816
+ // A valid signature is accepted.
817
+ assert.deepEqual(
818
+ await payment.verifyWebhook(
819
+ new Request('https://example.test/webhook', {
820
+ method: 'POST',
821
+ headers: {
822
+ 'Toss-Signature': signature,
823
+ 'Toss-Timestamp': timestamp,
824
+ },
825
+ body: rawBody,
826
+ }),
827
+ ),
828
+ {
829
+ type: 'payment.updated',
830
+ paymentId: 'pay_1',
831
+ eventId: 'payment_key_1',
832
+ },
833
+ )
834
+
835
+ // A tampered signature is rejected before any re-fetch.
836
+ await assert.rejects(
837
+ payment.verifyWebhook(
838
+ new Request('https://example.test/webhook', {
839
+ method: 'POST',
840
+ headers: {
841
+ 'Toss-Signature': 'not-the-signature',
842
+ 'Toss-Timestamp': timestamp,
843
+ },
844
+ body: rawBody,
845
+ }),
846
+ ),
847
+ /signature/,
848
+ )
849
+ } finally {
850
+ globalThis.fetch = originalFetch
851
+ restoreEnv(previousEnv)
852
+ }
853
+ })
854
+
855
+ // scaffold:provider:tosspayments:end
856
+ // scaffold:provider:portone:start
857
+ test('portone adapter requires API secret and browser SDK public keys', () => {
858
+ assert.throws(
859
+ () => getPortOneConfig({ PORTONE_API_SECRET: 'secret' }),
860
+ /NEXT_PUBLIC_PORTONE_STORE_ID/,
861
+ )
862
+
863
+ assert.deepEqual(
864
+ getPortOneConfig({
865
+ PORTONE_API_SECRET: 'secret',
866
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
867
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
868
+ NEXT_PUBLIC_PORTONE_PAY_METHOD: 'CARD',
869
+ PORTONE_WEBHOOK_SECRET: 'webhook_secret',
870
+ }),
871
+ {
872
+ apiSecret: 'secret',
873
+ storeId: 'store_test',
874
+ channelKey: 'channel_test',
875
+ payMethod: 'CARD',
876
+ webhookSecret: 'webhook_secret',
877
+ allowUnsignedWebhooks: true,
878
+ },
879
+ )
880
+
881
+ assert.throws(
882
+ () =>
883
+ getPortOneConfig({
884
+ NODE_ENV: 'production',
885
+ PORTONE_API_SECRET: 'secret',
886
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
887
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
888
+ }),
889
+ /PORTONE_WEBHOOK_SECRET/,
890
+ )
891
+
892
+ assert.equal(
893
+ getPortOneConfig({
894
+ NODE_ENV: 'production',
895
+ PORTONE_API_SECRET: 'secret',
896
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
897
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
898
+ PORTONE_ALLOW_UNSIGNED_WEBHOOKS: 'true',
899
+ }).allowUnsignedWebhooks,
900
+ true,
901
+ )
902
+ })
903
+
904
+ test('portone adapter returns browser SDK payload and maps server SDK payments', async () => {
905
+ const payment = createPortOnePaymentProviderForConfig(
906
+ {
907
+ apiSecret: 'secret',
908
+ storeId: 'store_test',
909
+ channelKey: 'channel_test',
910
+ payMethod: 'CARD',
911
+ allowUnsignedWebhooks: true,
912
+ },
913
+ {
914
+ async getPayment(options) {
915
+ assert.deepEqual(options, {
916
+ paymentId: 'pay_1',
917
+ storeId: 'store_test',
918
+ })
919
+ return {
920
+ id: 'pay_1',
921
+ status: 'PAID',
922
+ amount: {
923
+ total: 99000,
924
+ taxFree: 0,
925
+ discount: 0,
926
+ paid: 99000,
927
+ cancelled: 0,
928
+ cancelledTaxFree: 0,
929
+ },
930
+ } as never
931
+ },
932
+ },
933
+ )
934
+
935
+ assert.deepEqual(
936
+ await payment.requestPayment({
937
+ paymentId: 'pay_1',
938
+ orderNumber: 'ORD-1',
939
+ orderName: 'Daily Shirt',
940
+ amount: 99000,
941
+ currency: 'KRW',
942
+ customer: {
943
+ name: 'Ada',
944
+ email: 'ada@example.com',
945
+ phone: '01012345678',
946
+ },
947
+ }),
948
+ {
949
+ ok: true,
950
+ paymentId: 'pay_1',
951
+ clientPayment: {
952
+ provider: 'portone',
953
+ storeId: 'store_test',
954
+ channelKey: 'channel_test',
955
+ payMethod: 'CARD',
956
+ paymentId: 'pay_1',
957
+ orderNumber: 'ORD-1',
958
+ orderName: 'Daily Shirt',
959
+ amount: 99000,
960
+ currency: 'KRW',
961
+ customer: {
962
+ name: 'Ada',
963
+ email: 'ada@example.com',
964
+ phone: '01012345678',
965
+ },
966
+ },
967
+ },
968
+ )
969
+
970
+ assert.deepEqual(await payment.getPayment('pay_1'), {
971
+ paymentId: 'pay_1',
972
+ status: 'paid',
973
+ amount: 99000,
974
+ provider: 'portone',
975
+ })
976
+ })
977
+
978
+ test('portone adapter recovers orderNumber from payment customData', async () => {
979
+ const payment = createPortOnePaymentProviderForConfig(
980
+ {
981
+ apiSecret: 'secret',
982
+ storeId: 'store_test',
983
+ channelKey: 'channel_test',
984
+ payMethod: 'CARD',
985
+ allowUnsignedWebhooks: true,
986
+ },
987
+ {
988
+ async getPayment() {
989
+ return {
990
+ id: 'pay_1',
991
+ status: 'PAID',
992
+ amount: { total: 99000, paid: 99000 },
993
+ // PortOne serializes customData to a JSON string.
994
+ customData: JSON.stringify({ orderNumber: 'ORD-1' }),
995
+ } as never
996
+ },
997
+ },
998
+ )
999
+
1000
+ assert.deepEqual(await payment.getPayment('pay_1'), {
1001
+ paymentId: 'pay_1',
1002
+ status: 'paid',
1003
+ amount: 99000,
1004
+ provider: 'portone',
1005
+ orderNumber: 'ORD-1',
1006
+ })
1007
+ })
1008
+
1009
+ // scaffold:provider:portone:end
1010
+ // scaffold:provider:tosspayments:start
1011
+ function restoreEnv(values: Record<string, string | undefined>): void {
1012
+ for (const [key, value] of Object.entries(values)) {
1013
+ if (value === undefined) {
1014
+ delete process.env[key]
1015
+ } else {
1016
+ process.env[key] = value
1017
+ }
1018
+ }
1019
+ }
1020
+ // scaffold:provider:tosspayments:end
1021
+
1022
+ test('software adapter threads the SDK checkout → confirm round-trip', async () => {
1023
+ const { client, calls, orderNumbers } = makeFakeSdkClient()
1024
+ const provider = buildSoftwareCommerceProvider(Promise.resolve(client), {
1025
+ currency: 'KRW',
1026
+ baseAmount: 3000,
1027
+ })
1028
+
1029
+ // Before placement, a by-payment lookup 404s and resolves to null.
1030
+ assert.equal(
1031
+ await provider.getOrderByPaymentId({ paymentId: 'pay_x', provider: 'mock' }),
1032
+ null,
1033
+ )
1034
+
1035
+ // Checkout converts the server cart into a pending order and mints the
1036
+ // pgPaymentId template-side.
1037
+ const pending = await provider.checkoutCart({
1038
+ cartToken: 'tok_1',
1039
+ customerSnapshot: { name: 'Ada', email: 'ada@example.com', phone: '010' },
1040
+ shippingAddress: {
1041
+ recipientName: 'Ada',
1042
+ phone: '010',
1043
+ postalCode: '04524',
1044
+ address: 'Seoul',
1045
+ detailAddress: '101',
1046
+ deliveryMessage: '',
1047
+ },
1048
+ })
1049
+ const orderNumber = pending.order.orderNumber
1050
+ assert.equal(pending.order.displayStatus, 'pending')
1051
+ assert.equal(pending.amount, 53000)
1052
+ assert.match(pending.paymentId, /^pay_/)
1053
+ // The buyer address is persisted to the cart before checkout, and checkout is
1054
+ // called with the cart id + generated order number.
1055
+ assert.ok(calls.includes('carts.update:cart_1'))
1056
+ assert.ok(calls.includes(`checkout:cart_1:${orderNumber}`))
1057
+ assert.equal(orderNumbers[0], orderNumber)
1058
+
1059
+ // Confirm with the minted payment id + provider-verified amount → placed paid
1060
+ // order.
1061
+ const paid = await provider.confirmOrderPayment({
1062
+ paymentId: pending.paymentId,
1063
+ orderNumber,
1064
+ provider: 'mock',
1065
+ amount: pending.amount,
1066
+ })
1067
+ assert.equal(paid.displayStatus, 'paid')
1068
+ assert.equal(paid.transactions[0]?.status, 'paid')
1069
+ assert.equal(paid.transactions[0]?.amount, 53000)
1070
+ assert.ok(calls.includes(`confirm:${orderNumber}:${pending.paymentId}:53000`))
1071
+
1072
+ // The placed order is now resolvable by PG payment id (idempotency key).
1073
+ const resolved = await provider.getOrderByPaymentId({
1074
+ paymentId: pending.paymentId,
1075
+ provider: 'mock',
1076
+ })
1077
+ assert.equal(resolved?.displayStatus, 'paid')
1078
+ assert.equal(resolved?.orderNumber, orderNumber)
1079
+ })
1080
+
1081
+ function makeFakeSdkClient(): {
1082
+ client: SoftwareSdkClientLike
1083
+ calls: string[]
1084
+ orderNumbers: string[]
1085
+ } {
1086
+ const calls: string[] = []
1087
+ const orderNumbers: string[] = []
1088
+ const placed = new Map<
1089
+ string,
1090
+ { order: Record<string, unknown>; transaction: Record<string, unknown> }
1091
+ >()
1092
+
1093
+ const cart = {
1094
+ id: 'cart_1',
1095
+ currency: 'KRW',
1096
+ subtotalAmount: 50000,
1097
+ shippingAmount: 3000,
1098
+ totalAmount: 53000,
1099
+ items: [
1100
+ {
1101
+ id: 'ci_1',
1102
+ product: 'prod-1',
1103
+ variant: 'var-1',
1104
+ quantity: 1,
1105
+ unitPrice: 50000,
1106
+ discountedTotalPrice: 50000,
1107
+ },
1108
+ ],
1109
+ }
1110
+
1111
+ const client: SoftwareSdkClientLike = {
1112
+ collections: {
1113
+ from: (slug) => ({
1114
+ async find() {
1115
+ return { docs: [] }
1116
+ },
1117
+ async update(id) {
1118
+ calls.push(`${slug}.update:${id}`)
1119
+ return {}
1120
+ },
1121
+ }),
1122
+ },
1123
+ commerce: {
1124
+ product: {
1125
+ async detail() {
1126
+ return { found: false }
1127
+ },
1128
+ async stockCheck() {
1129
+ return { allAvailable: true, results: [] }
1130
+ },
1131
+ },
1132
+ cart: {
1133
+ async create() {
1134
+ return { cartToken: 'tok_1' }
1135
+ },
1136
+ async get() {
1137
+ return cart
1138
+ },
1139
+ async addItem() {
1140
+ return {}
1141
+ },
1142
+ async updateItem() {
1143
+ return {}
1144
+ },
1145
+ async removeItem() {
1146
+ return {}
1147
+ },
1148
+ async clear() {
1149
+ return {}
1150
+ },
1151
+ },
1152
+ orders: {
1153
+ async checkout(params) {
1154
+ calls.push(`checkout:${params.cartId}:${params.orderNumber}`)
1155
+ orderNumbers.push(params.orderNumber)
1156
+ return {
1157
+ id: 'ord_pending',
1158
+ orderNumber: params.orderNumber,
1159
+ displayFinancialStatus: 'pending',
1160
+ totalAmount: cart.totalAmount,
1161
+ subtotalAmount: cart.subtotalAmount,
1162
+ shippingAmount: cart.shippingAmount,
1163
+ customerSnapshot: params.customerSnapshot,
1164
+ items: [
1165
+ {
1166
+ variant: 'var-1',
1167
+ productTitle: 'Daily Shirt',
1168
+ quantity: 1,
1169
+ unitPrice: 50000,
1170
+ totalPrice: 50000,
1171
+ },
1172
+ ],
1173
+ }
1174
+ },
1175
+ async confirmPaymentReturningOrder(params) {
1176
+ calls.push(
1177
+ `confirm:${params.orderNumber}:${params.pgPaymentId}:${params.amount}`,
1178
+ )
1179
+ const result = {
1180
+ order: {
1181
+ id: 'ord_1',
1182
+ orderNumber: params.orderNumber,
1183
+ displayFinancialStatus: 'paid',
1184
+ totalAmount: params.amount,
1185
+ items: [],
1186
+ },
1187
+ transaction: {
1188
+ pgPaymentId: params.pgPaymentId,
1189
+ pgProvider: params.pgProvider,
1190
+ status: 'paid',
1191
+ amount: params.amount,
1192
+ },
1193
+ }
1194
+ placed.set(params.pgPaymentId, result)
1195
+ return result
1196
+ },
1197
+ async getByPaymentId(params) {
1198
+ const found = placed.get(params.pgPaymentId)
1199
+ if (!found) {
1200
+ const error = new Error('not found') as Error & { status: number }
1201
+ error.status = 404
1202
+ throw error
1203
+ }
1204
+ return found
1205
+ },
1206
+ async updateTransaction() {
1207
+ return {}
1208
+ },
1209
+ async cancelOrder() {
1210
+ return {}
1211
+ },
1212
+ },
1213
+ },
1214
+ }
1215
+
1216
+ return { client, calls, orderNumbers }
1217
+ }
1218
+
1219
+ function makePendingOrder(input: { totalAmount: number }): Order {
1220
+ return {
1221
+ id: 'order_1',
1222
+ orderNumber: 'ORD-1',
1223
+ displayStatus: 'pending',
1224
+ items: [],
1225
+ customerSnapshot: {
1226
+ name: 'Ada',
1227
+ email: 'ada@example.com',
1228
+ phone: '010-0000-0000',
1229
+ },
1230
+ shippingAddress: {
1231
+ recipientName: 'Ada',
1232
+ phone: '010-0000-0000',
1233
+ postalCode: '04524',
1234
+ address: 'Seoul',
1235
+ detailAddress: '101',
1236
+ deliveryMessage: '',
1237
+ },
1238
+ subtotalAmount: input.totalAmount,
1239
+ shippingAmount: 0,
1240
+ totalAmount: input.totalAmount,
1241
+ transactions: [
1242
+ {
1243
+ paymentId: 'pay_1',
1244
+ provider: 'mock',
1245
+ status: 'pending',
1246
+ amount: input.totalAmount,
1247
+ },
1248
+ ],
1249
+ }
1250
+ }
1251
+
1252
+ type FakeCommerceProvider = CommerceProvider & {
1253
+ confirmed: boolean
1254
+ confirmedAmount: number | null
1255
+ }
1256
+
1257
+ function makeCommerceProvider(order: Order | null): FakeCommerceProvider {
1258
+ const emptyCart = {
1259
+ id: 'cart_1',
1260
+ currency: 'KRW',
1261
+ subtotalAmount: 0,
1262
+ shippingAmount: 0,
1263
+ discountAmount: 0,
1264
+ totalAmount: 0,
1265
+ items: [],
1266
+ }
1267
+ return {
1268
+ confirmed: false,
1269
+ confirmedAmount: null,
1270
+ async listProducts() {
1271
+ return { products: [], total: 0 }
1272
+ },
1273
+ async getProductBySlug() {
1274
+ return null
1275
+ },
1276
+ async getVariantsByIds() {
1277
+ return []
1278
+ },
1279
+ async getShippingPolicy() {
1280
+ return { currency: 'KRW', baseAmount: 0 }
1281
+ },
1282
+ async checkStock() {
1283
+ return { ok: true, lines: [] }
1284
+ },
1285
+ async createCart() {
1286
+ return { cartToken: 'tok_1' }
1287
+ },
1288
+ async getCart() {
1289
+ return emptyCart
1290
+ },
1291
+ async addCartItem() {
1292
+ return emptyCart
1293
+ },
1294
+ async updateCartItem() {
1295
+ return emptyCart
1296
+ },
1297
+ async removeCartItem() {
1298
+ return emptyCart
1299
+ },
1300
+ async clearCart() {
1301
+ return emptyCart
1302
+ },
1303
+ async checkoutCart() {
1304
+ throw new Error('not used')
1305
+ },
1306
+ async getOrderByPaymentId({ paymentId }) {
1307
+ return order && paymentId === 'pay_1' ? order : null
1308
+ },
1309
+ async confirmOrderPayment(input) {
1310
+ this.confirmed = true
1311
+ this.confirmedAmount = input.amount
1312
+ return {
1313
+ ...(order ?? makePendingOrder({ totalAmount: input.amount })),
1314
+ displayStatus: 'paid',
1315
+ }
1316
+ },
1317
+ async markPaymentFailed() {
1318
+ return order
1319
+ },
1320
+ async cancelPendingOrder() {
1321
+ return order ? { ...order, displayStatus: 'canceled' } : null
1322
+ },
1323
+ }
1324
+ }
1325
+
1326
+ function makePaymentProvider(
1327
+ payment: Awaited<ReturnType<PaymentProvider['getPayment']>>,
1328
+ ): PaymentProvider {
1329
+ return {
1330
+ provider: payment?.provider ?? 'mock',
1331
+ async requestPayment() {
1332
+ return { ok: true, paymentId: payment?.paymentId ?? 'pay_1' }
1333
+ },
1334
+ async getPayment() {
1335
+ return payment
1336
+ },
1337
+ async confirmPayment() {
1338
+ if (!payment) throw new Error('missing payment')
1339
+ return payment
1340
+ },
1341
+ async verifyWebhook() {
1342
+ return {
1343
+ type: 'payment.updated',
1344
+ paymentId: payment?.paymentId ?? 'pay_1',
1345
+ eventId: 'evt_1',
1346
+ }
1347
+ },
1348
+ }
1349
+ }
1350
+
1351
+ function clearMockPaymentStore(): void {
1352
+ if (existsSync(mockPaymentStorePath)) {
1353
+ rmSync(mockPaymentStorePath)
1354
+ }
1355
+ }
1356
+
1357
+ // ── #1544 / I6: payment lifecycle hardening ──
1358
+
1359
+ test('assertAmountEquals passes when amounts (and currencies) match', () => {
1360
+ assert.doesNotThrow(() =>
1361
+ assertAmountEquals({ context: 'unit', expected: 99000, actual: 99000 }),
1362
+ )
1363
+ assert.doesNotThrow(() =>
1364
+ assertAmountEquals({
1365
+ context: 'unit',
1366
+ expected: 99000,
1367
+ actual: 99000,
1368
+ expectedCurrency: 'KRW',
1369
+ actualCurrency: 'KRW',
1370
+ }),
1371
+ )
1372
+ })
1373
+
1374
+ test('assertAmountEquals throws AmountMismatchError on any mismatch', () => {
1375
+ assert.throws(
1376
+ () =>
1377
+ assertAmountEquals({ context: 'unit', expected: 99000, actual: 9000 }),
1378
+ (error: unknown) =>
1379
+ error instanceof AmountMismatchError && error.code === 'amount_mismatch',
1380
+ )
1381
+ // Non-finite fails closed.
1382
+ assert.throws(
1383
+ () =>
1384
+ assertAmountEquals({
1385
+ context: 'unit',
1386
+ expected: 99000,
1387
+ actual: Number.NaN,
1388
+ }),
1389
+ AmountMismatchError,
1390
+ )
1391
+ // Currency mismatch even when the numeric amount matches.
1392
+ assert.throws(
1393
+ () =>
1394
+ assertAmountEquals({
1395
+ context: 'unit',
1396
+ expected: 99000,
1397
+ actual: 99000,
1398
+ expectedCurrency: 'KRW',
1399
+ actualCurrency: 'USD',
1400
+ }),
1401
+ AmountMismatchError,
1402
+ )
1403
+ })
1404
+
1405
+ test('syncOrderPayment recovers the orderNumber from the PG payment on a webhook with none', async () => {
1406
+ const commerce = makeCommerceProvider(null)
1407
+ const payment = makePaymentProvider({
1408
+ paymentId: 'pay_1',
1409
+ status: 'paid',
1410
+ amount: 99000,
1411
+ provider: 'mock',
1412
+ orderNumber: 'ORD-1',
1413
+ })
1414
+
1415
+ const result = await syncOrderPayment({
1416
+ paymentId: 'pay_1',
1417
+ providerEventId: 'evt_1',
1418
+ // No orderNumber: the webhook path recovers it from the PG re-fetch.
1419
+ commerceProvider: commerce,
1420
+ paymentProvider: payment,
1421
+ })
1422
+
1423
+ assert.equal(result.status, 'paid')
1424
+ assert.equal(commerce.confirmed, true)
1425
+ assert.equal(commerce.confirmedAmount, 99000)
1426
+ })
1427
+
1428
+ test('syncOrderPayment amount gate rejects a PG amount that differs from the placed order', async () => {
1429
+ const order = makePendingOrder({ totalAmount: 50000 })
1430
+ const commerce = makeCommerceProvider(order)
1431
+ const payment = makePaymentProvider({
1432
+ paymentId: 'pay_1',
1433
+ status: 'paid',
1434
+ amount: 99000,
1435
+ provider: 'mock',
1436
+ })
1437
+
1438
+ await assert.rejects(
1439
+ syncOrderPayment({
1440
+ paymentId: 'pay_1',
1441
+ orderNumber: 'ORD-1',
1442
+ commerceProvider: commerce,
1443
+ paymentProvider: payment,
1444
+ }),
1445
+ AmountMismatchError,
1446
+ )
1447
+ assert.equal(commerce.confirmed, false)
1448
+ })
1449
+
1450
+ test('server-only guards are present on runtime-bearing modules', () => {
1451
+ const guarded = [
1452
+ 'lib/payment/provider.server.ts',
1453
+ 'lib/payment/sync-order-payment.ts',
1454
+ 'lib/payment/adapters/mock.ts',
1455
+ 'lib/commerce/provider.server.ts',
1456
+ 'lib/commerce/adapters/software.ts',
1457
+ 'lib/cart/select-provider.ts',
1458
+ 'lib/checkout/start-checkout.ts',
1459
+ // scaffold:customer:start
1460
+ 'lib/customer/session.ts',
1461
+ 'lib/customer/client.server.ts',
1462
+ 'lib/customer/auth-actions.ts',
1463
+ 'lib/customer/current-customer.ts',
1464
+ // scaffold:customer:end
1465
+ // scaffold:provider:portone:start
1466
+ 'lib/payment/adapters/portone.ts',
1467
+ // scaffold:provider:portone:end
1468
+ // scaffold:provider:tosspayments:start
1469
+ 'lib/payment/adapters/tosspayments.ts',
1470
+ // scaffold:provider:tosspayments:end
1471
+ ]
1472
+ for (const rel of guarded) {
1473
+ const source = readFileSync(join(process.cwd(), rel), 'utf8')
1474
+ assert.match(
1475
+ source,
1476
+ /server-only-guard/,
1477
+ `${rel} must import the server-only guard`,
1478
+ )
1479
+ }
1480
+ })
1481
+
1482
+ // Cross-PG parity: each adapter is checked against the SAME security contract,
1483
+ // so a fix can never land on one fork only. The registry entries are guarded by
1484
+ // scaffold markers, so a scaffolded single-provider app keeps exactly one entry
1485
+ // and still asserts the shared contract.
1486
+ type ParityProviderCase = {
1487
+ id: string
1488
+ // Minimal valid creds WITHOUT a webhook secret, for the signed-webhook gate.
1489
+ credsWithoutWebhookSecret: Record<string, string | undefined>
1490
+ configFromEnv: (env: Record<string, string | undefined>) => {
1491
+ allowUnsignedWebhooks: boolean
1492
+ }
1493
+ }
1494
+
1495
+ const PARITY_PROVIDERS: ParityProviderCase[] = [
1496
+ // scaffold:provider:portone:start
1497
+ {
1498
+ id: 'portone',
1499
+ credsWithoutWebhookSecret: {
1500
+ PORTONE_API_SECRET: 'secret',
1501
+ NEXT_PUBLIC_PORTONE_STORE_ID: 'store_test',
1502
+ NEXT_PUBLIC_PORTONE_CHANNEL_KEY: 'channel_test',
1503
+ },
1504
+ configFromEnv: getPortOneConfig,
1505
+ },
1506
+ // scaffold:provider:portone:end
1507
+ // scaffold:provider:tosspayments:start
1508
+ {
1509
+ id: 'tosspayments',
1510
+ credsWithoutWebhookSecret: {
1511
+ TOSSPAYMENTS_SECRET_KEY: 'test_sk',
1512
+ NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY: 'test_ck',
1513
+ },
1514
+ configFromEnv: getTossPaymentsConfig,
1515
+ },
1516
+ // scaffold:provider:tosspayments:end
1517
+ ]
1518
+
1519
+ for (const pg of PARITY_PROVIDERS) {
1520
+ test(`cross-PG parity: ${pg.id} enforces signed webhooks outside development`, () => {
1521
+ // Default-allow only inside local development.
1522
+ assert.equal(
1523
+ pg.configFromEnv({ ...pg.credsWithoutWebhookSecret })
1524
+ .allowUnsignedWebhooks,
1525
+ true,
1526
+ )
1527
+ // No default-allow-unsigned outside development.
1528
+ assert.throws(
1529
+ () =>
1530
+ pg.configFromEnv({
1531
+ ...pg.credsWithoutWebhookSecret,
1532
+ NODE_ENV: 'production',
1533
+ }),
1534
+ /WEBHOOK_SECRET/,
1535
+ )
1536
+ })
1537
+ }