@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,254 @@
|
|
|
1
|
+
import '../../server-only-guard.ts'
|
|
2
|
+
import { PaymentClient, Webhook } from '@portone/server-sdk'
|
|
3
|
+
import {
|
|
4
|
+
isUnrecognizedPayment,
|
|
5
|
+
type Payment as PortOnePayment,
|
|
6
|
+
} from '@portone/server-sdk/payment'
|
|
7
|
+
|
|
8
|
+
import type { PaymentProvider } from '../provider.ts'
|
|
9
|
+
import type { ProviderPayment } from '../types.ts'
|
|
10
|
+
|
|
11
|
+
export function createPortOnePaymentProvider(): PaymentProvider {
|
|
12
|
+
const config = getPortOneConfig(process.env)
|
|
13
|
+
return createPortOnePaymentProviderForConfig(config)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPortOnePaymentProviderForConfig(
|
|
17
|
+
config: ReturnType<typeof getPortOneConfig>,
|
|
18
|
+
paymentClient: Pick<
|
|
19
|
+
ReturnType<typeof PaymentClient>,
|
|
20
|
+
'getPayment'
|
|
21
|
+
> = PaymentClient({
|
|
22
|
+
secret: config.apiSecret,
|
|
23
|
+
}),
|
|
24
|
+
): PaymentProvider {
|
|
25
|
+
return {
|
|
26
|
+
provider: 'portone',
|
|
27
|
+
|
|
28
|
+
async requestPayment(input) {
|
|
29
|
+
// Official browser SDK docs:
|
|
30
|
+
// https://developers.portone.io/opi/ko/integration/pg/v2/kcp-v2
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
paymentId: input.paymentId,
|
|
34
|
+
clientPayment: {
|
|
35
|
+
provider: 'portone',
|
|
36
|
+
storeId: config.storeId,
|
|
37
|
+
channelKey: config.channelKey,
|
|
38
|
+
payMethod: config.payMethod,
|
|
39
|
+
paymentId: input.paymentId,
|
|
40
|
+
orderNumber: input.orderNumber,
|
|
41
|
+
orderName: input.orderName,
|
|
42
|
+
amount: input.amount,
|
|
43
|
+
currency: input.currency,
|
|
44
|
+
customer: input.customer,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async getPayment(paymentId) {
|
|
50
|
+
const payment = await fetchPortOnePayment(
|
|
51
|
+
paymentClient,
|
|
52
|
+
config,
|
|
53
|
+
paymentId,
|
|
54
|
+
)
|
|
55
|
+
return mapPortOnePayment(payment, paymentId)
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async confirmPayment(input) {
|
|
59
|
+
const payment = await this.getPayment(input.paymentId)
|
|
60
|
+
if (!payment) throw new Error('PortOne payment not found')
|
|
61
|
+
return payment
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async verifyWebhook(request) {
|
|
65
|
+
const rawBody = await request.text()
|
|
66
|
+
const body = config.webhookSecret
|
|
67
|
+
? await verifyPortOneWebhook(config.webhookSecret, rawBody, request)
|
|
68
|
+
: config.allowUnsignedWebhooks
|
|
69
|
+
? parsePortOneWebhookBody(rawBody)
|
|
70
|
+
: rejectUnsignedPortOneWebhook()
|
|
71
|
+
const paymentId = body.data?.paymentId ?? body.paymentId
|
|
72
|
+
const eventId =
|
|
73
|
+
request.headers.get('webhook-id') ??
|
|
74
|
+
body.eventId ??
|
|
75
|
+
body.id ??
|
|
76
|
+
paymentId
|
|
77
|
+
|
|
78
|
+
if (!paymentId || !eventId) {
|
|
79
|
+
throw new Error('Invalid PortOne webhook')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await fetchPortOnePayment(paymentClient, config, paymentId)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
type: 'payment.updated',
|
|
86
|
+
paymentId,
|
|
87
|
+
eventId,
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type PaymentEnv = Record<string, string | undefined>
|
|
94
|
+
|
|
95
|
+
export function hasPortOneCredentials(env: PaymentEnv): boolean {
|
|
96
|
+
return Boolean(
|
|
97
|
+
env.PORTONE_API_SECRET?.trim() &&
|
|
98
|
+
env.NEXT_PUBLIC_PORTONE_STORE_ID?.trim() &&
|
|
99
|
+
env.NEXT_PUBLIC_PORTONE_CHANNEL_KEY?.trim(),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getPortOneConfig(env: PaymentEnv): {
|
|
104
|
+
apiSecret: string
|
|
105
|
+
storeId: string
|
|
106
|
+
channelKey: string
|
|
107
|
+
payMethod: string
|
|
108
|
+
webhookSecret?: string
|
|
109
|
+
allowUnsignedWebhooks: boolean
|
|
110
|
+
} {
|
|
111
|
+
const apiSecret = env.PORTONE_API_SECRET?.trim()
|
|
112
|
+
const storeId = env.NEXT_PUBLIC_PORTONE_STORE_ID?.trim()
|
|
113
|
+
const channelKey = env.NEXT_PUBLIC_PORTONE_CHANNEL_KEY?.trim()
|
|
114
|
+
const webhookSecret = env.PORTONE_WEBHOOK_SECRET?.trim()
|
|
115
|
+
const allowUnsignedWebhooks =
|
|
116
|
+
env.PORTONE_ALLOW_UNSIGNED_WEBHOOKS === 'true' ||
|
|
117
|
+
env.NODE_ENV !== 'production'
|
|
118
|
+
if (!apiSecret) {
|
|
119
|
+
throw new Error('PortOne payments require PORTONE_API_SECRET.')
|
|
120
|
+
}
|
|
121
|
+
if (!storeId) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'PortOne payments require NEXT_PUBLIC_PORTONE_STORE_ID.',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
if (!channelKey) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'PortOne payments require NEXT_PUBLIC_PORTONE_CHANNEL_KEY.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (!webhookSecret && !allowUnsignedWebhooks) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'PortOne payments require PORTONE_WEBHOOK_SECRET in production. Set PORTONE_ALLOW_UNSIGNED_WEBHOOKS=true only for local development.',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
apiSecret,
|
|
139
|
+
storeId,
|
|
140
|
+
channelKey,
|
|
141
|
+
payMethod: env.NEXT_PUBLIC_PORTONE_PAY_METHOD?.trim() || 'CARD',
|
|
142
|
+
webhookSecret: webhookSecret || undefined,
|
|
143
|
+
allowUnsignedWebhooks,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchPortOnePayment(
|
|
148
|
+
paymentClient: Pick<ReturnType<typeof PaymentClient>, 'getPayment'>,
|
|
149
|
+
config: ReturnType<typeof getPortOneConfig>,
|
|
150
|
+
paymentId: string,
|
|
151
|
+
): Promise<PortOnePayment> {
|
|
152
|
+
// Official docs:
|
|
153
|
+
// - Server SDK JS reference: https://portone-io.github.io/server-sdk/js/
|
|
154
|
+
// - Checkout/payment lookup guide: https://developers.portone.io/opi/ko/integration/start/v2/checkout
|
|
155
|
+
const payment = await paymentClient.getPayment({
|
|
156
|
+
paymentId,
|
|
157
|
+
storeId: config.storeId,
|
|
158
|
+
})
|
|
159
|
+
if (isUnrecognizedPayment(payment)) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Unrecognized PortOne payment status: ${String(payment.status)}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return payment
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mapPortOnePayment(
|
|
169
|
+
payment: PortOnePayment,
|
|
170
|
+
fallbackPaymentId: string,
|
|
171
|
+
): ProviderPayment {
|
|
172
|
+
const orderNumber = readPortOneOrderNumber(payment)
|
|
173
|
+
return {
|
|
174
|
+
paymentId: 'id' in payment ? payment.id : fallbackPaymentId,
|
|
175
|
+
status: mapPortOneStatus(String(payment.status)),
|
|
176
|
+
amount: readPortOneAmount(payment),
|
|
177
|
+
provider: 'portone',
|
|
178
|
+
// Omit when absent so the wire shape stays minimal and stable.
|
|
179
|
+
...(orderNumber ? { orderNumber } : {}),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Recover the merchant order number from the PG payment's `customData` — the
|
|
185
|
+
* durable `orderNumber↔pgPaymentId` mapping threaded in at `requestPayment`
|
|
186
|
+
* time (#1544 / I6). PortOne serializes `customData` to a JSON string; parse it
|
|
187
|
+
* best-effort and ignore anything that is not our `{ orderNumber }` shape.
|
|
188
|
+
*/
|
|
189
|
+
function readPortOneOrderNumber(payment: PortOnePayment): string | undefined {
|
|
190
|
+
if (!('customData' in payment) || typeof payment.customData !== 'string') {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(payment.customData) as { orderNumber?: unknown }
|
|
195
|
+
return typeof parsed.orderNumber === 'string' && parsed.orderNumber.length > 0
|
|
196
|
+
? parsed.orderNumber
|
|
197
|
+
: undefined
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function mapPortOneStatus(
|
|
204
|
+
status: string | undefined,
|
|
205
|
+
): ProviderPayment['status'] {
|
|
206
|
+
if (status === 'PAID') return 'paid'
|
|
207
|
+
if (status === 'FAILED') return 'failed'
|
|
208
|
+
if (status === 'CANCELLED' || status === 'CANCELED') return 'canceled'
|
|
209
|
+
return 'pending'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readPortOneAmount(payment: PortOnePayment): number {
|
|
213
|
+
if (!('amount' in payment)) return 0
|
|
214
|
+
if (typeof payment.amount.paid === 'number') return payment.amount.paid
|
|
215
|
+
if (typeof payment.amount.total === 'number') return payment.amount.total
|
|
216
|
+
return 0
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type PortOneWebhookBody = {
|
|
220
|
+
id?: string
|
|
221
|
+
eventId?: string
|
|
222
|
+
paymentId?: string
|
|
223
|
+
data?: {
|
|
224
|
+
paymentId?: string
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function verifyPortOneWebhook(
|
|
229
|
+
webhookSecret: string,
|
|
230
|
+
rawBody: string,
|
|
231
|
+
request: Request,
|
|
232
|
+
): Promise<PortOneWebhookBody> {
|
|
233
|
+
// Official webhook verification docs:
|
|
234
|
+
// https://portone-io.github.io/server-sdk/js/modules/Webhook.html
|
|
235
|
+
const webhook = await Webhook.verify(webhookSecret, rawBody, {
|
|
236
|
+
'webhook-id': request.headers.get('webhook-id') ?? undefined,
|
|
237
|
+
'webhook-timestamp': request.headers.get('webhook-timestamp') ?? undefined,
|
|
238
|
+
'webhook-signature': request.headers.get('webhook-signature') ?? undefined,
|
|
239
|
+
})
|
|
240
|
+
if (Webhook.isUnrecognizedWebhook(webhook)) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Unrecognized PortOne webhook type: ${String(webhook.type)}`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
return webhook as PortOneWebhookBody
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parsePortOneWebhookBody(rawBody: string): PortOneWebhookBody {
|
|
249
|
+
return JSON.parse(rawBody) as PortOneWebhookBody
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function rejectUnsignedPortOneWebhook(): never {
|
|
253
|
+
throw new Error('Unsigned PortOne webhook is not allowed.')
|
|
254
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import "../../server-only-guard.ts";
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import type { PaymentProvider } from "../provider.ts";
|
|
4
|
+
import type { ProviderPayment } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
type TossPayment = {
|
|
7
|
+
paymentKey?: string;
|
|
8
|
+
orderId?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
totalAmount?: number;
|
|
11
|
+
balanceAmount?: number;
|
|
12
|
+
metadata?: Record<string, unknown> | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function createTossPaymentsProvider(): PaymentProvider {
|
|
16
|
+
const config = getTossPaymentsConfig(process.env);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
provider: "tosspayments",
|
|
20
|
+
|
|
21
|
+
async requestPayment(input) {
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
paymentId: input.paymentId,
|
|
25
|
+
clientPayment: {
|
|
26
|
+
provider: "tosspayments",
|
|
27
|
+
clientKey: config.clientKey,
|
|
28
|
+
customerKey: config.customerKey,
|
|
29
|
+
paymentId: input.paymentId,
|
|
30
|
+
orderNumber: input.orderNumber,
|
|
31
|
+
orderName: input.orderName,
|
|
32
|
+
amount: input.amount,
|
|
33
|
+
currency: input.currency,
|
|
34
|
+
customer: input.customer,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async getPayment(paymentId) {
|
|
40
|
+
const payment = await fetchTossPaymentByOrderId(config, paymentId);
|
|
41
|
+
return mapTossPayment(payment, paymentId);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async confirmPayment(input) {
|
|
45
|
+
if (!input.providerPaymentId || input.amount == null) {
|
|
46
|
+
const payment = await this.getPayment(input.paymentId);
|
|
47
|
+
if (!payment) throw new Error("TossPayments payment not found");
|
|
48
|
+
return payment;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const payment = await confirmTossPayment(config, {
|
|
52
|
+
paymentKey: input.providerPaymentId,
|
|
53
|
+
orderId: input.paymentId,
|
|
54
|
+
amount: input.amount,
|
|
55
|
+
});
|
|
56
|
+
return mapTossPayment(payment, input.paymentId);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async verifyWebhook(request) {
|
|
60
|
+
// Verify-then-server-refetch parity with PortOne (#1544 / I6). Toss
|
|
61
|
+
// webhooks are not delivered through the browser SDK, so trust is
|
|
62
|
+
// established two ways: (1) an HMAC-SHA256 signature when a secret is
|
|
63
|
+
// configured, mirroring the Console billing webhook scheme
|
|
64
|
+
// (`${timestamp}.${rawBody}`, base64, `Toss-Signature`/`Toss-Timestamp`);
|
|
65
|
+
// (2) an authoritative server re-fetch of the payment from the Toss API
|
|
66
|
+
// (secret-key authenticated), never the unauthenticated request body.
|
|
67
|
+
// Outside local development an unsigned webhook is rejected — no
|
|
68
|
+
// default-trust of the body.
|
|
69
|
+
const rawBody = await request.text();
|
|
70
|
+
|
|
71
|
+
if (config.webhookSecret) {
|
|
72
|
+
verifyTossWebhookSignature(config.webhookSecret, rawBody, request);
|
|
73
|
+
} else if (!config.allowUnsignedWebhooks) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"TossPayments webhook signature required outside development.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = parseTossWebhookBody(rawBody);
|
|
80
|
+
const paymentId = body.data?.orderId ?? body.data?.paymentKey;
|
|
81
|
+
const eventId =
|
|
82
|
+
body.eventId ??
|
|
83
|
+
body.data?.paymentKey ??
|
|
84
|
+
(body.eventType && paymentId
|
|
85
|
+
? `${body.eventType}:${paymentId}`
|
|
86
|
+
: paymentId);
|
|
87
|
+
if (!paymentId || !eventId) {
|
|
88
|
+
throw new Error("Invalid TossPayments webhook");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Server re-fetch: confirm the event corresponds to a real payment before
|
|
92
|
+
// trusting it. A lookup failure throws and the webhook route 400s.
|
|
93
|
+
await fetchTossPaymentByOrderId(config, paymentId);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
type: "payment.updated",
|
|
97
|
+
paymentId,
|
|
98
|
+
eventId,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type PaymentEnv = Record<string, string | undefined>;
|
|
105
|
+
|
|
106
|
+
export function hasTossPaymentsCredentials(env: PaymentEnv): boolean {
|
|
107
|
+
return Boolean(
|
|
108
|
+
env.TOSSPAYMENTS_SECRET_KEY?.trim() &&
|
|
109
|
+
env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY?.trim(),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getTossPaymentsConfig(env: PaymentEnv): {
|
|
114
|
+
secretKey: string;
|
|
115
|
+
clientKey: string;
|
|
116
|
+
customerKey?: string;
|
|
117
|
+
apiBaseUrl: string;
|
|
118
|
+
webhookSecret?: string;
|
|
119
|
+
allowUnsignedWebhooks: boolean;
|
|
120
|
+
} {
|
|
121
|
+
const secretKey = env.TOSSPAYMENTS_SECRET_KEY?.trim();
|
|
122
|
+
const clientKey = env.NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY?.trim();
|
|
123
|
+
const webhookSecret = env.TOSSPAYMENTS_WEBHOOK_SECRET?.trim();
|
|
124
|
+
const allowUnsignedWebhooks =
|
|
125
|
+
env.TOSSPAYMENTS_ALLOW_UNSIGNED_WEBHOOKS === "true" ||
|
|
126
|
+
env.NODE_ENV !== "production";
|
|
127
|
+
if (!secretKey) {
|
|
128
|
+
throw new Error("TossPayments requires TOSSPAYMENTS_SECRET_KEY.");
|
|
129
|
+
}
|
|
130
|
+
if (!clientKey) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"TossPayments requires NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (!webhookSecret && !allowUnsignedWebhooks) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"TossPayments requires TOSSPAYMENTS_WEBHOOK_SECRET in production. Set TOSSPAYMENTS_ALLOW_UNSIGNED_WEBHOOKS=true only for local development.",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
secretKey,
|
|
143
|
+
clientKey,
|
|
144
|
+
customerKey: env.NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY?.trim() || undefined,
|
|
145
|
+
apiBaseUrl: (
|
|
146
|
+
env.TOSSPAYMENTS_API_BASE_URL ?? "https://api.tosspayments.com/v1"
|
|
147
|
+
).replace(/\/$/, ""),
|
|
148
|
+
webhookSecret: webhookSecret || undefined,
|
|
149
|
+
allowUnsignedWebhooks,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function confirmTossPayment(
|
|
154
|
+
config: ReturnType<typeof getTossPaymentsConfig>,
|
|
155
|
+
input: { paymentKey: string; orderId: string; amount: number },
|
|
156
|
+
): Promise<TossPayment> {
|
|
157
|
+
// Official docs:
|
|
158
|
+
// - Browser SDK requestPayment: https://docs.tosspayments.com/en/integration
|
|
159
|
+
// - Confirm API: https://docs.tosspayments.com/en/api-guide
|
|
160
|
+
// - Widget success redirect params: https://docs.tosspayments.com/en/integration-widget
|
|
161
|
+
const response = await fetch(`${config.apiBaseUrl}/payments/confirm`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
Authorization: tossAuthorization(config.secretKey),
|
|
165
|
+
"Content-Type": "application/json",
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(input),
|
|
168
|
+
cache: "no-store",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(`TossPayments confirmation failed: ${response.status}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (await response.json()) as TossPayment;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetchTossPaymentByOrderId(
|
|
179
|
+
config: ReturnType<typeof getTossPaymentsConfig>,
|
|
180
|
+
orderId: string,
|
|
181
|
+
): Promise<TossPayment> {
|
|
182
|
+
// Official docs:
|
|
183
|
+
// - Payment lookup reference: https://docs.tosspayments.com/reference
|
|
184
|
+
// - API guide: https://docs.tosspayments.com/en/api-guide
|
|
185
|
+
const response = await fetch(
|
|
186
|
+
`${config.apiBaseUrl}/payments/orders/${encodeURIComponent(orderId)}`,
|
|
187
|
+
{
|
|
188
|
+
headers: {
|
|
189
|
+
Authorization: tossAuthorization(config.secretKey),
|
|
190
|
+
},
|
|
191
|
+
cache: "no-store",
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new Error(`TossPayments payment lookup failed: ${response.status}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (await response.json()) as TossPayment;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function mapTossPayment(
|
|
203
|
+
payment: TossPayment,
|
|
204
|
+
fallbackPaymentId: string,
|
|
205
|
+
): ProviderPayment {
|
|
206
|
+
const orderNumber = readMetadataOrderNumber(payment.metadata);
|
|
207
|
+
return {
|
|
208
|
+
paymentId: payment.orderId ?? fallbackPaymentId,
|
|
209
|
+
status: mapTossStatus(payment.status),
|
|
210
|
+
amount: payment.totalAmount ?? payment.balanceAmount ?? 0,
|
|
211
|
+
provider: "tosspayments",
|
|
212
|
+
// Omit when absent so the wire shape stays minimal and stable.
|
|
213
|
+
...(orderNumber ? { orderNumber } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function mapTossStatus(status: string | undefined): ProviderPayment["status"] {
|
|
218
|
+
if (status === "DONE") return "paid";
|
|
219
|
+
if (status === "ABORTED" || status === "EXPIRED") return "failed";
|
|
220
|
+
if (status === "CANCELED" || status === "PARTIAL_CANCELED") return "canceled";
|
|
221
|
+
return "pending";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readMetadataOrderNumber(
|
|
225
|
+
metadata: Record<string, unknown> | null | undefined,
|
|
226
|
+
): string | undefined {
|
|
227
|
+
const value = metadata?.orderNumber;
|
|
228
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
type TossWebhookBody = {
|
|
232
|
+
eventId?: string;
|
|
233
|
+
eventType?: string;
|
|
234
|
+
data?: {
|
|
235
|
+
orderId?: string;
|
|
236
|
+
paymentKey?: string;
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function parseTossWebhookBody(rawBody: string): TossWebhookBody {
|
|
241
|
+
return JSON.parse(rawBody) as TossWebhookBody;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* HMAC-SHA256 over `${timestamp}.${rawBody}`, base64-encoded, timing-safe
|
|
246
|
+
* compared against the `Toss-Signature` header. A one-sided timestamp window
|
|
247
|
+
* (`0 <= now - ts <= 300s`) rejects stale and future deliveries. Mirrors the
|
|
248
|
+
* Console billing webhook verification scheme.
|
|
249
|
+
*/
|
|
250
|
+
function verifyTossWebhookSignature(
|
|
251
|
+
webhookSecret: string,
|
|
252
|
+
rawBody: string,
|
|
253
|
+
request: Request,
|
|
254
|
+
): void {
|
|
255
|
+
const signature = request.headers.get("Toss-Signature");
|
|
256
|
+
const timestamp = request.headers.get("Toss-Timestamp");
|
|
257
|
+
if (!signature) {
|
|
258
|
+
throw new Error("Missing TossPayments webhook signature.");
|
|
259
|
+
}
|
|
260
|
+
if (!timestamp || !/^\d+$/.test(timestamp)) {
|
|
261
|
+
throw new Error("Missing or invalid TossPayments webhook timestamp.");
|
|
262
|
+
}
|
|
263
|
+
const ts = Number(timestamp);
|
|
264
|
+
if (!Number.isSafeInteger(ts)) {
|
|
265
|
+
throw new Error("Invalid TossPayments webhook timestamp.");
|
|
266
|
+
}
|
|
267
|
+
const age = Date.now() / 1000 - ts;
|
|
268
|
+
if (age < 0 || age > 300) {
|
|
269
|
+
throw new Error("TossPayments webhook timestamp is out of window.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const expected = createHmac("sha256", webhookSecret)
|
|
273
|
+
.update(`${timestamp}.${rawBody}`)
|
|
274
|
+
.digest("base64");
|
|
275
|
+
const sigBuf = Buffer.from(signature);
|
|
276
|
+
const expBuf = Buffer.from(expected);
|
|
277
|
+
const lengthMatch = sigBuf.length === expBuf.length;
|
|
278
|
+
const match =
|
|
279
|
+
lengthMatch && timingSafeEqual(sigBuf, expBuf);
|
|
280
|
+
if (!match) {
|
|
281
|
+
throw new Error("Invalid TossPayments webhook signature.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function tossAuthorization(secretKey: string): string {
|
|
286
|
+
return `Basic ${Buffer.from(`${secretKey}:`).toString("base64")}`;
|
|
287
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, unconditional amount-equality gate (#1544 / I6).
|
|
3
|
+
*
|
|
4
|
+
* Every order-advancing path — the PG redirect return, the success-page
|
|
5
|
+
* reconciliation, and the webhook — runs this before a payment is confirmed, so
|
|
6
|
+
* a client-tamperable amount can never advance an order at a price other than
|
|
7
|
+
* the server-known one.
|
|
8
|
+
*
|
|
9
|
+
* Layering: the Console `confirmPayment` endpoint is the *authoritative* quote
|
|
10
|
+
* check — it rejects `amount !== checkout.totalAmount` (and `!== order.totalAmount`
|
|
11
|
+
* on retries) server-side, tenant-scoped, inside the trusted backend. This
|
|
12
|
+
* template-side gate is the complementary layer that (a) refuses to forward a
|
|
13
|
+
* client-supplied amount to a PG confirm or to the Console — only PG-re-fetched
|
|
14
|
+
* amounts cross that boundary — and (b) asserts that any two independently known
|
|
15
|
+
* amount sources agree before confirm. Where only one source exists (a
|
|
16
|
+
* webhook-first placement has no client amount and no placed order yet), the
|
|
17
|
+
* gate is still invoked but necessarily delegates the quote check to the Console;
|
|
18
|
+
* the cross-PG parity test documents this explicitly.
|
|
19
|
+
*/
|
|
20
|
+
export class AmountMismatchError extends Error {
|
|
21
|
+
readonly code = "amount_mismatch";
|
|
22
|
+
readonly context: string;
|
|
23
|
+
readonly expected: number;
|
|
24
|
+
readonly actual: number;
|
|
25
|
+
readonly currency?: { expected?: string; actual?: string };
|
|
26
|
+
|
|
27
|
+
constructor(input: {
|
|
28
|
+
context: string;
|
|
29
|
+
expected: number;
|
|
30
|
+
actual: number;
|
|
31
|
+
currency?: { expected?: string; actual?: string };
|
|
32
|
+
}) {
|
|
33
|
+
super(
|
|
34
|
+
`Amount mismatch on ${input.context}: expected ${input.expected}, got ${input.actual}`,
|
|
35
|
+
);
|
|
36
|
+
this.name = "AmountMismatchError";
|
|
37
|
+
this.context = input.context;
|
|
38
|
+
this.expected = input.expected;
|
|
39
|
+
this.actual = input.actual;
|
|
40
|
+
this.currency = input.currency;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Assert two independently sourced payment amounts (and, when both are known,
|
|
46
|
+
* currencies) are equal. Amounts are integer minor units (e.g. KRW), so a strict
|
|
47
|
+
* `!==` comparison is correct. A non-finite amount on either side is treated as
|
|
48
|
+
* a mismatch (fail closed). Throws {@link AmountMismatchError} on any mismatch.
|
|
49
|
+
*/
|
|
50
|
+
export function assertAmountEquals(input: {
|
|
51
|
+
context: string;
|
|
52
|
+
expected: number;
|
|
53
|
+
actual: number;
|
|
54
|
+
expectedCurrency?: string;
|
|
55
|
+
actualCurrency?: string;
|
|
56
|
+
}): void {
|
|
57
|
+
if (!Number.isFinite(input.expected) || !Number.isFinite(input.actual)) {
|
|
58
|
+
throw new AmountMismatchError({
|
|
59
|
+
context: input.context,
|
|
60
|
+
expected: input.expected,
|
|
61
|
+
actual: input.actual,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (input.expected !== input.actual) {
|
|
65
|
+
throw new AmountMismatchError({
|
|
66
|
+
context: input.context,
|
|
67
|
+
expected: input.expected,
|
|
68
|
+
actual: input.actual,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
input.expectedCurrency != null &&
|
|
73
|
+
input.actualCurrency != null &&
|
|
74
|
+
input.expectedCurrency !== input.actualCurrency
|
|
75
|
+
) {
|
|
76
|
+
throw new AmountMismatchError({
|
|
77
|
+
context: input.context,
|
|
78
|
+
expected: input.expected,
|
|
79
|
+
actual: input.actual,
|
|
80
|
+
currency: {
|
|
81
|
+
expected: input.expectedCurrency,
|
|
82
|
+
actual: input.actualCurrency,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
import { createMockPaymentProvider } from "./adapters/mock.ts";
|
|
3
|
+
// scaffold:provider:portone:start
|
|
4
|
+
import {
|
|
5
|
+
createPortOnePaymentProvider,
|
|
6
|
+
hasPortOneCredentials,
|
|
7
|
+
} from "./adapters/portone.ts";
|
|
8
|
+
// scaffold:provider:portone:end
|
|
9
|
+
// scaffold:provider:tosspayments:start
|
|
10
|
+
import {
|
|
11
|
+
createTossPaymentsProvider,
|
|
12
|
+
hasTossPaymentsCredentials,
|
|
13
|
+
} from "./adapters/tosspayments.ts";
|
|
14
|
+
// scaffold:provider:tosspayments:end
|
|
15
|
+
import type { PaymentProvider } from "./provider.ts";
|
|
16
|
+
|
|
17
|
+
type PaymentAdapterRegistration = {
|
|
18
|
+
hasCredentials: (env: NodeJS.ProcessEnv) => boolean;
|
|
19
|
+
create: () => PaymentProvider;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Payment adapter registry. To add a payment provider: implement the
|
|
23
|
+
// PaymentProvider port in `./adapters/<pg>.ts` (exporting `create<Pg>Provider`
|
|
24
|
+
// and `has<Pg>Credentials`), then register it here inside a matching pair of
|
|
25
|
+
// `scaffold:provider:<id>` markers. `create-01-software-app` prunes the
|
|
26
|
+
// markers (and the dependency) for every provider the scaffolded app did not
|
|
27
|
+
// select, so a generated app ships with exactly one PG adapter.
|
|
28
|
+
const PAYMENT_ADAPTERS: PaymentAdapterRegistration[] = [
|
|
29
|
+
// scaffold:provider:portone:start
|
|
30
|
+
{
|
|
31
|
+
hasCredentials: hasPortOneCredentials,
|
|
32
|
+
create: createPortOnePaymentProvider,
|
|
33
|
+
},
|
|
34
|
+
// scaffold:provider:portone:end
|
|
35
|
+
// scaffold:provider:tosspayments:start
|
|
36
|
+
{
|
|
37
|
+
hasCredentials: hasTossPaymentsCredentials,
|
|
38
|
+
create: createTossPaymentsProvider,
|
|
39
|
+
},
|
|
40
|
+
// scaffold:provider:tosspayments:end
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export function getPaymentProvider(): PaymentProvider {
|
|
44
|
+
for (const adapter of PAYMENT_ADAPTERS) {
|
|
45
|
+
if (adapter.hasCredentials(process.env)) {
|
|
46
|
+
return adapter.create();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return createMockPaymentProvider();
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentRequestInput,
|
|
3
|
+
PaymentRequestResult,
|
|
4
|
+
PaymentWebhookEvent,
|
|
5
|
+
ProviderPayment,
|
|
6
|
+
} from './types.ts'
|
|
7
|
+
|
|
8
|
+
export type PaymentProvider = {
|
|
9
|
+
provider: string
|
|
10
|
+
requestPayment(input: PaymentRequestInput): Promise<PaymentRequestResult>
|
|
11
|
+
getPayment(paymentId: string): Promise<ProviderPayment | null>
|
|
12
|
+
confirmPayment(input: {
|
|
13
|
+
paymentId: string
|
|
14
|
+
providerPaymentId?: string
|
|
15
|
+
amount?: number
|
|
16
|
+
}): Promise<ProviderPayment>
|
|
17
|
+
verifyWebhook(request: Request): Promise<PaymentWebhookEvent>
|
|
18
|
+
}
|