@sayren/store-sdk 0.15.1 → 0.17.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 +2264 -134
- package/dist/index.mjs +1408 -147
- package/package.json +1 -1
- package/src/client.ts +188 -1
- package/src/index.ts +5 -0
- package/src/schemas/claim.ts +24 -2
- package/src/schemas/coupon.ts +312 -0
- package/src/schemas/digital-asset.ts +65 -0
- package/src/schemas/fulfillment-code.ts +143 -0
- package/src/schemas/fulfillment.ts +145 -0
- package/src/schemas/misc.ts +7 -1
- package/src/schemas/order.ts +206 -6
- package/src/schemas/product.ts +147 -14
- package/src/schemas/promotion.ts +806 -0
- package/src/schemas/refund-policy.ts +24 -2
- package/src/schemas/scope.ts +4 -0
- package/src/schemas/shipping-policy.ts +28 -3
- package/src/schemas/store.ts +32 -1
- package/src/schemas/upload.ts +48 -2
- package/src/schemas/webhook-event.ts +148 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 파일 다운로드 상품의 판매 파일 (이슈 #48 단계 3).
|
|
5
|
+
*
|
|
6
|
+
* 파일은 CDN **비공개 객체**다. `POST /v1/uploads`(`purpose: "digital-asset"`)로 자리를 받아 응답의 `headers`와 함께 PUT하고,
|
|
7
|
+
* 응답의 `key`로 상품에 연결한다. 객체 키와 파일 주소는 어떤 응답에도 싣지 않는다 — 구매자는 제공된 주문상품에서
|
|
8
|
+
* 수명 300초의 서명 주소로만 받는다. 주문은 주문 시점 파일을 스냅샷하므로 파일을 바꿔도 이미 산 구매자는 옛 파일을 받는다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const digitalAssetAttachRequestSchema = z.object({
|
|
12
|
+
key: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(512)
|
|
16
|
+
.describe("업로드 자리 발급 응답의 `key`. 이 스토어의 비공개 업로드 키여야 한다"),
|
|
17
|
+
fileName: z.string().trim().min(1).max(200).describe("구매자에게 보일 파일 이름(확장자 포함)"),
|
|
18
|
+
sizeBytes: z.number().int().positive().optional().describe("파일 크기(바이트). 안내용이다"),
|
|
19
|
+
contentType: z.string().max(200).optional().describe("파일 형식(MIME). 안내용이다"),
|
|
20
|
+
variantId: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("이 파일을 줄 옵션(variant). 생략하면 모든 옵션의 구매자가 받는다"),
|
|
24
|
+
});
|
|
25
|
+
export type DigitalAssetAttachRequest = z.infer<typeof digitalAssetAttachRequestSchema>;
|
|
26
|
+
|
|
27
|
+
/** 판매 파일 — 객체 키·주소는 싣지 않는다 */
|
|
28
|
+
export const digitalAssetSchema = z.object({
|
|
29
|
+
assetId: z.string(),
|
|
30
|
+
productId: z.string(),
|
|
31
|
+
variantId: z.string().nullable().describe("옵션 전용 파일이면 그 옵션, 모든 옵션 공통이면 null"),
|
|
32
|
+
fileName: z.string(),
|
|
33
|
+
contentType: z.string().nullable(),
|
|
34
|
+
sizeBytes: z.number().int().nullable(),
|
|
35
|
+
status: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe(
|
|
38
|
+
"`ACTIVE`(새 주문에 제공)·`REMOVED`(해제 — 이미 산 구매자는 계속 받는다). 값이 추가될 수 있어 문자열로 받는다",
|
|
39
|
+
),
|
|
40
|
+
createdAt: z.string(),
|
|
41
|
+
});
|
|
42
|
+
export type DigitalAsset = z.infer<typeof digitalAssetSchema>;
|
|
43
|
+
|
|
44
|
+
/** 셀러 확인용 서명 주소 — `POST /v1/products/{productId}/digital-assets/{assetId}/preview-url` */
|
|
45
|
+
export const digitalAssetPreviewSchema = z.object({
|
|
46
|
+
url: z.url().describe("수명 300초의 서명 주소"),
|
|
47
|
+
expiresAt: z.string(),
|
|
48
|
+
});
|
|
49
|
+
export type DigitalAssetPreview = z.infer<typeof digitalAssetPreviewSchema>;
|
|
50
|
+
|
|
51
|
+
/** 주문상품의 다운로드 제공 정보 — 주문 응답의 `fulfillment.download` */
|
|
52
|
+
export const orderItemDownloadSchema = z.object({
|
|
53
|
+
count: z.number().int().describe("지금까지 받은 횟수"),
|
|
54
|
+
limit: z.number().int().describe("받을 수 있는 횟수"),
|
|
55
|
+
expiresAt: z.string().nullable().describe("받을 수 있는 기한 (ISO 8601)"),
|
|
56
|
+
firstDownloadedAt: z.string().nullable().describe("처음 받은 시각 — 제공 개시 증거다"),
|
|
57
|
+
files: z.array(
|
|
58
|
+
z.object({
|
|
59
|
+
assetId: z.string(),
|
|
60
|
+
fileName: z.string(),
|
|
61
|
+
sizeBytes: z.number().int().nullable(),
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
export type OrderItemDownload = z.infer<typeof orderItemDownloadSchema>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { pageQueryShape, pageSchema } from "./common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 코드 발급 상품의 코드 풀 (이슈 #48 단계 2) — 라이선스 키·기프트 코드처럼 결제 확정과 함께 한 개씩 배정하는 코드다.
|
|
6
|
+
*
|
|
7
|
+
* **평문 코드는 두 경로로만 나간다** — 구매자 열람(`POST /storefront/v1/me/order-items/{id}/fulfillment/reveal`)과 셀러 콘솔
|
|
8
|
+
* 열람(`POST /v1/fulfillment-codes/{codeId}/reveal`, 콘솔 전용). 목록·주문·웹훅·API 로그에는 가린 값(`hint`)만 싣는다.
|
|
9
|
+
* 코드는 결제 자격 증명과 같은 키링·AES-256-GCM으로 암호화해 저장한다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 코드 상태.
|
|
14
|
+
* - `AVAILABLE` 판매 가능 — 재고로 센다
|
|
15
|
+
* - `ASSIGNED` 주문상품에 배정됨
|
|
16
|
+
* - `REVOKED` 환불로 회수됨(구매자 화면에서 숨긴다. 코드 자체의 무효화는 셀러 시스템 몫이다)
|
|
17
|
+
* - `EXPIRED` 유효기간이 지나 배정하지 않음
|
|
18
|
+
* - `VOID` 셀러가 폐기함
|
|
19
|
+
*/
|
|
20
|
+
export const fulfillmentCodeStatusSchema = z.enum([
|
|
21
|
+
"AVAILABLE",
|
|
22
|
+
"ASSIGNED",
|
|
23
|
+
"REVOKED",
|
|
24
|
+
"EXPIRED",
|
|
25
|
+
"VOID",
|
|
26
|
+
]);
|
|
27
|
+
export type FulfillmentCodeStatus = z.infer<typeof fulfillmentCodeStatusSchema>;
|
|
28
|
+
|
|
29
|
+
/** 한 번에 올릴 수 있는 코드 수 */
|
|
30
|
+
export const MAX_CODES_PER_UPLOAD = 10_000;
|
|
31
|
+
/** 코드 한 개의 최대 길이 */
|
|
32
|
+
export const MAX_CODE_LENGTH = 200;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 코드 일괄 업로드 요청 — `POST /v1/products/{productId}/codes`. `codes`(배열)와 `csv`(본문) 중 하나를 보낸다.
|
|
36
|
+
* 앞뒤 공백은 지우고 빈 줄은 건너뛴다. 같은 상품에 이미 있는 코드와 요청 안의 중복은 받지 않는다(`duplicates`).
|
|
37
|
+
*/
|
|
38
|
+
export const fulfillmentCodeUploadRequestSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
variantId: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("코드를 채울 옵션(variant) ID. 옵션 없는 상품은 생략한다(기본 옵션)"),
|
|
44
|
+
codes: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.max(MAX_CODES_PER_UPLOAD)
|
|
47
|
+
.optional()
|
|
48
|
+
.describe(`코드 목록. 한 번에 ${MAX_CODES_PER_UPLOAD}개까지. \`csv\`와 둘 중 하나만 보낸다`),
|
|
49
|
+
csv: z
|
|
50
|
+
.string()
|
|
51
|
+
.max(4_000_000)
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
"CSV 본문 — 줄마다 첫 칸이 코드다. 첫 줄의 첫 칸이 `code`면 머리글로 보고 건너뛴다. " +
|
|
55
|
+
`한 번에 ${MAX_CODES_PER_UPLOAD}줄까지`,
|
|
56
|
+
),
|
|
57
|
+
expiresAt: z.iso
|
|
58
|
+
.datetime({ offset: true })
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("이 업로드 코드의 유효기간 (ISO 8601). 지나면 배정하지 않고 만료로 바꾼다"),
|
|
61
|
+
})
|
|
62
|
+
.refine((value) => (value.codes === undefined) !== (value.csv === undefined), {
|
|
63
|
+
message: "codes와 csv 중 하나만 보내야 합니다",
|
|
64
|
+
});
|
|
65
|
+
export type FulfillmentCodeUploadRequest = z.infer<typeof fulfillmentCodeUploadRequestSchema>;
|
|
66
|
+
|
|
67
|
+
/** 받지 않은 줄 */
|
|
68
|
+
export const fulfillmentCodeRejectionSchema = z.object({
|
|
69
|
+
line: z.number().int().describe("요청 안의 위치 (1부터). CSV는 머리글을 포함한 줄 번호다"),
|
|
70
|
+
reason: z
|
|
71
|
+
.string()
|
|
72
|
+
.describe(
|
|
73
|
+
"사유. `DUPLICATE`(이미 있는 코드이거나 요청 안에서 겹침)·`TOO_LONG`(200자 초과)·`INVALID_CHARACTER`(제어 문자). 값이 추가될 수 있어 문자열로 받는다",
|
|
74
|
+
),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** 업로드 결과 — 배치 요약 */
|
|
78
|
+
export const fulfillmentCodeBatchSchema = z.object({
|
|
79
|
+
batchId: z.string(),
|
|
80
|
+
productId: z.string(),
|
|
81
|
+
variantId: z.string(),
|
|
82
|
+
total: z.number().int().describe("요청에서 읽은 코드 수(빈 줄·머리글 제외)"),
|
|
83
|
+
accepted: z.number().int().describe("받은 코드 수 — 재고가 이만큼 는다"),
|
|
84
|
+
duplicates: z.number().int().describe("중복이라 받지 않은 코드 수"),
|
|
85
|
+
rejected: z
|
|
86
|
+
.array(fulfillmentCodeRejectionSchema)
|
|
87
|
+
.describe("받지 않은 줄(중복 포함). 최대 1,000개까지 싣는다"),
|
|
88
|
+
expiresAt: z.string().nullable(),
|
|
89
|
+
createdAt: z.string(),
|
|
90
|
+
});
|
|
91
|
+
export type FulfillmentCodeBatch = z.infer<typeof fulfillmentCodeBatchSchema>;
|
|
92
|
+
|
|
93
|
+
/** 코드 목록의 한 줄 — **가린 값만** 싣는다 */
|
|
94
|
+
export const fulfillmentCodeSchema = z.object({
|
|
95
|
+
codeId: z.string(),
|
|
96
|
+
productId: z.string(),
|
|
97
|
+
variantId: z.string(),
|
|
98
|
+
batchId: z.string().nullable().describe("업로드 배치. 수동 제공으로 셀러가 넘긴 코드는 null"),
|
|
99
|
+
hint: z.string().describe("가린 코드 — 끝 4자만 보인다(예: `****AB12`)"),
|
|
100
|
+
status: z
|
|
101
|
+
.string()
|
|
102
|
+
.describe(
|
|
103
|
+
"코드 상태. `AVAILABLE`·`ASSIGNED`·`REVOKED`·`EXPIRED`·`VOID`. 값이 추가될 수 있어 문자열로 받는다",
|
|
104
|
+
),
|
|
105
|
+
orderItemId: z.string().nullable().describe("배정된 주문상품. 배정 전이면 null"),
|
|
106
|
+
assignedAt: z.string().nullable(),
|
|
107
|
+
revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각 — 제공 개시 증거다"),
|
|
108
|
+
expiresAt: z.string().nullable(),
|
|
109
|
+
createdAt: z.string(),
|
|
110
|
+
});
|
|
111
|
+
export type FulfillmentCode = z.infer<typeof fulfillmentCodeSchema>;
|
|
112
|
+
|
|
113
|
+
export const fulfillmentCodePageSchema = pageSchema(fulfillmentCodeSchema).extend({
|
|
114
|
+
available: z.number().int().describe("판매 가능한 코드 수(필터와 무관한 상품 전체)"),
|
|
115
|
+
});
|
|
116
|
+
export type FulfillmentCodePage = z.infer<typeof fulfillmentCodePageSchema>;
|
|
117
|
+
|
|
118
|
+
/** `GET /v1/products/{productId}/codes` 쿼리 */
|
|
119
|
+
export const fulfillmentCodeListQuerySchema = z.object({
|
|
120
|
+
status: fulfillmentCodeStatusSchema.optional().describe("상태 필터"),
|
|
121
|
+
variantId: z.string().optional().describe("옵션 필터"),
|
|
122
|
+
batchId: z.string().optional().describe("업로드 배치 필터"),
|
|
123
|
+
orderItemId: z.string().optional().describe("주문상품 필터"),
|
|
124
|
+
...pageQueryShape(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** 셀러 콘솔 코드 열람 응답 — `POST /v1/fulfillment-codes/{codeId}/reveal` */
|
|
128
|
+
export const fulfillmentCodeRevealSchema = z.object({
|
|
129
|
+
codeId: z.string(),
|
|
130
|
+
code: z.string().describe("코드 평문. 응답은 캐시하지 않는다(`Cache-Control: no-store`)"),
|
|
131
|
+
});
|
|
132
|
+
export type FulfillmentCodeReveal = z.infer<typeof fulfillmentCodeRevealSchema>;
|
|
133
|
+
|
|
134
|
+
/** 주문상품에 배정된 코드의 가린 값 — 주문 응답의 `fulfillment.codes` */
|
|
135
|
+
export const orderItemCodeSchema = z.object({
|
|
136
|
+
codeId: z.string(),
|
|
137
|
+
hint: z.string().describe("가린 코드 — 끝 4자만 보인다"),
|
|
138
|
+
status: z
|
|
139
|
+
.string()
|
|
140
|
+
.describe("`ASSIGNED`(유효)·`REVOKED`(환불로 회수). 값이 추가될 수 있어 문자열로 받는다"),
|
|
141
|
+
revealedAt: z.string().nullable().describe("구매자가 처음 열람한 시각. 열람 전이면 null"),
|
|
142
|
+
});
|
|
143
|
+
export type OrderItemCode = z.infer<typeof orderItemCodeSchema>;
|
|
@@ -0,0 +1,145 @@
|
|
|
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";
|
package/src/schemas/misc.ts
CHANGED
|
@@ -147,6 +147,10 @@ export const eventTypeSchema = z
|
|
|
147
147
|
"ORDER.PURCHASE_DECIDED",
|
|
148
148
|
// 현재 발송하지 않음. 공개 계약 호환을 위해 값만 남겼다(구독해도 이벤트가 오지 않는다)
|
|
149
149
|
"ORDER.ADDRESS_CHANGE_REQUESTED",
|
|
150
|
+
// 셀러 직권취소 (이슈 #50). 뒤에 붙여 기존 값의 순서를 바꾸지 않는다
|
|
151
|
+
"ORDER.CANCELED",
|
|
152
|
+
// 비실물 상품 제공 (이슈 #48). 구독한 수신자에게만 간다
|
|
153
|
+
"ORDER.FULFILLED",
|
|
150
154
|
"CLAIM.CANCEL_REQUESTED",
|
|
151
155
|
"CLAIM.RETURN_REQUESTED",
|
|
152
156
|
"CLAIM.EXCHANGE_REQUESTED",
|
|
@@ -327,7 +331,9 @@ export const webhookDeliverySchema = z.object({
|
|
|
327
331
|
.number()
|
|
328
332
|
.int()
|
|
329
333
|
.min(0)
|
|
330
|
-
.describe(
|
|
334
|
+
.describe(
|
|
335
|
+
"지금까지 전송을 시작한 횟수. 최초 시도 1회 + 재시도 최대 4회. 전송 도중 장애로 결과를 기록하지 못한 시도도 센다",
|
|
336
|
+
),
|
|
331
337
|
createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
|
|
332
338
|
attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
|
|
333
339
|
nextAttemptAt: z
|
package/src/schemas/order.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
|
|
3
|
+
import { orderItemDownloadSchema } from "./digital-asset";
|
|
4
|
+
import { fulfillmentTypeResponseSchema } from "./fulfillment";
|
|
5
|
+
import { MAX_CODE_LENGTH, orderItemCodeSchema } from "./fulfillment-code";
|
|
3
6
|
import { cashReceiptSchema, easyPayProviderResponseSchema } from "./payment";
|
|
4
7
|
|
|
5
8
|
export const orderSummarySchema = z.object({
|
|
@@ -30,6 +33,57 @@ export const shippingAddressSchema = z.object({
|
|
|
30
33
|
|
|
31
34
|
export type ShippingAddress = z.infer<typeof shippingAddressSchema>;
|
|
32
35
|
|
|
36
|
+
/** 비실물 주문상품의 제공 정보 (이슈 #48) */
|
|
37
|
+
export const orderItemFulfillmentSchema = z.object({
|
|
38
|
+
status: z
|
|
39
|
+
.string()
|
|
40
|
+
.describe(
|
|
41
|
+
"제공 상태. `FULFILLED`(제공함)·`REVOKED`(환불 등으로 회수함). 값이 추가될 수 있어 문자열로 받는다",
|
|
42
|
+
),
|
|
43
|
+
mode: z.string().nullable().describe("제공 방식. `AUTO`·`MANUAL`"),
|
|
44
|
+
fulfilledAt: z.string().describe("제공 시각 (ISO 8601)"),
|
|
45
|
+
note: z.string().nullable().describe("셀러가 남긴 이용 안내. 없으면 null"),
|
|
46
|
+
codes: z
|
|
47
|
+
.array(orderItemCodeSchema)
|
|
48
|
+
.optional()
|
|
49
|
+
.default([])
|
|
50
|
+
.describe(
|
|
51
|
+
"코드 발급 상품에 배정한 코드의 가린 값. 평문은 싣지 않는다. 코드 상품이 아니면 빈 배열. 서버는 항상 싣는다",
|
|
52
|
+
),
|
|
53
|
+
download: orderItemDownloadSchema
|
|
54
|
+
.nullable()
|
|
55
|
+
.optional()
|
|
56
|
+
.default(null)
|
|
57
|
+
.describe(
|
|
58
|
+
"파일 다운로드 상품의 횟수·기한·파일 목록. 파일 주소는 싣지 않는다. 다른 유형은 null. 서버는 항상 싣는다",
|
|
59
|
+
),
|
|
60
|
+
mail: z
|
|
61
|
+
.object({
|
|
62
|
+
status: z
|
|
63
|
+
.string()
|
|
64
|
+
.describe(
|
|
65
|
+
"제공 안내 메일 상태. 현재 값은 `PENDING`(보낼 예정)·`SENDING`·`SENT`·`FAILED`·`SKIPPED`(보내지 않음: 스토어 설정 꺼짐·받을 주소 없음)이고 값이 추가될 수 있다",
|
|
66
|
+
),
|
|
67
|
+
sentAt: z.string().nullable(),
|
|
68
|
+
errorCode: z.string().nullable().describe("실패·건너뜀 사유 코드"),
|
|
69
|
+
})
|
|
70
|
+
.nullable()
|
|
71
|
+
.optional()
|
|
72
|
+
.default(null)
|
|
73
|
+
.describe("구매자에게 보낸 제공 안내 메일(이슈 #48). 기록이 없으면 null. 서버는 항상 싣는다"),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** 제공 안내 메일 재발송 결과 */
|
|
77
|
+
export const fulfillmentMailResendSchema = z.object({
|
|
78
|
+
mailStatus: z
|
|
79
|
+
.string()
|
|
80
|
+
.describe("재발송 뒤 메일 상태 — `SENT`·`PENDING`(곧 다시 보낸다)·`FAILED`·`SKIPPED`"),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export type FulfillmentMailResend = z.infer<typeof fulfillmentMailResendSchema>;
|
|
84
|
+
|
|
85
|
+
export type OrderItemFulfillment = z.infer<typeof orderItemFulfillmentSchema>;
|
|
86
|
+
|
|
33
87
|
export const orderItemSchema = z.object({
|
|
34
88
|
orderItemId: z.string(),
|
|
35
89
|
orderId: z.string(),
|
|
@@ -40,6 +94,13 @@ export const orderItemSchema = z.object({
|
|
|
40
94
|
optionId: z.string().nullable(),
|
|
41
95
|
optionName: z.string().nullable(),
|
|
42
96
|
sellerCode: z.string().nullable(),
|
|
97
|
+
fulfillmentType: fulfillmentTypeResponseSchema
|
|
98
|
+
.optional()
|
|
99
|
+
.default("SHIPPING")
|
|
100
|
+
.describe(
|
|
101
|
+
"주문 시점 상품 유형. 현재 값은 `SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`이고 값이 추가될 수 있어 문자열로 받는다. " +
|
|
102
|
+
"배송 상품이 아니면 발송·배송완료 대신 제공 처리를 하고, `DELIVERED`는 제공 완료(방문 수령은 수령 완료)다. 서버는 항상 싣는다",
|
|
103
|
+
),
|
|
43
104
|
quantity: z.number().int().describe("주문 수량"),
|
|
44
105
|
canceledQuantity: z
|
|
45
106
|
.number()
|
|
@@ -51,13 +112,39 @@ export const orderItemSchema = z.object({
|
|
|
51
112
|
.describe("살아 있는 수량 (`quantity` − `canceledQuantity`). 0이면 주문상품이 전량 취소됐다"),
|
|
52
113
|
unitPrice: z.number().int(),
|
|
53
114
|
discountAmount: z.number().int(),
|
|
54
|
-
totalPrice: z
|
|
115
|
+
totalPrice: z
|
|
116
|
+
.number()
|
|
117
|
+
.int()
|
|
118
|
+
.describe("주문상품 금액 (즉시할인 반영 단가 × 주문 수량). 쿠폰 할인 전 금액이다"),
|
|
119
|
+
couponDiscountAmount: z
|
|
120
|
+
.number()
|
|
121
|
+
.int()
|
|
122
|
+
.optional()
|
|
123
|
+
.default(0)
|
|
124
|
+
.describe(
|
|
125
|
+
"이 주문상품에 배분된 쿠폰 할인 — 상품 쿠폰 + 주문 쿠폰 배분. 실결제 금액은 `totalPrice − couponDiscountAmount`이고 " +
|
|
126
|
+
"취소·반품 환불은 실결제 금액을 수량 비율로 나눈다. 쿠폰이 없으면 0. 서버는 항상 싣는다",
|
|
127
|
+
),
|
|
55
128
|
hold: z.object({ reason: z.string(), expectedDispatchDate: z.string() }).nullable(),
|
|
129
|
+
fulfillment: orderItemFulfillmentSchema
|
|
130
|
+
.nullable()
|
|
131
|
+
.optional()
|
|
132
|
+
.default(null)
|
|
133
|
+
.describe(
|
|
134
|
+
"비실물 상품의 제공 정보(이슈 #48). 제공 전이거나 배송 상품이면 null이다. 코드 평문·파일 주소는 싣지 않는다. 서버는 항상 싣는다",
|
|
135
|
+
),
|
|
56
136
|
autoDecisionDate: z.string().nullable(),
|
|
57
137
|
sellerMemo: z.string().nullable(),
|
|
58
138
|
orderedAt: z.string(),
|
|
59
139
|
lastChangedAt: z.string(),
|
|
60
140
|
lastChangedType: changeTypeSchema,
|
|
141
|
+
withdrawalRestricted: z
|
|
142
|
+
.boolean()
|
|
143
|
+
.optional()
|
|
144
|
+
.default(false)
|
|
145
|
+
.describe(
|
|
146
|
+
"청약철회 제한 주문상품인가(이슈 #48). 구매자가 결제 전에 동의했고 제공이 시작되면(다운로드·열람·이용 시작) 구매자 취소·반품을 받지 않는다. 서버는 항상 싣는다",
|
|
147
|
+
),
|
|
61
148
|
testPayment: z
|
|
62
149
|
.boolean()
|
|
63
150
|
.describe(
|
|
@@ -67,6 +154,21 @@ export const orderItemSchema = z.object({
|
|
|
67
154
|
|
|
68
155
|
export type OrderItem = z.infer<typeof orderItemSchema>;
|
|
69
156
|
|
|
157
|
+
/** 주문이 받은 쿠폰 한 장 (이슈 #47) */
|
|
158
|
+
export const orderDiscountSchema = z.object({
|
|
159
|
+
couponId: z.string().describe("쿠폰 id"),
|
|
160
|
+
name: z.string().describe("주문 시점 쿠폰 이름"),
|
|
161
|
+
kind: z
|
|
162
|
+
.string()
|
|
163
|
+
.describe(
|
|
164
|
+
"쿠폰 종류. 현재 값은 `ORDER`(주문 쿠폰)·`PRODUCT`(상품 쿠폰)·`DELIVERY`(배송비 쿠폰)이고 값이 추가될 수 있어 문자열로 받는다",
|
|
165
|
+
),
|
|
166
|
+
discountAmount: z.number().int().describe("이 쿠폰의 할인액 (원)"),
|
|
167
|
+
issueId: z.string().nullable().describe("발급 쿠폰 id. 코드 입력형은 null"),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export type OrderDiscount = z.infer<typeof orderDiscountSchema>;
|
|
171
|
+
|
|
70
172
|
export const orderSchema = z.object({
|
|
71
173
|
orderId: z.string(),
|
|
72
174
|
orderedAt: z.string(),
|
|
@@ -75,6 +177,13 @@ export const orderSchema = z.object({
|
|
|
75
177
|
phone: z.string(),
|
|
76
178
|
email: z.email().optional(),
|
|
77
179
|
}),
|
|
180
|
+
requiresShipping: z
|
|
181
|
+
.boolean()
|
|
182
|
+
.optional()
|
|
183
|
+
.default(true)
|
|
184
|
+
.describe(
|
|
185
|
+
"택배 배송이 필요한 주문상품이 있는가. false면 `shippingAddress`는 주문자 이름·연락처만 있고 주소는 빈 문자열이다. 서버는 항상 싣는다",
|
|
186
|
+
),
|
|
78
187
|
shippingAddress: shippingAddressSchema,
|
|
79
188
|
payment: z.object({
|
|
80
189
|
method: z
|
|
@@ -102,10 +211,38 @@ export const orderSchema = z.object({
|
|
|
102
211
|
"그 밖의 결제수단과 간편결제 이전 주문은 null. 서버는 항상 싣는다. " +
|
|
103
212
|
"현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
|
|
104
213
|
),
|
|
105
|
-
productAmount: z.number().int(),
|
|
106
|
-
deliveryFee: z.number().int(),
|
|
107
|
-
discountAmount: z
|
|
108
|
-
|
|
214
|
+
productAmount: z.number().int().describe("상품 금액 합 (즉시할인 반영, 쿠폰 전)"),
|
|
215
|
+
deliveryFee: z.number().int().describe("배송비 (쿠폰 전)"),
|
|
216
|
+
discountAmount: z
|
|
217
|
+
.number()
|
|
218
|
+
.int()
|
|
219
|
+
.describe(
|
|
220
|
+
"즉시할인 합 (정가 − 할인 단가) × 수량. 쿠폰 할인은 `couponDiscountAmount`로 따로 온다",
|
|
221
|
+
),
|
|
222
|
+
couponDiscountAmount: z
|
|
223
|
+
.number()
|
|
224
|
+
.int()
|
|
225
|
+
.optional()
|
|
226
|
+
.default(0)
|
|
227
|
+
.describe("상품·주문 쿠폰 할인 합계. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
|
|
228
|
+
deliveryDiscountAmount: z
|
|
229
|
+
.number()
|
|
230
|
+
.int()
|
|
231
|
+
.optional()
|
|
232
|
+
.default(0)
|
|
233
|
+
.describe("배송비 쿠폰 할인. 쿠폰이 없으면 0. 서버는 항상 싣는다"),
|
|
234
|
+
discounts: z
|
|
235
|
+
.array(orderDiscountSchema)
|
|
236
|
+
.optional()
|
|
237
|
+
.default([])
|
|
238
|
+
.describe("주문이 받은 쿠폰. 쿠폰이 없으면 빈 배열. 서버는 항상 싣는다"),
|
|
239
|
+
totalAmount: z
|
|
240
|
+
.number()
|
|
241
|
+
.int()
|
|
242
|
+
.describe(
|
|
243
|
+
"결제 금액 = productAmount + deliveryFee − couponDiscountAmount − deliveryDiscountAmount. " +
|
|
244
|
+
"쿠폰이 없는 주문은 productAmount + deliveryFee다",
|
|
245
|
+
),
|
|
109
246
|
paidAt: z.string().nullable(),
|
|
110
247
|
cashReceipt: cashReceiptSchema
|
|
111
248
|
.nullable()
|
|
@@ -115,6 +252,14 @@ export const orderSchema = z.object({
|
|
|
115
252
|
"현금영수증. 구매자가 계좌이체·가상계좌 결제에서 신청했을 때만 있고 그 밖에는 null. 서버는 항상 싣는다",
|
|
116
253
|
),
|
|
117
254
|
}),
|
|
255
|
+
withdrawalAgreedAt: z
|
|
256
|
+
.string()
|
|
257
|
+
.nullable()
|
|
258
|
+
.optional()
|
|
259
|
+
.default(null)
|
|
260
|
+
.describe(
|
|
261
|
+
"청약철회 제한 안내에 동의한 시각(이슈 #48). 제한 상품이 없던 주문은 null. 서버는 항상 싣는다",
|
|
262
|
+
),
|
|
118
263
|
testPayment: z
|
|
119
264
|
.boolean()
|
|
120
265
|
.describe(
|
|
@@ -159,6 +304,12 @@ export const orderListQuerySchema = z.object({
|
|
|
159
304
|
.optional()
|
|
160
305
|
.describe("주문상품 상태 필터 — 이 상태의 주문상품이 하나라도 있는 주문"),
|
|
161
306
|
keyword: z.string().optional().describe("주문번호(일치)·주문자명·수령인명(포함) 검색"),
|
|
307
|
+
fulfillmentType: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe(
|
|
311
|
+
"상품 유형 필터(이슈 #48) — 이 유형(`SHIPPING`·`DOWNLOAD`·`CODE`·`SERVICE`·`PICKUP`)의 주문상품이 하나라도 있는 주문",
|
|
312
|
+
),
|
|
162
313
|
testPayment: testPaymentQuery,
|
|
163
314
|
...pageQuery,
|
|
164
315
|
size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)"),
|
|
@@ -187,6 +338,8 @@ export interface OrderListParams extends PageParams {
|
|
|
187
338
|
orderedTo?: string;
|
|
188
339
|
status?: z.infer<typeof orderItemStatusSchema>;
|
|
189
340
|
keyword?: string;
|
|
341
|
+
/** 상품 유형 필터 — 이 유형의 주문상품이 하나라도 있는 주문(이슈 #48) */
|
|
342
|
+
fulfillmentType?: z.infer<typeof fulfillmentTypeResponseSchema>;
|
|
190
343
|
/** 테스트 결제 필터 — `"true"` 테스트 결제만, `"false"` 실결제만, 생략하면 전부 */
|
|
191
344
|
testPayment?: "true" | "false";
|
|
192
345
|
}
|
|
@@ -345,13 +498,48 @@ export const cancelBySellerRequestSchema = z.object({
|
|
|
345
498
|
|
|
346
499
|
export type CancelBySellerRequest = z.infer<typeof cancelBySellerRequestSchema>;
|
|
347
500
|
|
|
501
|
+
/**
|
|
502
|
+
* 제공 처리 요청 (이슈 #48) — `POST /v1/order-items/{orderItemId}/fulfill`. 수동 제공 비실물 주문상품(이용권·방문 수령 등)을
|
|
503
|
+
* 제공 완료(`DELIVERED`)로 바꾼다.
|
|
504
|
+
*/
|
|
505
|
+
export const fulfillRequestSchema = z.object({
|
|
506
|
+
note: z
|
|
507
|
+
.string()
|
|
508
|
+
.trim()
|
|
509
|
+
.max(1000)
|
|
510
|
+
.optional()
|
|
511
|
+
.describe("구매자에게 보일 이용 안내(예: 예약 방법, 수령 장소). 주문 상세에 실린다"),
|
|
512
|
+
codes: z
|
|
513
|
+
.array(z.string().trim().min(1).max(MAX_CODE_LENGTH))
|
|
514
|
+
.min(1)
|
|
515
|
+
.max(100)
|
|
516
|
+
.optional()
|
|
517
|
+
.describe(
|
|
518
|
+
"수동 제공 코드 발급 상품의 코드 — 셀러 시스템이 발급한 코드를 넘긴다. 남은 수량만큼 보낸다(`CODE_COUNT_MISMATCH`). " +
|
|
519
|
+
"암호화해 저장하고 구매자가 열람할 때만 평문으로 나간다. 다른 유형은 보내지 않는다",
|
|
520
|
+
),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
export type FulfillRequest = z.infer<typeof fulfillRequestSchema>;
|
|
524
|
+
|
|
525
|
+
/** 제공 처리 일괄 요청 — `POST /v1/order-items/fulfill` */
|
|
526
|
+
export const fulfillBulkRequestSchema = z.object({
|
|
527
|
+
orderItemIds: z.array(z.string()).min(1).max(100),
|
|
528
|
+
note: fulfillRequestSchema.shape.note,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export type FulfillBulkRequest = z.infer<typeof fulfillBulkRequestSchema>;
|
|
532
|
+
|
|
348
533
|
/** `POST /v1/order-items/{orderItemId}/cancel` 응답 — 셀러 직권취소 환불 결과 */
|
|
349
534
|
export const cancelBySellerResultSchema = z.object({
|
|
350
535
|
orderItemId: z.string(),
|
|
351
536
|
refundAmount: z
|
|
352
537
|
.number()
|
|
353
538
|
.int()
|
|
354
|
-
.describe(
|
|
539
|
+
.describe(
|
|
540
|
+
"실제 환불한 금액 — 수량 비율 상품 실결제 금액(쿠폰 배분을 뺀 금액) + 배송비 환불 − 조건부 무료배송 미달 차감 " +
|
|
541
|
+
"+ 되돌린 쿠폰 차감(`couponChargeRefundAmount`)",
|
|
542
|
+
),
|
|
355
543
|
quantity: z.number().int().describe("취소한 수량"),
|
|
356
544
|
canceledQuantity: z.number().int().describe("취소 후 누적 취소 수량"),
|
|
357
545
|
deliveryFeeRefundAmount: z
|
|
@@ -364,6 +552,18 @@ export const cancelBySellerResultSchema = z.object({
|
|
|
364
552
|
.number()
|
|
365
553
|
.int()
|
|
366
554
|
.describe("조건부 무료배송 기준 미달로 환불액에서 뺀 배송비. 정책이 꺼져 있으면 항상 0이다"),
|
|
555
|
+
couponChargeAmount: z
|
|
556
|
+
.number()
|
|
557
|
+
.int()
|
|
558
|
+
.optional()
|
|
559
|
+
.default(0)
|
|
560
|
+
.describe("쿠폰 조건 미달 차감. 판매자 취소는 차감하지 않으므로 늘 0이다. 서버는 항상 싣는다"),
|
|
561
|
+
couponChargeRefundAmount: z
|
|
562
|
+
.number()
|
|
563
|
+
.int()
|
|
564
|
+
.optional()
|
|
565
|
+
.default(0)
|
|
566
|
+
.describe("환불액에 더한, 앞선 환불에서 뺀 쿠폰 차감을 되돌린 몫. 서버는 항상 싣는다"),
|
|
367
567
|
refundedAt: z.string(),
|
|
368
568
|
});
|
|
369
569
|
|