@sayren/store-sdk 0.8.0 → 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/index.d.mts +557 -11
- package/dist/index.mjs +406 -36
- package/package.json +1 -1
- package/src/client.ts +79 -2
- package/src/index.ts +1 -0
- package/src/schemas/claim.ts +38 -4
- package/src/schemas/common.ts +3 -0
- package/src/schemas/customer.ts +72 -0
- package/src/schemas/order.ts +35 -3
- package/src/schemas/product.ts +84 -10
- package/src/schemas/refund-policy.ts +163 -0
- package/src/schemas/store.ts +85 -0
- package/src/schemas/upload.ts +86 -0
- package/src/schemas/webhook-event.ts +40 -0
package/src/client.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { base64Utf8 } from "./base64";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ApiError,
|
|
5
|
+
createHttp,
|
|
6
|
+
path,
|
|
7
|
+
type Query,
|
|
8
|
+
resolveToken,
|
|
9
|
+
type TokenSource,
|
|
10
|
+
unwrapData,
|
|
11
|
+
} from "./http";
|
|
4
12
|
import {
|
|
5
13
|
type AnalyticsPagesParams,
|
|
6
14
|
type AnalyticsProductsParams,
|
|
@@ -62,6 +70,8 @@ import {
|
|
|
62
70
|
} from "./schemas/claim";
|
|
63
71
|
import { bulkResultSchema, type PageParams, pageSchema } from "./schemas/common";
|
|
64
72
|
import {
|
|
73
|
+
type ConsentKind,
|
|
74
|
+
customerConsentSchema,
|
|
65
75
|
customerDetailSchema,
|
|
66
76
|
type customerListQuerySchema,
|
|
67
77
|
customerOrderSchema,
|
|
@@ -173,7 +183,14 @@ import {
|
|
|
173
183
|
type UpdateStoreRequest,
|
|
174
184
|
updateStoreRequestSchema,
|
|
175
185
|
} from "./schemas/store";
|
|
176
|
-
import {
|
|
186
|
+
import {
|
|
187
|
+
type UploadPurpose,
|
|
188
|
+
type UploadRequest,
|
|
189
|
+
type UploadTicket,
|
|
190
|
+
uploadRejection,
|
|
191
|
+
uploadRequestSchema,
|
|
192
|
+
uploadTicketSchema,
|
|
193
|
+
} from "./schemas/upload";
|
|
177
194
|
import type { AuthorizeOptions, StoreSession } from "./session";
|
|
178
195
|
|
|
179
196
|
export interface StoreClientOptions {
|
|
@@ -372,6 +389,44 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
372
389
|
storeHttp.request("POST", "/uploads", uploadTicketSchema, {
|
|
373
390
|
body: uploadRequestSchema.parse(body),
|
|
374
391
|
}),
|
|
392
|
+
/**
|
|
393
|
+
* 자리 발급 + 바이트 PUT을 한 번에 — 끝나면 `url`이 공개 주소다.
|
|
394
|
+
*
|
|
395
|
+
* 용도 제한(확장자·크기)은 서버가 판정하지만, 같은 규칙으로 여기서 먼저 걸러
|
|
396
|
+
* 큰 파일을 CDN까지 보내고 나서 거절되는 왕복을 없앤다.
|
|
397
|
+
*/
|
|
398
|
+
upload: async (
|
|
399
|
+
file: Blob,
|
|
400
|
+
options?: { purpose?: UploadPurpose; filename?: string },
|
|
401
|
+
): Promise<UploadTicket> => {
|
|
402
|
+
const filename =
|
|
403
|
+
options?.filename ??
|
|
404
|
+
(typeof File !== "undefined" && file instanceof File ? file.name : "");
|
|
405
|
+
if (!filename) {
|
|
406
|
+
throw new ApiError(400, "UPLOAD_FILENAME_REQUIRED", "파일명이 필요합니다");
|
|
407
|
+
}
|
|
408
|
+
if (file.size === 0) {
|
|
409
|
+
// 빈 파일은 PUT 자체는 성공하고 0바이트 객체가 공개돼 깨진 이미지로 남는다
|
|
410
|
+
throw new ApiError(400, "EMPTY_FILE", `파일이 비어 있습니다: ${filename}`);
|
|
411
|
+
}
|
|
412
|
+
const purpose = options?.purpose ?? "description";
|
|
413
|
+
const rejection = uploadRejection(purpose, filename, file.size);
|
|
414
|
+
if (rejection) {
|
|
415
|
+
throw new ApiError(rejection.status, rejection.code, rejection.message);
|
|
416
|
+
}
|
|
417
|
+
const ticket = await storeHttp.request("POST", "/uploads", uploadTicketSchema, {
|
|
418
|
+
body: uploadRequestSchema.parse({ filename, purpose, size: file.size }),
|
|
419
|
+
});
|
|
420
|
+
const put = await fetchFn(ticket.uploadUrl, {
|
|
421
|
+
method: "PUT",
|
|
422
|
+
headers: { "content-type": file.type || "application/octet-stream" },
|
|
423
|
+
body: file,
|
|
424
|
+
});
|
|
425
|
+
if (!put.ok) {
|
|
426
|
+
throw new ApiError(put.status, "UPLOAD_PUT_FAILED", `업로드에 실패했습니다: ${filename}`);
|
|
427
|
+
}
|
|
428
|
+
return ticket;
|
|
429
|
+
},
|
|
375
430
|
},
|
|
376
431
|
|
|
377
432
|
products: {
|
|
@@ -532,6 +587,20 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
532
587
|
storeHttp.requestVoid("PUT", path`/order-items/${orderItemId}/dispatch/tracking`, {
|
|
533
588
|
body: { carrierCode, trackingNumber },
|
|
534
589
|
}),
|
|
590
|
+
/**
|
|
591
|
+
* 배송완료 처리 — `DISPATCHED`·`DELIVERING`만 넘어간다. 응답의 `autoDecisionDate`가 자동 구매확정 예정일이다
|
|
592
|
+
* (스토어 설정 `autoConfirmDays`가 0이면 null).
|
|
593
|
+
*/
|
|
594
|
+
markDelivered: (orderItemId: string, idempotencyKey?: string) =>
|
|
595
|
+
storeHttp.request("POST", path`/order-items/${orderItemId}/delivered`, orderItemSchema, {
|
|
596
|
+
headers: idempotency(idempotencyKey),
|
|
597
|
+
}),
|
|
598
|
+
/** 배송완료 일괄 처리 — 항목별 부분 성공이다 */
|
|
599
|
+
markDeliveredBulk: (orderItemIds: string[], idempotencyKey?: string) =>
|
|
600
|
+
storeHttp.request("POST", "/order-items/delivered", bulkResultSchema, {
|
|
601
|
+
body: { orderItemIds },
|
|
602
|
+
headers: idempotency(idempotencyKey),
|
|
603
|
+
}),
|
|
535
604
|
getDelivery: (orderItemId: string) =>
|
|
536
605
|
storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryStatusSchema),
|
|
537
606
|
},
|
|
@@ -641,6 +710,14 @@ export function createStoreClient(options: StoreClientOptions) {
|
|
|
641
710
|
query: params as Query,
|
|
642
711
|
},
|
|
643
712
|
),
|
|
713
|
+
/** 동의 이력 — 최근 순. 가입·재동의·마케팅 변경이 한 줄씩 남는다 */
|
|
714
|
+
consents: (customerId: string, params?: PageParams & { kind?: ConsentKind }) =>
|
|
715
|
+
storeHttp.request(
|
|
716
|
+
"GET",
|
|
717
|
+
path`/customers/${customerId}/consents`,
|
|
718
|
+
pageSchema(customerConsentSchema),
|
|
719
|
+
{ query: params as Query },
|
|
720
|
+
),
|
|
644
721
|
withdraw: (customerId: string, body: WithdrawCustomerRequest) =>
|
|
645
722
|
storeHttp.requestVoid("POST", path`/customers/${customerId}/withdraw`, {
|
|
646
723
|
body: withdrawCustomerRequestSchema.parse(body),
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export * from "./schemas/misc";
|
|
|
24
24
|
export * from "./schemas/order";
|
|
25
25
|
export * from "./schemas/payment";
|
|
26
26
|
export * from "./schemas/product";
|
|
27
|
+
export * from "./schemas/refund-policy";
|
|
27
28
|
export * from "./schemas/scope";
|
|
28
29
|
export * from "./schemas/store";
|
|
29
30
|
export * from "./schemas/upload";
|
package/src/schemas/claim.ts
CHANGED
|
@@ -41,8 +41,24 @@ export const claimSchema = z.object({
|
|
|
41
41
|
reason: claimReasonSchema,
|
|
42
42
|
reasonDetail: z.string().nullable(),
|
|
43
43
|
responsibility: z.enum(["BUYER", "SELLER"]),
|
|
44
|
-
claimDeliveryFee: z.number().int(),
|
|
45
|
-
expectedRefundAmount: z
|
|
44
|
+
claimDeliveryFee: z.number().int().describe("구매자가 부담하는 반품·교환 배송비. 원 단위"),
|
|
45
|
+
expectedRefundAmount: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.nullable()
|
|
49
|
+
.describe(
|
|
50
|
+
"접수 시점에 계산한 예상 환불액 — 수량 비율 상품 금액 − 반품 배송비 − 조건부 무료배송 미달 차감. " +
|
|
51
|
+
"**배송비 환불은 포함되지 않고 `expectedDeliveryFeeRefundAmount`로 따로 온다**" +
|
|
52
|
+
"(승인 시점의 배송 묶음 상태로 확정되기 때문이다). 교환은 환불이 없어 null이다",
|
|
53
|
+
),
|
|
54
|
+
expectedDeliveryFeeRefundAmount: z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.nullable()
|
|
58
|
+
.describe(
|
|
59
|
+
"접수 시점에 계산한 예상 배송비 환불액. 배송 묶음(같은 상품의 주문상품)이 모두 취소되거나 " +
|
|
60
|
+
"판매자 귀책일 때만 0보다 크다. 이 필드가 생기기 전에 접수된 클레임은 null이다",
|
|
61
|
+
),
|
|
46
62
|
exchangeOption: z.object({ optionId: z.string(), optionName: z.string() }).nullable(),
|
|
47
63
|
collect: z
|
|
48
64
|
.object({
|
|
@@ -119,8 +135,26 @@ export type RejectClaimRequest = z.infer<typeof rejectClaimRequestSchema>;
|
|
|
119
135
|
|
|
120
136
|
export const approveClaimResultSchema = z.object({
|
|
121
137
|
claimId: z.string(),
|
|
122
|
-
refundAmount: z
|
|
123
|
-
|
|
138
|
+
refundAmount: z
|
|
139
|
+
.number()
|
|
140
|
+
.int()
|
|
141
|
+
.describe(
|
|
142
|
+
"실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 반품 배송비 − 조건부 무료배송 미달 차감 − " +
|
|
143
|
+
"추가 차감(`deductAmount`)",
|
|
144
|
+
),
|
|
145
|
+
deductAmount: z.number().int().describe("승인 때 셀러가 추가로 차감한 금액"),
|
|
146
|
+
quantity: z.number().int().describe("환불한 수량"),
|
|
147
|
+
deliveryFeeRefundAmount: z
|
|
148
|
+
.number()
|
|
149
|
+
.int()
|
|
150
|
+
.describe("환불에 포함된 배송비. 배송 묶음 전량 취소나 판매자 귀책일 때만 0보다 크다"),
|
|
151
|
+
deliveryFeeChargeAmount: z
|
|
152
|
+
.number()
|
|
153
|
+
.int()
|
|
154
|
+
.describe(
|
|
155
|
+
"환불액에서 뺀 **배송비** — 반품·교환 배송비 + 조건부 무료배송 미달 차감. " +
|
|
156
|
+
"추가 차감(`deductAmount`)은 포함하지 않는다",
|
|
157
|
+
),
|
|
124
158
|
refundedAt: z.string(),
|
|
125
159
|
});
|
|
126
160
|
|
package/src/schemas/common.ts
CHANGED
|
@@ -67,6 +67,9 @@ export const changeTypeSchema = z.enum([
|
|
|
67
67
|
"CLAIM.CANCEL_REQUESTED",
|
|
68
68
|
"CLAIM.RETURN_REQUESTED",
|
|
69
69
|
"CLAIM.EXCHANGE_REQUESTED",
|
|
70
|
+
// 취소·반품 승인으로 주문상품이 취소됐다 — 서버가 이 값을 쓰므로 목록에 있어야 한다
|
|
71
|
+
// (빠져 있던 동안 클레임이 끝난 주문상품이 섞이면 응답 검증이 깨졌다)
|
|
72
|
+
"CLAIM.COMPLETED",
|
|
70
73
|
"ORDER.ADDRESS_CHANGE_REQUESTED",
|
|
71
74
|
]);
|
|
72
75
|
|
package/src/schemas/customer.ts
CHANGED
|
@@ -58,6 +58,75 @@ export const customerAddressSchema = z.object({
|
|
|
58
58
|
address2: z.string().nullable(),
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
// ── 동의 이력 (이슈 #26) ────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 동의 종류 — 이용약관·개인정보 수집이용은 가입 필수, 마케팅 수신은 선택 */
|
|
64
|
+
export const consentKindSchema = z.enum(["TERMS", "PRIVACY", "MARKETING"]);
|
|
65
|
+
export type ConsentKind = z.infer<typeof consentKindSchema>;
|
|
66
|
+
|
|
67
|
+
export const consentActionSchema = z.enum(["AGREED", "WITHDRAWN"]);
|
|
68
|
+
export type ConsentAction = z.infer<typeof consentActionSchema>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 동의가 일어난 경로. 값이 추가될 수 있어 모르는 값은 그대로 보여 준다.
|
|
72
|
+
* `SIGNUP` 비밀번호 가입 · `SOCIAL_SIGNUP` 소셜 가입 · `DELEGATED` 셀러 자체 회원 연동 ·
|
|
73
|
+
* `RECONSENT` 약관 개정 재동의 · `MEMBER_UPDATE` 구매자가 직접 변경 · `SELLER` 셀러가 처리
|
|
74
|
+
*/
|
|
75
|
+
export const consentSourceSchema = z.enum([
|
|
76
|
+
"SIGNUP",
|
|
77
|
+
"SOCIAL_SIGNUP",
|
|
78
|
+
"DELEGATED",
|
|
79
|
+
"RECONSENT",
|
|
80
|
+
"MEMBER_UPDATE",
|
|
81
|
+
"SELLER",
|
|
82
|
+
]);
|
|
83
|
+
export type ConsentSource = z.infer<typeof consentSourceSchema>;
|
|
84
|
+
|
|
85
|
+
/** 동의 이력 한 줄 — 추가만 되고 지워지지 않는다(구매자 탈퇴 시에만 함께 지워진다) */
|
|
86
|
+
export const customerConsentSchema = z.object({
|
|
87
|
+
consentId: z.string(),
|
|
88
|
+
kind: consentKindSchema,
|
|
89
|
+
/** 동의한 약관 버전. 마케팅은 버전이 없어 null */
|
|
90
|
+
version: z.string().nullable(),
|
|
91
|
+
/** 동의 시점의 약관 문서 주소. 스토어가 주소를 정하지 않았거나 마케팅이면 null */
|
|
92
|
+
documentUrl: z.string().nullable(),
|
|
93
|
+
action: consentActionSchema,
|
|
94
|
+
source: consentSourceSchema,
|
|
95
|
+
occurredAt: z.string(),
|
|
96
|
+
});
|
|
97
|
+
export type CustomerConsent = z.infer<typeof customerConsentSchema>;
|
|
98
|
+
|
|
99
|
+
/** 종류별 현재 동의 상태 */
|
|
100
|
+
export const customerConsentStateSchema = z.object({
|
|
101
|
+
kind: consentKindSchema,
|
|
102
|
+
agreed: z.boolean(),
|
|
103
|
+
/** 동의한 약관 버전. 마케팅이거나 이력이 없으면 null */
|
|
104
|
+
agreedVersion: z.string().nullable(),
|
|
105
|
+
/** 스토어의 현재 약관 버전. 마케팅은 null */
|
|
106
|
+
currentVersion: z.string().nullable(),
|
|
107
|
+
/** 스토어가 정한 현재 약관 문서 주소. 정하지 않았거나 마케팅이면 null */
|
|
108
|
+
currentDocumentUrl: z.string().nullable(),
|
|
109
|
+
updatedAt: z.string().nullable(),
|
|
110
|
+
/** 약관이 개정돼 다시 받아야 하는가. 마케팅은 항상 false */
|
|
111
|
+
reconsentRequired: z.boolean(),
|
|
112
|
+
});
|
|
113
|
+
export type CustomerConsentState = z.infer<typeof customerConsentStateSchema>;
|
|
114
|
+
|
|
115
|
+
export const customerConsentsSchema = z.object({
|
|
116
|
+
terms: customerConsentStateSchema,
|
|
117
|
+
privacy: customerConsentStateSchema,
|
|
118
|
+
marketing: customerConsentStateSchema,
|
|
119
|
+
/** 필수 동의 중 하나라도 현재 버전이 아니면 true */
|
|
120
|
+
reconsentRequired: z.boolean(),
|
|
121
|
+
});
|
|
122
|
+
export type CustomerConsents = z.infer<typeof customerConsentsSchema>;
|
|
123
|
+
|
|
124
|
+
/** `GET /v1/customers/{id}/consents` 쿼리 — 최근 순이다 */
|
|
125
|
+
export const customerConsentListQuerySchema = z.object({
|
|
126
|
+
kind: consentKindSchema.optional().describe("동의 종류 필터. 생략하면 전체"),
|
|
127
|
+
...pageQueryShape(),
|
|
128
|
+
});
|
|
129
|
+
|
|
61
130
|
/** 구매자 상세 — 휴대폰 전체를 보여 준다 */
|
|
62
131
|
export const customerDetailSchema = z.object({
|
|
63
132
|
customerId: z.string(),
|
|
@@ -76,6 +145,9 @@ export const customerDetailSchema = z.object({
|
|
|
76
145
|
}),
|
|
77
146
|
)
|
|
78
147
|
.describe("로그인 방법 — 이메일·비밀번호(`password`)와 연결된 소셜 계정"),
|
|
148
|
+
consents: customerConsentsSchema.describe(
|
|
149
|
+
"약관·개인정보·마케팅 동의의 현재 상태. 이력은 `GET /v1/customers/{customerId}/consents`",
|
|
150
|
+
),
|
|
79
151
|
addresses: z.array(customerAddressSchema),
|
|
80
152
|
stats: customerStatsSchema.extend({
|
|
81
153
|
canceledOrReturnedCount: z.number().int().describe("취소된 주문 상품 + 완료된 반품 수"),
|
package/src/schemas/order.ts
CHANGED
|
@@ -40,10 +40,18 @@ export const orderItemSchema = z.object({
|
|
|
40
40
|
optionId: z.string().nullable(),
|
|
41
41
|
optionName: z.string().nullable(),
|
|
42
42
|
sellerCode: z.string().nullable(),
|
|
43
|
-
quantity: z.number().int(),
|
|
43
|
+
quantity: z.number().int().describe("주문 수량"),
|
|
44
|
+
canceledQuantity: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.describe("취소·반품으로 환불된 누적 수량. 수량 단위 부분 취소가 없었으면 0이다"),
|
|
48
|
+
activeQuantity: z
|
|
49
|
+
.number()
|
|
50
|
+
.int()
|
|
51
|
+
.describe("살아 있는 수량 (`quantity` − `canceledQuantity`). 0이면 주문상품이 전량 취소됐다"),
|
|
44
52
|
unitPrice: z.number().int(),
|
|
45
53
|
discountAmount: z.number().int(),
|
|
46
|
-
totalPrice: z.number().int(),
|
|
54
|
+
totalPrice: z.number().int().describe("주문상품 금액 (할인 반영 단가 × 주문 수량)"),
|
|
47
55
|
hold: z.object({ reason: z.string(), expectedDispatchDate: z.string() }).nullable(),
|
|
48
56
|
autoDecisionDate: z.string().nullable(),
|
|
49
57
|
sellerMemo: z.string().nullable(),
|
|
@@ -256,6 +264,15 @@ export type HoldOrderItemRequest = z.infer<typeof holdOrderItemRequestSchema>;
|
|
|
256
264
|
export const cancelBySellerRequestSchema = z.object({
|
|
257
265
|
reason: z.enum(["OUT_OF_STOCK", "PRICE_ERROR", "DELIVERY_IMPOSSIBLE", "ETC"]),
|
|
258
266
|
detail: z.string().max(500).optional(),
|
|
267
|
+
quantity: z
|
|
268
|
+
.number()
|
|
269
|
+
.int()
|
|
270
|
+
.min(1)
|
|
271
|
+
.optional()
|
|
272
|
+
.describe(
|
|
273
|
+
"취소할 수량. 생략하면 남은 수량 전체다. 남은 수량보다 크면 400 `INVALID_QUANTITY`. " +
|
|
274
|
+
"일부만 취소하면 주문상품은 상태를 유지한 채 `canceledQuantity`가 늘고, 전 수량이 소진되면 `CANCELED`가 된다",
|
|
275
|
+
),
|
|
259
276
|
});
|
|
260
277
|
|
|
261
278
|
export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
|
|
@@ -263,7 +280,22 @@ export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
|
|
|
263
280
|
/** `POST /v1/order-items/{orderItemId}/cancel` 응답 — 셀러 직권취소 환불 결과 */
|
|
264
281
|
export const cancelBySellerResultSchema = z.object({
|
|
265
282
|
orderItemId: z.string(),
|
|
266
|
-
refundAmount: z
|
|
283
|
+
refundAmount: z
|
|
284
|
+
.number()
|
|
285
|
+
.int()
|
|
286
|
+
.describe("실제 환불한 금액 — 수량 비율 상품 금액 + 배송비 환불 − 조건부 무료배송 미달 차감"),
|
|
287
|
+
quantity: z.number().int().describe("취소한 수량"),
|
|
288
|
+
canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
|
|
289
|
+
deliveryFeeRefundAmount: z
|
|
290
|
+
.number()
|
|
291
|
+
.int()
|
|
292
|
+
.describe(
|
|
293
|
+
"환불에 포함된 배송비. 배송 묶음(같은 상품)이 모두 취소됐고 환불 정책이 켜져 있을 때만 0보다 크다",
|
|
294
|
+
),
|
|
295
|
+
deliveryFeeChargeAmount: z
|
|
296
|
+
.number()
|
|
297
|
+
.int()
|
|
298
|
+
.describe("조건부 무료배송 기준 미달로 환불액에서 뺀 배송비. 정책이 꺼져 있으면 항상 0이다"),
|
|
267
299
|
refundedAt: z.string(),
|
|
268
300
|
});
|
|
269
301
|
|
package/src/schemas/product.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type PageParams, pageQueryShape } from "./common";
|
|
3
|
+
import { refundPolicyPatchSchema } from "./refund-policy";
|
|
4
|
+
import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
|
|
3
5
|
|
|
4
6
|
export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
|
|
5
7
|
|
|
@@ -89,6 +91,10 @@ export const productSchema = productSummarySchema.extend({
|
|
|
89
91
|
conditionalFreeAmount: z.number().int().nullable().optional(),
|
|
90
92
|
returnFee: z.number().int().optional(),
|
|
91
93
|
exchangeFee: z.number().int().optional(),
|
|
94
|
+
refundPolicy: refundPolicyPatchSchema
|
|
95
|
+
.nullable()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("상품별 환불 정책 덮어쓰기. null이면 스토어 기본 환불 정책을 쓴다"),
|
|
92
98
|
shippingAddressId: z.string().nullable().optional(),
|
|
93
99
|
returnAddressId: z.string().nullable().optional(),
|
|
94
100
|
createdAt: z.string().optional(),
|
|
@@ -96,6 +102,62 @@ export const productSchema = productSummarySchema.extend({
|
|
|
96
102
|
|
|
97
103
|
export type Product = z.infer<typeof productSchema>;
|
|
98
104
|
|
|
105
|
+
/** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
|
|
106
|
+
export const MAX_PRODUCT_IMAGES = 10;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 상품 이미지 주소의 와이어 형식 — URL이고 2048자 이하다.
|
|
110
|
+
*
|
|
111
|
+
* https·확장자 규칙은 여기서 강제하지 않고 `productImageRejection`이 판정한다. 서버가
|
|
112
|
+
* **새로 추가된 주소에만** 그 규칙을 적용해야 하기 때문이다(규칙이 생기기 전에 저장된
|
|
113
|
+
* 주소 하나로 상품 수정 전체가 막히면 셀러가 빠져나갈 길이 없다).
|
|
114
|
+
*
|
|
115
|
+
* 응답 스키마(`productSchema.images`)도 넓게 둔다. 과거에 저장된 주소를 파싱 오류로
|
|
116
|
+
* 만들면 상품 조회 자체가 막힌다.
|
|
117
|
+
*/
|
|
118
|
+
export const productImageUrlSchema = z
|
|
119
|
+
.url()
|
|
120
|
+
.max(2048)
|
|
121
|
+
.describe("상품 이미지 주소. https만 허용하고, 확장자가 있으면 이미지 형식이어야 한다");
|
|
122
|
+
|
|
123
|
+
export interface ProductImageRejection {
|
|
124
|
+
code: "INVALID_IMAGE_URL" | "UNSUPPORTED_FILE_TYPE";
|
|
125
|
+
message: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 상품에 **새로 붙이는** 이미지 주소의 규칙. 통과하면 `null`.
|
|
130
|
+
*
|
|
131
|
+
* - https만 받는다. http는 https 스토어프론트에서 혼합 콘텐츠로 차단되고 `data:`·`javascript:`는
|
|
132
|
+
* 상세 화면에 박히는 주입 경로가 된다.
|
|
133
|
+
* - 호스트는 제한하지 않는다 — 셀러가 이미 쓰는 이미지 호스트를 그대로 붙일 수 있어야 한다.
|
|
134
|
+
* - 경로에 확장자가 있으면 상품 이미지 형식이어야 한다(svg·동영상 거부). **확장자가 없으면 통과시킨다**
|
|
135
|
+
* — 외부 이미지 CDN은 확장자 없는 키를 쓰는 경우가 흔하고, 주소만으로는 실제 형식을 알 수 없다.
|
|
136
|
+
*/
|
|
137
|
+
export function productImageRejection(url: string): ProductImageRejection | null {
|
|
138
|
+
if (url.length > 2048) {
|
|
139
|
+
return { code: "INVALID_IMAGE_URL", message: "이미지 주소가 너무 깁니다" };
|
|
140
|
+
}
|
|
141
|
+
let parsed: URL;
|
|
142
|
+
try {
|
|
143
|
+
parsed = new URL(url);
|
|
144
|
+
} catch {
|
|
145
|
+
return { code: "INVALID_IMAGE_URL", message: "이미지 주소 형식이 아닙니다" };
|
|
146
|
+
}
|
|
147
|
+
if (parsed.protocol !== "https:") {
|
|
148
|
+
return { code: "INVALID_IMAGE_URL", message: "이미지 주소는 https로 시작해야 합니다" };
|
|
149
|
+
}
|
|
150
|
+
const extension = uploadExtensionOf(parsed.pathname);
|
|
151
|
+
const allowed = UPLOAD_PURPOSE_LIMITS["product-image"].extensions;
|
|
152
|
+
if (extension && !allowed.includes(extension)) {
|
|
153
|
+
return {
|
|
154
|
+
code: "UNSUPPORTED_FILE_TYPE",
|
|
155
|
+
message: `상품 이미지는 ${allowed.join(", ")}만 쓸 수 있습니다`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
99
161
|
/** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
|
|
100
162
|
export const MAX_OPTION_GROUPS = 3;
|
|
101
163
|
/** 축 하나가 가질 수 있는 값의 최대 개수 */
|
|
@@ -212,7 +274,12 @@ export const productCreateRequestSchema = z
|
|
|
212
274
|
* 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
|
|
213
275
|
*/
|
|
214
276
|
descriptionJson: z.string().nullable().optional(),
|
|
215
|
-
images: z
|
|
277
|
+
images: z
|
|
278
|
+
.array(productImageUrlSchema)
|
|
279
|
+
.min(1)
|
|
280
|
+
.max(MAX_PRODUCT_IMAGES)
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
|
|
216
283
|
attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
|
|
217
284
|
deliveryType: deliveryTypeSchema
|
|
218
285
|
.default("PAID")
|
|
@@ -223,8 +290,22 @@ export const productCreateRequestSchema = z
|
|
|
223
290
|
.int()
|
|
224
291
|
.optional()
|
|
225
292
|
.describe("CONDITIONAL_FREE일 때 무료 배송이 되는 주문 금액. 원 단위"),
|
|
226
|
-
returnFee: z
|
|
227
|
-
|
|
293
|
+
returnFee: z
|
|
294
|
+
.number()
|
|
295
|
+
.int()
|
|
296
|
+
.default(0)
|
|
297
|
+
.describe("반품 배송비. 원 단위. `refundPolicy.returnDeliveryFee`와 같은 값이다"),
|
|
298
|
+
exchangeFee: z
|
|
299
|
+
.number()
|
|
300
|
+
.int()
|
|
301
|
+
.default(0)
|
|
302
|
+
.describe("교환 배송비. 원 단위. `refundPolicy.exchangeDeliveryFee`와 같은 값이다"),
|
|
303
|
+
refundPolicy: refundPolicyPatchSchema
|
|
304
|
+
.nullish()
|
|
305
|
+
.describe(
|
|
306
|
+
"상품별 환불 정책 — 준 항목만 스토어 기본을 덮어쓴다. `null`이나 생략은 스토어 기본을 그대로 쓴다는 뜻이다. " +
|
|
307
|
+
"`returnDeliveryFee`·`exchangeDeliveryFee`를 주면 `returnFee`·`exchangeFee`도 같은 값으로 맞춘다",
|
|
308
|
+
),
|
|
228
309
|
shippingAddressId: z.string().optional(),
|
|
229
310
|
returnAddressId: z.string().optional(),
|
|
230
311
|
})
|
|
@@ -276,13 +357,6 @@ export const productListQuerySchema = z.object({
|
|
|
276
357
|
...pageQueryShape(),
|
|
277
358
|
});
|
|
278
359
|
|
|
279
|
-
/** `POST /v1/product-images` 응답 — 업로드된 이미지의 CDN 주소 */
|
|
280
|
-
export const productImageUploadResultSchema = z.object({
|
|
281
|
-
images: z.array(z.object({ url: z.url(), width: z.number().int(), height: z.number().int() })),
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
export type ProductImageUploadResult = z.infer<typeof productImageUploadResultSchema>;
|
|
285
|
-
|
|
286
360
|
export const discountSchema = z.object({
|
|
287
361
|
productId: z.string(),
|
|
288
362
|
discountType: z.enum(["RATE", "AMOUNT"]),
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 환불 정책 (이슈 #25) — 취소·반품·교환에서 **배송비를 누가 부담하는지**를 정한다.
|
|
5
|
+
*
|
|
6
|
+
* 두 층이다. 스토어 설정(`PATCH /v1/store`의 `refundPolicy`)이 기본값이고, 상품별 정책
|
|
7
|
+
* (`POST/PUT /v1/products`의 `refundPolicy`)이 그 상품에만 덮어쓴다. 상품이 정하지 않은 항목은
|
|
8
|
+
* 스토어 기본을 따르고, 스토어도 정하지 않았으면 `REFUND_POLICY_DEFAULTS`다.
|
|
9
|
+
*
|
|
10
|
+
* 한 주문에 정책이 섞이면 **상품별로 계산한다**. 배송비 묶음도 상품 단위다(주문서·장바구니의
|
|
11
|
+
* 배송비 합산과 같은 규칙: 같은 상품의 주문상품이 한 묶음이고 묶음마다 배송비가 붙는다).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const descriptions = {
|
|
15
|
+
preShipmentDeliveryFeeRefund:
|
|
16
|
+
"발송 전 취소로 배송 묶음(같은 상품의 주문상품)이 모두 취소되면 그 묶음의 배송비를 환불한다. " +
|
|
17
|
+
"false면 배송비를 환불하지 않는다. 기본 true",
|
|
18
|
+
returnDeliveryFee:
|
|
19
|
+
"단순 변심(구매자 귀책) 반품의 반품 배송비. 원 단위. 환불액에서 뺀다. 기본 0(무료). " +
|
|
20
|
+
"상품별 정책에서 이 값을 주면 상품의 `returnFee`도 같은 값으로 맞춘다",
|
|
21
|
+
exchangeDeliveryFee:
|
|
22
|
+
"단순 변심(구매자 귀책) 교환의 교환 배송비. 원 단위. 구매자에게 청구할 금액을 안내값으로 싣는다. 기본 0(무료). " +
|
|
23
|
+
"상품별 정책에서 이 값을 주면 상품의 `exchangeFee`도 같은 값으로 맞춘다",
|
|
24
|
+
sellerFaultFreeShipping:
|
|
25
|
+
"판매자 귀책(셀러 직권취소, 불량·오배송 반품)이면 배송비를 셀러가 부담한다 — 배송비를 환불하고 반품비를 받지 않는다. " +
|
|
26
|
+
"false면 구매자 귀책과 같은 규칙을 쓴다. 기본 true",
|
|
27
|
+
conditionalFreeShortfallCharge:
|
|
28
|
+
"조건부 무료배송(`CONDITIONAL_FREE`) 상품에서 일부 취소로 남은 금액이 무료 기준액에 미달하면 그 배송비를 환불액에서 뺀다. " +
|
|
29
|
+
"false면 추가 청구 없이 셀러가 부담한다. 기본 false",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** 유효 환불 정책 — 스토어 기본 + 상품별 덮어쓰기를 합친 결과 */
|
|
33
|
+
export const refundPolicySchema = z.object({
|
|
34
|
+
preShipmentDeliveryFeeRefund: z.boolean().describe(descriptions.preShipmentDeliveryFeeRefund),
|
|
35
|
+
returnDeliveryFee: z.number().int().min(0).describe(descriptions.returnDeliveryFee),
|
|
36
|
+
exchangeDeliveryFee: z.number().int().min(0).describe(descriptions.exchangeDeliveryFee),
|
|
37
|
+
sellerFaultFreeShipping: z.boolean().describe(descriptions.sellerFaultFreeShipping),
|
|
38
|
+
conditionalFreeShortfallCharge: z.boolean().describe(descriptions.conditionalFreeShortfallCharge),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type RefundPolicy = z.infer<typeof refundPolicySchema>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 스토어가 정하지 않았을 때 서버가 쓰는 값.
|
|
45
|
+
*
|
|
46
|
+
* `as const`가 아니라 `RefundPolicy`로 타입을 좁힌다 — 리터럴 타입이면 `Partial<typeof …>`로 일부만 바꿀 때
|
|
47
|
+
* `true` 자리에 `false`를 넣을 수 없다.
|
|
48
|
+
*/
|
|
49
|
+
export const REFUND_POLICY_DEFAULTS: RefundPolicy = {
|
|
50
|
+
preShipmentDeliveryFeeRefund: true,
|
|
51
|
+
returnDeliveryFee: 0,
|
|
52
|
+
exchangeDeliveryFee: 0,
|
|
53
|
+
sellerFaultFreeShipping: true,
|
|
54
|
+
conditionalFreeShortfallCharge: false,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 부분 정책 — 스토어 설정 요청과 상품별 덮어쓰기가 쓴다. 준 항목만 바뀐다.
|
|
59
|
+
*
|
|
60
|
+
* `strictObject`라 모르는 키는 400이다. 오타(`returnFee` 등)를 조용히 무시하면 셀러는 저장됐다고
|
|
61
|
+
* 믿는데 정책은 기본값으로 남는다.
|
|
62
|
+
*/
|
|
63
|
+
export const refundPolicyPatchSchema = z.strictObject({
|
|
64
|
+
preShipmentDeliveryFeeRefund: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(descriptions.preShipmentDeliveryFeeRefund),
|
|
68
|
+
returnDeliveryFee: z.number().int().min(0).optional().describe(descriptions.returnDeliveryFee),
|
|
69
|
+
exchangeDeliveryFee: z
|
|
70
|
+
.number()
|
|
71
|
+
.int()
|
|
72
|
+
.min(0)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe(descriptions.exchangeDeliveryFee),
|
|
75
|
+
sellerFaultFreeShipping: z.boolean().optional().describe(descriptions.sellerFaultFreeShipping),
|
|
76
|
+
conditionalFreeShortfallCharge: z
|
|
77
|
+
.boolean()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe(descriptions.conditionalFreeShortfallCharge),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export type RefundPolicyPatch = z.infer<typeof refundPolicyPatchSchema>;
|
|
83
|
+
|
|
84
|
+
/** 정책 해석에 쓰는 상품 값 — 상품별 덮어쓰기와 레거시 배송비 필드 */
|
|
85
|
+
export interface RefundPolicyProductInput {
|
|
86
|
+
/** 상품별 덮어쓰기. 없으면 스토어 기본을 그대로 쓴다 */
|
|
87
|
+
refundPolicy?: RefundPolicyPatch | null;
|
|
88
|
+
/** @deprecated 레거시 상품 반품비 — `refundPolicy`가 없는 옛 상품의 값이다 */
|
|
89
|
+
returnFee?: number | null;
|
|
90
|
+
/** @deprecated 레거시 상품 교환비 — `refundPolicy`가 없는 옛 상품의 값이다 */
|
|
91
|
+
exchangeFee?: number | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 유효 정책 해석 — 상품별 덮어쓰기 → 스토어 기본 → `REFUND_POLICY_DEFAULTS` 순이다.
|
|
96
|
+
*
|
|
97
|
+
* 반품비·교환비는 레거시 필드(`products.returnFee`·`exchangeFee`)와 **연결**한다. 상품이
|
|
98
|
+
* `refundPolicy.returnDeliveryFee`를 정하면 그 값이 원천이고(서버가 `returnFee`도 같은 값으로 맞춘다),
|
|
99
|
+
* 정하지 않았으면 레거시 값이 0보다 클 때 그 값을 쓴다. 0이면 스토어 기본을 따른다 —
|
|
100
|
+
* 정책이 생기기 전에 만든 상품(반품비 0)이 스토어 기본을 물려받게 하는 규칙이다.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveRefundPolicy(
|
|
103
|
+
storePolicy?: RefundPolicyPatch | null,
|
|
104
|
+
product?: RefundPolicyProductInput | null,
|
|
105
|
+
): RefundPolicy {
|
|
106
|
+
const store = { ...REFUND_POLICY_DEFAULTS, ...dropUndefined(storePolicy) };
|
|
107
|
+
return { ...store, ...(productRefundPolicyOverride(product) ?? {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 상품의 **덮어쓰기 자체** — 레거시 반품비·교환비를 같은 형태로 승격한 값이다. 덮어쓰기가 없으면 null.
|
|
112
|
+
*
|
|
113
|
+
* `resolveRefundPolicy`가 합친 결과로는 "상품이 정한 값"과 "스토어 기본을 물려받은 값"을 가를 수 없다.
|
|
114
|
+
* 관리 API 응답의 `refundPolicy`와 콘솔 폼의 덮어쓰기 스위치가 이 함수를 원천으로 쓴다 — 가르지 못하면
|
|
115
|
+
* 스토어 기본을 쓰는 상품을 저장할 때 그 순간의 기본값이 상품에 박히거나, 레거시 값이 조용히 사라진다.
|
|
116
|
+
*/
|
|
117
|
+
export function productRefundPolicyOverride(
|
|
118
|
+
product?: RefundPolicyProductInput | null,
|
|
119
|
+
): RefundPolicyPatch | null {
|
|
120
|
+
const override = dropUndefined(product?.refundPolicy);
|
|
121
|
+
const legacy: RefundPolicyPatch = {};
|
|
122
|
+
if (override.returnDeliveryFee === undefined && (product?.returnFee ?? 0) > 0) {
|
|
123
|
+
legacy.returnDeliveryFee = product?.returnFee ?? 0;
|
|
124
|
+
}
|
|
125
|
+
if (override.exchangeDeliveryFee === undefined && (product?.exchangeFee ?? 0) > 0) {
|
|
126
|
+
legacy.exchangeDeliveryFee = product?.exchangeFee ?? 0;
|
|
127
|
+
}
|
|
128
|
+
const merged = { ...legacy, ...override };
|
|
129
|
+
if (product?.refundPolicy == null && Object.keys(merged).length === 0) return null;
|
|
130
|
+
return merged;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `undefined` 값을 빼고 얕게 복사한다 — 전개 순서로 덮어쓸 때 `undefined`가 기본값을 지우지 않게 */
|
|
134
|
+
function dropUndefined(patch?: RefundPolicyPatch | null): RefundPolicyPatch {
|
|
135
|
+
if (!patch) return {};
|
|
136
|
+
return Object.fromEntries(
|
|
137
|
+
Object.entries(patch).filter(([, value]) => value !== undefined),
|
|
138
|
+
) as RefundPolicyPatch;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 수량 단위 부분 취소의 환불 기준액 — **누적 내림의 차분**이다.
|
|
143
|
+
*
|
|
144
|
+
* 원 단위 반올림 규칙: `floor(주문상품 금액 × 누적 수량 ÷ 전체 수량)`을 두 번 구해 그 차를 쓴다.
|
|
145
|
+
* 부분 취소를 몇 번 나눠도 합이 주문상품 금액과 정확히 같아서 1원이 남거나 넘치지 않는다.
|
|
146
|
+
* 할인은 이미 단가에 들어 있어(주문 단위 쿠폰이 없다) 별도 안분이 없다.
|
|
147
|
+
*
|
|
148
|
+
* @param totalPrice 주문상품 금액 (할인 반영 단가 × 전체 수량)
|
|
149
|
+
* @param quantity 주문상품 전체 수량
|
|
150
|
+
* @param settledQuantity 이미 취소·반품된 누적 수량
|
|
151
|
+
* @param cancelQuantity 이번에 취소·반품할 수량
|
|
152
|
+
*/
|
|
153
|
+
export function proratedRefundBase(
|
|
154
|
+
totalPrice: number,
|
|
155
|
+
quantity: number,
|
|
156
|
+
settledQuantity: number,
|
|
157
|
+
cancelQuantity: number,
|
|
158
|
+
): number {
|
|
159
|
+
if (quantity <= 0 || cancelQuantity <= 0) return 0;
|
|
160
|
+
const before = Math.floor((totalPrice * settledQuantity) / quantity);
|
|
161
|
+
const after = Math.floor((totalPrice * (settledQuantity + cancelQuantity)) / quantity);
|
|
162
|
+
return Math.max(0, after - before);
|
|
163
|
+
}
|