@sayren/store-sdk 0.16.0 → 0.18.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.
@@ -0,0 +1,576 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 상품 유형과 제공 방식 (이슈 #48) — 배송이 필요 없는 상품을 팔기 위한 축이다.
5
+ *
6
+ * **상품 유형은 제공 방식 하나로 표현한다.** `fulfillmentType` 한 필드가 배송 필요 여부, 이행 흐름, 재고 의미를 정한다.
7
+ * 코드 곳곳에서 유형 이름으로 분기하지 않고 이 파일의 판정 함수(`requiresShipping`·`fulfillmentRuleOf`)를 지난다.
8
+ * 유형이 늘어나도 판정표 한 곳만 고치면 배송비 계산·결제 시작·클레임·콘솔 버튼이 같이 따라온다.
9
+ *
10
+ * 주문상품 상태 enum은 바꾸지 않는다. 비실물의 **제공 완료는 `DELIVERED`를 재사용한다** — 새 상태를 더하면 기존 소비자의
11
+ * 상태 분기(배송완료 뒤 구매확정 대기, 반품 가능 판정)가 새 값을 모른다. 표시는 `fulfillmentType`으로 가른다.
12
+ */
13
+
14
+ /**
15
+ * 상품 유형.
16
+ * - `SHIPPING` 배송 상품(기본, 이 필드 이전의 상품 전부)
17
+ * - `DOWNLOAD` 파일 다운로드
18
+ * - `CODE` 코드 발급(라이선스 키·기프트 코드)
19
+ * - `SERVICE` 이용권·상품권·서비스(예약 없음)
20
+ * - `PICKUP` 방문 수령 실물
21
+ */
22
+ export const fulfillmentTypeSchema = z.enum(["SHIPPING", "DOWNLOAD", "CODE", "SERVICE", "PICKUP"]);
23
+
24
+ export type FulfillmentType = z.infer<typeof fulfillmentTypeSchema>;
25
+
26
+ /** 제공 방식 — `AUTO`는 결제 확정과 함께 제공하고, `MANUAL`은 셀러가 제공 처리한다 */
27
+ export const fulfillmentModeSchema = z.enum(["AUTO", "MANUAL"]);
28
+
29
+ export type FulfillmentMode = z.infer<typeof fulfillmentModeSchema>;
30
+
31
+ /**
32
+ * 응답 스키마용 유형 — 서버가 나중에 유형을 더해도 옛 SDK가 응답 파싱에서 깨지지 않게 문자열로 받는다.
33
+ * 요청은 `fulfillmentTypeSchema`(닫힌 enum)로 검증한다.
34
+ */
35
+ export const fulfillmentTypeResponseSchema = z
36
+ .string()
37
+ .describe(
38
+ "상품 유형. 현재 값은 `SHIPPING`(배송 상품)·`DOWNLOAD`(파일 다운로드)·`CODE`(코드 발급)·`SERVICE`(이용권·서비스)·" +
39
+ "`PICKUP`(방문 수령)이고 값이 추가될 수 있어 문자열로 받는다",
40
+ );
41
+
42
+ /** 유형 하나의 규칙 — 배송·제공·재고·클레임이 이 표를 읽는다 */
43
+ export interface FulfillmentRule {
44
+ /** 택배 배송이 필요한가 — 배송비·배송지·발송 흐름의 대상인가 */
45
+ requiresShipping: boolean;
46
+ /** 허용하는 제공 방식. 배송 상품은 제공 방식이 없다(빈 배열) */
47
+ modes: readonly FulfillmentMode[];
48
+ /** 제공 방식을 정하지 않았을 때의 기본값. 배송 상품은 null */
49
+ defaultMode: FulfillmentMode | null;
50
+ /** 반품에 수거가 필요한가 — 실물만 수거한다(방문 수령 포함) */
51
+ requiresCollection: boolean;
52
+ /** 교환을 받는가 — 실물만 받는다 */
53
+ exchangeSupported: boolean;
54
+ }
55
+
56
+ /**
57
+ * 유형별 규칙표 — **유일한 원천이다.**
58
+ *
59
+ * `PICKUP`은 실물이라 반품 수거와 교환을 받지만 택배 배송은 없다. `DOWNLOAD`는 결제 즉시 권한을 발급하므로 `AUTO`만,
60
+ * `PICKUP`은 셀러가 수령 완료를 누르므로 `MANUAL`만 둔다.
61
+ */
62
+ export const FULFILLMENT_RULES: Readonly<Record<FulfillmentType, FulfillmentRule>> = {
63
+ SHIPPING: {
64
+ requiresShipping: true,
65
+ modes: [],
66
+ defaultMode: null,
67
+ requiresCollection: true,
68
+ exchangeSupported: true,
69
+ },
70
+ DOWNLOAD: {
71
+ requiresShipping: false,
72
+ modes: ["AUTO"],
73
+ defaultMode: "AUTO",
74
+ requiresCollection: false,
75
+ exchangeSupported: false,
76
+ },
77
+ CODE: {
78
+ requiresShipping: false,
79
+ modes: ["AUTO", "MANUAL"],
80
+ defaultMode: "AUTO",
81
+ requiresCollection: false,
82
+ exchangeSupported: false,
83
+ },
84
+ SERVICE: {
85
+ requiresShipping: false,
86
+ modes: ["MANUAL", "AUTO"],
87
+ defaultMode: "MANUAL",
88
+ requiresCollection: false,
89
+ exchangeSupported: false,
90
+ },
91
+ PICKUP: {
92
+ requiresShipping: false,
93
+ modes: ["MANUAL"],
94
+ defaultMode: "MANUAL",
95
+ requiresCollection: true,
96
+ exchangeSupported: true,
97
+ },
98
+ };
99
+
100
+ /**
101
+ * 저장된 값 → 유형. null·모르는 값은 `SHIPPING`이다 — 이 필드 이전의 주문상품(`order_items.fulfillment_type` null)과
102
+ * 상품은 모두 배송 상품이었다.
103
+ */
104
+ export function fulfillmentTypeOf(value: string | null | undefined): FulfillmentType {
105
+ const parsed = fulfillmentTypeSchema.safeParse(value);
106
+ return parsed.success ? parsed.data : "SHIPPING";
107
+ }
108
+
109
+ /** 유형의 규칙 — 모르는 값은 배송 상품 규칙이다 */
110
+ export function fulfillmentRuleOf(type: string | null | undefined): FulfillmentRule {
111
+ return FULFILLMENT_RULES[fulfillmentTypeOf(type)];
112
+ }
113
+
114
+ /** 택배 배송이 필요한가 — 배송비 묶음·배송지·발송 흐름의 판정은 이 함수 하나를 지난다 */
115
+ export function requiresShipping(type: string | null | undefined): boolean {
116
+ return fulfillmentRuleOf(type).requiresShipping;
117
+ }
118
+
119
+ /** 반품에 수거가 필요한가 — 비실물 반품은 수거 등록·수거 완료 없이 승인한다 */
120
+ export function requiresCollection(type: string | null | undefined): boolean {
121
+ return fulfillmentRuleOf(type).requiresCollection;
122
+ }
123
+
124
+ /**
125
+ * 유효 제공 방식 — 요청 값이 유형에 허용되면 그 값, 생략이면 유형 기본값이다. 배송 상품은 null이다.
126
+ * 허용되지 않는 값은 `null`을 돌려주지 않고 호출부가 거부하도록 `undefined`를 돌려준다.
127
+ */
128
+ export function resolveFulfillmentMode(
129
+ type: FulfillmentType,
130
+ mode: FulfillmentMode | null | undefined,
131
+ ): FulfillmentMode | null | undefined {
132
+ const rule = FULFILLMENT_RULES[type];
133
+ if (rule.modes.length === 0) return null;
134
+ if (mode === null || mode === undefined) return rule.defaultMode;
135
+ return rule.modes.includes(mode) ? mode : undefined;
136
+ }
137
+
138
+ /**
139
+ * 배송이 없는 주문상품의 배송 묶음 키 (`order_items.delivery_bundle_key`).
140
+ *
141
+ * null로 두면 환불 정산이 #29 이전 주문의 레거시 폴백(「묶음 = 같은 상품」, 상품의 **현재** 배송비)을 탄다.
142
+ * 이 키를 만나면 배송비 환불·차감이 0이고 `orders.delivery_fee_settlements`에 항목을 만들지 않는다.
143
+ * 정책 지문 키(`b:…`)와 겹치지 않는다.
144
+ */
145
+ export const NO_SHIPPING_BUNDLE_KEY = "none";
146
+
147
+ // ─── 상품 이행 (이슈 #57) ─────────────────────────────────────
148
+ //
149
+ // 상품은 공통 필드 + `fulfillment` 판별 유니온이다. 유형 키 안에 그 유형의 속성만 있다(서버는 `product_{type}` 1:1 테이블).
150
+ // - 요청은 닫힌 유니온이다. 모르는 `type`·다른 유형의 키·오타는 400이다(`strictObject`).
151
+ // - 등록은 유형 안 필드에 기본값이 있다. 수정(PUT·PATCH)은 `fulfillment`를 통째로 받고 **유형 안 필드가 모두 필수다** —
152
+ // 통째 교체에 기본값을 두면 부분 수정이 출고지·청약철회 제한을 조용히 되돌린다.
153
+ // - 응답은 열린 유니온이다. 서버가 유형을 더해도(#56 `RESERVATION`) SDK가 깨지지 않게 모르는 유형은
154
+ // `{ type: "OTHER", rawType }`로 읽는다. 알려진 유형의 모양이 틀리면 파싱 오류다.
155
+
156
+ /** 배송비 유형 — 무료·유료·조건부 무료 */
157
+ export const deliveryTypeSchema = z.enum(["FREE", "PAID", "CONDITIONAL_FREE"]);
158
+
159
+ export type DeliveryType = z.infer<typeof deliveryTypeSchema>;
160
+
161
+ /** 파일 다운로드 기본값 — 횟수 5회·기간 30일(#48 결정 8) */
162
+ export const DOWNLOAD_DEFAULTS = { limit: 5, days: 30 } as const;
163
+
164
+ const KNOWN_FULFILLMENT_TYPES: readonly string[] = fulfillmentTypeSchema.options;
165
+
166
+ const ADDRESS_ID_DESCRIPTIONS = {
167
+ shipping:
168
+ "출고지 주소 id(`GET /v1/seller-addresses?type=SHIPPING`). 등록에서 생략하면 그 시점 스토어 기본 출고지를 저장한다. " +
169
+ "`null`이면 비워 둔다(스토어 기본 출고지를 따른다). 이 스토어의 `SHIPPING` 주소가 아니면 거부한다",
170
+ return:
171
+ "반품지 주소 id(`GET /v1/seller-addresses?type=RETURN`). 등록에서 생략하면 그 시점 스토어 기본 반품지를 저장한다. " +
172
+ "`null`이면 비워 둔다(스토어 기본 반품지를 따른다). 이 스토어의 `RETURN` 주소가 아니면 거부한다",
173
+ pickup:
174
+ "방문 수령지 주소 id(`GET /v1/seller-addresses?type=PICKUP`). 등록에서 생략하면 그 시점 스토어 기본 방문 수령지를 저장하고, " +
175
+ "기본 방문 수령지도 없으면 400 `PICKUP_ADDRESS_REQUIRED`다. 이 스토어의 `PICKUP` 주소가 아니면 거부한다",
176
+ } as const;
177
+
178
+ /**
179
+ * 배송비 필드 사이의 일관성 — FREE의 배송비, 조건부 무료가 아닌 상품의 무료 기준을 조용히 0·null로 바꿔 저장하지 않고 400으로 돌려준다
180
+ * (셀러가 저장됐다고 믿는 값이 사라지지 않게). 조건부 무료는 기준 금액이 필수다 — 기준 없는 조건부 무료는 계산상 유료 배송이다.
181
+ */
182
+ function refineShippingConsistency(
183
+ value: { deliveryType: string; deliveryFee?: number; conditionalFreeAmount?: number | null },
184
+ ctx: z.RefinementCtx,
185
+ ) {
186
+ if (value.deliveryType === "FREE" && value.deliveryFee !== undefined && value.deliveryFee !== 0) {
187
+ ctx.addIssue({
188
+ code: "custom",
189
+ path: ["deliveryFee"],
190
+ message: "무료 배송(FREE)의 배송비는 0이거나 생략해야 합니다",
191
+ });
192
+ }
193
+ const threshold = value.conditionalFreeAmount;
194
+ const hasThreshold = threshold !== undefined && threshold !== null;
195
+ if (value.deliveryType === "CONDITIONAL_FREE" && !hasThreshold) {
196
+ ctx.addIssue({
197
+ code: "custom",
198
+ path: ["conditionalFreeAmount"],
199
+ message: "조건부 무료(CONDITIONAL_FREE)는 무료 기준 금액이 필요합니다",
200
+ });
201
+ }
202
+ if (value.deliveryType !== "CONDITIONAL_FREE" && hasThreshold) {
203
+ ctx.addIssue({
204
+ code: "custom",
205
+ path: ["conditionalFreeAmount"],
206
+ message: "무료 기준 금액은 조건부 무료(CONDITIONAL_FREE)에서만 받습니다",
207
+ });
208
+ }
209
+ }
210
+
211
+ const deliveryTypeDescription =
212
+ "배송비 유형. `FREE` 무료, `PAID` 유료, `CONDITIONAL_FREE` 조건부 무료";
213
+
214
+ // ── 등록 요청 ──
215
+
216
+ const shippingCreateSchema = z
217
+ .strictObject({
218
+ deliveryType: deliveryTypeSchema.default("PAID").describe(deliveryTypeDescription),
219
+ deliveryFee: z
220
+ .number()
221
+ .int()
222
+ .min(0)
223
+ .optional()
224
+ .describe(
225
+ "배송비. 원 단위. 생략하면 스토어 기본 배송비(`GET /v1/store`의 `shippingPolicy.baseDeliveryFee`)를 저장한다. FREE면 0이거나 생략한다",
226
+ ),
227
+ conditionalFreeAmount: z
228
+ .number()
229
+ .int()
230
+ .min(0)
231
+ .nullable()
232
+ .optional()
233
+ .describe("무료 배송이 되는 묶음 소계. 원 단위. CONDITIONAL_FREE에서만 받고 필수다"),
234
+ shippingAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
235
+ returnAddressId: z.string().nullable().optional().describe(ADDRESS_ID_DESCRIPTIONS.return),
236
+ })
237
+ .superRefine(refineShippingConsistency);
238
+
239
+ const downloadCreateSchema = z.strictObject({
240
+ limit: z
241
+ .number()
242
+ .int()
243
+ .min(1)
244
+ .max(100)
245
+ .default(DOWNLOAD_DEFAULTS.limit)
246
+ .describe("다운로드 횟수 한도(수량 1개당). 생략하면 5회"),
247
+ days: z
248
+ .number()
249
+ .int()
250
+ .min(1)
251
+ .max(3650)
252
+ .default(DOWNLOAD_DEFAULTS.days)
253
+ .describe("다운로드 기간(제공일부터 일수). 생략하면 30일"),
254
+ stockTracking: z
255
+ .boolean()
256
+ .default(false)
257
+ .describe("재고를 세는가. 생략하면 false(재고와 무관하게 판다)"),
258
+ withdrawalRestricted: z
259
+ .boolean()
260
+ .default(false)
261
+ .describe(
262
+ "청약철회 제한 대상인가. true면 주문서가 제한 고지 동의를 받고, 첫 다운로드 뒤 구매자 단순 변심 취소·반품을 받지 않는다",
263
+ ),
264
+ });
265
+
266
+ const codeCreateSchema = z.strictObject({
267
+ mode: fulfillmentModeSchema
268
+ .default("AUTO")
269
+ .describe(
270
+ "제공 방식. `AUTO`는 코드 풀에서 결제 확정과 함께 배정하고, `MANUAL`은 셀러가 제공 처리 때 코드를 넘긴다",
271
+ ),
272
+ withdrawalRestricted: z
273
+ .boolean()
274
+ .default(false)
275
+ .describe("청약철회 제한 대상인가. true면 첫 열람 뒤 구매자 단순 변심 취소·반품을 받지 않는다"),
276
+ });
277
+
278
+ const serviceCreateSchema = z.strictObject({
279
+ mode: fulfillmentModeSchema
280
+ .default("MANUAL")
281
+ .describe("제공 방식. `AUTO`는 결제 확정과 함께 제공 완료, `MANUAL`은 셀러가 제공 처리한다"),
282
+ });
283
+
284
+ const pickupCreateSchema = z.strictObject({
285
+ addressId: z.string().optional().describe(ADDRESS_ID_DESCRIPTIONS.pickup),
286
+ });
287
+
288
+ /**
289
+ * 상품 이행 — 등록(POST). 유형 키를 생략하면 그 유형의 기본값이다(`{ "type": "DOWNLOAD" }`만으로 등록할 수 있다).
290
+ */
291
+ export const productFulfillmentCreateSchema = z.discriminatedUnion("type", [
292
+ z.strictObject({ type: z.literal("SHIPPING"), shipping: shippingCreateSchema.optional() }),
293
+ z.strictObject({
294
+ type: z.literal("DOWNLOAD"),
295
+ download: downloadCreateSchema.default({
296
+ limit: DOWNLOAD_DEFAULTS.limit,
297
+ days: DOWNLOAD_DEFAULTS.days,
298
+ stockTracking: false,
299
+ withdrawalRestricted: false,
300
+ }),
301
+ }),
302
+ z.strictObject({
303
+ type: z.literal("CODE"),
304
+ code: codeCreateSchema.default({ mode: "AUTO", withdrawalRestricted: false }),
305
+ }),
306
+ z.strictObject({
307
+ type: z.literal("SERVICE"),
308
+ service: serviceCreateSchema.default({ mode: "MANUAL" }),
309
+ }),
310
+ z.strictObject({ type: z.literal("PICKUP"), pickup: pickupCreateSchema.default({}) }),
311
+ ]);
312
+
313
+ export type ProductFulfillmentCreate = z.infer<typeof productFulfillmentCreateSchema>;
314
+ /** 등록 요청의 입력 모양 — 기본값이 채워지기 전(생략 가능한 필드가 optional) */
315
+ export type ProductFulfillmentCreateInput = z.input<typeof productFulfillmentCreateSchema>;
316
+
317
+ // ── 수정 요청 ──
318
+
319
+ const shippingUpdateSchema = z
320
+ .strictObject({
321
+ deliveryType: deliveryTypeSchema.describe(deliveryTypeDescription),
322
+ deliveryFee: z.number().int().min(0).describe("배송비. 원 단위. FREE면 0"),
323
+ conditionalFreeAmount: z
324
+ .number()
325
+ .int()
326
+ .min(0)
327
+ .nullable()
328
+ .describe("무료 배송이 되는 묶음 소계. CONDITIONAL_FREE면 필수, 아니면 null"),
329
+ shippingAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.shipping),
330
+ returnAddressId: z.string().nullable().describe(ADDRESS_ID_DESCRIPTIONS.return),
331
+ })
332
+ .superRefine(refineShippingConsistency);
333
+
334
+ /**
335
+ * 상품 이행 — 수정(PUT·PATCH). 통째 교체이고 유형 키와 그 안의 필드가 **모두 필수**다. 서버는 현재 값과 병합하지 않는다.
336
+ * `fulfillment` 자체를 생략하면 이행을 바꾸지 않는다.
337
+ */
338
+ export const productFulfillmentUpdateSchema = z.discriminatedUnion("type", [
339
+ z.strictObject({ type: z.literal("SHIPPING"), shipping: shippingUpdateSchema }),
340
+ z.strictObject({
341
+ type: z.literal("DOWNLOAD"),
342
+ download: z.strictObject({
343
+ limit: z.number().int().min(1).max(100),
344
+ days: z.number().int().min(1).max(3650),
345
+ stockTracking: z.boolean(),
346
+ withdrawalRestricted: z.boolean(),
347
+ }),
348
+ }),
349
+ z.strictObject({
350
+ type: z.literal("CODE"),
351
+ code: z.strictObject({ mode: fulfillmentModeSchema, withdrawalRestricted: z.boolean() }),
352
+ }),
353
+ z.strictObject({
354
+ type: z.literal("SERVICE"),
355
+ service: z.strictObject({ mode: fulfillmentModeSchema }),
356
+ }),
357
+ z.strictObject({
358
+ type: z.literal("PICKUP"),
359
+ pickup: z.strictObject({ addressId: z.string().describe(ADDRESS_ID_DESCRIPTIONS.pickup) }),
360
+ }),
361
+ ]);
362
+
363
+ export type ProductFulfillmentUpdate = z.infer<typeof productFulfillmentUpdateSchema>;
364
+
365
+ // ── 응답(저장된 값) ──
366
+
367
+ /**
368
+ * 저장된 상품 이행 — 알려진 유형의 닫힌 유니온. 서버의 상품 엔티티와 응답이 이 모양을 쓴다.
369
+ * 응답 파싱은 `productFulfillmentResponseSchema`(열린 유니온)를 쓴다.
370
+ */
371
+ export const productFulfillmentSchema = z.discriminatedUnion("type", [
372
+ z.object({
373
+ type: z.literal("SHIPPING"),
374
+ shipping: z.object({
375
+ deliveryType: deliveryTypeSchema,
376
+ deliveryFee: z.number().int().describe("배송비. 원 단위. FREE면 0"),
377
+ conditionalFreeAmount: z
378
+ .number()
379
+ .int()
380
+ .nullable()
381
+ .describe("조건부 무료 기준 묶음 소계. CONDITIONAL_FREE가 아니면 null"),
382
+ shippingAddressId: z
383
+ .string()
384
+ .nullable()
385
+ .describe("출고지 주소 id. null이면 스토어 기본 출고지를 따른다"),
386
+ returnAddressId: z
387
+ .string()
388
+ .nullable()
389
+ .describe("반품지 주소 id. null이면 스토어 기본 반품지를 따른다"),
390
+ }),
391
+ }),
392
+ z.object({
393
+ type: z.literal("DOWNLOAD"),
394
+ download: z.object({
395
+ limit: z.number().int().describe("다운로드 횟수 한도(수량 1개당)"),
396
+ days: z.number().int().describe("다운로드 기간(제공일부터 일수)"),
397
+ stockTracking: z.boolean().describe("재고를 세는가"),
398
+ withdrawalRestricted: z.boolean().describe("청약철회 제한 대상인가"),
399
+ }),
400
+ }),
401
+ z.object({
402
+ type: z.literal("CODE"),
403
+ code: z.object({
404
+ mode: fulfillmentModeSchema.describe("제공 방식"),
405
+ withdrawalRestricted: z.boolean().describe("청약철회 제한 대상인가"),
406
+ }),
407
+ }),
408
+ z.object({
409
+ type: z.literal("SERVICE"),
410
+ service: z.object({ mode: fulfillmentModeSchema.describe("제공 방식") }),
411
+ }),
412
+ z.object({
413
+ type: z.literal("PICKUP"),
414
+ pickup: z.object({
415
+ addressId: z
416
+ .string()
417
+ .nullable()
418
+ .describe(
419
+ "방문 수령지 주소 id. null은 수령지가 지워진 상품이다(수령지를 다시 지정할 때까지 팔 수 없다)",
420
+ ),
421
+ }),
422
+ }),
423
+ ]);
424
+
425
+ export type ProductFulfillment = z.infer<typeof productFulfillmentSchema>;
426
+
427
+ /** 유형 이름 → 그 유형의 저장된 이행 */
428
+ export type ProductFulfillmentOf<T extends FulfillmentType> = Extract<
429
+ ProductFulfillment,
430
+ { type: T }
431
+ >;
432
+
433
+ /** 응답이 알려 주는 모르는 유형 — SDK가 아직 모르는 유형(서버가 나중에 더한 유형) */
434
+ export interface UnknownFulfillment {
435
+ type: "OTHER";
436
+ /** 서버가 보낸 유형 이름 */
437
+ rawType: string;
438
+ }
439
+
440
+ /**
441
+ * 열린 응답 유니온 — 알려진 유형은 판별 유니온으로 검사하고, **모르는 유형만** `{ type: "OTHER", rawType }`로 읽는다.
442
+ *
443
+ * `type: z.string()` 가지를 그대로 두면 TS가 `type === "SHIPPING"`으로 좁혀도 문자열 가지가 남는다. `OTHER` 리터럴로 바꿔 좁히기를
444
+ * 지킨다. 알려진 유형의 모양이 틀린 응답은 폴백으로 숨기지 않고 파싱 오류로 둔다.
445
+ */
446
+ export function openFulfillmentUnion<T extends z.ZodType>(known: T) {
447
+ return z.union([
448
+ known,
449
+ z
450
+ .looseObject({
451
+ type: z.string().refine((type) => !KNOWN_FULFILLMENT_TYPES.includes(type), {
452
+ message: "알려진 유형은 해당 유형의 모양이어야 합니다",
453
+ }),
454
+ })
455
+ .transform((value): UnknownFulfillment => ({ type: "OTHER", rawType: value.type })),
456
+ ]);
457
+ }
458
+
459
+ export const productFulfillmentResponseSchema = openFulfillmentUnion(
460
+ productFulfillmentSchema,
461
+ ).describe(
462
+ '상품 이행. `type`이 유형이고 같은 이름의 키가 유형별 속성이다. 값이 추가될 수 있어 SDK는 모르는 유형을 `{ type: "OTHER", rawType }`로 읽는다',
463
+ );
464
+
465
+ export type ProductFulfillmentView = z.infer<typeof productFulfillmentResponseSchema>;
466
+
467
+ // ── 판정 헬퍼 ──
468
+
469
+ /** 제공 방식 — CODE·SERVICE는 저장된 값, DOWNLOAD는 AUTO, PICKUP은 MANUAL, 배송 상품은 null */
470
+ export function fulfillmentModeOf(fulfillment: ProductFulfillment): FulfillmentMode | null {
471
+ switch (fulfillment.type) {
472
+ case "CODE":
473
+ return fulfillment.code.mode;
474
+ case "SERVICE":
475
+ return fulfillment.service.mode;
476
+ default:
477
+ return FULFILLMENT_RULES[fulfillment.type].defaultMode;
478
+ }
479
+ }
480
+
481
+ /** 재고를 세는가 — 파일 다운로드만 끌 수 있다 */
482
+ export function stockTrackingOf(fulfillment: ProductFulfillment): boolean {
483
+ return fulfillment.type === "DOWNLOAD" ? fulfillment.download.stockTracking : true;
484
+ }
485
+
486
+ /** 청약철회 제한 대상인가 — 파일 다운로드·코드 발급만 켤 수 있다(전자상거래법 제17조 제2항 제5호, 디지털 콘텐츠) */
487
+ export function withdrawalRestrictedOf(fulfillment: ProductFulfillment): boolean {
488
+ if (fulfillment.type === "DOWNLOAD") return fulfillment.download.withdrawalRestricted;
489
+ if (fulfillment.type === "CODE") return fulfillment.code.withdrawalRestricted;
490
+ return false;
491
+ }
492
+
493
+ /** 코드 풀을 쓰는가 — 코드 발급 + 자동 제공. 재고가 판매 가능 코드 수다 */
494
+ export function usesCodePool(fulfillment: ProductFulfillment): boolean {
495
+ return fulfillment.type === "CODE" && fulfillment.code.mode === "AUTO";
496
+ }
497
+
498
+ /** 결제 확정과 함께 제공까지 끝내는가 — 비실물 + 자동 제공 */
499
+ export function autoFulfillableOf(fulfillment: ProductFulfillment): boolean {
500
+ return (
501
+ !FULFILLMENT_RULES[fulfillment.type].requiresShipping &&
502
+ fulfillmentModeOf(fulfillment) === "AUTO"
503
+ );
504
+ }
505
+
506
+ // ─── 주문상품 이행 스냅샷 (이슈 #57) ─────────────────────────────
507
+
508
+ /**
509
+ * 주문상품의 주문 시점 이행 — 결제 확정이 주문서 줄에서 굳힌다. 상품의 현재 값이 아니다. 서버는 주문상품 컬럼
510
+ * (`fulfillment_type`·`delivery_bundle_key`·`stock_tracked`·`withdrawal_restricted`)과 jsonb(`fulfillment_snapshot`)를 합쳐 이 모양을 만든다.
511
+ *
512
+ * `mode`는 **주문 시점 상품의 방식**이다. 실제 제공 방식은 제공 기록(`fulfillment.mode`)에 있고 다를 수 있다(자동 제공 상품의 수동 복구).
513
+ */
514
+ export const orderItemFulfillmentSnapshotSchema = z.discriminatedUnion("type", [
515
+ z.object({
516
+ type: z.literal("SHIPPING"),
517
+ shipping: z.object({
518
+ bundleKey: z
519
+ .string()
520
+ .nullable()
521
+ .describe(
522
+ "배송 묶음 키. 같은 키의 주문상품이 배송비를 한 번 낸 한 묶음이다. #29 이전 주문은 null",
523
+ ),
524
+ returnAddressId: z
525
+ .string()
526
+ .nullable()
527
+ .describe(
528
+ "주문 시점 반품지 주소 id. null이거나 지워진 주소면 스토어 기본 반품지로 안내한다",
529
+ ),
530
+ }),
531
+ }),
532
+ z.object({
533
+ type: z.literal("DOWNLOAD"),
534
+ download: z.object({
535
+ limit: z.number().int().describe("주문 시점 다운로드 횟수 한도(수량 1개당)"),
536
+ days: z.number().int().describe("주문 시점 다운로드 기간(제공일부터 일수)"),
537
+ stockTracked: z.boolean().describe("주문 시점에 재고를 뺐는가"),
538
+ withdrawalRestricted: z
539
+ .boolean()
540
+ .describe("구매자가 제한 고지에 동의한 청약철회 제한 주문상품인가"),
541
+ }),
542
+ }),
543
+ z.object({
544
+ type: z.literal("CODE"),
545
+ code: z.object({
546
+ mode: z.string().describe("주문 시점 상품의 제공 방식. 현재 값은 `AUTO`·`MANUAL`"),
547
+ withdrawalRestricted: z
548
+ .boolean()
549
+ .describe("구매자가 제한 고지에 동의한 청약철회 제한 주문상품인가"),
550
+ }),
551
+ }),
552
+ z.object({
553
+ type: z.literal("SERVICE"),
554
+ service: z.object({
555
+ mode: z.string().describe("주문 시점 상품의 제공 방식. 현재 값은 `AUTO`·`MANUAL`"),
556
+ }),
557
+ }),
558
+ z.object({
559
+ type: z.literal("PICKUP"),
560
+ pickup: z.object({
561
+ addressId: z.string().nullable().describe("주문 시점 방문 수령지 주소 id"),
562
+ }),
563
+ }),
564
+ ]);
565
+
566
+ export type OrderItemFulfillmentSnapshot = z.infer<typeof orderItemFulfillmentSnapshotSchema>;
567
+
568
+ export const orderItemFulfillmentSnapshotResponseSchema = openFulfillmentUnion(
569
+ orderItemFulfillmentSnapshotSchema,
570
+ ).describe(
571
+ "주문 시점 이행 스냅샷. 상품을 나중에 고쳐도 바뀌지 않는다. 실제 제공 기록은 `fulfillment`에 있다. 값이 추가될 수 있어 SDK는 모르는 유형을 `OTHER`로 읽는다",
572
+ );
573
+
574
+ export type OrderItemFulfillmentSnapshotView = z.infer<
575
+ typeof orderItemFulfillmentSnapshotResponseSchema
576
+ >;
@@ -149,6 +149,8 @@ export const eventTypeSchema = z
149
149
  "ORDER.ADDRESS_CHANGE_REQUESTED",
150
150
  // 셀러 직권취소 (이슈 #50). 뒤에 붙여 기존 값의 순서를 바꾸지 않는다
151
151
  "ORDER.CANCELED",
152
+ // 비실물 상품 제공 (이슈 #48). 구독한 수신자에게만 간다
153
+ "ORDER.FULFILLED",
152
154
  "CLAIM.CANCEL_REQUESTED",
153
155
  "CLAIM.RETURN_REQUESTED",
154
156
  "CLAIM.EXCHANGE_REQUESTED",
@@ -329,7 +331,9 @@ export const webhookDeliverySchema = z.object({
329
331
  .number()
330
332
  .int()
331
333
  .min(0)
332
- .describe("지금까지의 시도 횟수. 최초 시도 1회 + 재시도 최대 4회"),
334
+ .describe(
335
+ "지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다",
336
+ ),
333
337
  createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
334
338
  attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
335
339
  nextAttemptAt: z