@01.software/init 0.9.1 → 0.10.0

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