@sayren/store-sdk 0.17.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -204,12 +204,14 @@ import {
204
204
  type OptionModelRequest,
205
205
  optionModelRequestSchema,
206
206
  optionModelSchema,
207
- type ProductCreateRequest,
207
+ type ProductCreateRequestInput,
208
208
  type ProductListParams,
209
209
  type ProductPatchRequest,
210
+ type ProductReplaceRequestInput,
210
211
  type ProductStatus,
211
212
  productCreateRequestSchema,
212
213
  productPatchRequestSchema,
214
+ productReplaceRequestSchema,
213
215
  productSchema,
214
216
  productStatusSchema,
215
217
  productSummarySchema,
@@ -234,6 +236,30 @@ import {
234
236
  updateMemberRoleRequestSchema,
235
237
  updateStoreRequestSchema,
236
238
  } from "./schemas/store";
239
+ import {
240
+ type ChangeStorefrontSlugRequest,
241
+ type ChangeStorefrontTemplateRequest,
242
+ type CreateStorefrontPreviewLinkRequest,
243
+ type CreateStorefrontSiteRequest,
244
+ changeStorefrontSlugRequestSchema,
245
+ changeStorefrontTemplateRequestSchema,
246
+ createStorefrontPreviewLinkRequestSchema,
247
+ createStorefrontSiteRequestSchema,
248
+ type DisableStorefrontHostingRequest,
249
+ disableStorefrontHostingRequestSchema,
250
+ type PublishStorefrontVersionRequest,
251
+ publishStorefrontVersionRequestSchema,
252
+ type RollbackStorefrontRequest,
253
+ rollbackStorefrontRequestSchema,
254
+ type SetStorefrontVisibilityRequest,
255
+ setStorefrontVisibilityRequestSchema,
256
+ storefrontHostingSchema,
257
+ storefrontPreviewLinkSchema,
258
+ storefrontSiteSchema,
259
+ storefrontSlugAvailabilitySchema,
260
+ storefrontTemplateSchema,
261
+ storefrontVersionSchema,
262
+ } from "./schemas/storefront-hosting";
237
263
  import {
238
264
  type UploadPurpose,
239
265
  type UploadRequest,
@@ -488,14 +514,14 @@ export function createStoreClient(options: StoreClientOptions) {
488
514
  }),
489
515
  get: (productId: string) =>
490
516
  storeHttp.request("GET", path`/products/${productId}`, productSchema),
491
- create: (body: ProductCreateRequest, idempotencyKey?: string) =>
517
+ create: (body: ProductCreateRequestInput, idempotencyKey?: string) =>
492
518
  storeHttp.request("POST", "/products", productSchema, {
493
519
  body: productCreateRequestSchema.parse(body),
494
520
  headers: idempotency(idempotencyKey),
495
521
  }),
496
- update: (productId: string, body: ProductCreateRequest) =>
522
+ update: (productId: string, body: ProductReplaceRequestInput) =>
497
523
  storeHttp.request("PUT", path`/products/${productId}`, productSchema, {
498
- body: productCreateRequestSchema.parse(body),
524
+ body: productReplaceRequestSchema.parse(body),
499
525
  }),
500
526
  patch: (productId: string, body: ProductPatchRequest) =>
501
527
  storeHttp.requestVoid("PATCH", path`/products/${productId}`, {
@@ -1327,6 +1353,78 @@ export function createStoreClient(options: StoreClientOptions) {
1327
1353
  storeHttp.requestVoid("POST", path`/seller-addresses/${addressId}/default`),
1328
1354
  },
1329
1355
 
1356
+ /**
1357
+ * 스토어프론트 호스팅 (이슈 #53) — 조회 `storefront:r`, 쓰기 `storefront:rw`(OWNER·ADMIN).
1358
+ * 쓰기 응답은 바뀐 사이트다. Cloudflare 반영은 커밋 뒤라 `provisioning.status`가 잠시 `PENDING`일 수 있다.
1359
+ * 에이전트 토큰의 발행·되돌리기·템플릿 변경·공개 전환·슬러그 변경·끄기는 승인 대기(`ApprovalPendingError`)다.
1360
+ */
1361
+ storefront: {
1362
+ /** `GET /v1/storefront/templates` — 템플릿 카탈로그 */
1363
+ listTemplates: () =>
1364
+ storeHttp.request("GET", "/storefront/templates", z.array(storefrontTemplateSchema)),
1365
+ /** `GET /v1/storefront/site` — 스위치 상태와 현재 사이트(없으면 `site: null`) */
1366
+ get: () => storeHttp.request("GET", "/storefront/site", storefrontHostingSchema),
1367
+ /** `GET /v1/storefront/slug-availability?slug=` — 슬러그를 쓸 수 있는가 */
1368
+ checkSlug: (slug: string) =>
1369
+ storeHttp.request(
1370
+ "GET",
1371
+ "/storefront/slug-availability",
1372
+ storefrontSlugAvailabilitySchema,
1373
+ {
1374
+ query: { slug },
1375
+ },
1376
+ ),
1377
+ /** `POST /v1/storefront/site` (201) — 템플릿으로 사이트를 만들고 v1을 발행한다. 공개 상태는 오픈 준비 중이다 */
1378
+ create: (body: CreateStorefrontSiteRequest) =>
1379
+ storeHttp.request("POST", "/storefront/site", storefrontSiteSchema, {
1380
+ body: createStorefrontSiteRequestSchema.parse(body),
1381
+ }),
1382
+ /** `POST /v1/storefront/site/template` — 템플릿을 바꾼다. 새 버전을 만들고 기본으로 곧바로 발행한다 */
1383
+ changeTemplate: (body: ChangeStorefrontTemplateRequest) =>
1384
+ storeHttp.request("POST", "/storefront/site/template", storefrontSiteSchema, {
1385
+ body: changeStorefrontTemplateRequestSchema.parse(body),
1386
+ }),
1387
+ /** `GET /v1/storefront/site/versions` — 버전 목록(최신 먼저) */
1388
+ listVersions: () =>
1389
+ storeHttp.request("GET", "/storefront/site/versions", z.array(storefrontVersionSchema)),
1390
+ /** `POST /v1/storefront/site/versions/{versionId}/publish` — 그 버전을 발행한다(이미 발행돼 있으면 그대로 200) */
1391
+ publish: (versionId: string, body: PublishStorefrontVersionRequest = {}) =>
1392
+ storeHttp.request(
1393
+ "POST",
1394
+ path`/storefront/site/versions/${versionId}/publish`,
1395
+ storefrontSiteSchema,
1396
+ { body: publishStorefrontVersionRequestSchema.parse(body) },
1397
+ ),
1398
+ /** `POST /v1/storefront/site/rollback` — 이전 발행 버전으로 되돌린다(재빌드 없음) */
1399
+ rollback: (body: RollbackStorefrontRequest = {}) =>
1400
+ storeHttp.request("POST", "/storefront/site/rollback", storefrontSiteSchema, {
1401
+ body: rollbackStorefrontRequestSchema.parse(body),
1402
+ }),
1403
+ /** `PUT /v1/storefront/site/slug` — 슬러그를 바꾼다. 옛 슬러그는 90일 보류되고 새 주소로 301한다 */
1404
+ changeSlug: (body: ChangeStorefrontSlugRequest) =>
1405
+ storeHttp.request("PUT", "/storefront/site/slug", storefrontSiteSchema, {
1406
+ body: changeStorefrontSlugRequestSchema.parse(body),
1407
+ }),
1408
+ /** `PUT /v1/storefront/site/visibility` — 오픈 준비 중 ↔ 공개 */
1409
+ setVisibility: (body: SetStorefrontVisibilityRequest) =>
1410
+ storeHttp.request("PUT", "/storefront/site/visibility", storefrontSiteSchema, {
1411
+ body: setStorefrontVisibilityRequestSchema.parse(body),
1412
+ }),
1413
+ /**
1414
+ * `POST /v1/storefront/site/preview-link` — 오픈 준비 중인 사이트를 구성원이 볼 수 있는 5분짜리 링크.
1415
+ * 콘솔에서 로그인한 OWNER·ADMIN만 받는다
1416
+ */
1417
+ createPreviewLink: (body: CreateStorefrontPreviewLinkRequest = {}) =>
1418
+ storeHttp.request("POST", "/storefront/site/preview-link", storefrontPreviewLinkSchema, {
1419
+ body: createStorefrontPreviewLinkRequestSchema.parse(body),
1420
+ }),
1421
+ /** `POST /v1/storefront/site/disable` — 호스팅을 끈다(사이트 `CLOSED`, 슬러그 90일 보류). 다시 만들 수 있다 */
1422
+ disable: (body: DisableStorefrontHostingRequest) =>
1423
+ storeHttp.request("POST", "/storefront/site/disable", storefrontSiteSchema, {
1424
+ body: disableStorefrontHostingRequestSchema.parse(body),
1425
+ }),
1426
+ },
1427
+
1330
1428
  store: {
1331
1429
  get: () => storeHttp.request("GET", "/store", storeSchema),
1332
1430
  /**
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export * from "./schemas/refund-policy";
40
40
  export * from "./schemas/scope";
41
41
  export * from "./schemas/shipping-policy";
42
42
  export * from "./schemas/store";
43
+ export * from "./schemas/storefront-hosting";
43
44
  export * from "./schemas/telemetry";
44
45
  export * from "./schemas/upload";
45
46
  export * from "./schemas/webhook-event";
@@ -143,3 +143,434 @@ export function resolveFulfillmentMode(
143
143
  * 정책 지문 키(`b:…`)와 겹치지 않는다.
144
144
  */
145
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
+ >;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
3
3
  import { orderItemDownloadSchema } from "./digital-asset";
4
- import { fulfillmentTypeResponseSchema } from "./fulfillment";
4
+ import { orderItemFulfillmentSnapshotResponseSchema } from "./fulfillment";
5
5
  import { MAX_CODE_LENGTH, orderItemCodeSchema } from "./fulfillment-code";
6
6
  import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
7
7
 
@@ -94,13 +94,10 @@ export const orderItemSchema = z.object({
94
94
  optionId: z.string().nullable(),
95
95
  optionName: z.string().nullable(),
96
96
  sellerCode: z.string().nullable(),
97
- fulfillmentType: fulfillmentTypeResponseSchema
98
- .optional()
99
- .default("SHIPPING")
100
- .describe(
101
- "주문 시점 상품 유형. 현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. " +
102
- "배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, `DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. 서버는 항상 싣는다",
103
- ),
97
+ fulfillmentSnapshot: orderItemFulfillmentSnapshotResponseSchema.describe(
98
+ "주문 시점 이행 스냅샷(이슈 #57). `type`이 주문 시점 상품 유형이다. 배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, " +
99
+ "`DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. 상품을 나중에 고쳐도 바뀌지 않는다. 실제 제공 기록은 `fulfillment`에 있다",
100
+ ),
104
101
  quantity: z.number().int().describe("주문 수량"),
105
102
  canceledQuantity: z
106
103
  .number()
@@ -138,13 +135,6 @@ export const orderItemSchema = z.object({
138
135
  orderedAt: z.string(),
139
136
  lastChangedAt: z.string(),
140
137
  lastChangedType: changeTypeSchema,
141
- withdrawalRestricted: z
142
- .boolean()
143
- .optional()
144
- .default(false)
145
- .describe(
146
- "청약철회 제한 주문상품인가(이슈 #48). 구매자가 결제 전에 동의했고 제공이 시작되면(다운로드·열람·이용 시작) 구매자 취소·반품을 받지 않는다. 서버는 항상 싣는다",
147
- ),
148
138
  testPayment: z
149
139
  .boolean()
150
140
  .describe(
@@ -339,7 +329,7 @@ export interface OrderListParams extends PageParams {
339
329
  status?: z.infer<typeof orderItemStatusSchema>;
340
330
  keyword?: string;
341
331
  /** 상품 유형 필터 — 이 유형의 주문상품이 하나라도 있는 주문(이슈 #48) */
342
- fulfillmentType?: z.infer<typeof fulfillmentTypeResponseSchema>;
332
+ fulfillmentType?: string;
343
333
  /** 테스트 결제 필터 — `"true"` 테스트 결제만, `"false"` 실결제만, 생략하면 전부 */
344
334
  testPayment?: "true" | "false";
345
335
  }