@01.software/init 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-docs.d.ts +13 -0
- package/dist/ai-docs.js +1 -1
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-TBGKXE3Q.js → chunk-NJ4X7VNK.js} +5 -5
- package/dist/chunk-NJ4X7VNK.js.map +1 -0
- package/dist/{chunk-5K2CB2Y5.js → chunk-Q6MSORYN.js} +14 -37
- package/dist/chunk-Q6MSORYN.js.map +1 -0
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-2IGKOSK7.js → chunk-WDWJ73KP.js} +41 -215
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +30 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +139 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
- package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
- package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
- package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
- package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
- package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
- package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
- package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
- package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
- package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
- package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +119 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +267 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
- package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
- package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
- package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
- package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
- package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
- package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
- package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
- package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
- package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
- package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
- package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
- package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
- package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
- package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +76 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +63 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +3 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +336 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +913 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +206 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
- package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
- package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
- package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
- package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
- package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
- package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
- package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
- package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
- package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
- package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
- package/dist/create-app-templates/ecommerce/package.json +33 -0
- package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
- package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
- package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +392 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1537 -0
- package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
- package/dist/create-app-templates/registry.json +66 -0
- package/dist/create-app.d.ts +40 -0
- package/dist/create-app.js +652 -0
- package/dist/create-app.js.map +1 -0
- package/dist/detect-Bjxp9wcS.d.ts +13 -0
- package/dist/file-ops.d.ts +21 -0
- package/dist/file-ops.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +5 -4
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +31 -15
- package/dist/chunk-2IGKOSK7.js.map +0 -1
- package/dist/chunk-5K2CB2Y5.js.map +0 -1
- package/dist/chunk-TBGKXE3Q.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,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
|
+
}
|