diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1393e44 --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +# Bootpay server SDK example/test credentials +BOOTPAY_ENV=production + +# PG 인증 방식: new (client_key/secret_key, default) 또는 legacy (application_id/private_key) +BOOTPAY_AUTH_MODE=new + +# PG API - recommended client_key/secret_key +BOOTPAY_PG_CLIENT_KEY_PROD=OdKci2s0ux9iyFWsgYHdKw +BOOTPAY_PG_SECRET_KEY_PROD=L15AxIjXxGwFj9xe7NUUOt9VPHqP-CvyXJuK-FqMHto= +BOOTPAY_PG_CLIENT_KEY_DEV=K1Xok7RzFxbT7zMBmiBXNw +BOOTPAY_PG_SECRET_KEY_DEV=vcd_5OXoQAxTA8JSg2VGaSnwmQPkd8DgQ6xiyL6QkyE= + +# PG 테스트 fixture — 우선순위(순차) 결제 빌링키 조회용 위젯키 +BOOTPAY_TEST_PG_WIDGET_KEY= + +# PG API - legacy application_id/private_key (호환성 검증용) +BOOTPAY_PG_APPLICATION_ID_PROD=5b8f6a4d396fa665fdc2b5ea +BOOTPAY_PG_PRIVATE_KEY_PROD=rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw= +BOOTPAY_PG_APPLICATION_ID_DEV=59bfc738e13f337dbd6ca48a +BOOTPAY_PG_PRIVATE_KEY_DEV=pDc0NwlkEX3aSaHTp/PPL/i8vn5E/CqRChgyEp/gHD0= + +# Commerce API +BOOTPAY_COMMERCE_CLIENT_KEY_PROD=JfF1ML0fWiXwnfpKRGvGOA +BOOTPAY_COMMERCE_SECRET_KEY_PROD=MrNdlu26zkKc1axKJM2rj3DyOwOGJJpMKB9RxIKf0Pg= +BOOTPAY_COMMERCE_CLIENT_KEY_DEV=ZYEi9d93uIaQFEuxXEZfyQ +BOOTPAY_COMMERCE_SECRET_KEY_DEV=j8ONDlZQVHgAWq52g97pGNCqxahGatyZKuC2O09r9MM= + +# Commerce test fixtures — 실제 데이터 ID. 빈 값이면 해당 endpoint 테스트는 placeholder 로 동작. +# 검증 가능한 데이터로 채워야 detail/update/delete 류 테스트가 실제 통신. +BOOTPAY_TEST_COMMERCE_USER_ID= +BOOTPAY_TEST_COMMERCE_USER_GROUP_ID= +BOOTPAY_TEST_COMMERCE_PRODUCT_ID= +BOOTPAY_TEST_COMMERCE_CATEGORY_ID= +BOOTPAY_TEST_COMMERCE_COUPON_TEMPLATE_ID= +BOOTPAY_TEST_COMMERCE_INVOICE_ID= +BOOTPAY_TEST_COMMERCE_ORDER_ID= +BOOTPAY_TEST_COMMERCE_ORDER_NUMBER= +BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_ID= +BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_BILL_ID= +BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_ADJUSTMENT_ID= +BOOTPAY_TEST_COMMERCE_ORDER_CANCEL_REQUEST_HISTORY_ID= +BOOTPAY_TEST_COMMERCE_STAND_ID= +# 수시결제(온디맨드) charge_key — supervisor 전용 order_subscriptions/charge 테스트용 +BOOTPAY_TEST_COMMERCE_CHARGE_KEY= + +# Commerce 공통 검색/필터 기본값 +BOOTPAY_TEST_COMMERCE_KEYWORD=테스트 +BOOTPAY_TEST_COMMERCE_S_AT=2024-01-01 +BOOTPAY_TEST_COMMERCE_E_AT=2099-12-31 + +# Commerce role default (user/manager/supervisor/vendor/partner) +# orderCancel.*, orderSubscriptionAdjustment.*, orderSubscription.update 류는 manager+ 필요. +BOOTPAY_TEST_COMMERCE_ROLE=user diff --git a/.gitignore b/.gitignore index 4304d78..c0e9f31 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,12 @@ node_modules/* package-lock.json yarn.lock dist +test/requestSubscribeRest.js + +# Environment files +.env +.env.* +!.env.example + +# Tool runtime dirs +.omj/ diff --git a/.npmignore b/.npmignore index 82f4082..1633382 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,9 @@ src/* +test/* +tests_*.mjs +.env +.env.example +.env.* .gitattributes .gitignore package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c76b87a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,156 @@ +### 2.9.0 +* Commerce: 죽은 경로 정정 — 회원 endpoint 는 단수 `user/...` 가 아니라 복수 `users/...` 다 (commerce-api v1 에 단수 라우트가 없다) + - `user.userLogin`: `POST users/login` (v1/users/login#create) — `POST users/session` 은 라우트만 있고 create 액션이 없으므로 쓰지 않는다 + - `user.userSession`: `GET users/session` / `user.userLogout`: `DELETE users/session` + - `user.userJoin`: `POST users/join` / `user.userJoinCheck`: `GET users/join/{type}?pk={pk}` + - `userJoin`↔`join`, `userJoinCheck`↔`checkExist` 는 같은 endpoint 를 부르지만 서버가 파라미터 조합으로 분기하므로 둘 다 유지 +* Commerce: 신규 endpoint 추가 + - `user.uidExist(uid)`: `GET users/join/uid-exist?pk={uid}` — `*_exist` 전용형 5종 완성 + - `webhook.sendTest({ header_content_type })`: `POST webhook/test` — 테스트 웹훅 발송 + - `orderSubscription.requestIng.purchase`: `POST order_subscriptions/requests/ing/purchase` (중도인수 요청) + - `orderSubscription.requestIng.transfer`: `POST order_subscriptions/requests/ing/transfer` (이전/승계 요청) +* Commerce: multipart 전송 계층 신설 — `postMultipart` 추가 및 요청 인터셉터가 지정된 `Content-Type` 을 덮어쓰지 않도록 수정 + - 덮어쓰면 form-data 의 boundary 가 사라져 서버가 본문을 null 로 읽는 버그가 있었다 + - `product.create` 는 이미지가 없으면 JSON, 있으면 multipart(`images[0]`, `images[1]` … 인덱싱)로 전송 +* Commerce: 인자·응답 규약 정정 + - `invoice.list` 응답은 `{ items, total }` 이 아니라 `{ list, count }` — 타입 선언 정정, `limit` 기본값 24, `cs_type`/`user_id`/`product_type`/`css_at`/`cse_at` 파라미터 추가 + - `invoice.notify` 의 `sendTypes` 를 선택 인자로 변경 (미전달시 서버가 빈 배열로 처리) + - `orderCancel.approve`/`reject`/`withdraw` 인자명을 `order_cancellation_request_id` 로 통일 (구 이름 `order_cancel_request_history_id` 도 계속 지원) + - `orderSubscriptionAdjustment.delete` 는 대상 ID 를 query 가 아니라 body 로 전송 + - `orderSubscriptionAdjustment.update` 에 `adjustments` 배열 지원 (서버는 `duration` 회차 단위로 교체) + - `userGroup.limit` 에 `limit_month_purchase`/`limit_week_purchase` 추가 (서버 정식 인자명; `update` 로는 한도가 반영되지 않는다) + - `order.list` 에 `search_date_from`/`search_date_to` 추가 (`css_at`/`cse_at` 는 서버 별칭으로 계속 지원) + - `orderSubscription.list` 에 `search_date_from`/`search_date_to`/`status` 추가 + - `orderSubscriptionRequest.list` 에 `order_subscription_id`/`user_id`/`user_group_id` 추가 + - `product.products` 의 `keyword` 는 서버가 읽지 않음을 문서화 (인자는 하위호환 유지) +* Commerce: 서버가 요구하는 scope 를 endpoint 별로 명시 — 상품 쓰기/그룹 한도는 `manager`, 구독 계약변경·조정항목·요청 승인은 `supervisor`, 나머지는 `user` + - `orderSubscriptionRequest.list`/`detail` 은 `project_id` 가 있으면 `supervisor`, 없으면 `user` +* PG: `lookupSequentialBillingKey(widgetKey, billingKey, userId)` — `user_id` 쿼리 파라미터 추가 +* 의존성: `form-data` 를 dependencies 에 명시 (multipart 전송에 직접 사용) + +### 2.8.0 +* Commerce: 쇼핑몰(V1 Mall API) 회원 endpoint 정정 및 추가 — 단수형 `user/...` 경로 사용 (기존 `users/...` 외부 회원 연동 API 는 그대로 유지) + - `user.userLogin({ login_id, password, corporate_type })`: `POST user/login` — corporate_type 미지정시 0 + - `user.userSession(userJwt)`: `GET user/session` + - `user.userLogout(userJwt)`: `DELETE user/session` + - `user.userJoin({ login_id, password, name, ... })`: `POST user/join` — null/undefined 값은 전송하지 않음 + - `user.userJoinCheck(type, pk)`: `GET user/join/{type}?pk={pk}` + - 세션이 필요한 호출은 회원 JWT 를 `Bootpay-User-JWT` 헤더로 전달 (값이 있을 때만 부착) +* Commerce: 상품 조회 Mall API parity + - `product.products`: `GET products` — `page`/`limit` 기본값 1/20, `category_id`/`sort` 파라미터 및 `user_jwt` 지원 + - `product.productDetail(productId, userJwt)`: `GET products/{product_id}` — 회원 JWT 지원 +* Commerce: `store.getStore`/`getStoreDetail` 에 `Idempotency-Key` 헤더 부착 (`idempotencyKey` 인자로 직접 지정 가능) + +### 2.7.0 +* PG: 우선순위(순차) 결제 빌링키 조회 `lookupSequentialBillingKey(widgetKey, billingKey)` 추가 — `GET subscribe/sequential_billing_key/{billing_key}?widget_key={widget_key}` +* Commerce: 수시결제(온디맨드) charge_key 결제/해지 추가 (supervisor 전용) + - `orderSubscription.supervisorCharge`: `POST order_subscriptions/charge` — charge_key 는 body 로만 전송 (URL/query 금지) + - `orderSubscription.supervisorChargeRevoke`: `DELETE order_subscriptions/charge` — 해지 후 해당 키로 재결제 불가 + - 두 endpoint 모두 `Idempotency-Key` 헤더 자동 생성 (`idempotency_key` 파라미터로 직접 지정 가능) +* Commerce: 몰 설정 모듈 `mallSetting` 추가 (supervisor 전용) + - `getMallSetting`/`detail`: `GET mall-setting` + - `updateMallSetting`/`update`: `PUT mall-setting` — flatten 바디, null/undefined 값은 전송하지 않음 +* Commerce: 요청별로 지정된 `BOOTPAY-ROLE` 헤더를 인터셉터가 덮어쓰지 않도록 수정 (supervisor 전용 endpoint 대응, 미지정시 기존 동작 그대로) + +### 2.6.0 +* 인증: client_key/secret_key Basic Auth 지원 (PG + Commerce 공통) + - 기존 application_id/private_key Bearer 방식 하위 호환 유지 + - ck/sk 모드에서는 request/token 호출 불필요 (getAccessToken 합성 응답) + - ck 또는 sk 한쪽만 지정 + legacy 키도 없으면 NEED_CLIENT_KEY(-101) reject +* Commerce: V1 신설 모듈 추가 — category, coupon, point, orderSubscriptionRequest, cart + - cart.orderPreview: 권위적 배송비/할인 계산 응답 (guest/member 모드) +* Commerce: userGroup URL parity 정정 — `/add_user` → `/user`, `/remove_user` → `/user/{userId}` (서버 routes.rb 와 정렬, 옛 URL 은 서버 미존재) +* Commerce: 서버에 존재하지 않는 endpoint 3종 제거 (`coupon.preview`, `point.previewUsage`, `point.calculateLimit`) — npm 미공개 모듈이라 사용자 영향 없음 +* Wallet API (`requestWalletPayment`, `WalletRequestParameters`, `WalletPaymentResponseParameters`) `@deprecated` 표시 — 다음 메이저 버전에서 제거 예정 +* `http_status` 응답 필드 `@deprecated` 표시 — 다음 메이저 버전에서 제거 예정 (성공 여부는 `status` 필드 사용) +* 테스트 인프라: `.env` / `BOOTPAY_AUTH_MODE=new|legacy` 토글로 ck/sk · legacy 양쪽 검증, PG 테스트 디렉터리 분리(`test/pg/`) +* docs: CHANGELOG 파일명 오타 정정 (`CHNAGELOG.md` → `CHANGELOG.md`) + +### 2.4.1 +* Commerce 응답포맷 개선 + +### 2.4.0 +* Commerce 기능 추가 + +### 2.3.6 + +* 본인인증 REST API로 요청시 client_ip 파라메터 필수 추가 + +### 2.3.5 + +* walletPayment response type bug fixed + +### 2.3.3 + +* wallet api 추가 + +### 2.3.2 + +* 배송등록 api 필드 추가 + +### 2.3.1 + +* requestSubscribePayment 함수 추가 + +### 2.3.0 + +* 계좌 자동 결제 추가 + +### 2.1.11 + +* 필드명 back_username -> bank_username 으로 오타 수정 + +### 2.1.4 + +* 날짜 타입을 string -> Date 로 명시적으로 수정 + +### 2.1.3 + +* 정기결제요청시 feedback_url, metadata, content_type 파라미터 정의 추가 + +### 2.1.2 + +* 버전 재배포 + +### 2.1.1 + +* 정기결제 예약시 order_id 파라미터 정의 추가 + +### 2.1.0 + +* 결제취소 요청시 refund optional 로 수정 + +### 2.0.9 ( Stable ) + +* 네이버페이 포인트, 페이코포인트, 카카오머니, 토스포인트 결제시 리턴되는 포맷 interface 추가 정의 + +### 2.0.8 + +* 현금영수증 cash_receipt_data interface 정의 + +### 2.0.7 + +* inteface model 정의 parameters 누락 및 optional 체크 +* 현금영수증 별건 발행 / 취소 API 추가 + +### 2.0.6 + +* SubscriptionBillingResponseParameters interface 누락된 값 추가 ( status, status_locale, gateway_url, method_symbol ) + +### 2.0.5 + +* typescript에서 TS7016 root에서 import가 되지 않는 문제 해결 + +### 2.0.4 + +* package.json import가 되지 않는 환경 예외처리 + +### 2.0.3 + +* 기존 결제 현금영수증 발행 +* 별건 현금영수증 발행 +* REST API 통신 요청시 Header에 버전 및 SDK 종류 명시 ( 부트페이 서버에서 CS용으로 수집 ) + +### 2.0.0 + +새로운 v2 API에 맞도록 수정 \ No newline at end of file diff --git a/CHNAGELOG.md b/CHNAGELOG.md deleted file mode 100644 index 683340f..0000000 --- a/CHNAGELOG.md +++ /dev/null @@ -1,36 +0,0 @@ -### 1.1.01 -package.json에 type module 삭제 - -### 1.1.0 -bootpay-backend-nodejs 로 패키지 네임 변경 -package.json에 type module 추가 - -### 1.0.9 -bootpay로 alias 변경 -주석 추가 -@bootpay/backend-nodejs 로 패키지 네임 변경 - -### 1.0.8 -readme 업데이트 -precompile 옵션 변경 - -### 1.0.7 -requestPayment params 데이터를 전달되도록 변경 - -### 1.0.6 -* axios instance로 생성, interceptor가 global 영향을 받지 않도록 수정 - -### 1.0.4 -* isBlank {} 체크 못하는 버그 수정 -* subscribe payment ( 정기결제 ) extra 추가 - -### 1.0.3 -* extra를 underscore로 보내는 로직 추가 - -### 1.0.2 -* item 정보를 underscore로 보내는 로직 추가 -* user_info 정보를 underscore로 보내는 로직 추가 - -### 1.0.0 -* typescript로 코딩이 되어있습니다 -* d.ts 파일이 첨부되어 typescript로도 코딩이 가능합니다. \ No newline at end of file diff --git a/README.md b/README.md index 05e25f3..9c08328 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Bootpay Server Side Package for Node.js [![alt text](https://cdn.bootpay.co.kr/icon/npm.svg)](https://www.npmjs.com/package/bootpay-backend-nodejs) - +# Bootpay Server Side Package for Node.js [![alt text](https://cdn.bootpay.co.kr/icon/npm.svg)](https://www.npmjs.com/package/@bootpay/backend-js) ## Bootpay Node.js Server Side Library + 부트페이 공식 Node.js 라이브러리 입니다 (서버사이드 용) node환경에서 작성된 어플리케이션, 프레임워크 등에서 사용가능합니다. @@ -9,96 +9,155 @@ node환경에서 작성된 어플리케이션, 프레임워크 등에서 사용 * PG 결제창 연동은 클라이언트 라이브러리에서 수행됩니다. (Javascript, Android, iOS, React Native, Flutter 등) * 결제 검증 및 취소, 빌링키 발급, 본인인증 등의 수행은 서버사이드에서 진행됩니다. (Java, PHP, Python, Ruby, Node.js, Go, ASP.NET 등) +## 목차 +- [PG API 사용하기](#사용하기) + - [1. 토큰 발급](#1-토큰-발급) + - [2. 결제 단건 조회](#2-결제-단건-조회) + - [3. 결제 취소 (전액 취소 / 부분 취소)](#3-결제-취소-전액-취소--부분-취소) + - [4. 자동/빌링/정기 결제](#4-자동빌링정기-결제) + - [4-1. 카드 빌링키 발급](#4-1-카드-빌링키-발급) + - [4-2. 계좌 빌링키 발급](#4-2-계좌-빌링키-발급) + - [4-3. 결제 요청하기](#4-3-결제-요청하기) + - [4-4. 결제 예약하기](#4-4-결제-예약하기) + - [4-5. 예약 조회하기](#4-5-예약-조회하기) + - [4-6. 예약 취소하기](#4-6-예약-취소하기) + - [4-7. 빌링키 삭제하기](#4-7-빌링키-삭제하기) + - [4-8. 빌링키 조회하기](#4-8-빌링키-조회하기) + - [4-9. 우선순위 결제 빌링키 조회하기](#4-9-우선순위-결제-빌링키-조회하기) + - [5. 회원 토큰 발급요청](#5-회원-토큰-발급요청) + - [6. 서버 승인 요청](#6-서버-승인-요청) + - [7. 본인 인증 결과 조회](#7-본인-인증-결과-조회) + - [8. 에스크로 이용시 PG사로 배송정보 보내기](#8-에스크로-이용시-pg사로-배송정보-보내기) + - [9-1. 현금영수증 발행하기](#9-1-현금영수증-발행하기) + - [9-2. 현금영수증 발행 취소](#9-2-현금영수증-발행-취소) + - [9-3. 별건 현금영수증 발행](#9-3-별건-현금영수증-발행) + - [9-4. 별건 현금영수증 발행 취소](#9-4-별건-현금영수증-발행-취소) +- [Commerce API 사용하기](#10-commerce-api) + - [10-1. Commerce API 초기화](#10-1-commerce-api-초기화) + - [10-2. 사용자 관리](#10-2-사용자-관리) + - [10-3. 상품 관리](#10-3-상품-관리) + - [10-4. 주문 관리](#10-4-주문-관리) + - [10-5. 정기구독 관리](#10-5-정기구독-관리) + - [10-6. 청구서 관리](#10-6-청구서-관리) + - [10-7. 몰 설정 관리](#10-7-몰-설정-관리) + - [10-8. 쇼핑몰 회원 세션 관리](#10-8-쇼핑몰-회원-세션-관리) + - [10-9. 가맹점 정보 조회](#10-9-가맹점-정보-조회) +- [Example 프로젝트](#example-프로젝트) +- [Documentation](#documentation) +- [기술문의](#기술문의) +- [License](#license) + + +## npm으로 설치하기 + + +``` +npm install --save @bootpay/backend-js +``` -## 기능 -1. (부트페이 통신을 위한) 토큰 발급 요청 -2. 결제 검증 -3. 결제 취소 (전액 취소 / 부분 취소) -4. 빌링키 발급 - - 4-1. 발급된 빌링키로 결제 승인 요청 - - 4-2. 발급된 빌링키로 결제 승인 예약 요청 - - 4-2-1. 발급된 빌링키로 결제 승인 예약 - 취소 요청 - - 4-3. 빌링키 삭제 -5. (부트페이 단독) 사용자 토큰 발급 -6. (부트페이 단독) 결제 링크 생성 -7. 서버 승인 요청 -8. 본인 인증 결과 조회 -## npm으로 설치하기 +## 환경변수 설정 +예제와 테스트는 각 SDK 루트의 `.env` 파일을 우선 읽습니다. 먼저 `.env.example`을 복사한 뒤 필요한 키만 변경하세요. `.env`는 gitignore 처리되어 커밋되지 않습니다. + +```bash +cp .env.example .env +# BOOTPAY_ENV=production 또는 development ``` -npm install --save bootpay-backend-nodejs + +주요 변수: + +```env +BOOTPAY_ENV=production +BOOTPAY_PG_CLIENT_KEY_PROD=... +BOOTPAY_PG_SECRET_KEY_PROD=... +BOOTPAY_PG_CLIENT_KEY_DEV=... +BOOTPAY_PG_SECRET_KEY_DEV=... +BOOTPAY_COMMERCE_CLIENT_KEY_PROD=... +BOOTPAY_COMMERCE_SECRET_KEY_PROD=... +BOOTPAY_COMMERCE_CLIENT_KEY_PROD=... +BOOTPAY_COMMERCE_SECRET_KEY_PROD=... ``` +변수가 없으면 SDK 테스트용 기본값(NodeJS 기준 ck/sk)으로 fallback 합니다. + +# 사용하기 -## 사용하기 +> 권장 인증 방식은 `client_key/secret_key`입니다. 기존 `application_id/private_key` 설정도 하위 호환을 위해 계속 동작합니다. 둘 다 설정된 경우 `client_key/secret_key`가 우선됩니다. ```javascript -async function getAccessToken() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) +// Legacy fallback: +// Bootpay.setConfiguration({ +// application_id: process.env.BOOTPAY_APPLICATION_ID, +// private_key: process.env.BOOTPAY_PRIVATE_KEY +// }) +``` + +```javascript +import { Bootpay } from "@bootpay/backend-js"; + +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) try { - let response = await Bootpay.getAccessToken() + await Bootpay.getAccessToken() + const response = await Bootpay.cancelPayment({ + receipt_id: '628b2206d01c7e00209b6087', + cancel_price: 1000, + cancel_username: '테스트 사용자', + cancel_message: '테스트 취소입니다.' + }) console.log(response) - } catch(e) { + } catch (e) { console.log(e) } -}; +})() ``` -함수 단위의 샘플 코드는 [이곳](https://github.com/bootpay/backend-nodejs/tree/main/test)을 참조하세요. -## 1. 토큰 발급 +## 1. 토큰 발급 부트페이와 서버간 통신을 하기 위해서는 부트페이 서버로부터 토큰을 발급받아야 합니다. 발급된 토큰은 30분간 유효하며, 최초 발급일로부터 30분이 지날 경우 토큰 발급 함수를 재호출 해주셔야 합니다. -```javascript -async function getAccessToken() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) + +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) try { let response = await Bootpay.getAccessToken() console.log(response) - } catch(e) { + } catch (e) { console.log(e) } -} +})() + ``` -## 2. 결제 검증 +## 2. 결제 단건 조회 결제창 및 정기결제에서 승인/취소된 결제건에 대하여 올바른 결제건인지 서버간 통신으로 결제검증을 합니다. -```javascript -async function verify() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - const token = await Bootpay.getAccessToken() - if (token.status === 200) { - let result - try { - result = await Bootpay.verify('612df0250d681b001de61de6') - } catch (e) { - return console.log(e) - } - console.log(result) +```javascript +(async () => { + const Bootpay = require('@bootpay/backend-js').Bootpay + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.receiptPayment('62b12f4b6262500007629fec') + console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` - ## 3. 결제 취소 (전액 취소 / 부분 취소) price를 지정하지 않으면 전액취소 됩니다. * 휴대폰 결제의 경우 이월될 경우 이통사 정책상 취소되지 않습니다 @@ -110,293 +169,586 @@ price를 지정하지 않으면 전액취소 됩니다. 간혹 개발사에서 실수로 여러번 부분취소를 보내서 여러번 취소되는 경우가 있기때문에, 부트페이에서는 부분취소 중복 요청을 막기 위해 cancel_id 라는 필드를 추가했습니다. cancel_id를 지정하시면, 해당 건에 대해 중복 요청방지가 가능합니다. ```javascript -async function cancel() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.cancel({ - receiptId: '612df0250d681b001de61de6', - price: 1000, - name: '취소자명', - reason: '취소합니다' - }) - } catch (e) { - console.log(e) - return - } +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.cancelPayment({ + receipt_id: '628b2206d01c7e00209b6087', + cancel_price: 1000, + cancel_username: '테스트 사용자', + cancel_message: '테스트 취소입니다.' + }) console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 4. 빌링키 발급 +## 4. 자동/빌링/정기 결제 +## 4-1. 카드 빌링키 발급 REST API 방식으로 고객으로부터 카드 정보를 전달하여, PG사에게 빌링키를 발급받을 수 있습니다. 발급받은 빌링키를 저장하고 있다가, 원하는 시점, 원하는 금액에 결제 승인 요청하여 좀 더 자유로운 결제시나리오에 적용이 가능합니다. * 비인증 정기결제(REST API) 방식을 지원하는 PG사만 사용 가능합니다. -```javascript -async function getBillingKey() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestSubscribeBillingKey({ - orderId: (new Date()).getTime(), - pg: 'nicepay', - itemName: '정기결제 30일권', - cardNo: '[ 카드 번호 ]', - cardPw: '[ 카드 비밀번호 앞 2자리 ]', - expireYear: '[ 카드 만료 연도 ]', - expireMonth: '[ 카드 만료 월 ]', - identifyNumber: '[ 카드 소유주 생년월일 혹은 법인 번호 ]', - extra: { - subscribeTestPayment: 1 // 100원 결제 후 결제가 되면 billing key를 발행, 결제가 실패하면 에러 - } - }) - } catch (e) { - console.log(e) - return - } +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribeBillingKey({ + pg: '나이스페이', + order_name: '테스트결제', + subscription_id: (new Date()).getTime(), + card_no: '5570********1074', //카드번호 + card_pw: '**', //카드 비밀번호 2자리 + card_identity_no: '******', //카드 소유주 생년월일 6자리 + card_expire_year: '**', //카드 유효기간 년 2자리 + card_expire_month: '**', //카드 유효기간 월 2자리 + user: { + username: '홍길동', + phone: '01012345678' + } + }) console.log(response) + } catch (e) { + console.log(e) } +})() +``` + +## 4-2. 계좌 빌링키 발급 +REST API 방식으로 고객의 계좌 정보를 전달하여, PG사에게 빌링키 발급을 요청합니다. 요청 후 빌링키가 바로 발급되진 않고, 출금동의 확인 절차까지 진행해야 빌링키가 발급됩니다. +먼저 빌링키를 요청합니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribeAutomaticTransferBillingKey({ + pg: '나이스페이', + order_name: '테스트결제', + subscription_id: (new Date()).getTime(), + price: 1000, + username: '홍길동', + bank_name: '국민', + bank_account: '67561234123492472', + identity_no: '901014', + cash_receipt_identity_no: '01012341234', + phone: '01012341234', + user: { + username: '홍길동', + phone: '01012345678' + } + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() + +``` + +이후 빌링키 발급 요청시 응답받은 receipt_id로, 출금 동의 확인을 요청합니다. +```javascript +try { + await Bootpay.getAccessToken() + const response = await Bootpay.publishAutomaticTransferBillingKey('6655069ca691573f1bb9c28a') + console.log(response) +} catch (e) { + console.log(e) } ``` -## 4-1. 발급된 빌링키로 결제 승인 요청 + + +## 4-3. 결제 요청하기 발급된 빌링키로 원하는 시점에 원하는 금액으로 결제 승인 요청을 할 수 있습니다. 잔액이 부족하거나 도난 카드 등의 특별한 건이 아니면 PG사에서 결제를 바로 승인합니다. -```javascript -async function subscribeBilling() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestSubscribeBillingPayment({ - billingKey: '612deb53019943001fb52312', - itemName: '테스트', - price: 1000, - orderId: (new Date()).getTime(), - feedbackUrl: 'https://dev-api.bootpay.co.kr/callback', - feedbackContentType: 'json' - }) - } catch (e) { - return console.log(e) - } +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribeCardPayment({ + billing_key: '62b3d166cf9f6d001bd20d59', + order_name: '테스트 결제', + order_id: (new Date()).getTime(), + price: 100, + tax_free: 0 + }) console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 4-2. 발급된 빌링키로 결제 예약 요청 -원하는 시점에 4-1로 결제 승인 요청을 보내도 되지만, 빌링키 발급 이후에 바로 결제 예약 할 수 있습니다. (빌링키당 최대 5건) -```javascript -async function subscribeBillingReserve() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.reserveSubscribeBilling({ - billingKey: '612deb53019943001fb52312', - itemName: '테스트', - price: 1000, - orderId: (new Date()).getTime(), - userInfo: { - username: '테스트', - phone: '01000000000' - }, - feedbackUrl: 'https://dev-api.bootpay.co.kr/callback', - feedbackContentType: 'json', - schedulerType: 'oneshot', - executeAt: ((new Date()).getTime() / 1000) + 5 - }) - } catch (e) { - return console.log(e) - } +## 4-4. 결제 예약하기 +발급된 빌링키로 결제를 예약합니다. (빌링키당 최대 10건) +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + // console.log(new Date((new Date()).getTime() + 5000)) + await Bootpay.getAccessToken() + const response = await Bootpay.subscribePaymentReserve({ + billing_key: '62b3d166cf9f6d001bd20d59', + order_name: '테스트 결제', + order_id: (new Date()).getTime(), + price: 1000, + reserve_execute_at: new Date((new Date()).getTime() + 5000) + }) console.log(response) + } catch (e) { + console.log(e) } -} +})() +``` + +## 4-5. 예약 조회하기 +예약시 응답받은 reserveId로 예약된 건을 조회합니다. +```javascript +const reserve_id = "5b8f6a4d396fa665fdc2b5ea" +await Bootpay.subscribePaymentReserveLookup(reserve_id) ``` -## 4-2-1. 발급된 빌링키로 결제 예약 - 취소 요청 -빌링키로 예약된 결제건을 취소합니다. -```javascript -async function subscribeBillingReserveCancel() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.destroyReserveSubscribeBilling('612debc70d681b0039e6133d') - console.log(response) - } catch (e) { - return console.log(e) + + +## 4-6. 예약 취소하기 +예약시 응답받은 reserveId로 예약된 건을 취소합니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + // console.log(new Date((new Date()).getTime() + 5000)) + await Bootpay.getAccessToken() + const response = await Bootpay.subscribePaymentReserve({ + billing_key: '62b3d166cf9f6d001bd20d59', + order_name: '테스트 결제', + order_id: (new Date()).getTime(), + price: 1000, + reserve_execute_at: new Date((new Date()).getTime() + 5000) + }) + if (response.reserve_id !== undefined) { + const cancel = await Bootpay.cancelSubscribeReserve(response.reserve_id) + console.log(cancel) } - console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 4-3. 빌링키 삭제 -발급된 빌링키로 더 이상 사용되지 않도록, 삭제 요청합니다. + +## 4-7. 빌링키 삭제하기 +발급된 빌링키를 삭제합니다. 삭제하더라도 예약된 결제건은 취소되지 않습니다. 예약된 결제건 취소를 원하시면 예약 취소하기를 요청하셔야 합니다. ```javascript -async function deleteBillingKey() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.destroySubscribeBillingKey('612debc70d681b0039e6133d') - console.log(response) - } catch (e) { - return console.log(e) - } +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.destroyBillingKey('62b3d166cf9f6d001bd20d59') console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 5. (부트페이 단독 - 간편결제창, 생체인증 기반의 사용자를 위한) 사용자 토큰 발급 -(부트페이 단독) 부트페이에서 제공하는 간편결제창, 생체인증 기반의 결제 사용을 위해서는 개발사에서 회원 고유번호를 관리해야하며, 해당 회원에 대한 사용자 토큰을 발급합니다. -이 토큰값을 기반으로 클라이언트에서 결제요청 하시면 되겠습니다. -```javascript -async function getUserToken() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestUserToken({ - userId: '1234', - email: 'test@gmail.com', - name: '홍길동' - }) - console.log(response) - } catch (e) { - return console.log(e) - } + +## 4-8. 빌링키 조회하기 +클라이언트에서 빌링키 발급시, 보안상 클라이언트 이벤트에 빌링키를 전달해주지 않습니다. 그러므로 이 API를 통해 조회해야 합니다. +다음은 빌링키 발급 요청했던 receiptId 로 빌링키를 조회합니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.lookupSubscribeBillingKey('62b3cbbecf9f6d001bd20ce8') console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 6. 결제 링크 생성 -(부트페이 단독) 요청 하시면 결제링크가 리턴되며, 해당 url을 고객에게 안내, 결제 유도하여 결제를 진행할 수 있습니다. -```javascript -async function requestPayment() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestPayment({ - pg: 'kcp', - method: 'card', - orderId: (new Date).getTime(), - price: 1000, - itemName: '테스트 부트페이 상품', - returnUrl: 'https://dev-api.bootpay.co.kr/callback', - extra: { - expire: 30 - } - }) - } catch (e) { - return console.log(e) - } + +아래는 billingKey로 조회합니다. +```javascript +const response = await Bootpay.lookupBillingKey('66542dfb4d18d5fc7b43e1b6') +console.log(response) +``` + +## 4-9. 우선순위 결제 빌링키 조회하기 +우선순위(순차) 결제에 사용되는 빌링키를 위젯키·회원 ID 와 함께 조회합니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.lookupSequentialBillingKey('WIDGET_KEY', '66542dfb4d18d5fc7b43e1b6', 'USER_ID') console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 7. 서버 승인 요청 + +## 5. 회원 토큰 발급요청 +ㅇㅇ페이 사용을 위해 가맹점 회원의 토큰을 발급합니다. 가맹점은 회원의 고유번호를 관리해야합니다. +이 토큰값을 기반으로 클라이언트에서 결제요청(payload.user_token) 하시면 되겠습니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.requestUserToken({ + user_id: 'gosomi1', + phone:'01012345678' + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() +``` + +## 6. 서버 승인 요청 결제승인 방식은 클라이언트 승인 방식과, 서버 승인 방식으로 총 2가지가 있습니다. -클라이언트 승인 방식은 javascript나 native 등에서 confirm 함수에서 진행하는 일반적인 방법입니다만, 경우에 따라 서버 승인 방식이 필요할 수 있습니다. +클라이언트 승인 방식은 웹, 앱에서 진행하는 일반적인 방법입니다만, 경우에 따라 서버 승인 방식이 필요할 수 있습니다. 필요한 이유 1. 100% 안정적인 결제 후 고객 안내를 위해 - 클라이언트에서 PG결제 진행 후 승인 완료될 때 onDone이 수행되지 않아 (인터넷 환경 등), 결제 이후 고객에게 안내하지 못할 수 있습니다 2. 단일 트랜잭션의 개념이 필요할 경우 - 재고파악이 중요한 커머스를 운영할 경우 트랜잭션 개념이 필요할 수 있겠으며, 이를 위해서는 서버 승인을 사용해야 합니다. -```javascript -async function submit() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.verify('612df0250d681b001de61de6') - } catch (e) { - return console.log(e) - } +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.confirmPayment('62876963d01c7e00209b6028') console.log(response) + } catch (e) { + console.log(e) } -} +})() ``` -## 8. 본인 인증 결과 조회 +## 7. 본인 인증 결과 조회 다날 본인인증 후 결과값을 조회합니다. 다날 본인인증에서 통신사, 외국인여부, 전화번호 이 3가지 정보는 다날에 추가로 요청하셔야 받으실 수 있습니다. -```javascript -async function certificate() { - const Bootpay = require('bootpay-backend-nodejs').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.certificate('612df0250d681b001de61de6') - } catch (e) { - return console.log(e) - } +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.certificate('625783a6cf9f6d001d0aed19') console.log(response) + } catch (e) { + console.log(e) } -} +})() +``` + + +8. (에스크로 이용시) PG사로 배송정보 보내기 +현금 거래에 한해 구매자의 안전거래를 보장하는 방법으로, 판매자와 구매자의 온라인 전자상거래가 원활하게 이루어질 수 있도록 중계해주는 매매보호서비스입니다. 국내법에 따라 전자상거래에서 반드시 적용이 되어 있어야합니다. PG에서도 에스크로 결제를 지원하며, 에스크로 결제 사용을 원하시면 PG사 가맹시에 에스크로결제를 미리 얘기하고나서 진행을 하시는 것이 수월합니다. + +PG사로 배송정보( 이니시스, KCP만 지원 )를 보내서 에스크로 상태를 변경하는 API 입니다. +```javascript +(async () => { + Bootpay.setConfiguration({ + client_key: process.env.BOOTPAY_PG_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_PG_SECRET_KEY_PROD + }) + try { + await Bootpay.getAccessToken() + const response = await Bootpay.shippingStart({ + receipt_id: "62a9379ad01c7e001f7dc1f3", + tracking_number: '123456', + delivery_corp: 'CJ대한통운', + user: { + username: '테스트', + phone: '01000000000', + address: '서울특별시 종로구', + zipcode: '08490' + } + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() +``` + +## 10. Commerce API + +부트페이 Commerce API를 사용하여 사용자, 상품, 주문, 정기구독 등을 관리할 수 있습니다. + +### 10-1. Commerce API 초기화 + +```javascript +const { BootpayCommerce } = require('@bootpay/backend-js') + +const commerce = new BootpayCommerce({ + client_key: process.env.BOOTPAY_COMMERCE_CLIENT_KEY_PROD, + secret_key: process.env.BOOTPAY_COMMERCE_SECRET_KEY_PROD, + mode: 'production' // 'production' | 'development' | 'stage' +}) + +// 토큰 발급 +await commerce.getAccessToken() +``` + +### 10-2. 사용자 관리 + +```javascript +// 사용자 목록 조회 +const users = await commerce.user.list({ page: 1, limit: 10 }) + +// 사용자 상세 조회 +const user = await commerce.user.detail('USER_ID') + +// 회원가입 +const newUser = await commerce.user.join({ + login_id: 'test@example.com', + login_pw: 'password123', + name: '홍길동', + email: 'test@example.com', + phone: '010-1234-5678' +}) + +// 사용자 정보 수정 +const updatedUser = await commerce.user.update({ + user_id: 'USER_ID', + name: '수정된 이름' +}) ``` +### 10-3. 상품 관리 + +```javascript +// 상품 목록 조회 +const products = await commerce.product.list({ page: 1, limit: 10 }) + +// 상품 생성 +const product = await commerce.product.create({ + name: '테스트 상품', + price: 10000, + description: '상품 설명' +}) + +// 상품 상세 조회 +const productDetail = await commerce.product.detail('PRODUCT_ID') + +// 상품 수정 +const updatedProduct = await commerce.product.update({ + product_id: 'PRODUCT_ID', + name: '수정된 상품명', + price: 15000 +}) +``` + +### 10-4. 주문 관리 + +```javascript +// 주문 목록 조회 +const orders = await commerce.order.list({ page: 1, limit: 10 }) + +// 주문 상세 조회 +const order = await commerce.order.detail('ORDER_ID') + +// 월별 주문 조회 +const monthOrders = await commerce.order.month('USER_GROUP_ID', '2024-12') +``` + +### 10-5. 정기구독 관리 + +```javascript +// 정기구독 목록 조회 +const subscriptions = await commerce.orderSubscription.list() + +// 정기구독 상세 조회 +const subscription = await commerce.orderSubscription.detail('ORDER_SUBSCRIPTION_ID') + +// 정기구독 일시정지 +await commerce.orderSubscription.pause({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID', + pause_days: 30, + reason: '일시정지 사유' +}) + +// 정기구독 재개 +await commerce.orderSubscription.resume({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID' +}) + +// 정기구독 해지 +await commerce.orderSubscription.termination({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID', + reason: '해지 사유' +}) + +// 수시결제(온디맨드) charge_key 즉시 결제 — supervisor 전용 +// charge_key 는 body 로만 전송됩니다 (URL/query 금지 — 액세스 로그 노출 방지) +await commerce.asSupervisor().orderSubscription.supervisorCharge({ + charge_key: 'CHARGE_KEY', + price: 1000, + tax_free_price: 0, + user: { id: 'USER_ID' }, + metadata: { memo: '수시결제' } +}) + +// 수시결제(온디맨드) charge_key 해지 — 해지 이후 해당 키로의 재결제는 불가능합니다 +await commerce.asSupervisor().orderSubscription.supervisorChargeRevoke({ + charge_key: 'CHARGE_KEY' +}) +``` + +### 10-6. 청구서 관리 + +```javascript +// 청구서 목록 조회 — 응답은 { list, count } 구조이며 limit 기본값은 24 입니다. +const invoices = await commerce.invoice.list() +const filtered = await commerce.invoice.list({ + page: 1, + limit: 24, + keyword: '청구서', + cs_type: 'CS_TYPE', + user_id: 'USER_ID', + product_type: 1, + css_at: '2024-01-01', + cse_at: '2024-12-31' +}) + +// 청구서 상세 조회 +const invoiceDetail = await commerce.invoice.detail('INVOICE_ID') + +// 청구서 생성 +const invoice = await commerce.invoice.create({ + user_id: 'USER_ID', + amount: 50000, + title: '청구서 제목' +}) + +// 청구서 알림 재발송 — send_types 를 생략하면 서버가 빈 배열로 처리합니다. +// ⚠️ 실제 고객에게 알림이 발송되므로 테스트 호출에 주의하세요. +await commerce.invoice.notify('INVOICE_ID', [1, 2]) // 1: SMS, 2: Email +``` + +### 10-6-1. 테스트 웹훅 발송 + +등록된 웹훅 URL 로 테스트 페이로드를 보내 연동을 확인합니다. + +```javascript +await commerce.webhook.sendTest() +await commerce.webhook.sendTest({ header_content_type: 1 }) +``` + +### 10-7. 몰 설정 관리 + +supervisor scope 토큰(또는 키)으로만 호출할 수 있습니다. + +```javascript +// 몰 설정 조회 +const mallSetting = await commerce.mallSetting.getMallSetting() + +// 몰 설정 수정 — 전달한 값(non-null)만 서버로 전송됩니다 +await commerce.mallSetting.updateMallSetting({ + name: '부트페이몰', + description: '몰 소개', + use_cart: true, + cart_max_limit: 100, + use_point: true, + point_rate: 1 +}) +``` + +### 10-8. 쇼핑몰 회원 세션 관리 + +쇼핑몰(Mall) 회원 API 입니다. 단수형 `user/...` 경로를 사용하며, 외부 회원 연동용 `users/...` API(`user.login`, `user.join`, `user.checkExist`)와는 별개의 endpoint 입니다. + +```javascript +// 회원가입 — 전달한 값(non-null)만 서버로 전송되며, corporate_type 미지정시 0(개인) +await commerce.user.userJoin({ + login_id: 'test_user@example.com', + password: 'password123', + name: '테스트 사용자', + email: 'test_user@example.com', + phone: '010-1234-5678' +}) + +// 회원가입 중복 확인 — email-exist, id-exist, phone-exist, group-business-number-exist +await commerce.user.userJoinCheck('email-exist', 'test_user@example.com') + +// 로그인 +const login = await commerce.user.userLogin({ + login_id: 'test_user@example.com', + password: 'password123' +}) + +// 세션 조회 / 로그아웃 — 로그인시 발급받은 회원 JWT 를 Bootpay-User-JWT 헤더로 전달합니다 +await commerce.user.userSession(userJwt) +await commerce.user.userLogout(userJwt) + +// 회원 JWT 를 넘기면 상품 조회에도 회원 컨텍스트가 적용됩니다 +await commerce.product.products({ page: 1, limit: 20, category_id: 'CATEGORY_ID', user_jwt: userJwt }) +await commerce.product.productDetail('PRODUCT_ID', userJwt) +``` + +### 10-9. 가맹점 정보 조회 + +```javascript +// 가맹점 기본 정보 +const store = await commerce.store.getStore() + +// 가맹점 상세 정보 +const storeDetail = await commerce.store.getStoreDetail() +``` + +더 자세한 Commerce API 사용 예제는 [test/commerce](./test/commerce) 디렉토리를 참고해주세요. + ## Example 프로젝트 -[적용한 샘플 프로젝트](https://github.com/bootpay/backend-nodejs-example)을 참조해주세요 +[적용한 샘플 프로젝트](https://github.com/bootpay/backend-python-example)을 참조해주세요 ## Documentation -[부트페이 개발매뉴얼](https://bootpay.gitbook.io/docs/)을 참조해주세요 +[부트페이 개발매뉴얼](https://developer.bootpay.co.kr/)을 참조해주세요 ## 기술문의 @@ -405,4 +757,4 @@ async function certificate() { ## License [MIT License](https://opensource.org/licenses/MIT). - \ No newline at end of file + diff --git a/package.json b/package.json index 228aa34..896a7b6 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,34 @@ { - "name": "bootpay-backend-nodejs", - "version": "1.1.1", + "name": "@bootpay/backend-js", + "version": "2.9.0", "description": "Bootpay Server Side Package for Node.js", - "main": "dist/bootpay.js", "types": "dist/bootpay.d.ts", + "main": "dist/bootpay.js", + "module": "dist/bootpay.js", + "exports": { + ".": { + "import": "./dist/bootpay.js", + "require": "./dist/bootpay.js", + "types": "./dist/bootpay.d.ts" + } + }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "tsc --build", + "build": "rm -rf ./dist && tsc --p ./tsconfig.json && rm -rf ./dist/src && cp ./README.md dist/", "clear": "tsc --build --clean" }, "dependencies": { - "axios": "^0.21.1" + "axios": "^1.7.2", + "form-data": "^4.0.0" }, "devDependencies": { - "ts-node": "^10.2.1", - "typescript": "^4.4.3" + "@types/node": "^18.6.2", + "ts-node": "^10.7.0", + "typescript": "^5.3.3" }, "repository": { "type": "git", - "url": "git+https://github.com/bootpay/backend-nodejs" + "url": "git+https://github.com/bootpay/backend-nodejs.git" }, "keywords": [ "결제", @@ -29,10 +39,16 @@ "부트페이", "bootpay" ], + "ts-node": { + "esm": true + }, "author": "Bootpay", "license": "MIT", "bugs": { "url": "https://bootpay.channel.io/" }, - "homepage": "https://www.bootpay.co.kr" + "homepage": "https://www.bootpay.co.kr", + "directories": { + "test": "test" + } } diff --git a/src/bootpay-commerce.ts b/src/bootpay-commerce.ts new file mode 100644 index 0000000..ccc9e12 --- /dev/null +++ b/src/bootpay-commerce.ts @@ -0,0 +1,180 @@ +import { BootpayCommerceResource, CommerceConfiguration, BootpayCommerceResponse } from './lib/commerce-resource' +import { UserModule } from './lib/commerce/modules/user' +import { UserGroupModule } from './lib/commerce/modules/user-group' +import { ProductModule } from './lib/commerce/modules/product' +import { InvoiceModule } from './lib/commerce/modules/invoice' +import { OrderModule } from './lib/commerce/modules/order' +import { OrderCancelModule } from './lib/commerce/modules/order-cancel' +import { OrderSubscriptionModule } from './lib/commerce/modules/order-subscription' +import { OrderSubscriptionBillModule } from './lib/commerce/modules/order-subscription-bill' +import { OrderSubscriptionAdjustmentModule } from './lib/commerce/modules/order-subscription-adjustment' +import { OrderSubscriptionRequestModule } from './lib/commerce/modules/order-subscription-request' +import { CategoryModule } from './lib/commerce/modules/category' +import { CouponModule } from './lib/commerce/modules/coupon' +import { PointModule } from './lib/commerce/modules/point' +import { CartModule } from './lib/commerce/modules/cart' +import { StoreModule } from './lib/commerce/modules/store' +import { MallSettingModule } from './lib/commerce/modules/mall-setting' +import { WebhookModule } from './lib/commerce/modules/webhook' + +export interface CommerceTokenResponse { + access_token: string + expired_at?: string +} + +export class BootpayCommerce extends BootpayCommerceResource { + public user!: UserModule + public userGroup!: UserGroupModule + public product!: ProductModule + public invoice!: InvoiceModule + public order!: OrderModule + public orderCancel!: OrderCancelModule + public orderSubscription!: OrderSubscriptionModule + public orderSubscriptionBill!: OrderSubscriptionBillModule + public orderSubscriptionAdjustment!: OrderSubscriptionAdjustmentModule + public orderSubscriptionRequest!: OrderSubscriptionRequestModule + public category!: CategoryModule + public coupon!: CouponModule + public point!: PointModule + public cart!: CartModule + public store!: StoreModule + public mallSetting!: MallSettingModule + public webhook!: WebhookModule + + constructor(configuration?: CommerceConfiguration) { + super() + if (configuration) { + this.setConfiguration(configuration) + } + this.initModules() + } + + private initModules(): void { + this.user = new UserModule(this) + this.userGroup = new UserGroupModule(this) + this.product = new ProductModule(this) + this.invoice = new InvoiceModule(this) + this.order = new OrderModule(this) + this.orderCancel = new OrderCancelModule(this) + this.orderSubscription = new OrderSubscriptionModule(this) + this.orderSubscriptionBill = new OrderSubscriptionBillModule(this) + this.orderSubscriptionAdjustment = new OrderSubscriptionAdjustmentModule(this) + this.orderSubscriptionRequest = new OrderSubscriptionRequestModule(this) + this.category = new CategoryModule(this) + this.coupon = new CouponModule(this) + this.point = new PointModule(this) + this.cart = new CartModule(this) + this.store = new StoreModule(this) + this.mallSetting = new MallSettingModule(this) + this.webhook = new WebhookModule(this) + } + + /** + * 액세스 토큰 발급 + * client_key/secret_key로 인증 + */ + async getAccessToken(): Promise { + try { + const { client_key, secret_key } = this.commerceConfiguration + + const response: any = await this.postWithBasicAuth('request/token', { + client_key, + secret_key + }) + + if (response?.access_token) { + this.setToken(response.access_token) + } + + return response + } catch (e: any) { + return Promise.reject(e) + } + } + + /** + * 토큰을 발급받아 설정합니다. (메서드 체이닝 지원) + */ + async withToken(): Promise { + await this.getAccessToken() + return this + } + + /** + * 현재 설정된 토큰을 반환합니다. + */ + getCurrentToken(): string | undefined { + return this.getToken() + } + + /** + * 토큰이 설정되어 있는지 확인합니다. + */ + hasToken(): boolean { + const token = this.getToken() + return token !== undefined && token !== '' + } + + /** + * 현재 role을 설정합니다. (메서드 체이닝 지원) + * @param role 설정할 role + */ + withRole(role: string): BootpayCommerce { + this.setRole(role) + return this + } + + /** + * 일반 사용자 role로 설정합니다. + */ + asUser(): BootpayCommerce { + return this.withRole('user') + } + + /** + * 매니저 role로 설정합니다. + */ + asManager(): BootpayCommerce { + return this.withRole('manager') + } + + /** + * 파트너 role로 설정합니다. + */ + asPartner(): BootpayCommerce { + return this.withRole('partner') + } + + /** + * 벤더 role로 설정합니다. + */ + asVendor(): BootpayCommerce { + return this.withRole('vendor') + } + + /** + * 슈퍼바이저 role로 설정합니다. + */ + asSupervisor(): BootpayCommerce { + return this.withRole('supervisor') + } + + /** + * 현재 role을 반환합니다. + */ + getCurrentRole(): string { + return this.getRole() + } + + /** + * role을 기본값(user)으로 초기화합니다. + */ + clearRole(): BootpayCommerce { + this.setRole('user') + return this + } +} + +export { BootpayCommerceResource, CommerceConfiguration, BootpayCommerceResponse } +export * from './lib/commerce/types' +export * from './lib/commerce/modules' diff --git a/src/bootpay.ts b/src/bootpay.ts index 7a8169a..432ab1e 100644 --- a/src/bootpay.ts +++ b/src/bootpay.ts @@ -1,517 +1,508 @@ -import { BootpaySingleton } from "./lib/bootpay/singleton" -import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from "axios" -import { isBlank, isPresent, objectKeyToUnderscore } from "./lib/bootpay/support" - -const API_URL: any = { - development: 'https://dev-api.bootpay.co.kr', - stage: 'https://stage-api.bootpay.co.kr', - production: 'https://api.bootpay.co.kr' -} - -export interface BootpayCommonResponse { - status: Number - code: Number - message?: String - data?: T -} - -export interface BootpayCancelData { - receiptId: string, // 부트페이에서 발급한 영수증 id - price?: number, // (선택사항) 부분취소 요청시 금액을 지정, 미지정시 전액 취소 (부분취소가 가능한 PG사, 결제수단에 한해 적용됨) - name?: string, // 취소 요청자 이름 - reason?: string, // 취소 요청 사유 - refund?: BootpayRefundData -} - -export interface BootpayRefundData { - account: string, - accountholder: string, - bankcode: string -} - -export interface BootpaySubscribeBillingData { - orderId: string, // 개발사에서 지정하는 고유주문번호 - pg: string, // PG사의 Alias ex) danal, kcp, inicis 등 - itemName: string, // 상품명 - cardNo: string, // 카드 일련번호 - cardPw: string, // 카드 비밀번호 앞 2자리 - expireYear: string, // 카드 유효기간 년 - expireMonth: string, // 카드 유효기간 월 - identifyNumber: string, // 주민등록번호 또는 사업자번호 - userInfo?: BootpayUserInfoData, // 구매자 정보 - extra?: BootpaySubscribeExtraData //기타 옵션 -} - -export interface BootpayRequestSubscribeBillingPaymentData { - billingKey: string, // 발급받은 빌링키 - itemName: string, // 결제할 상품명 - price: number, // 결제할 상품금액 - taxFree?: number, // 면세금액 - orderId: string, // 개발사에서 지정하는 고유주문번호 - quota?: number, // 할부 개월수 - interest?: number, // 무이자 여부 ( 웰컴 페이먼츠만 가능 ) - userInfo?: BootpayUserInfoData, // 구매자 정보, 특정 PG사의 경우 구매자 휴대폰 번호를 필수로 받는다 - items?: Array, // 구매할 상품정보 - feedbackUrl?: string, // 결제 완료 후 피드백 받을 URL - feedbackContentType?: string, // Feedback 받을 경우 content-type - json, urlencoded - extra?: BootpaySubscribeExtraData//기타 옵션 -} - -export interface BootpayReserveSubscribeBillingData { - billingKey: string, // 발급받은 빌링키 - itemName: string, // 결제할 상품명 - price: number, // 결제할 상품금액 - taxFree: number, // 면세금액 - orderId: string, // 개발사에서 지정하는 고유주문번호 - quota?: number, // 할부 개월수 - interest?: number, // 무이자 여부 ( 웰컴 페이먼츠만 가능 ) - schedulerType: string, // 실행 방법 - oneshot - executeAt: number, // (예약) 결제 실행시간 - userInfo?: BootpayUserInfoData, // 구매자 정보, 특정 PG사의 경우 구매자 휴대폰 번호를 필수로 받는다 - items?: Array, // 구매할 상품정보 - feedbackUrl: string, // 결제 완료 후 피드백 받을 URL - feedbackContentType: string, // Feedback 받을 경우 content-type - json, urlencoded - extra?: BootpaySubscribeExtraData//기타 옵션 -} - -export interface BootpayRequestPaymentData { - pg?: string, // [PG 결제] 사용하고자 하는 PG사의 Alias를 입력. ex) danal, kcp, inicis등, 미 지정시 통합결제창이 오픈 - method?: string, //card:카드, phone: 휴대폰, bank: 실시간 계좌이체, vbank: 가상계좌, auth: 본인인증, card_rebill: 정기결제, easy: 카카오,페이코,네이버페이 등의 간편결제, 미지정시 통합결제창 오픈 - methods?: Array, // 통합결제시 사용할 method 배열 형태 - orderId: string, // 개발사에서 관리하는 고유결제번호 - price: number, // 결제금액 - taxFree?: number, // 비과세 금액 - itemName: string, // 결제할 상품명 - // returnUrl?: string, - params?: string, // string 형태로 전달 할 값, 결제 후 똑같이 리턴해드림 - userInfo?: BootpayUserInfoData, // 구매자 정보 - items?: Array, // 상품정보 - extra?: any // 기타 옵션 -} +import { BootpayBackendNodejsResource } from './lib/resource' +import { + AccessTokenResponseParameters, + CancelPaymentParameters, + CertificateResponseParameters, + DestroySubscribeResponseParameters, + ReceiptResponseParameters, + SubscriptionBillingRequestParameters, + SubscriptionBillingResponseParameters, + SubscriptionCardPaymentRequestParameters, + UserTokenRequestParameters, + UserTokenResponseParameters, + SubscribePaymentReserveParameters, + SubscribePaymentReserveResponse, + CancelSubscribeReserveResponse, + ShippingRequestParameters, + CashReceiptPublishOnReceiptParameters, + CashReceiptCancelOnReceiptParameters, + RequestCashReceiptParameters, + CancelCashReceiptParameters, + RequestAuthenticateParameters, + SubscribePaymentLookupResponse, + SubscriptionBillingTransferRequestParameters, + SubscriptionPaymentRequestParameters, + WalletDataPart, + WalletRequestParameters, + WalletPaymentResponseParameters +} from './lib/response' + +class BootpayBackendNodejs extends BootpayBackendNodejsResource { + constructor() { + super() + } -export interface BootpayRequestUserTokenData { - userId: string, // 개발사에서 관리하는 회원 고유 id - email?: string, // 회원 email - name?: string, // 회원명 - gender?: number, // 0 - 여자, 1 - 남자 - birth?: string, // 생일 901004 - phone?: string //01012341234 -} + /** + * Get Access Token + * Comment by GOSOMI + * @returns Promise + */ + async getAccessToken(): Promise { + try { + const { application_id, private_key, client_key, secret_key } = this.bootpayConfiguration + const hasLegacyCredentials = application_id && private_key + if ((client_key && !secret_key) || (!client_key && secret_key && !hasLegacyCredentials)) { + return Promise.reject({ + error_code: -101, + message: 'client_key/secret_key를 함께 입력해주세요.' + }) + } + // client_key/secret_key 인증은 매 요청 인터셉터가 Basic Auth 헤더를 직접 부착한다. + // request/token 호출이 불필요하므로, 호환을 위해 합성 응답만 즉시 반환한다. + if (client_key && secret_key) { + return Promise.resolve({ access_token: '', expire_in: 0 }) + } + const response: AccessTokenResponseParameters = await this.post('request/token', { + application_id, + private_key + }) + // set Token + this.setToken(response.access_token) + return Promise.resolve(response) + } catch (e: any) { + return Promise.reject(e) + } + } -export interface BootpayItemData { - unique: string, // 상품 고유키 - qty: number, // 수량 - itemName: string, // 상품명 - price: number, // 상품단가 - cat1?: string, // 카테고리 상 - cat2?: string, // 카테고리 중 - cat3?: string // 카테고리 하 -} + /** + * Lookup Receipt + * Comment by GOSOMI + * @param receiptId: string + * @param lookupUserData: boolean + */ + async receiptPayment(receiptId: string, lookupUserData: boolean = false): Promise { + try { + const response: ReceiptResponseParameters = await this.get(`receipt/${ receiptId }?lookup_user_data=${ lookupUserData ? 'true' : 'false' }`) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } -export interface BootpaySubscribeExtraData { - subscribeTestPayment: number, // 100원 결제 후 결제가 되면 billing key를 발행, 결제가 실패하면 에러 - rawData?: number //PG 오류 코드 및 메세지까지 리턴 -} + /** + * Cancel Payment + * Comment by GOSOMI + * @param cancelPayment: CancelPaymentParameters + * @returns Promise + */ + async cancelPayment(cancelPayment: CancelPaymentParameters): Promise { + try { + const response: ReceiptResponseParameters = await this.post('cancel', { + ...cancelPayment + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } -export interface BootpayUserInfoData { - id: string, // 개발사에서 관리하는 회원 고유 id - username?: string, //구매자 이름 - email?: string, // 구매자 email - phone?: string, //01012341234 - gender?: number, //0:여자, 1:남자 - area?: string, // 서울|인천|대구|광주|부산|울산|경기|강원|충청북도|충북|충청남도|충남|전라북도|전북|전라남도|전남|경상북도|경북|경상남도|경남|제주|세종|대전 중 택 1 - birth?: string -} + /** + * Lookup Certificate Data + * Comment by GOSOMI + * @param receiptId: string + * @returns Promise + */ + async certificate(receiptId: string): Promise { + try { + const response: CertificateResponseParameters = await this.get(`certificate/${ receiptId }`) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } + /** + * ConfirmPayment + * Comment by GOSOMI + * @param receiptId: string + * @returns Promise + */ + async confirmPayment(receiptId: string): Promise { + try { + const response: ReceiptResponseParameters = await this.post('confirm', { + receipt_id: receiptId + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } -class BootpayRestClient extends BootpaySingleton { + /** + * lookupSubscribeBillingKey + * Comment by GOSOMI + * @param receiptId: string + * @returns Promise + */ + async lookupSubscribeBillingKey(receiptId: string): Promise { + try { + const response: SubscriptionBillingResponseParameters = await this.get(`subscribe/billing_key/${ receiptId }`) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } - $http: AxiosInstance - $token?: string - applicationId?: string - privateKey?: string - mode: string + /** + * lookupBillingKey + * Comment by ehowlsla + * @param billingKey: string + * @returns Promise + */ + async lookupBillingKey(billingKey: string): Promise { + try { + const response: SubscriptionBillingResponseParameters = await this.get(`billing_key/${ billingKey }`) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } - constructor() { - super() - this.mode = 'production' - this.$token = undefined - this.$http = axios.create({ - timeout: 60000 - }) - this.$http.interceptors.response.use((response: AxiosResponse): any => { - if (isPresent(response.request) && isPresent(response.headers)) { - return response.data as BootpayCommonResponse - } else { - return { - code: -100, - status: 500, - message: `오류가 발생했습니다. ${ response }`, - data: response - } as BootpayCommonResponse - } - }, function (error) { - if (isPresent(error.response)) { - return Promise.reject(error.response.data) - } else { - return Promise.reject({ - code: -100, - message: `통신오류가 발생하였습니다. ${ error.message }`, - status: 500 - }) - } - }) - this.$http.interceptors.request.use((config: AxiosRequestConfig) => { - if (isPresent(this.$token)) { - config.headers.authorization = this.$token - } - config.headers['Content-Type'] = 'application/json' - config.headers['Accept'] = 'application/json' - return config - }, (error) => { - return Promise.reject(error) - }) + /** + * lookupSequentialBillingKey + * 우선순위(순차) 결제 빌링키 조회 + * Comment by GOSOMI + * @date: 2026-07-03 + * @param widgetKey: string + * @param billingKey: string + * @param userId: string 조회 대상 회원 ID (서버가 빌링키 소유자 검증에 사용한다) + * @returns Promise + */ + async lookupSequentialBillingKey(widgetKey: string, billingKey: string, userId: string): Promise { + try { + const response: SubscriptionBillingResponseParameters = await this.get(`subscribe/sequential_billing_key/${ billingKey }?widget_key=${ encodeURIComponent(widgetKey) }&user_id=${ encodeURIComponent(userId) }`) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } } /** - * rest api configure - * Comment by rumi - * @date: 2020-10-27 - * @param (applicationId, privateKey, mode) - * @returns void + * requestSubscribeBillingKey + * Comment by GOSOMI + * @param subscriptionBillingRequest: SubscriptionBillingRequestParameters + * @returns Promise */ - setConfig(applicationId: string, privateKey: string, mode: string = 'production') { - this.applicationId = applicationId - this.privateKey = privateKey - this.mode = isPresent(mode) ? mode : 'production' - if (isBlank(API_URL[this.mode])) { - throw new Error(`환경설정 설정이 잘못되었습니다. 현재 설정된 모드: ${ this.mode }, 가능한 모드: development, stage, production`) + async requestSubscribeBillingKey(subscriptionBillingRequest: SubscriptionBillingRequestParameters): Promise { + try { + const response: SubscriptionBillingResponseParameters = await this.post('request/subscribe', { + ...subscriptionBillingRequest + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) } - return } /** - * 1. 토큰 발급 - * getting access token - * Comment by rumi - * @date: 2020-10-27 - * @param void - * @returns Promise + * requestSubscribeCardPayment + * Comment by GOSOMI + * @param subscriptionCardRequest: SubscriptionCardPaymentRequestParameters + * @returns Promise */ - async getAccessToken(): Promise { - let response: BootpayCommonResponse + async requestSubscribeCardPayment(subscriptionCardRequest: SubscriptionCardPaymentRequestParameters): Promise { try { - response = await this.$http.post( - this.getApiUrl('request/token'), - { - application_id: this.applicationId, - private_key: this.privateKey - } - ) + const response: ReceiptResponseParameters = await this.post('subscribe/payment', { + ...subscriptionCardRequest + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - this.$token = response.data.token - return Promise.resolve(response) } /** - * 2. 결제 검증 - * receipt verify - * Comment by rumi - * @date: 2020-10-27 - * @param receiptId - * @returns Promise + * requestSubscribePayment + * Comment by ehowlsla + * @param subscriptionRequest: SubscriptionPaymentRequestParameters + * @returns Promise */ - async verify(receiptId: string): Promise { - let response: BootpayCommonResponse + async requestSubscribePayment(subscriptionRequest: SubscriptionPaymentRequestParameters): Promise { try { - response = await this.$http.get( - this.getApiUrl(`receipt/${ receiptId }`) - ) + const response: ReceiptResponseParameters = await this.post('subscribe/payment', { + ...subscriptionRequest + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } /** - * 3. 결제 취소 (전액 취소 / 부분 취소) - * Payment Cancel - * Comment by rumi - * @date: 2020-10-27 - * @param data: BootpayCancelData - * @returns Promise + * destroyBillingKey + * Comment by GOSOMI + * @param billingKey:string + * @returns Promise */ - async cancel(data: BootpayCancelData) { - let response: BootpayCommonResponse + async destroyBillingKey(billingKey: string): Promise { try { - response = await this.$http.post( - this.getApiUrl('cancel'), - { - receipt_id: data.receiptId, - price: data.price, - name: data.name, - reason: data.reason, - refund: data.refund - } - ) + const response: DestroySubscribeResponseParameters = await this.delete(`subscribe/billing_key/${ billingKey }`) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } /** - * 4. 빌링키 발급 - * Request Subscribe Card Billing Key - * Comment by rumi - * @date: 2020-10-27 - * @param data: BootpaySubscribeBillingData - * @returns Promise + * requestUserToken + * Comment by GOSOMI + * @param userTokenRequest:UserTokenRequestParameters + * @returns Promise */ - async requestSubscribeBillingKey(data: BootpaySubscribeBillingData) { - let response: BootpayCommonResponse + async requestUserToken(userTokenRequest: UserTokenRequestParameters): Promise { try { - response = await this.$http.post( - this.getApiUrl('request/card_rebill'), - { - order_id: data.orderId, - pg: data.pg, - item_name: data.itemName, - card_no: data.cardNo, - card_pw: data.cardPw, - expire_year: data.expireYear, - expire_month: data.expireMonth, - identify_number: data.identifyNumber, - user_info: data.userInfo, - extra: objectKeyToUnderscore(data.extra) - } - ) + const response: UserTokenResponseParameters = await this.post('request/user/token', { + ...userTokenRequest + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } + /** + * subscribePaymentReserve + * Comment by GOSOMI + * @param subscribePaymentReserveRequest:SubscribePaymentReserveParameters + * @returns Promise + */ + async subscribePaymentReserve(subscribePaymentReserveRequest: SubscribePaymentReserveParameters) { + try { + const response: SubscribePaymentReserveResponse = await this.post('subscribe/payment/reserve', { + ...subscribePaymentReserveRequest + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } /** - * 4-1. 발급된 빌링키로 결제 승인 요청 - * subscribe payment by billing key - * Comment by rumi - * @date: 2020-10-27 - * @param data: BootpayRequestSubscribeBillingPaymentData - * @returns Promise + * SubscribeReserve Lookup + * Comment by GOSOMI + * @date: 2023-03-07 + * @param reserveId: string + * @returns Promise */ - async requestSubscribeBillingPayment(data: BootpayRequestSubscribeBillingPaymentData) { - let response: BootpayCommonResponse + async subscribePaymentReserveLookup(reserveId: string) { try { - response = await this.$http.post( - this.getApiUrl('subscribe/billing'), - { - billing_key: data.billingKey, - order_id: data.orderId, - item_name: data.itemName, - price: data.price, - tax_free: data.taxFree, - interest: data.interest, - quota: data.quota, - items: objectKeyToUnderscore(data.items), - user_info: objectKeyToUnderscore(data.userInfo), - feedback_url: data.feedbackUrl, - feedback_content_type: data.feedbackContentType, - extra: data.extra - } - ) + const response: SubscribePaymentLookupResponse = await this.get(`subscribe/payment/reserve/${ reserveId }`) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } /** - * 4-2. 발급된 빌링키로 결제 예약 요청 - * reserve payment by billing key - * Comment by rumi - * @date: 2020-10-27 - * @param data: BootpayReserveSubscribeBillingData - * @returns Promise + * cancelSubscribeReserve + * Comment by GOSOMI + * @param reserveId:string + * @returns Promise */ - async reserveSubscribeBilling(data: BootpayReserveSubscribeBillingData) { - let response: BootpayCommonResponse + async cancelSubscribeReserve(reserveId: string) { try { - response = await this.$http.post( - this.getApiUrl('subscribe/billing/reserve'), - { - billing_key: data.billingKey, - order_id: data.orderId, - price: data.price, - tax_free: data.taxFree, - user_info: objectKeyToUnderscore(data.userInfo), - item_info: objectKeyToUnderscore(data.items), - item_name: data.itemName, - feedback_url: data.feedbackUrl, - feedback_content_type: data.feedbackContentType, - scheduler_type: data.schedulerType, - execute_at: data.executeAt - } - ) + const response: CancelSubscribeReserveResponse = await this.delete(`subscribe/payment/reserve/${ reserveId }`) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } /** - * 4-2-1. 발급된 빌링키로 결제 예약 - 취소 요청 - * Cancel Reserve Subscribe Billing - * Comment by rumi - * @date: 2020-10-27 - * @param reserveId: string - * @returns Promise + * 배송시작 REST API 시작 + * Comment by GOSOMI + * @date: 2022-06-14 */ - async destroyReserveSubscribeBilling(reserveId: string) { - let response: BootpayCommonResponse + async shippingStart(shippingRequest: ShippingRequestParameters): Promise { try { - response = await this.$http.delete( - this.getApiUrl(`subscribe/billing/reserve/${ reserveId }`) - ) + const response: ReceiptResponseParameters = await this.put(`escrow/shipping/start/${ shippingRequest.receipt_id }`, shippingRequest) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } + /** + * 기존결제 현금영수증 발행 API + * Comment by GOSOMI + * @date: 2022-07-28 + */ + async cashReceiptPublishOnReceipt(cashReceiptPublishRequest: CashReceiptPublishOnReceiptParameters) { + try { + const response: ReceiptResponseParameters = await this.post('request/receipt/cash/publish', cashReceiptPublishRequest) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } /** - * 4-3. 빌링키 삭제 - * destroy billing key - * Comment by rumi - * @date: 2020-10-27 - * @param billingKey: string - * @returns Promise + * 기존 결제 현금영수증 발행 취소 API + * Comment by GOSOMI + * @date: 2022-08-09 */ - async destroySubscribeBillingKey(billingKey: string) { - let response: BootpayCommonResponse + async cashReceiptCancelOnReceipt(cashReceiptCancelRequest: CashReceiptCancelOnReceiptParameters) { try { - response = await this.$http.delete( - this.getApiUrl(`subscribe/billing/${ billingKey }`) - ) + const response: null = await this.delete(`request/receipt/cash/cancel/${ cashReceiptCancelRequest.receipt_id }`, { + params: cashReceiptCancelRequest + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } + /** + * 별건 현금영수증 발행하기 + * Comment by GOSOMI + * @date: 2022-08-09 + */ + async requestCashReceipt(cashReceiptRequest: RequestCashReceiptParameters) { + try { + const response: ReceiptResponseParameters = await this.post('request/cash/receipt', cashReceiptRequest) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } /** - * 5. (부트페이 단독 - 간편결제창, 생체인증 기반의 사용자를 위한) 사용자 토큰 발급 - * get user token - * Comment by rumi - * @date: 2020-10-27 - * @param data: BootpayRequestUserTokenData - * @returns Promise + * 별건 현금영수증 취소하기 + * Comment by GOSOMI + * @date: 2022-08-09 */ - async requestUserToken(data: BootpayRequestUserTokenData) { - let response: BootpayCommonResponse + async cancelCashReceipt(cancelCashReceiptRequest: CancelCashReceiptParameters) { try { - response = await this.$http.post( - this.getApiUrl('request/user/token'), - { - user_id: data.userId, - email: data.email, - name: data.name, - gender: data.gender, - birth: data.birth, - phone: data.phone - } - ) + const response: ReceiptResponseParameters = await this.delete(`request/cash/receipt/${ cancelCashReceiptRequest.receipt_id }`, { + params: cancelCashReceiptRequest + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } /** - * 6. 결제링크 생성 - * REST API로 결제 요청을 합니다 - * Comment by rumi - * @date: 2020-10-27 - * @param data: any - * @returns Promise + * 본인인증 REST API 요청 + * Comment by GOSOMI + * @date: 2022-11-07 */ - async requestPayment(data: BootpayRequestPaymentData) { - let response: BootpayCommonResponse + async requestAuthentication(authenticateRequest: RequestAuthenticateParameters) { try { - response = await this.$http.post( - this.getApiUrl('request/payment'), - { - pg: data.pg, - method: data.method, - methods: data.methods, - order_id: data.orderId, - price: data.price, - params: data.params, - tax_free: data.taxFree, - name: data.itemName, - user_info: objectKeyToUnderscore(data.userInfo), - items: objectKeyToUnderscore(data.items), - // return_url: data.returnUrl, - extra: objectKeyToUnderscore(data.extra) - } - ) + const response: CertificateResponseParameters = await this.post('request/authentication', authenticateRequest) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } + /** + * 본인인증 승인하기 + * Comment by GOSOMI + * @date: 2022-11-07 + */ + async confirmAuthentication(receipt_id: string, otp: null | string = null) { + try { + const response: CertificateResponseParameters = await this.post('authenticate/confirm', { + receipt_id, + otp + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } /** - * 7. 서버 승인 요청 - * Server Submit method - * Comment by rumi - * @date: 2020-10-27 - * @param receiptId - * @returns Promise + * 본인인증 SMS 재전송 + * Comment by GOSOMI + * @date: 2022-11-07 */ - async submit(receiptId: string): Promise { - let response: BootpayCommonResponse + async realarmAuthentication(receipt_id: string) { try { - response = await this.$http.post( - this.getApiUrl('submit'), - { receipt_id: receiptId } - ) + const response: CertificateResponseParameters = await this.post('authenticate/realarm', { + receipt_id + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) } + /** + * 계좌 자동이체를 위한 빌링키 발급 요청 + * Comment by ehowlsla + * @date: 2024-05-27 + */ + async requestSubscribeAutomaticTransferBillingKey(parameters: SubscriptionBillingTransferRequestParameters) { + try { + const response: ReceiptResponseParameters = await this.post('request/subscribe/automatic-transfer', parameters) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } /** - * 8. 본인 인증 결과 검증 - * Certificate Data - * Comment by rumi - * @date: 2020-10-27 - * @param receiptId: string - * @returns Promise + * 계좌 자동이체를 위한 출금 동의 확인 요청 + * Comment by ehowlsla + * @date: 2024-05-27 */ - async certificate(receiptId: string) { - let response: BootpayCommonResponse + async publishAutomaticTransferBillingKey(receipt_id: string) { try { - response = await this.$http.get( - this.getApiUrl(`certificate/${ receiptId }`) - ) + const response: SubscriptionBillingResponseParameters = await this.post('request/subscribe/automatic-transfer/publish', { + "receipt_id": receipt_id + }) + return Promise.resolve(response) } catch (e) { return Promise.reject(e) } - return Promise.resolve(response) - } + } + + /** + * 등록된 지갑 리스트 가져오기 + * Comment by ehowlsla + * @date: 2025-03-16 + * @deprecated 다음 메이저 버전에서 제거 예정. wallet 엔드포인트는 폐기 예정이며, 결제는 Request::PaymentController#create 의 wallet_id + user_token 으로 처리됩니다. + */ + async getUserWallets(user_id: string, sandbox: boolean): Promise { + try { + const queryParams = new URLSearchParams({ user_id, sandbox: sandbox.toString() }).toString(); + const response: WalletDataPart[] = await this.get(`wallet?${queryParams}`); + return Promise.resolve(response) + } catch (error) { + return Promise.reject(error) + } + } - private getApiUrl(uri: string) { - return [API_URL[this.mode], uri].join('/') + // async getUserWallets(user_id: string, sandbox: boolean) { + // try { + // const response: WalletDataPart[] = await this.get(`wallet?user_id=${user_id}&sandbox=${sandbox}`) + // return Promise.resolve(response) + // } catch (e) { + // return Promise.reject(e) + // } + // } + + /** @deprecated wallet 엔드포인트는 폐기 예정. 다음 메이저 버전에서 제거됩니다. wallet_id + user_token 흐름으로 전환하세요. */ + async requestWalletPayment(walletRequest: WalletRequestParameters) { + try { + const response: WalletPaymentResponseParameters = await this.post('wallet/payment', { + ...walletRequest + }) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } } } -export const Bootpay = BootpayRestClient.currentInstance() \ No newline at end of file +const Bootpay: BootpayBackendNodejs = new BootpayBackendNodejs() + +export { Bootpay } + +export default Bootpay + +export * from './lib/response' +export * from './lib/resource' +export * from './bootpay-commerce' diff --git a/src/lib/bootpay/singleton.ts b/src/lib/bootpay/singleton.ts deleted file mode 100644 index eb14325..0000000 --- a/src/lib/bootpay/singleton.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class BootpaySingleton { - private static __instance: any - - /** - * Singleton Instance Return - * Comment by rumi - * @date: 2020-10-20 - * @param - * @returns - */ - static currentInstance() { - if (!this.__instance) { - this.__instance = new this() - } - return this.__instance as T - } -} \ No newline at end of file diff --git a/src/lib/bootpay/support.ts b/src/lib/bootpay/support.ts deleted file mode 100644 index b803a8a..0000000 --- a/src/lib/bootpay/support.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BootpaySingleton } from "./singleton" - -export interface Validate { - isBlank(value: any): Boolean - - isType(value: any, type: string): Boolean - - isPresent(value: any): Boolean - - presence(value: any, defaultValue: any): any - - toUnderscore(value: any): any - - objectKeyToUnderscore(value: any): any -} - -class ValidateMethod extends BootpaySingleton implements Validate { - isBlank(value: any): Boolean { - let valid: Boolean = false - if (typeof value === 'string') { - valid = value.length === 0 - } else if (Array.isArray(value)) { - valid = value.length === 0 - } else { - valid = value === undefined || - value === null || - (this.isType(value, 'object') && value.constructor === Object && Object.keys(value).length === 0) - } - return valid - } - - isType(value: any, type: string) { - return (typeof value === type) - } - - isPresent(value: any): Boolean { - return !this.isBlank(value) - } - - presence(value: any, defaultValue: any): any { - if (this.isBlank(value)) { - return defaultValue - } else { - return value - } - } - - objectKeyToUnderscore(value: any): any { - let cloneObject: any = undefined - if (isPresent(value)) { - const _this = this - if (Array.isArray(value)) { - cloneObject = [] - value.forEach((_value) => { - let childObject: any = {} - Object.keys(_value).forEach((key) => { - childObject[_this.toUnderscore(key)] = _value[key] - }) - cloneObject.push(childObject) - }) - } else { - cloneObject = {} - Object.keys(value).forEach((key) => { - cloneObject[_this.toUnderscore(key)] = value[key] - }) - } - } - return cloneObject - } - - toUnderscore(value: any): any { - return value.split(/(?=[A-Z])/).join('_').toLowerCase() - } -} - -const ValidClass: ValidateMethod = ValidateMethod.currentInstance() - -export const isPresent = (value: any) => ValidClass.isPresent(value) -export const isBlank = (value: any) => ValidClass.isBlank(value) -export const presence = (value: any, defaultValue: any) => ValidClass.presence(value, defaultValue) -export const toUnderscore = (value: any) => ValidClass.toUnderscore(value) -export const objectKeyToUnderscore = (value: any) => ValidClass.objectKeyToUnderscore(value) -export const isType = (value: any, type: string) => ValidClass.isType(value, type) \ No newline at end of file diff --git a/src/lib/commerce-resource.ts b/src/lib/commerce-resource.ts new file mode 100644 index 0000000..b56017b --- /dev/null +++ b/src/lib/commerce-resource.ts @@ -0,0 +1,246 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import FormData from 'form-data' + +export interface BootpayCommerceRestApiErrorResponse { + error_code?: number + message?: string +} + +interface CommerceEntrypoints { + development: string + stage: string + production: string +} + +export interface CommerceConfiguration { + client_key?: string + secret_key?: string + mode?: 'development' | 'production' | 'stage' +} + +export interface BootpayCommerceResponse { + success: boolean + data: T + error?: string +} + +export class BootpayCommerceResource { + $http: AxiosInstance + $token?: string + $role: string + mode: 'development' | 'production' | 'stage' + commerceConfiguration: CommerceConfiguration + API_ENTRYPOINTS: CommerceEntrypoints + apiVersion: string = '1.0.0' + sdkVersion: string = '1.0.0' + + constructor() { + this.mode = 'production' + this.$role = 'user' + this.$http = axios.create({ + timeout: 60000 + }) + this.$token = undefined + this.commerceConfiguration = { + client_key: '', + secret_key: '', + mode: 'production' + } + this.API_ENTRYPOINTS = { + development: 'https://dev-api.bootapi.com/v1', + stage: 'https://stage-api.bootapi.com/v1', + production: 'https://api.bootapi.com/v1' + } + + this.$http.interceptors.response.use( + (response: AxiosResponse): any => { + return response.data + }, + (error: any) => { + if (error.response !== undefined) { + return Promise.reject(error.response.data) + } else { + return Promise.reject({ + error: `Request Rest Api Failed to Bootpay Commerce Server, ${error.message}` + }) + } + } + ) + + this.$http.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // ⚠️ 요청이 Content-Type 을 직접 지정한 경우(multipart/form-data 등) 덮어쓰지 않는다. + // 덮어쓰면 form-data 가 붙인 boundary 가 사라져 본문이 서버에서 null 로 파싱된다. + if (!config.headers.has('Content-Type')) { + config.headers.set('Content-Type', 'application/json') + } + config.headers.set('Accept', 'application/json') + config.headers.set('Accept-Charset', 'utf-8') + config.headers.set('BOOTPAY-SDK-VERSION', this.sdkVersion) + config.headers.set('BOOTPAY-API-VERSION', this.apiVersion) + config.headers.set('BOOTPAY-SDK-TYPE', '301') + // 요청별로 role 이 지정된 경우(supervisor 전용 endpoint 등)에는 그 값을 유지한다. + if (!config.headers.has('BOOTPAY-ROLE')) { + config.headers.set('BOOTPAY-ROLE', this.$role || 'user') + } + + const authorization = this.authorizationHeader() + if (authorization) { + config.headers.set('Authorization', authorization) + } + return config + }, + (error: any) => { + return Promise.reject(error) + } + ) + } + + setConfiguration(configuration: CommerceConfiguration): void { + if (configuration.mode === undefined) { + configuration.mode = 'production' + } + this.commerceConfiguration = configuration + } + + setApiVersion(version: string) { + this.apiVersion = version + } + + setToken(token: string): void { + this.$token = token + } + + getToken(): string | undefined { + return this.$token + } + + setRole(role: string): void { + this.$role = role + } + + getRole(): string { + return this.$role + } + + /** + * Authorization 헤더 + * 토큰이 발급되어 있으면 Bearer, 없으면 client_key/secret_key Basic Auth 를 사용한다. + */ + authorizationHeader(): string { + const token = this.$token + if (token !== undefined && token !== '') { + return `Bearer ${token}` + } + return this.getBasicAuthHeader() + } + + /** + * client_key/secret_key Basic Auth 헤더 + * 계산 결과를 $token 에 저장하지 않는다 — 저장하면 다음 요청부터 Basic 값이 + * Bearer 토큰으로 오인되어 인증이 깨진다. + */ + private getBasicAuthHeader(): string { + const { client_key, secret_key } = this.commerceConfiguration + if (client_key && secret_key) { + const credentials = `${client_key}:${secret_key}` + const encoded = Buffer.from(credentials).toString('base64') + return `Basic ${encoded}` + } + return '' + } + + entrypoints(url: string): string { + const mode = this.commerceConfiguration.mode || 'production' + return [this.API_ENTRYPOINTS[mode], url].join('/') + } + + async get(url: string, config?: AxiosRequestConfig): Promise> { + try { + const response = await this.$http.get(this.entrypoints(url), config) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } + + async post( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise> { + try { + const response = await this.$http.post(this.entrypoints(url), data, config) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } + + /** + * multipart/form-data 전송 (파일 업로드용) + * ⚠️ Content-Type 은 form-data 가 생성한 값(boundary 포함)을 그대로 사용한다. + * 직접 지정하거나 인터셉터가 덮어쓰면 boundary 가 사라져 본문이 깨진다. + * 기존 post 는 JSON 고정이라 손대지 않고 별도 메서드로 둔다. + */ + async postMultipart( + url: string, + form: FormData, + config?: AxiosRequestConfig + ): Promise> { + try { + const response = await this.$http.post(this.entrypoints(url), form, { + ...config, + headers: { + ...config?.headers, + ...form.getHeaders() + } + }) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } + + async postWithBasicAuth( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise> { + try { + const authConfig: AxiosRequestConfig = { + ...config, + headers: { + ...config?.headers, + Authorization: this.getBasicAuthHeader() + } + } + const response = await this.$http.post(this.entrypoints(url), data, authConfig) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } + + async put( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise> { + try { + const response = await this.$http.put(this.entrypoints(url), data, config) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } + + async delete(url: string, config?: AxiosRequestConfig): Promise> { + try { + const response = await this.$http.delete(this.entrypoints(url), config) + return Promise.resolve(response as unknown as BootpayCommerceResponse) + } catch (e) { + return Promise.reject(e) + } + } +} diff --git a/src/lib/commerce/modules/cart.ts b/src/lib/commerce/modules/cart.ts new file mode 100644 index 0000000..c02daf1 --- /dev/null +++ b/src/lib/commerce/modules/cart.ts @@ -0,0 +1,23 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { OrderPreviewParams, OrderPreviewResponse } from '../types' + +export class CartModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 주문 미리보기 (배송비/할인 권위적 계산) + * POST /v1/cart/order-preview + * + * member_mode='guest' (기본): cart_items 필수 + * member_mode='member': 서버 장바구니 사용 (user 토큰 필요) + */ + async orderPreview( + params: OrderPreviewParams = {} + ): Promise> { + return this.bootpay.post('cart/order-preview', params) + } +} diff --git a/src/lib/commerce/modules/category.ts b/src/lib/commerce/modules/category.ts new file mode 100644 index 0000000..cb25efa --- /dev/null +++ b/src/lib/commerce/modules/category.ts @@ -0,0 +1,46 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceCategory, CategoryCreateParams, CategoryUpdateParams } from '../types' + +export class CategoryModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 카테고리 트리 조회 + */ + async list(): Promise> { + return this.bootpay.get('categories') + } + + /** + * 카테고리 단건 조회 + */ + async detail(categoryId: string): Promise> { + return this.bootpay.get(`categories/${categoryId}`) + } + + /** + * 카테고리 생성 + */ + async create(params: CategoryCreateParams): Promise> { + return this.bootpay.post('categories', params) + } + + /** + * 카테고리 수정 + */ + async update(params: CategoryUpdateParams): Promise> { + const { category_id, ...rest } = params + return this.bootpay.put(`categories/${category_id}`, rest) + } + + /** + * 카테고리 삭제 + */ + async destroy(categoryId: string): Promise> { + return this.bootpay.delete(`categories/${categoryId}`) + } +} diff --git a/src/lib/commerce/modules/coupon.ts b/src/lib/commerce/modules/coupon.ts new file mode 100644 index 0000000..3695364 --- /dev/null +++ b/src/lib/commerce/modules/coupon.ts @@ -0,0 +1,38 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceCoupon, CouponListParams, CouponDownloadParams } from '../types' + +export class CouponModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 사용자 보유 쿠폰 목록 + */ + async list(params?: CouponListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.status) queryParams.append('status', params.status) + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + } + const query = queryParams.toString() + return this.bootpay.get(`coupon${query ? `?${query}` : ''}`) + } + + /** + * 다운로드 가능한 쿠폰 목록 + */ + async available(): Promise> { + return this.bootpay.get('coupon/available') + } + + /** + * 쿠폰 다운로드 (issue_from_template) + */ + async download(params: CouponDownloadParams): Promise> { + return this.bootpay.post('coupon/download', params) + } +} diff --git a/src/lib/commerce/modules/index.ts b/src/lib/commerce/modules/index.ts new file mode 100644 index 0000000..c7cab97 --- /dev/null +++ b/src/lib/commerce/modules/index.ts @@ -0,0 +1,18 @@ +export * from './user' +export * from './user-group' +export * from './product' +export * from './invoice' +export * from './order' +export * from './order-cancel' +export * from './order-subscription' +export * from './order-subscription-bill' +export * from './order-subscription-adjustment' +export * from './order-subscription-request' +export * from './category' +export * from './coupon' +export * from './point' +export * from './cart' +export * from './mall-setting' +export * from './webhook' + +export * from './store' diff --git a/src/lib/commerce/modules/invoice.ts b/src/lib/commerce/modules/invoice.ts new file mode 100644 index 0000000..14e7238 --- /dev/null +++ b/src/lib/commerce/modules/invoice.ts @@ -0,0 +1,87 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceInvoice, InvoiceListParams, InvoiceListResponse } from '../types' +import { randomUUID } from 'crypto' + +export class InvoiceModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 청구서 목록 조회 + * GET /v1/invoices + * 응답은 { list: [...], count: N } 구조다 ({ items, total } 아님). + * limit 미지정시 서버 기본값과 동일한 24 를 보낸다. + * @param params 조회 파라미터 + */ + async list(params?: InvoiceListParams): Promise> { + const { idempotency_key, ...rest } = params || {} + const queryParams = new URLSearchParams() + queryParams.append('page', (rest.page === undefined ? 1 : rest.page).toString()) + queryParams.append('limit', (rest.limit === undefined ? 24 : rest.limit).toString()) + if (rest.keyword) queryParams.append('keyword', rest.keyword) + if (rest.cs_type) queryParams.append('cs_type', rest.cs_type) + if (rest.user_id) queryParams.append('user_id', rest.user_id) + if (rest.product_type !== undefined) queryParams.append('product_type', rest.product_type.toString()) + if (rest.css_at) queryParams.append('css_at', rest.css_at) + if (rest.cse_at) queryParams.append('cse_at', rest.cse_at) + + return this.bootpay.get(`invoices?${queryParams.toString()}`, { + headers: this.invoiceHeaders(idempotency_key) + }) + } + + /** + * 청구서 생성 + * @param invoice 청구서 정보 + */ + async create(invoice: CommerceInvoice): Promise> { + return this.bootpay.post('invoices', invoice) + } + + /** + * 청구서 알림 재발송 + * POST /v1/invoices/{invoice_id}/notify + * sendTypes 미전달시 서버가 빈 배열로 처리한다. + * ⚠️ 실제 고객에게 알림이 발송되므로 테스트 호출 주의. + * @param invoiceId 청구서 ID + * @param sendTypes 발송 타입 배열 (예: [1, 2] - SMS, Email 등) + * @param idempotencyKey 미지정시 자동 생성 + */ + async notify( + invoiceId: string, + sendTypes?: number[], + idempotencyKey?: string + ): Promise> { + const payload: Record = {} + if (sendTypes !== undefined && sendTypes !== null) payload.send_types = sendTypes + return this.bootpay.post(`invoices/${invoiceId}/notify`, payload, { + headers: this.invoiceHeaders(idempotencyKey) + }) + } + + /** + * 청구서 상세 조회 + * GET /v1/invoices/{invoice_id} + * @param invoiceId 청구서 ID + * @param idempotencyKey 미지정시 자동 생성 + */ + async detail(invoiceId: string, idempotencyKey?: string): Promise> { + return this.bootpay.get(`invoices/${invoiceId}`, { + headers: this.invoiceHeaders(idempotencyKey) + }) + } + + /** + * 청구서 API 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private invoiceHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'user' + } + } +} diff --git a/src/lib/commerce/modules/mall-setting.ts b/src/lib/commerce/modules/mall-setting.ts new file mode 100644 index 0000000..c6ab3d7 --- /dev/null +++ b/src/lib/commerce/modules/mall-setting.ts @@ -0,0 +1,70 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceMallSetting, MallSettingUpdateParams } from '../types' +import { randomUUID } from 'crypto' + +export class MallSettingModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 몰 설정 조회 + * GET /v1/mall-setting + * supervisor scope 토큰 전용 + */ + async getMallSetting(idempotencyKey?: string): Promise> { + return this.bootpay.get('mall-setting', { + headers: this.supervisorHeaders(idempotencyKey) + }) + } + + async detail(idempotencyKey?: string): Promise> { + return this.getMallSetting(idempotencyKey) + } + + /** + * 몰 설정 수정 + * PUT /v1/mall-setting + * supervisor scope 토큰 전용 + * 요청 바디는 flatten 형식이며 전달된 값(non-null)만 서버로 전송된다. + * @param params 수정할 설정값 + * @param idempotencyKey 미지정시 자동 생성 + */ + async updateMallSetting( + params: MallSettingUpdateParams, + idempotencyKey?: string + ): Promise> { + return this.bootpay.put('mall-setting', this.compact(params), { + headers: this.supervisorHeaders(idempotencyKey) + }) + } + + async update( + params: MallSettingUpdateParams, + idempotencyKey?: string + ): Promise> { + return this.updateMallSetting(params, idempotencyKey) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(params: MallSettingUpdateParams): Record { + return Object.fromEntries( + Object.entries(params || {}).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * supervisor 전용 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private supervisorHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'supervisor' + } + } +} diff --git a/src/lib/commerce/modules/order-cancel.ts b/src/lib/commerce/modules/order-cancel.ts new file mode 100644 index 0000000..e9618f3 --- /dev/null +++ b/src/lib/commerce/modules/order-cancel.ts @@ -0,0 +1,134 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { + OrderCancelListParams, + OrderCancelParams, + OrderCancelActionParams, + OrderCancelWithdrawParams, + CommerceOrderCancelRequestHistory +} from '../types' +import { randomUUID } from 'crypto' + +export class OrderCancelModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 주문 취소 요청 내역 조회 + * GET /v1/order/cancel + * order_number 또는 order_id 로 필터한다. 둘 다 없으면 전체. + * approve / reject / withdraw 에 넘길 order_cancellation_request_id 를 여기서 얻는다. + * @param params 조회 파라미터 + */ + async list(params?: OrderCancelListParams): Promise> { + const { idempotency_key, ...rest } = params || {} + const queryParams = new URLSearchParams() + if (rest.order_number) queryParams.append('order_number', rest.order_number) + if (rest.order_id) queryParams.append('order_id', rest.order_id) + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceOrderCancelRequestHistory[]; total: number }>( + `order/cancel${query ? `?${query}` : ''}`, + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 취소 요청 + * @param params 취소 요청 파라미터 + */ + async request(params: OrderCancelParams): Promise> { + return this.bootpay.post('order/cancel', params) + } + + /** + * (구매자) 주문 취소 요청 철회 + * PUT /v1/order/cancel/{order_cancellation_request_id}/withdraw + * ⚠️ DELETE /v1/order/cancel/{id} 와는 다른 라우트다. 서버에 둘 다 있지만 매뉴얼이 문서화한 쪽은 withdraw 다. + * @param params 취소 요청 이력 ID (문자열로 바로 넘겨도 된다) + */ + async withdraw(params: OrderCancelWithdrawParams | string): Promise> { + const normalized = typeof params === 'string' ? { order_cancellation_request_id: params } : params + const cancellationId = this.cancellationId(normalized) + if (!cancellationId) { + return Promise.reject({ success: false, error: 'order_cancellation_request_id is required' }) + } + return this.bootpay.put(`order/cancel/${cancellationId}/withdraw`, {}, { + headers: this.userHeaders(normalized.idempotency_key) + }) + } + + /** + * (관리자) 취소 요청 승인 + * PUT /v1/order/cancel/{order_cancellation_request_id}/approve + * @param params 취소 승인 파라미터 + */ + async approve(params: OrderCancelActionParams): Promise> { + const cancellationId = this.cancellationId(params) + if (!cancellationId) { + return Promise.reject({ success: false, error: 'order_cancellation_request_id is required' }) + } + return this.bootpay.put( + `order/cancel/${cancellationId}/approve`, + this.actionPayload(params), + { headers: this.supervisorHeaders(params.idempotency_key) } + ) + } + + /** + * (관리자) 취소 요청 반려 + * PUT /v1/order/cancel/{order_cancellation_request_id}/reject + * @param params 취소 거절 파라미터 + */ + async reject(params: OrderCancelActionParams): Promise> { + const cancellationId = this.cancellationId(params) + if (!cancellationId) { + return Promise.reject({ success: false, error: 'order_cancellation_request_id is required' }) + } + return this.bootpay.put( + `order/cancel/${cancellationId}/reject`, + this.actionPayload(params), + { headers: this.supervisorHeaders(params.idempotency_key) } + ) + } + + /** + * 취소 요청 이력 ID 를 뽑는다. + * 서버는 approve / reject / withdraw 셋 다 params[:id] 를 order_cancellation_request_id 로 동일하게 취급한다. + * 정식 이름은 order_cancellation_request_id 이며, 구 이름 order_cancel_request_history_id 도 계속 받는다. + */ + private cancellationId(params: OrderCancelActionParams | OrderCancelWithdrawParams): string | undefined { + return params.order_cancellation_request_id || params.order_cancel_request_history_id + } + + /** + * 승인/반려 payload — 서버가 읽는 값은 message 다. + */ + private actionPayload(params: OrderCancelActionParams): Record { + const { order_cancellation_request_id, order_cancel_request_history_id, idempotency_key, ...payload } = params + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * 구매자 scope 요청 헤더 + */ + private userHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'user' + } + } + + /** + * 관리자(승인/반려) scope 요청 헤더 + */ + private supervisorHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'supervisor' + } + } +} diff --git a/src/lib/commerce/modules/order-subscription-adjustment.ts b/src/lib/commerce/modules/order-subscription-adjustment.ts new file mode 100644 index 0000000..e3c31e1 --- /dev/null +++ b/src/lib/commerce/modules/order-subscription-adjustment.ts @@ -0,0 +1,100 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceOrderSubscriptionAdjustment, OrderSubscriptionAdjustmentUpdateParams } from '../types' +import { randomUUID } from 'crypto' + +/** + * 구독 가감산 조정항목 모듈 + * + * ⚠️ /adjustments 한 경로에 POST · PUT · DELETE 세 동사가 걸려 있다. + * 경로만 보고 메서드를 유추하지 말 것. + */ +export class OrderSubscriptionAdjustmentModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 가감산 조정항목 추가 + * POST /v1/order_subscriptions/{order_subscription_id}/adjustments + * type 미전달시 서버가 price > 0 이면 SETUP_PRICE, 아니면 PERIOD_DISCOUNT 로 자동 판정한다. + * @param orderSubscriptionId 정기구독 ID + * @param adjustment 조정 정보 (price/duration/tax_free_price 미지정시 각각 0 / 1 / 0) + * @param idempotencyKey 미지정시 자동 생성 + */ + async create( + orderSubscriptionId: string, + adjustment: CommerceOrderSubscriptionAdjustment, + idempotencyKey?: string + ): Promise> { + const payload = this.compact({ + price: 0, + duration: 1, + tax_free_price: 0, + ...adjustment + }) + return this.bootpay.post( + `order_subscriptions/${orderSubscriptionId}/adjustments`, + payload, + { headers: this.supervisorHeaders(idempotencyKey) } + ) + } + + /** + * 특정 회차의 조정항목을 통째로 교체 + * PUT /v1/order_subscriptions/{order_subscription_id}/adjustments + * 서버는 duration(회차) 단위로 adjustments 배열을 갈아끼운다. + * @param params 수정 파라미터 + */ + async update(params: OrderSubscriptionAdjustmentUpdateParams): Promise> { + if (!params.order_subscription_id) { + return Promise.reject({ success: false, error: 'order_subscription_id is required' }) + } + const { order_subscription_id, idempotency_key, ...payload } = params + return this.bootpay.put( + `order_subscriptions/${order_subscription_id}/adjustments`, + this.compact({ duration: 1, ...payload }), + { headers: this.supervisorHeaders(idempotency_key) } + ) + } + + /** + * 조정항목 삭제 + * DELETE /v1/order_subscriptions/{order_subscription_id}/adjustments + * ⚠️ 대상 ID 는 query 가 아니라 body 로 보낸다. + * @param orderSubscriptionId 정기구독 ID + * @param orderSubscriptionAdjustmentId 조정 ID + * @param idempotencyKey 미지정시 자동 생성 + */ + async delete( + orderSubscriptionId: string, + orderSubscriptionAdjustmentId: string, + idempotencyKey?: string + ): Promise> { + return this.bootpay.delete(`order_subscriptions/${orderSubscriptionId}/adjustments`, { + data: { order_subscription_adjustment_id: orderSubscriptionAdjustmentId }, + headers: this.supervisorHeaders(idempotencyKey) + }) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * 조정항목 API 요청 헤더 — 서버가 supervisor scope 를 요구한다. + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private supervisorHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'supervisor' + } + } +} diff --git a/src/lib/commerce/modules/order-subscription-bill.ts b/src/lib/commerce/modules/order-subscription-bill.ts new file mode 100644 index 0000000..b7854f7 --- /dev/null +++ b/src/lib/commerce/modules/order-subscription-bill.ts @@ -0,0 +1,67 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceOrderSubscriptionBill, OrderSubscriptionBillListParams } from '../types' +import { randomUUID } from 'crypto' + +export class OrderSubscriptionBillModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 정기구독 빌(회차) 목록 조회 + * GET /v1/order_subscription_bills + * ⚠️ 경로가 order_subscription_bills — 언더스코어다 (하이픈 아님). + * page/limit 미지정시 각각 1 / 20 이 적용된다. + * @param params 조회 파라미터 + */ + async list(params?: OrderSubscriptionBillListParams): Promise> { + const { idempotency_key, ...rest } = params || {} + const queryParams = new URLSearchParams() + if (rest.order_subscription_id) queryParams.append('order_subscription_id', rest.order_subscription_id) + queryParams.append('page', (rest.page === undefined ? 1 : rest.page).toString()) + queryParams.append('limit', (rest.limit === undefined ? 20 : rest.limit).toString()) + if (rest.keyword) queryParams.append('keyword', rest.keyword) + if (rest.status && rest.status.length > 0) { + queryParams.append('status', rest.status.join(',')) + } + return this.bootpay.get<{ items: CommerceOrderSubscriptionBill[]; total: number }>( + `order_subscription_bills?${queryParams.toString()}`, + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 정기구독 청구 상세 조회 + * @param orderSubscriptionBillId 청구 ID + */ + async detail(orderSubscriptionBillId: string): Promise> { + return this.bootpay.get(`order_subscription_bills/${orderSubscriptionBillId}`) + } + + /** + * 정기구독 청구 수정 + * @param orderSubscriptionBill 청구 정보 + */ + async update(orderSubscriptionBill: CommerceOrderSubscriptionBill): Promise> { + if (!orderSubscriptionBill.order_subscription_bill_id) { + return Promise.reject({ success: false, error: 'order_subscription_bill_id is required' }) + } + return this.bootpay.put( + `order_subscription_bills/${orderSubscriptionBill.order_subscription_bill_id}`, + orderSubscriptionBill + ) + } + + /** + * 빌 조회 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private userHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'user' + } + } +} diff --git a/src/lib/commerce/modules/order-subscription-request.ts b/src/lib/commerce/modules/order-subscription-request.ts new file mode 100644 index 0000000..1b11b75 --- /dev/null +++ b/src/lib/commerce/modules/order-subscription-request.ts @@ -0,0 +1,121 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { + OrderSubscriptionRequest, + OrderSubscriptionRequestListParams, + OrderSubscriptionRequestUpdateParams +} from '../types' +import { randomUUID } from 'crypto' + +/** + * V1 OrderSubscription Request 조회/승인 모듈 + * + * 본인 모드 (user role): project_id 없이 호출 → 본인 요청 목록/단건 + * 슈퍼바이저 모드 (supervisor role): project_id 포함 → 프로젝트 전체 + update (승인/거절) + * + * 구매자측 요청 생성 (pause/resume/purchase/termination/transfer) 은 + * `commerce.orderSubscription.requestIng.*` 모듈을 사용한다. + * + * ⚠️ 경로가 order-subscription-requests — 하이픈이다. + * order_subscriptions · order_subscription_bills 는 언더스코어라 복사해 고칠 때 가장 흔히 틀리는 지점. + */ +export class OrderSubscriptionRequestModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 구독 변경요청 목록 조회 (user / supervisor 공용) + * GET /v1/order-subscription-requests + * project_id 를 주면 supervisor 모드(프로젝트 전체 검색), 없으면 본인 요청만 조회한다. + * page/limit 미지정시 각각 1 / 20 이 적용된다. + */ + async list( + params?: OrderSubscriptionRequestListParams + ): Promise> { + const { idempotency_key, ...rest } = params || {} + const queryParams = new URLSearchParams() + if (rest.project_id) queryParams.append('project_id', rest.project_id) + if (rest.order_subscription_id) queryParams.append('order_subscription_id', rest.order_subscription_id) + queryParams.append('page', (rest.page === undefined ? 1 : rest.page).toString()) + queryParams.append('limit', (rest.limit === undefined ? 20 : rest.limit).toString()) + if (rest.keyword) queryParams.append('keyword', rest.keyword) + if (rest.s_at) queryParams.append('s_at', rest.s_at) + if (rest.e_at) queryParams.append('e_at', rest.e_at) + if (rest.status !== undefined) queryParams.append('status', rest.status.toString()) + if (rest.request_type !== undefined) queryParams.append('request_type', rest.request_type.toString()) + if (rest.user_id) queryParams.append('user_id', rest.user_id) + if (rest.user_group_id) queryParams.append('user_group_id', rest.user_group_id) + + return this.bootpay.get<{ items: OrderSubscriptionRequest[]; total: number }>( + `order-subscription-requests?${queryParams.toString()}`, + { headers: this.requestHeaders(rest.project_id, idempotency_key) } + ) + } + + /** + * 구독 변경요청 단건 조회 (user / supervisor 공용) + * GET /v1/order-subscription-requests/{id} + */ + async detail( + orderSubscriptionRequestHistoryId: string, + projectId?: string, + idempotencyKey?: string + ): Promise> { + const queryParams = new URLSearchParams() + if (projectId) queryParams.append('project_id', projectId) + const query = queryParams.toString() + return this.bootpay.get( + `order-subscription-requests/${orderSubscriptionRequestHistoryId}${query ? `?${query}` : ''}`, + { headers: this.requestHeaders(projectId, idempotencyKey) } + ) + } + + /** + * 구독 변경요청 승인/반려 (supervisor 전용) + * PUT /v1/order-subscription-requests/{id} + * ⚠️ 승인과 반려는 별도 액션이 아니다. 라우트는 index/show/update 셋뿐이고 + * approval: 'approve' | 'reject' 파라미터로 갈린다. + * 서버가 params[:action] 을 Rails 예약어로 쓰기 때문에 키 이름이 approval 이다. + */ + async update( + params: OrderSubscriptionRequestUpdateParams + ): Promise> { + const { order_subscription_request_history_id, idempotency_key, ...payload } = params + return this.bootpay.put( + `order-subscription-requests/${order_subscription_request_history_id}`, + this.compact(payload as Record), + { headers: this.supervisorHeaders(idempotency_key) } + ) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * 조회 요청 헤더 — project_id 가 있으면 supervisor, 없으면 user scope 다. + */ + private requestHeaders(projectId?: string, idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': projectId ? 'supervisor' : 'user' + } + } + + /** + * 승인/반려 요청 헤더 — 서버가 supervisor scope 를 요구한다. + */ + private supervisorHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'supervisor' + } + } +} diff --git a/src/lib/commerce/modules/order-subscription.ts b/src/lib/commerce/modules/order-subscription.ts new file mode 100644 index 0000000..b8196fe --- /dev/null +++ b/src/lib/commerce/modules/order-subscription.ts @@ -0,0 +1,306 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { + CommerceOrderSubscription, + OrderSubscriptionListParams, + OrderSubscriptionUpdateParams, + OrderSubscriptionPauseParams, + OrderSubscriptionResumeParams, + OrderSubscriptionPurchaseParams, + OrderSubscriptionTransferParams, + OrderSubscriptionTerminationParams, + CalcTerminateFeeResponse, + SupervisorOrderSubscriptionApproveParams, + SupervisorOrderSubscriptionRejectParams, + SupervisorOrderSubscriptionTerminateParams, + SupervisorOrderSubscriptionPauseParams, + SupervisorOrderSubscriptionResumeParams, + SupervisorOrderSubscriptionChargeParams, + SupervisorOrderSubscriptionChargeRevokeParams, + OrderSubscriptionChargeResponse, + OrderSubscriptionChargeRevokeResponse +} from '../types' +import { randomUUID } from 'crypto' + +export class OrderSubscriptionRequestIngModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 정기구독 일시정지 요청 + * POST /v1/order_subscriptions/requests/ing/pause + * @param params 일시정지 파라미터 + */ + async pause(params: OrderSubscriptionPauseParams): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.post( + 'order_subscriptions/requests/ing/pause', + this.compact(payload), + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 정기구독 재개 요청 + * PUT /v1/order_subscriptions/requests/ing/resume + * ⚠️ requests/ing 계열 중 유일하게 PUT 이다. 오타로 보고 POST 로 바꾸지 말 것. + * @param params 재개 파라미터 + */ + async resume(params: OrderSubscriptionResumeParams): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.put( + 'order_subscriptions/requests/ing/resume', + this.compact(payload), + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 중도인수 요청 + * POST /v1/order_subscriptions/requests/ing/purchase + * @param params 중도인수 파라미터 + */ + async purchase(params: OrderSubscriptionPurchaseParams): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.post( + 'order_subscriptions/requests/ing/purchase', + this.compact(payload), + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 구독 이전/승계 요청 + * POST /v1/order_subscriptions/requests/ing/transfer + * @param params 이전/승계 파라미터 + */ + async transfer(params: OrderSubscriptionTransferParams): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.post( + 'order_subscriptions/requests/ing/transfer', + this.compact(payload), + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * 중도해지 수수료 사전계산 + * GET /v1/order_subscriptions/requests/ing/calculate_termination_fee + * 해지 요청 전에 얼마가 나오는지 미리 보여줄 때 쓴다. + * @param orderSubscriptionId 정기구독 ID (선택) + * @param orderNumber 주문번호 (선택) + * @param idempotencyKey 미지정시 자동 생성 + */ + async calculateTerminationFee( + orderSubscriptionId?: string, + orderNumber?: string, + idempotencyKey?: string + ): Promise> { + if (!orderSubscriptionId && !orderNumber) { + return Promise.reject({ + success: false, + error: 'orderSubscriptionId or orderNumber is required' + }) + } + + const queryParams = new URLSearchParams() + if (orderSubscriptionId) queryParams.append('order_subscription_id', orderSubscriptionId) + if (orderNumber) queryParams.append('order_number', orderNumber) + + return this.bootpay.get( + `order_subscriptions/requests/ing/calculate_termination_fee?${queryParams.toString()}`, + { headers: this.userHeaders(idempotencyKey) } + ) + } + + /** + * 주문번호로 해지 수수료 계산 + * @param orderNumber 주문번호 + */ + async calculateTerminationFeeByOrderNumber( + orderNumber: string + ): Promise> { + return this.calculateTerminationFee(undefined, orderNumber) + } + + /** + * 중도해지 요청 + * POST /v1/order_subscriptions/requests/ing/termination + * @param params 해지 파라미터 + */ + async termination(params: OrderSubscriptionTerminationParams): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.post( + 'order_subscriptions/requests/ing/termination', + this.compact(payload), + { headers: this.userHeaders(idempotency_key) } + ) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * requests/ing 요청 헤더 — 구매자가 올리는 요청이므로 user scope 다. + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private userHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'user' + } + } +} + +export class OrderSubscriptionModule { + private bootpay: BootpayCommerceResource + public requestIng: OrderSubscriptionRequestIngModule + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + this.requestIng = new OrderSubscriptionRequestIngModule(bootpay) + } + + /** + * 정기구독 목록 조회 + * @param params 조회 파라미터 + */ + async list(params?: OrderSubscriptionListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.keyword) queryParams.append('keyword', params.keyword) + if (params.search_date_from) queryParams.append('search_date_from', params.search_date_from) + if (params.search_date_to) queryParams.append('search_date_to', params.search_date_to) + if (params.s_at) queryParams.append('s_at', params.s_at) + if (params.e_at) queryParams.append('e_at', params.e_at) + if (params.request_type) queryParams.append('request_type', params.request_type) + if (params.user_group_id) queryParams.append('user_group_id', params.user_group_id) + if (params.status !== undefined) queryParams.append('status', params.status.toString()) + if (params.user_id) queryParams.append('user_id', params.user_id) + } + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceOrderSubscription[]; total: number }>(`order_subscriptions${query ? `?${query}` : ''}`) + } + + /** + * 정기구독 상세 조회 + * @param orderSubscriptionId 정기구독 ID + */ + async detail(orderSubscriptionId: string): Promise> { + return this.bootpay.get(`order_subscriptions/${orderSubscriptionId}`) + } + + /** + * 구독 계약 내용 변경 + * PUT /v1/order_subscriptions/{order_subscription_id} + * 바뀐 값만 보내면 된다 (나머지는 서버가 그대로 유지한다). + * @param params 수정 파라미터 + */ + async update(params: OrderSubscriptionUpdateParams): Promise> { + if (!params.order_subscription_id) { + return Promise.reject({ success: false, error: 'order_subscription_id is required' }) + } + const { order_subscription_id, idempotency_key, ...payload } = params + return this.bootpay.put( + `order_subscriptions/${order_subscription_id}`, + this.compact(payload), + { headers: this.supervisorHeaders(idempotency_key) } + ) + } + + async supervisorApprove( + orderSubscriptionId: string, + params: SupervisorOrderSubscriptionApproveParams = {} + ): Promise> { + return this.bootpay.put(`order_subscriptions/${orderSubscriptionId}/approve`, params) + } + + async supervisorReject( + orderSubscriptionId: string, + params: SupervisorOrderSubscriptionRejectParams = {} + ): Promise> { + return this.bootpay.put(`order_subscriptions/${orderSubscriptionId}/reject`, params) + } + + async supervisorTerminate( + orderSubscriptionId: string, + params: SupervisorOrderSubscriptionTerminateParams = {} + ): Promise> { + return this.bootpay.put(`order_subscriptions/${orderSubscriptionId}/terminate`, params) + } + + async supervisorPause( + orderSubscriptionId: string, + params: SupervisorOrderSubscriptionPauseParams + ): Promise> { + return this.bootpay.put(`order_subscriptions/${orderSubscriptionId}/pause`, params) + } + + async supervisorResume( + orderSubscriptionId: string, + params: SupervisorOrderSubscriptionResumeParams = {} + ): Promise> { + return this.bootpay.put(`order_subscriptions/${orderSubscriptionId}/resume`, params) + } + + /** + * 수시결제(온디맨드) charge_key 즉시 결제 + * POST /v1/order_subscriptions/charge + * charge_key 는 body 로만 전송한다 (URL/query 금지 — 액세스 로그 노출 방지) + * @param params 결제 파라미터 + */ + async supervisorCharge( + params: SupervisorOrderSubscriptionChargeParams + ): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.post('order_subscriptions/charge', this.compact(payload), { + headers: this.supervisorHeaders(idempotency_key) + }) + } + + /** + * 수시결제(온디맨드) charge_key 해지 + * DELETE /v1/order_subscriptions/charge + * 해지 이후 해당 키로의 재결제는 불가능하다 + * @param params 해지 파라미터 + */ + async supervisorChargeRevoke( + params: SupervisorOrderSubscriptionChargeRevokeParams + ): Promise> { + const { idempotency_key, ...payload } = params + return this.bootpay.delete('order_subscriptions/charge', { + data: this.compact(payload), + headers: this.supervisorHeaders(idempotency_key) + }) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * supervisor 전용 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private supervisorHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'supervisor' + } + } +} diff --git a/src/lib/commerce/modules/order.ts b/src/lib/commerce/modules/order.ts new file mode 100644 index 0000000..966685e --- /dev/null +++ b/src/lib/commerce/modules/order.ts @@ -0,0 +1,66 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceOrder, OrderListParams } from '../types' + +export class OrderModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 주문 목록 조회 + * GET /v1/orders + * limit 은 서버 기본 20 · 최대 50 (초과분은 서버가 50 으로 클램프한다). + * @param params 조회 파라미터 + */ + async list(params?: OrderListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.keyword) queryParams.append('keyword', params.keyword) + if (params.user_id) queryParams.append('user_id', params.user_id) + if (params.user_group_id) queryParams.append('user_group_id', params.user_group_id) + if (params.cs_type) queryParams.append('cs_type', params.cs_type) + if (params.search_date_from) queryParams.append('search_date_from', params.search_date_from) + if (params.search_date_to) queryParams.append('search_date_to', params.search_date_to) + if (params.css_at) queryParams.append('css_at', params.css_at) + if (params.cse_at) queryParams.append('cse_at', params.cse_at) + if (params.subscription_billing_type !== undefined) { + queryParams.append('subscription_billing_type', params.subscription_billing_type.toString()) + } + if (params.status && params.status.length > 0) { + queryParams.append('status', params.status.join(',')) + } + if (params.payment_status && params.payment_status.length > 0) { + queryParams.append('payment_status', params.payment_status.join(',')) + } + if (params.order_subscription_ids && params.order_subscription_ids.length > 0) { + queryParams.append('order_subscription_ids', params.order_subscription_ids.join(',')) + } + } + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceOrder[]; total: number }>(`orders${query ? `?${query}` : ''}`) + } + + /** + * 주문 상세 조회 + * @param orderId 주문 ID + */ + async detail(orderId: string): Promise> { + return this.bootpay.get(`orders/${orderId}`) + } + + /** + * 월별 주문 조회 + * @param userGroupId 사용자 그룹 ID + * @param searchDate 검색 날짜 (YYYY-MM 형식) + */ + async month(userGroupId: string, searchDate: string): Promise> { + const queryParams = new URLSearchParams() + queryParams.append('user_group_id', userGroupId) + queryParams.append('search_date', searchDate) + return this.bootpay.get(`orders/month?${queryParams.toString()}`) + } +} diff --git a/src/lib/commerce/modules/point.ts b/src/lib/commerce/modules/point.ts new file mode 100644 index 0000000..7903c3f --- /dev/null +++ b/src/lib/commerce/modules/point.ts @@ -0,0 +1,42 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { + PointBalance, + PointTransactionsParams, + PointTransactionsResponse +} from '../types' + +export class PointModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 적립금 잔액 조회 + */ + async balance(): Promise> { + return this.bootpay.get('point/balance') + } + + /** + * 적립금 내역 조회 + */ + async transactions( + params?: PointTransactionsParams + ): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.transaction_type !== undefined) { + queryParams.append('transaction_type', params.transaction_type.toString()) + } + } + const query = queryParams.toString() + return this.bootpay.get( + `point/transactions${query ? `?${query}` : ''}` + ) + } + +} diff --git a/src/lib/commerce/modules/product.ts b/src/lib/commerce/modules/product.ts new file mode 100644 index 0000000..dd021a9 --- /dev/null +++ b/src/lib/commerce/modules/product.ts @@ -0,0 +1,208 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceProduct, ProductListParams, MallProductListParams, ProductStatusParams } from '../types' +import FormData from 'form-data' +import fs from 'fs' +import path from 'path' +import { randomUUID } from 'crypto' + +export class ProductModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 상품 목록 조회 + * @param params 조회 파라미터 + */ + async list(params?: ProductListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.keyword) queryParams.append('keyword', params.keyword) + if (params.type !== undefined) queryParams.append('type', params.type.toString()) + if (params.period_type) queryParams.append('period_type', params.period_type) + if (params.s_at) queryParams.append('s_at', params.s_at) + if (params.e_at) queryParams.append('e_at', params.e_at) + if (params.category_code) queryParams.append('category_code', params.category_code) + } + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceProduct[]; total: number }>(`products${query ? `?${query}` : ''}`) + } + + + /** + * 상품 목록 조회 (V1 Mall API) + * GET /v1/products + * page/limit 은 미지정시 각각 1 / 20 이 적용되고, 나머지 값은 지정된 것만 전송한다. + * ⚠️ keyword 는 서버(v1/products_controller#index)가 읽지 않는다 — page/limit/category_id/ex_uid/sort 만 사용하며 + * keyword 를 보내도 조용히 무시된다. 하위호환 때문에 인자는 남겨두되, 검색이 필요하면 서버 지원이 선행되어야 한다. + * @param params 조회 파라미터 + */ + async products(params?: MallProductListParams): Promise> { + const { user_jwt, idempotency_key, ...rest } = params || {} + const queryParams = new URLSearchParams() + queryParams.append('page', (rest.page === undefined ? 1 : rest.page).toString()) + queryParams.append('limit', (rest.limit === undefined ? 20 : rest.limit).toString()) + if (rest.category_id) queryParams.append('category_id', rest.category_id) + if (rest.sort) queryParams.append('sort', rest.sort) + if (rest.keyword) queryParams.append('keyword', rest.keyword) + if (rest.type !== undefined) queryParams.append('type', rest.type.toString()) + if (rest.period_type) queryParams.append('period_type', rest.period_type) + if (rest.s_at) queryParams.append('s_at', rest.s_at) + if (rest.e_at) queryParams.append('e_at', rest.e_at) + if (rest.category_code) queryParams.append('category_code', rest.category_code) + + return this.bootpay.get<{ items: CommerceProduct[]; total: number }>(`products?${queryParams.toString()}`, { + headers: this.mallHeaders(user_jwt, idempotency_key) + }) + } + + /** + * 상품 생성 + * POST /v1/products + * imagePaths 가 있으면 multipart/form-data, 없으면 JSON 으로 보낸다. + * @param product 상품 정보 (여기 명시되지 않은 값도 서버 _product_params 로 그대로 전달된다) + * @param imagePaths 이미지 파일 경로 배열 + * @param idempotencyKey 미지정시 자동 생성 + */ + async create( + product: CommerceProduct, + imagePaths?: string[], + idempotencyKey?: string + ): Promise> { + const payload = this.compact(product as Record) + const headers = this.managerHeaders(idempotencyKey) + + if (!imagePaths || imagePaths.length === 0) { + return this.bootpay.post('products', payload, { headers }) + } + + const formData = new FormData() + Object.entries(payload).forEach(([key, value]) => { + formData.append(key, this.multipartValue(value)) + }) + + // ⚠️ Rails 는 반복된 `images` 를 배열로 받지 않는다. images[0], images[1] ... 로 인덱싱해야 한다. + imagePaths.forEach((imagePath, index) => { + formData.append(`images[${index}]`, fs.createReadStream(imagePath), path.basename(imagePath)) + }) + + return this.bootpay.postMultipart('products', formData, { headers }) + } + + /** + * 상품 상세 조회 + * @param productId 상품 ID + */ + async detail(productId: string): Promise> { + return this.bootpay.get(`products/${productId}`) + } + + /** + * 상품 상세 조회 (V1 Mall API) + * GET /v1/products/{product_id} + * @param productId 상품 ID + * @param userJwt 회원 JWT (선택) + * @param idempotencyKey 미지정시 자동 생성 + */ + async productDetail( + productId: string, + userJwt?: string, + idempotencyKey?: string + ): Promise> { + return this.bootpay.get(`products/${productId}`, { + headers: this.mallHeaders(userJwt, idempotencyKey) + }) + } + + /** + * 상품 수정 + * PUT /v1/products/{product_id} + * 바뀐 값만 보내면 된다. ⚠️ category_id 는 키 존재 여부로 '해제 의사'를 판별하므로 주의. + * @param product 상품 정보 + * @param idempotencyKey 미지정시 자동 생성 + */ + async update(product: CommerceProduct, idempotencyKey?: string): Promise> { + if (!product.product_id) { + return Promise.reject({ success: false, error: 'product_id is required' }) + } + return this.bootpay.put( + `products/${product.product_id}`, + this.compact(product as Record), + { headers: this.managerHeaders(idempotencyKey) } + ) + } + + /** + * 상품 판매/노출 상태 변경 + * PUT /v1/products/{product_id}/status + * ⚠️ 재고(stock)는 여기가 아니라 update 로 바꾼다. + * @param params 상태 변경 파라미터 + */ + async status(params: ProductStatusParams): Promise> { + if (!params.product_id) { + return Promise.reject({ success: false, error: 'product_id is required' }) + } + const { product_id, idempotency_key, ...payload } = params + return this.bootpay.put(`products/${product_id}/status`, this.compact(payload), { + headers: this.managerHeaders(idempotency_key) + }) + } + + /** + * 상품 삭제 + * DELETE /v1/products/{product_id} + * @param productId 상품 ID + * @param idempotencyKey 미지정시 자동 생성 + */ + async delete(productId: string, idempotencyKey?: string): Promise> { + return this.bootpay.delete(`products/${productId}`, { + headers: this.managerHeaders(idempotencyKey) + }) + } + + /** + * 상품 쓰기(등록/수정/삭제/상태변경) 요청 헤더 + * 서버가 manager scope 를 요구한다. + */ + private managerHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'manager' + } + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * multipart form 값 정규화 — 배열/객체는 JSON, 나머지는 문자열로 보낸다. + */ + private multipartValue(value: any): string { + if (typeof value === 'object') return JSON.stringify(value) + return String(value) + } + + /** + * V1 Mall API 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성되고, Bootpay-User-JWT 는 값이 있을 때만 붙는다. + */ + private mallHeaders(userJwt?: string, idempotencyKey?: string): Record { + const headers: Record = { + 'Idempotency-Key': idempotencyKey || randomUUID() + } + if (userJwt !== undefined && userJwt !== null && userJwt !== '') { + headers['Bootpay-User-JWT'] = userJwt + } + return headers + } +} diff --git a/src/lib/commerce/modules/store.ts b/src/lib/commerce/modules/store.ts new file mode 100644 index 0000000..22ebd51 --- /dev/null +++ b/src/lib/commerce/modules/store.ts @@ -0,0 +1,48 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { randomUUID } from 'crypto' + +export class StoreModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 가맹점 기본 정보 조회 (/v1/store) + * @param idempotencyKey 미지정시 자동 생성 + */ + async getStore(idempotencyKey?: string): Promise> { + return this.bootpay.get('store', { + headers: this.storeHeaders(idempotencyKey) + }) + } + + async info(idempotencyKey?: string): Promise> { + return this.getStore(idempotencyKey) + } + + /** + * 가맹점 상세 정보 조회 (/v1/store/detail) + * @param idempotencyKey 미지정시 자동 생성 + */ + async getStoreDetail(idempotencyKey?: string): Promise> { + return this.bootpay.get('store/detail', { + headers: this.storeHeaders(idempotencyKey) + }) + } + + async detail(idempotencyKey?: string): Promise> { + return this.getStoreDetail(idempotencyKey) + } + + /** + * 가맹점 정보 조회 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private storeHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID() + } + } +} diff --git a/src/lib/commerce/modules/user-group.ts b/src/lib/commerce/modules/user-group.ts new file mode 100644 index 0000000..2d7dd2d --- /dev/null +++ b/src/lib/commerce/modules/user-group.ts @@ -0,0 +1,126 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { CommerceUserGroup, UserGroupListParams, UserGroupLimitParams, UserGroupAggregateTransactionParams } from '../types' +import { randomUUID } from 'crypto' + +export class UserGroupModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 사용자 그룹 생성 + * @param userGroup 그룹 정보 + */ + async create(userGroup: CommerceUserGroup): Promise> { + return this.bootpay.post('user-groups', userGroup) + } + + /** + * 사용자 그룹 목록 조회 + * @param params 조회 파라미터 + */ + async list(params?: UserGroupListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.keyword) queryParams.append('keyword', params.keyword) + if (params.corporate_type !== undefined) queryParams.append('corporate_type', params.corporate_type.toString()) + } + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceUserGroup[]; total: number }>(`user-groups${query ? `?${query}` : ''}`) + } + + /** + * 사용자 그룹 상세 조회 + * @param userGroupId 그룹 ID + */ + async detail(userGroupId: string): Promise> { + return this.bootpay.get(`user-groups/${userGroupId}`) + } + + /** + * 사용자 그룹 수정 + * @param userGroup 그룹 정보 + */ + async update(userGroup: CommerceUserGroup): Promise> { + if (!userGroup.user_group_id) { + return Promise.reject({ success: false, error: 'user_group_id is required' }) + } + return this.bootpay.put(`user-groups/${userGroup.user_group_id}`, userGroup) + } + + /** + * 그룹에 사용자 추가 + * @param userGroupId 그룹 ID + * @param userId 사용자 ID + */ + async userCreate(userGroupId: string, userId: string): Promise> { + return this.bootpay.post(`user-groups/${userGroupId}/user`, { user_id: userId }) + } + + /** + * 그룹에서 사용자 제거 + * @param userGroupId 그룹 ID + * @param userId 사용자 ID + */ + async userDelete(userGroupId: string, userId: string): Promise> { + return this.bootpay.delete(`user-groups/${userGroupId}/user/${userId}`) + } + + /** + * 그룹 구매한도 설정 + * PUT /v1/user-groups/{user_group_id}/limit + * ⚠️ update 로는 한도가 절대 반영되지 않는다 — 서버 user_groups_controller#update 가 + * use_limit / limit_message / limit_month_purchase / limit_week_purchase 를 명시적으로 제거하기 때문이다. + * 한도는 이 전용 라우트로만 바뀐다. 서버 scope: manager:limit + * @param params 제한 설정 파라미터 + */ + async limit(params: UserGroupLimitParams): Promise> { + if (!params.user_group_id) { + return Promise.reject({ success: false, error: 'user_group_id is required' }) + } + const { user_group_id, idempotency_key, ...payload } = params + return this.bootpay.put(`user-groups/${user_group_id}/limit`, this.compact(payload), { + headers: this.managerHeaders(idempotency_key) + }) + } + + /** + * 그룹 구독 합산청구(정산주기) 설정 변경 + * PUT /v1/user-groups/{user_group_id}/aggregate-transaction + * update 에도 같은 이름의 인자가 있지만 서버는 이 전용 라우트에서만 처리한다. + * @param params 집계 파라미터 + */ + async aggregateTransaction(params: UserGroupAggregateTransactionParams): Promise> { + if (!params.user_group_id) { + return Promise.reject({ success: false, error: 'user_group_id is required' }) + } + const { user_group_id, idempotency_key, ...payload } = params + return this.bootpay.put(`user-groups/${user_group_id}/aggregate-transaction`, this.compact(payload), { + headers: this.managerHeaders(idempotency_key) + }) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * 그룹 한도/합산청구 설정 요청 헤더 — 서버가 manager scope 를 요구한다. + * Idempotency-Key 는 미지정시 매 호출마다 생성된다. + */ + private managerHeaders(idempotencyKey?: string): Record { + return { + 'Idempotency-Key': idempotencyKey || randomUUID(), + 'BOOTPAY-ROLE': 'manager' + } + } +} diff --git a/src/lib/commerce/modules/user.ts b/src/lib/commerce/modules/user.ts new file mode 100644 index 0000000..e7af602 --- /dev/null +++ b/src/lib/commerce/modules/user.ts @@ -0,0 +1,226 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { + CommerceUser, + UserListParams, + UserTokenResponse, + UserLoginResponse, + MallUserLoginParams, + MallUserJoinParams, + MallUserJoinCheckType, + MallUserSessionResponse +} from '../types' +import { randomUUID } from 'crypto' + +export class UserModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 사용자 토큰 발급 + * @param userId 사용자 ID + */ + async token(userId: string): Promise> { + return this.bootpay.post('users/login/token', { user_id: userId }) + } + + /** + * 회원가입 + * @param user 사용자 정보 + */ + async join(user: CommerceUser): Promise> { + return this.bootpay.post('users/join', user) + } + + /** + * 중복 체크 + * @param key 체크할 필드 (login_id, phone, email 등) + * @param value 체크할 값 + */ + async checkExist(key: string, value: string): Promise> { + const encodedValue = encodeURIComponent(value) + return this.bootpay.get<{ exists: boolean }>(`users/join/${key}?pk=${encodedValue}`) + } + + /** + * 본인인증 데이터 조회 + * @param standId 인증 ID + */ + async authenticationData(standId: string): Promise> { + return this.bootpay.get(`users/authenticate/${standId}`) + } + + /** + * 로그인 + * @param loginId 로그인 ID + * @param loginPw 비밀번호 + */ + async login(loginId: string, loginPw: string): Promise> { + return this.bootpay.post('users/login', { + login_id: loginId, + login_pw: loginPw + }) + } + + /** + * 회원 로그인 (V1 API) + * POST /v1/users/login + * v1 에는 단수 user/* 라우트가 없다. 로그인은 v1/users/login#create 다. + * ⚠️ POST /v1/users/session 은 resource :session 이 만들어낸 라우트일 뿐 create 액션이 없다 — 그리로 보내면 안 된다. + * ⚠️ 서버(LoginService)는 login_id/password 만 읽는다. corporate_type 은 전달돼도 무시된다. + * @param params 로그인 파라미터 (corporate_type 미지정시 0) + */ + async userLogin(params: MallUserLoginParams): Promise> { + const { idempotency_key, corporate_type, ...rest } = params + return this.bootpay.post( + 'users/login', + this.compact({ ...rest, corporate_type: corporate_type === undefined ? 0 : corporate_type }), + { headers: this.mallHeaders(undefined, idempotency_key) } + ) + } + + /** + * 회원 세션 조회 (V1 API) + * GET /v1/users/session + * @param userJwt 로그인시 발급받은 회원 JWT + * @param idempotencyKey 미지정시 자동 생성 + */ + async userSession( + userJwt?: string, + idempotencyKey?: string + ): Promise> { + return this.bootpay.get('users/session', { + headers: this.mallHeaders(userJwt, idempotencyKey) + }) + } + + /** + * 회원 로그아웃 (V1 API) + * DELETE /v1/users/session + * @param userJwt 로그인시 발급받은 회원 JWT + * @param idempotencyKey 미지정시 자동 생성 + */ + async userLogout(userJwt: string, idempotencyKey?: string): Promise> { + return this.bootpay.delete('users/session', { + headers: this.mallHeaders(userJwt, idempotencyKey) + }) + } + + /** + * 회원가입 (V1 API) — 일반 회원가입용 + * POST /v1/users/join + * ⚠️ join(user) 과 같은 엔드포인트를 부른다. 중복이 아니라 용도가 다르다 — + * 이쪽은 password/corporate_type/group 을 쓰는 일반 회원가입, 저쪽은 uid/login_email/login_pw 를 쓰는 외부 uid 연동 가입이다. + * 서버가 파라미터 조합으로 분기하므로 둘 다 유지한다. + * @param params 회원가입 파라미터 (corporate_type 미지정시 0, 나머지 null/undefined 값은 전송하지 않는다) + */ + async userJoin(params: MallUserJoinParams): Promise> { + const { idempotency_key, corporate_type, ...rest } = params + return this.bootpay.post( + 'users/join', + this.compact({ ...rest, corporate_type: corporate_type === undefined ? 0 : corporate_type }), + { headers: this.mallHeaders(undefined, idempotency_key) } + ) + } + + /** + * 회원가입 중복 확인 (V1 API) — key 를 인자로 받는 일반형 + * GET /v1/users/join/{type}?pk={pk} + * ⚠️ uidExist 등 전용형과 기능이 겹치지만 둘 다 유지한다. + * 일반형은 서버에 새 key 가 생겨도 SDK 수정 없이 쓸 수 있다. + * @param type email-exist, id-exist, phone-exist, uid-exist, group-business-number-exist + * @param pk 중복 확인할 값 + * @param idempotencyKey 미지정시 자동 생성 + */ + async userJoinCheck( + type: MallUserJoinCheckType | string, + pk: string, + idempotencyKey?: string + ): Promise> { + return this.bootpay.get<{ exists: boolean }>(`users/join/${type}?pk=${encodeURIComponent(pk)}`, { + headers: this.mallHeaders(undefined, idempotencyKey) + }) + } + + /** + * 외부 uid(ex_uid) 중복 검사 + * GET /v1/users/join/uid-exist?pk={uid} + * email-exist / id-exist / phone-exist / group-business-number-exist 와 같은 전용형이다. + * @param uid 중복 확인할 외부 uid + * @param idempotencyKey 미지정시 자동 생성 + */ + async uidExist(uid: string, idempotencyKey?: string): Promise> { + return this.bootpay.get<{ exists: boolean }>(`users/join/uid-exist?pk=${encodeURIComponent(uid)}`, { + headers: { ...this.mallHeaders(undefined, idempotencyKey), 'BOOTPAY-ROLE': 'user' } + }) + } + + /** + * 사용자 목록 조회 + * @param params 조회 파라미터 + */ + async list(params?: UserListParams): Promise> { + const queryParams = new URLSearchParams() + if (params) { + if (params.page !== undefined) queryParams.append('page', params.page.toString()) + if (params.limit !== undefined) queryParams.append('limit', params.limit.toString()) + if (params.keyword) queryParams.append('keyword', params.keyword) + if (params.member_type !== undefined) queryParams.append('member_type', params.member_type.toString()) + if (params.type) queryParams.append('type', params.type) + } + const query = queryParams.toString() + return this.bootpay.get<{ items: CommerceUser[]; total: number }>(`users${query ? `?${query}` : ''}`) + } + + /** + * 사용자 상세 조회 + * @param userId 사용자 ID + */ + async detail(userId: string): Promise> { + return this.bootpay.get(`users/${userId}`) + } + + /** + * 사용자 정보 수정 + * @param user 사용자 정보 + */ + async update(user: CommerceUser): Promise> { + if (!user.user_id) { + return Promise.reject({ success: false, error: 'user_id is required' }) + } + return this.bootpay.put(`users/${user.user_id}`, user) + } + + /** + * 사용자 삭제 (회원탈퇴) + * @param userId 사용자 ID + */ + async delete(userId: string): Promise> { + return this.bootpay.delete(`users/${userId}`) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } + + /** + * V1 Mall API 요청 헤더 + * Idempotency-Key 는 미지정시 매 호출마다 생성되고, Bootpay-User-JWT 는 값이 있을 때만 붙는다. + */ + private mallHeaders(userJwt?: string, idempotencyKey?: string): Record { + const headers: Record = { + 'Idempotency-Key': idempotencyKey || randomUUID() + } + if (userJwt !== undefined && userJwt !== null && userJwt !== '') { + headers['Bootpay-User-JWT'] = userJwt + } + return headers + } +} diff --git a/src/lib/commerce/modules/webhook.ts b/src/lib/commerce/modules/webhook.ts new file mode 100644 index 0000000..caf2fa8 --- /dev/null +++ b/src/lib/commerce/modules/webhook.ts @@ -0,0 +1,33 @@ +import { BootpayCommerceResource, BootpayCommerceResponse } from '../../commerce-resource' +import { SendTestWebhookParams } from '../types' +import { randomUUID } from 'crypto' + +export class WebhookModule { + private bootpay: BootpayCommerceResource + + constructor(bootpay: BootpayCommerceResource) { + this.bootpay = bootpay + } + + /** + * 테스트 웹훅 발송 + * POST /v1/webhook/test + * 등록된 웹훅 URL 로 테스트 페이로드를 보내 연동을 확인할 때 쓴다. + * @param params 발송 파라미터 (header_content_type 미지정시 전송하지 않는다) + */ + async sendTest(params?: SendTestWebhookParams): Promise> { + const { idempotency_key, ...payload } = params || {} + return this.bootpay.post('webhook/test', this.compact(payload), { + headers: { 'Idempotency-Key': idempotency_key || randomUUID() } + }) + } + + /** + * null/undefined 값을 제거한다. (Ruby SDK 의 payload.compact 와 동일 동작) + */ + private compact(payload: Record): Record { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined && value !== null) + ) + } +} diff --git a/src/lib/commerce/types/cart.ts b/src/lib/commerce/types/cart.ts new file mode 100644 index 0000000..e7c33b6 --- /dev/null +++ b/src/lib/commerce/types/cart.ts @@ -0,0 +1,84 @@ +export interface CartItemPayload { + product_id: string + product_option_id?: string + quantity?: number + is_subscription?: boolean + subscription_period_id?: string +} + +export interface ShippingAddressPayload { + zipcode?: string +} + +export interface OrderPreviewParams { + member_mode?: 'guest' | 'member' + cart_items?: CartItemPayload[] + shipping_address?: ShippingAddressPayload + coupon_ids?: string[] + point_amount?: number + user_group_id?: string +} + +export interface DeliveryGroupItem { + cart_item_id?: string + product_id: string + product_option_id?: string + product_name?: string + quantity: number + price: number + subtotal?: number +} + +export interface DeliveryGroup { + group_key?: string + seller_id?: string + delivery_shipping_id?: string + delivery_shipping_bundle_id?: string + bundle_id?: string + items: DeliveryGroupItem[] + total_price: number + total_quantity: number + delivery_fee: number + delivery_extra_fee_jeju?: number + delivery_extra_fee_remote?: number + shipping_available?: boolean +} + +export interface AppliedCouponSnapshot { + coupon_id?: string + coupon_template_id?: string + name?: string + discount_type?: number + discount_value?: number + actual_discount_amount?: number + [key: string]: unknown +} + +export interface OrderPreviewSummary { + total_items: number + total_quantity: number + total_product_price: number + total_delivery_fee: number + total_delivery_extra_fee: number + coupon_discount_amount: number + applied_coupons: AppliedCouponSnapshot[] + point_use_amount: number + point_max_usable: number + point_balance_after: number + total_order_price: number +} + +export interface OrderPreviewUnavailableItem { + cart_item_id?: string + product_id: string + product_name?: string + reason?: string +} + +export interface OrderPreviewResponse { + cart_id?: string + user_id?: string + delivery_groups: DeliveryGroup[] + summary: OrderPreviewSummary + unavailable_items?: OrderPreviewUnavailableItem[] +} diff --git a/src/lib/commerce/types/category.ts b/src/lib/commerce/types/category.ts new file mode 100644 index 0000000..e851390 --- /dev/null +++ b/src/lib/commerce/types/category.ts @@ -0,0 +1,34 @@ +export interface CommerceCategory { + category_id?: string + seller_id?: string + project_id?: string + name?: string + parent_category_id?: string | null + parent_categories?: string[] + status_display?: boolean + status_best?: boolean + filter_color?: number + filter_size?: number + idx?: number + created_at?: string + updated_at?: string +} + +export interface CategoryCreateParams { + name: string + parent_category_id?: string + status_display?: boolean + status_best?: boolean + filter_color?: number + filter_size?: number +} + +export interface CategoryUpdateParams { + category_id: string + name?: string + parent_category_id?: string + status_display?: boolean + status_best?: boolean + filter_color?: number + filter_size?: number +} diff --git a/src/lib/commerce/types/common.ts b/src/lib/commerce/types/common.ts new file mode 100644 index 0000000..76bc111 --- /dev/null +++ b/src/lib/commerce/types/common.ts @@ -0,0 +1,23 @@ +// Common Types for Commerce API + +export interface ListParams { + page?: number + limit?: number + keyword?: string +} + +export interface CommerceAddress { + address_id?: string + zipcode?: string + addr1?: string + addr2?: string + phone?: string + name?: string + memo?: string + is_default?: boolean +} + +export interface CommerceAddressInstruction { + instruction_type?: number + instruction?: string +} diff --git a/src/lib/commerce/types/coupon.ts b/src/lib/commerce/types/coupon.ts new file mode 100644 index 0000000..18b05b0 --- /dev/null +++ b/src/lib/commerce/types/coupon.ts @@ -0,0 +1,26 @@ +export interface CommerceCoupon { + coupon_id?: string + coupon_template_id?: string + user_id?: string + project_id?: string + name?: string + discount_type?: number + discount_value?: number + min_order_amount?: number + max_discount_amount?: number + status?: number + issued_at?: string + used_at?: string | null + expires_at?: string | null + created_at?: string +} + +export interface CouponListParams { + status?: string + page?: number + limit?: number +} + +export interface CouponDownloadParams { + coupon_template_id: string +} diff --git a/src/lib/commerce/types/index.ts b/src/lib/commerce/types/index.ts new file mode 100644 index 0000000..c61c0e9 --- /dev/null +++ b/src/lib/commerce/types/index.ts @@ -0,0 +1,17 @@ +export * from './common' +export * from './user' +export * from './user-group' +export * from './product' +export * from './invoice' +export * from './order' +export * from './order-cancel' +export * from './order-subscription' +export * from './order-subscription-bill' +export * from './order-subscription-adjustment' +export * from './order-subscription-request' +export * from './category' +export * from './coupon' +export * from './point' +export * from './cart' +export * from './mall-setting' +export * from './webhook' diff --git a/src/lib/commerce/types/invoice.ts b/src/lib/commerce/types/invoice.ts new file mode 100644 index 0000000..851edff --- /dev/null +++ b/src/lib/commerce/types/invoice.ts @@ -0,0 +1,131 @@ +import { ListParams } from './common' + +// Constants +export const INVOICE_SEND_TYPE_SMS = 1 +export const INVOICE_SEND_TYPE_KAKAO = 2 +export const INVOICE_SEND_TYPE_EMAIL = 3 +export const INVOICE_SEND_TYPE_PUSH = 4 + +export interface CommerceInvoice { + invoice_id?: string + project_id?: string + seller_id?: string + + name?: string + title?: string + memo?: string + product_name?: string + + created_owner_id?: string + created_owner_type?: number + + unit?: number + metadata?: Record + + request_id?: string + sku?: string + + use_redirect?: boolean + redirect_url?: string + + type?: number + parent_id?: string + + subscription_type?: number + subscription_start_at?: string + subscription_end_at?: string + + expired_at?: string + status?: number + deleted?: boolean + + user_collection_type?: number + use_link_redirect?: boolean + + user_id?: string + + send_status?: number + send_types?: number[] + + message_template_id?: string + message_id?: string + message_from?: string + message_type?: number + message_response?: string + + sent_at?: string + pay_at?: string + + price?: number + tax_free_price?: number + + use_editable_username?: boolean + use_editable_phone?: boolean + use_editable_email?: boolean + use_memo?: boolean + + product_ids?: string[] + product_option_ids?: string[] + + tags?: string[] + + password?: string + order_id?: string + uuid?: string + + webhook_url?: string + header_content_type?: number + webhook_retry_count?: number + + product_type?: number + is_open_link?: boolean + + invoice_items?: CommerceInvoiceItem[] + selected_users?: string[] +} + +export interface CommerceInvoiceItem { + invoice_item_id?: string + name?: string + price?: number + qty?: number + tax_free_price?: number +} + +/** + * 청구서 목록 조회 파라미터 (GET /v1/invoices) + * limit 미지정시 서버 기본값과 동일한 24 로 전송된다. + */ +export interface InvoiceListParams extends ListParams { + cs_type?: string + user_id?: string + product_type?: number + css_at?: string + cse_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, query 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 청구서 목록 조회 응답 (GET /v1/invoices) + * ⚠️ { items, total } 이 아니라 { list, count } 다. + */ +export interface InvoiceListResponse { + list?: CommerceInvoice[] + count?: number +} + +export interface InvoiceCreateParams { + user_id?: string + user_group_id?: string + title: string + name?: string + description?: string + price: number + tax_free_price?: number + expired_at?: string + invoice_items?: CommerceInvoiceItem[] + send_types?: number[] + webhook_url?: string + metadata?: Record +} diff --git a/src/lib/commerce/types/mall-setting.ts b/src/lib/commerce/types/mall-setting.ts new file mode 100644 index 0000000..9b7da58 --- /dev/null +++ b/src/lib/commerce/types/mall-setting.ts @@ -0,0 +1,236 @@ +/** + * 몰 설정 (Mall Setting) + * 요청/응답 바디는 flatten 형식이며, 전달된 값(non-null)만 서버로 전송된다. + * supervisor scope 전용 endpoint. + */ +export interface MallSettingUpdateParams { + // 위젯 + normal_widget_key?: string + subscription_widget_key?: string + + // 사업자 정보 + seller_name?: string + seller_name_en?: string + biz_email?: string + biz_tel?: string + biz_fax?: string + registration_no?: string + corp_reg_no?: string + mail_order_sales_number?: string + owner_name?: string + zip?: string + addr_1?: string + addr_2?: string + privacy_name?: string + privacy_email?: string + + // 몰 기본 정보 + name?: string + description?: string + status?: number + invoice_title?: string + + // 브랜딩 + use_logo?: boolean + logo?: string + use_favicon?: boolean + favicon?: string + use_open_graph?: boolean + og_image?: string + use_signature?: boolean + signature?: string + + // 고객센터 운영시간 + use_operation_time?: boolean + customer_service_center_operation_time?: string + rest_start_hour?: number + rest_start_minute?: number + rest_end_hour?: number + rest_end_minute?: number + /** 휴무일 (요일 코드 배열 또는 서버 정의 문자열) */ + rest_day?: any + hosting_service?: string + + // 주문/연령 정책 + use_non_member_order?: boolean + use_age_accept_19?: boolean + use_age_accept_14?: boolean + use_age_accept_parent_name?: boolean + use_age_accept_parent_birth?: boolean + use_age_accept_parent_email?: boolean + + // 회원가입 수집 항목 + use_membership_collect_phone?: boolean + use_membership_collect_tel?: boolean + use_membership_collect_email?: boolean + use_membership_collect_address?: boolean + use_membership_collect_bank?: boolean + use_membership_collect_birth?: boolean + use_membership_collect_gender?: boolean + use_membership_collect_interest?: boolean + membership_collect_interest_number?: number + use_membership_collect_customs?: boolean + use_membership_collect_nickname?: boolean + use_membership_collect_recommend_id?: boolean + recommend_id_point_to?: number + recommend_id_point_from?: number + use_membership_collect_business?: boolean + use_membership_collect_register?: boolean + membership_only_business?: boolean + + // 기업(그룹) 회원 + use_corporate_department?: boolean + sub_group_type?: number + use_corporate_signup_approval?: boolean + /** 기업 회원 허용 이메일 도메인 목록 */ + corporate_email_domains?: any + use_corporate_auto_approve?: boolean + use_corporate_invite_only?: boolean + + // 회원 정보 노출 항목 + use_member_info_phone?: boolean + use_member_info_tel?: boolean + use_member_info_email?: boolean + use_member_info_address?: boolean + use_member_info_bank?: boolean + use_member_info_birth?: boolean + use_member_info_gender?: boolean + use_member_info_customs?: boolean + use_member_info_nickname?: boolean + use_member_info_register?: boolean + + // 주문자 수집 항목 + orderer_collect_phone?: boolean + orderer_collect_tel?: boolean + orderer_collect_email?: boolean + + // 주문/취소 정책 + order_prefix?: string + use_order_cancel?: boolean + /** 취소 승인 사용 여부 (서버 필드명 오타 그대로 유지) */ + use_oder_cancel_approval?: boolean + /** 취소 사유 목록 */ + order_cancel_reasons?: any + order_cancel_reason_required_type?: number + order_cancel_request_message?: string + order_cancel_done_message?: string + + // 회원 가입/인증 방식 + use_general_membership?: boolean + general_membership_duplication?: number + use_certification?: boolean + certification_type?: number + general_membership_id_type?: number + use_membership_duplication_email?: boolean + use_membership_duplication_phone?: boolean + use_social_membership?: boolean + /** 사용 소셜 로그인 타입 */ + social_membership_type?: any + + // 적립금 + use_point?: boolean + use_point_transaction?: boolean + point_display_name?: string + point_min_balance?: number + /** 적립 제외 조건 */ + point_not_condition?: any + /** 적립 조건 */ + point_condition?: any + use_point_max_rate?: boolean + point_max_rate?: number + use_point_max_amount?: boolean + point_max_amount?: number + point_rate?: number + point_calc_type1?: number + point_calc_type2?: number + use_point_advance_discount?: boolean + point_advance_discount_rate?: number + use_point_expire?: boolean + point_expire_type?: number + point_issue_event_type?: number + point_issue_delay_days?: number + + // 오픈마켓 / 상품 + use_open_market?: boolean + use_product_approval?: boolean + use_product_review?: boolean + use_product_review_point?: boolean + product_review_point?: number + product_review_photo_point?: number + use_product_review_answer?: boolean + use_product_review_auto_answer?: boolean + product_review_auto_answer_minute?: number + product_review_auto_answer_text?: string + use_product_qna?: boolean + product_qna_member_auth?: number + use_product_qna_answer_option?: boolean + + // 게시판 / 상담 + use_notice?: boolean + use_qna?: boolean + use_faq?: boolean + use_chat_support?: boolean + chat_support_type?: number + chat_support_key?: string + + // 휴면 / 탈퇴 + use_dormant?: boolean + dormant_year?: number + dormant_restore?: number + use_withdrawal?: boolean + use_withdrawal_guide_message?: boolean + use_withdrawal_guide_message_after?: boolean + withdrawal_guide_message_after?: string + use_withdrawal_auto?: boolean + withdrawal_auto_year?: number + + // 정기구독 정산 + use_subscription_aggregate_transaction?: boolean + subscription_month_day?: number + subscription_week_day?: number + + // 구매 한도 + use_limit?: boolean + limit_month_purchase?: number + limit_week_purchase?: number + use_limit_payment?: boolean + use_limit_message?: boolean + + // 약관 + terms_of_service?: string + terms_of_privacy_policy?: string + terms_of_privacy_collect?: string + terms_of_privacy_third?: string + + // 결제 / 노출 + payment_timeout?: number + product_sort_type?: number + mall_theme_type?: number + catalog_display_type?: number + catalog_headline?: string + catalog_bg_color?: string + catalog_view_type_pc?: number + catalog_view_type_mobile?: number + catalog_product_sort_type?: number + + // 장바구니 / 위시리스트 + use_cart?: boolean + cart_storage_period?: number + cart_max_limit?: number + cart_add_action?: number + cart_direct_purchase?: boolean + cart_option_change?: boolean + cart_discount_display?: boolean + use_wishlist?: boolean + wishlist_max_limit?: number + cart_wishlist_display?: boolean +} + +export interface CommerceMallSetting extends MallSettingUpdateParams { + mall_setting_id?: string + project_id?: string + seller_id?: string + created_at?: string + updated_at?: string + [key: string]: unknown +} diff --git a/src/lib/commerce/types/order-cancel.ts b/src/lib/commerce/types/order-cancel.ts new file mode 100644 index 0000000..5b07809 --- /dev/null +++ b/src/lib/commerce/types/order-cancel.ts @@ -0,0 +1,75 @@ +/** + * 주문 취소 요청 내역 조회 파라미터 (GET /v1/order/cancel) + * 둘 다 없으면 전체를 조회한다. + */ +export interface OrderCancelListParams { + order_id?: string + order_number?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, query 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export interface CancelProduct { + order_product_id?: string + product_id?: string + qty?: number + cancel_price?: number +} + +export interface CancelOrderSubscriptionBill { + order_subscription_bill_id?: string + cancel_price?: number +} + +export interface RequestCancelParameter { + cancel_products?: CancelProduct[] + cancel_order_subscription_bills?: CancelOrderSubscriptionBill[] + cancel_reason?: string + cancel_type?: number + refund_price?: number +} + +export interface OrderCancelParams { + order_number?: string + request_cancel_parameters?: RequestCancelParameter + is_supervisor?: boolean +} + +/** + * 취소 요청 승인/반려 파라미터 (PUT /v1/order/cancel/{id}/approve · /reject) + * 서버는 approve / reject / withdraw 셋 다 params[:id] 를 order_cancellation_request_id 로 동일하게 취급한다. + * 정식 이름은 order_cancellation_request_id 이며, 구 이름 order_cancel_request_history_id 도 계속 받는다. + */ +export interface OrderCancelActionParams { + order_cancellation_request_id?: string + /** @deprecated order_cancellation_request_id 를 사용할 것 (하위호환으로 계속 지원한다) */ + order_cancel_request_history_id?: string + message?: string + cancel_reason?: string + refund_price?: number + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 취소 요청 철회 파라미터 (PUT /v1/order/cancel/{id}/withdraw) + */ +export interface OrderCancelWithdrawParams { + order_cancellation_request_id?: string + /** @deprecated order_cancellation_request_id 를 사용할 것 (하위호환으로 계속 지원한다) */ + order_cancel_request_history_id?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송) */ + idempotency_key?: string +} + +export interface CommerceOrderCancelRequestHistory { + order_cancel_request_history_id?: string + order_id?: string + order_number?: string + status?: number + cancel_reason?: string + cancel_type?: number + requested_at?: string + processed_at?: string + refund_price?: number +} diff --git a/src/lib/commerce/types/order-subscription-adjustment.ts b/src/lib/commerce/types/order-subscription-adjustment.ts new file mode 100644 index 0000000..9a74d12 --- /dev/null +++ b/src/lib/commerce/types/order-subscription-adjustment.ts @@ -0,0 +1,29 @@ +// Constants +export const SUBSCRIPTION_ADJUSTMENT_TYPE_PERIOD_DISCOUNT = 1 + +export interface CommerceOrderSubscriptionAdjustment { + order_subscription_adjustment_id?: string + duration?: number + price?: number + tax_free_price?: number + name?: string + type?: number + created_at?: string +} + +/** + * 조정항목 수정 파라미터 (PUT /v1/order_subscriptions/{order_subscription_id}/adjustments) + * 서버는 duration(회차) 단위로 adjustments 배열을 통째로 교체한다. duration 미지정시 1 이 적용된다. + */ +export interface OrderSubscriptionAdjustmentUpdateParams { + order_subscription_id: string + duration?: number + adjustments?: CommerceOrderSubscriptionAdjustment[] + order_subscription_adjustment_id?: string + price?: number + tax_free_price?: number + name?: string + type?: number + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} diff --git a/src/lib/commerce/types/order-subscription-bill.ts b/src/lib/commerce/types/order-subscription-bill.ts new file mode 100644 index 0000000..ff9447a --- /dev/null +++ b/src/lib/commerce/types/order-subscription-bill.ts @@ -0,0 +1,74 @@ +import { ListParams } from './common' + +export interface CommerceOrderSubscriptionBill { + order_subscription_bill_id?: string + order_subscription_id?: string + user_id?: string + user_group_id?: string + + subscription_billing_type?: number + order_name?: string + paid_wallet_id?: string + reserved_wallet_id?: string + + order_number?: string + order_pre_id?: string + order_id?: string + duration?: number + total_subscription_duration?: number + + one_unit_price?: number + one_unit_tax_free_price?: number + setup_price?: number + + price?: number + tax_free_price?: number + unit?: number + + purchase_price?: number + purchase_tax_free_price?: number + + cancelled_price?: number + cancelled_tax_free_price?: number + cancelled_fee?: number + + membership_type?: number + + address_id?: string + user_address?: string + username?: string + user_phone?: string + user_email?: string + user_company_name?: string + user_business_number?: string + + product_ids?: string[] + product_option_ids?: string[] + product_snapshot_ids?: string[] + product_option_snapshot_ids?: string[] + product_type?: number + quantity?: number + + reserve_payment_at?: string + purchased_at?: string + revoked_at?: string + last_error_at?: string + + status?: number + cancel_status?: number + test_code?: string + + service_start_at?: string + service_end_at?: string +} + +/** + * 구독 빌(회차) 목록 조회 파라미터 (GET /v1/order_subscription_bills) + * page/limit 미지정시 각각 1 / 20 이 적용된다. + */ +export interface OrderSubscriptionBillListParams extends ListParams { + order_subscription_id?: string + status?: number[] + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, query 에는 포함되지 않는다) */ + idempotency_key?: string +} diff --git a/src/lib/commerce/types/order-subscription-request.ts b/src/lib/commerce/types/order-subscription-request.ts new file mode 100644 index 0000000..73aadac --- /dev/null +++ b/src/lib/commerce/types/order-subscription-request.ts @@ -0,0 +1,56 @@ +export interface OrderSubscriptionRequest { + order_subscription_request_history_id?: string + order_subscription_id?: string + project_id?: string + user_id?: string + request_type?: number + status?: number + reason?: string + requested_at?: string + processed_at?: string | null + created_at?: string + updated_at?: string +} + +/** + * 구독 변경요청 목록 조회 파라미터 (GET /v1/order-subscription-requests) + * project_id 를 주면 supervisor 모드(프로젝트 전체 검색), 없으면 본인 요청만 조회한다. + * page/limit 미지정시 각각 1 / 20 이 적용된다. + */ +export interface OrderSubscriptionRequestListParams { + project_id?: string + order_subscription_id?: string + page?: number + limit?: number + request_type?: number + status?: number + s_at?: string + e_at?: string + keyword?: string + user_id?: string + user_group_id?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, query 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export type OrderSubscriptionRequestApprovalAction = 'approve' | 'reject' + +/** + * 구독 변경요청 승인/반려 파라미터 (PUT /v1/order-subscription-requests/{id}) + * ⚠️ 승인과 반려는 별도 액션이 아니라 approval 값으로 갈린다. + * 서버가 params[:action] 을 Rails 예약어로 쓰기 때문에 키 이름이 approval 이다. + */ +export interface OrderSubscriptionRequestUpdateParams { + order_subscription_request_history_id: string + approval: OrderSubscriptionRequestApprovalAction + reason?: string + price?: number + tax_free_price?: number + termination_fee?: number + last_bill_refund_price?: number + final_fee?: number + service_end_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string + [extra: string]: unknown +} diff --git a/src/lib/commerce/types/order-subscription.ts b/src/lib/commerce/types/order-subscription.ts new file mode 100644 index 0000000..412d187 --- /dev/null +++ b/src/lib/commerce/types/order-subscription.ts @@ -0,0 +1,232 @@ +import { ListParams } from './common' + +export interface CommerceOrderSubscription { + order_subscription_id?: string + seller_id?: string + project_id?: string + order_id?: string + order_pre_id?: string + user_id?: string + user_group_id?: string + wallet_id?: string + + subscription_billing_type?: number + subscription_payment_cycle_type?: number + subscription_payment_date?: number + subscription_billing_base_day?: number + + quantity?: number + is_first_prepaid?: boolean + + one_unit_price?: number + one_unit_tax_free_price?: number + price?: number + tax_free_price?: number + setup_price?: number + + unit?: number + order_name?: string + product_name?: string + option_names?: string[] + + service_start_at?: string + service_end_at?: string + + last_billing_created_at?: string + latest_purchased_at?: string + latest_failed_at?: string + payment_next_at?: string + + current_duration?: number + created_last_duration?: number + payment_last_duration?: number + total_subscription_duration?: number + + membership_type?: number + use_subscription_times?: boolean + + renewal_status?: number + cancel_status?: number + status?: number + cancel_at?: string +} + +/** + * 정기구독 목록 조회 파라미터 (GET /v1/order_subscriptions) + * limit 미지정시 서버 기본값과 동일한 20 이 적용된다. + * ⚠️ 날짜 키는 search_date_from / search_date_to (또는 s_at / e_at) 다. orders 의 css_at / cse_at 와 다르다. + */ +export interface OrderSubscriptionListParams extends ListParams { + search_date_from?: string + search_date_to?: string + s_at?: string + e_at?: string + request_type?: string + user_group_id?: string + user_id?: string + status?: number +} + +/** + * 구독 계약 변경 파라미터 (PUT /v1/order_subscriptions/{order_subscription_id}) + * 바뀐 값만 보내면 된다. 서버가 supervisor scope 를 요구한다. + */ +export interface OrderSubscriptionUpdateParams { + order_subscription_id: string + product_id?: string + product_option_id?: string + order_name?: string + total_subscription_duration?: number + quantity?: number + address_id?: string + username?: string + phone?: string + email?: string + use_free_trial?: boolean + free_trial_day?: number + service_start_at?: string + next_billing_at?: string + billing_key?: string + status?: number + payment_next_at?: string + service_end_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +// Request Ing Types +export interface OrderSubscriptionPauseParams { + order_subscription_id?: string + order_number?: string + reason?: string + paused_at?: string + expected_resume_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export interface OrderSubscriptionResumeParams { + order_subscription_id?: string + order_number?: string + reason?: string + resume_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 중도인수 요청 파라미터 (POST /v1/order_subscriptions/requests/ing/purchase) + */ +export interface OrderSubscriptionPurchaseParams { + order_subscription_id?: string + order_number?: string + price?: number + tax_free_price?: number + reason?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 구독 이전/승계 요청 파라미터 (POST /v1/order_subscriptions/requests/ing/transfer) + */ +export interface OrderSubscriptionTransferParams { + order_subscription_id?: string + new_user_id?: string + new_username?: string + new_user_email?: string + new_user_phone?: string + new_user_address?: string + wallet_id?: string + reason?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export interface OrderSubscriptionTerminationParams { + order_subscription_id?: string + order_number?: string + termination_fee?: number + last_bill_refund_price?: number + final_fee?: number + service_end_at?: string + reason?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export interface CalcTerminateFeeResponse { + termination_fee?: number + refund_amount?: number + last_bill_refund_price?: number + final_fee?: number +} + +export interface SupervisorOrderSubscriptionApproveParams { + reason?: string +} + +export interface SupervisorOrderSubscriptionRejectParams { + reason?: string +} + +export interface SupervisorOrderSubscriptionTerminateParams { + reason?: string + termination_fee?: number + last_bill_refund_price?: number + final_fee?: number + service_end_at?: string + cancel_date?: string +} + +export interface SupervisorOrderSubscriptionPauseParams { + reason?: string + paused_at: string + expected_resume_at?: string +} + +export interface SupervisorOrderSubscriptionResumeParams { + reason?: string +} + +/** + * 수시결제(온디맨드) charge_key 즉시 결제 파라미터 + * charge_key 는 body 로만 전송된다 (URL/query 금지 — 액세스 로그 노출 방지) + */ +export interface SupervisorOrderSubscriptionChargeParams { + charge_key: string + price: number + tax_free_price?: number + user?: Record + metadata?: Record + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 수시결제(온디맨드) charge_key 해지 파라미터 + */ +export interface SupervisorOrderSubscriptionChargeRevokeParams { + charge_key: string + user?: Record + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +export interface OrderSubscriptionChargeResponse { + order_id?: string + order_number?: string + receipt_id?: string + charge_key?: string + price?: number + tax_free_price?: number + status?: number + [key: string]: unknown +} + +export interface OrderSubscriptionChargeRevokeResponse { + charge_key?: string + revoked_at?: string + status?: number + [key: string]: unknown +} diff --git a/src/lib/commerce/types/order.ts b/src/lib/commerce/types/order.ts new file mode 100644 index 0000000..c351757 --- /dev/null +++ b/src/lib/commerce/types/order.ts @@ -0,0 +1,73 @@ +import { ListParams } from './common' + +// Constants +export const SUBSCRIPTION_BILLING_TYPE_NONE = 0 +export const SUBSCRIPTION_BILLING_TYPE_EACH = 1 +export const SUBSCRIPTION_BILLING_TYPE_GROUP = 2 + +export interface CommerceChosenProductOption { + chosen_product_option_id?: string + product_id?: string + product_option_id?: string + product_name?: string + option_name?: string + price?: number + tax_free_price?: number + qty?: number +} + +export interface CommerceOrder { + order_id?: string + order_pre_id?: string + chosen_product_options?: CommerceChosenProductOption[] + + parent_order_id?: string + user_id?: string + seller_id?: string + project_id?: string + status?: number + currency?: number + is_subscription?: boolean + is_leaf?: boolean + total_price?: number + tax_free_price?: number + discount_amount?: number + delivery_price?: number + payment_method?: string + receipt_id?: string + webhook_url?: string + created_at?: string + updated_at?: string + + cancelled_request_history?: CommerceOrderCancellationRequestHistory[] +} + +export interface CommerceOrderCancellationRequestHistory { + order_cancellation_request_history_id?: string + order_id?: string + status?: number + cancel_reason?: string + cancel_type?: number + requested_at?: string + processed_at?: string +} + +/** + * 주문 목록 조회 파라미터 (GET /v1/orders) + * limit 은 서버 기본 20 · 최대 50 이며, 50 초과를 보내도 서버가 50 으로 클램프한다. + * ⚠️ 날짜 키의 정식 이름은 search_date_from / search_date_to 다. + * css_at / cse_at 는 서버가 받아주는 별칭이며 하위호환을 위해 남겨둔다. + */ +export interface OrderListParams extends ListParams { + user_id?: string + user_group_id?: string + status?: number[] + payment_status?: number[] + cs_type?: string + search_date_from?: string + search_date_to?: string + css_at?: string + cse_at?: string + subscription_billing_type?: number + order_subscription_ids?: string[] +} diff --git a/src/lib/commerce/types/point.ts b/src/lib/commerce/types/point.ts new file mode 100644 index 0000000..9a3bebc --- /dev/null +++ b/src/lib/commerce/types/point.ts @@ -0,0 +1,36 @@ +export interface PointBalance { + available_balance?: number + total_earned?: number + total_used?: number + is_negative?: boolean +} + +export interface PointTransaction { + transaction_id?: string + transaction_type?: number + amount?: number + balance_after?: number + reason?: string + type?: number + order_id?: string | null + review_id?: string | null + earned_at?: string | null + expires_at?: string | null + expired?: boolean + remaining_balance?: number + created_at?: string | null +} + +export interface PointTransactionsResponse { + transactions: PointTransaction[] + total_count: number + page: number + limit: number + total_pages: number +} + +export interface PointTransactionsParams { + page?: number + limit?: number + transaction_type?: number +} diff --git a/src/lib/commerce/types/product.ts b/src/lib/commerce/types/product.ts new file mode 100644 index 0000000..2e8e7b1 --- /dev/null +++ b/src/lib/commerce/types/product.ts @@ -0,0 +1,170 @@ +import { ListParams } from './common' + +export interface CommerceProduct { + product_id?: string + category_id?: string + project_id?: string + seller_id?: string + subscription_setting_id?: string + delivery_shipping_id?: string + brand_id?: string + manufacturer_id?: string + + ex_uid?: string + + name?: string + description?: string + images?: string[] + type?: number + tax_type?: number + use_stock?: boolean + stock?: number + use_option_stock?: boolean + use_stock_safe?: boolean + stock_safe?: number + + display_price?: number + tax_free_price?: number + use_discount?: boolean + discount_price?: number + discount_price_type?: number + use_discount_period?: boolean + discount_start_at?: string + discount_end_at?: string + + use_accumulation?: boolean + accumulation_point?: number + accumulation_point_type?: number + + status_display?: boolean + use_display_period?: boolean + display_start_at?: string + display_end_at?: string + status_sale?: boolean + use_sale_period?: boolean + sale_start_at?: string + sale_end_at?: string + + count_sale?: number + count_qna?: number + count_like?: number + count_review?: number + + barcode?: string + sku?: string + search_tags?: string[] + event_tags?: string[] + target_user_tags?: string[] + delivery_tags?: string[] + emotion_tags?: string[] + + use_coupon?: boolean + use_minor?: boolean + use_free_gift?: boolean + free_gift?: string + + use_bulk_purchase_discount?: boolean + bulk_purchase_discount?: Record + + use_review_point?: boolean + review_point?: Record + + use_seo?: boolean + seo_page_title?: string + seo_meta_description?: string + seo_meta_tags?: string[] + + model_id?: string + model_name?: string + manufacturer_name?: string + brand_name?: string + origin_code?: string + origin_name?: string + importer?: string + + used?: boolean + expired_at?: string + manufactured_at?: string + + use_setup_fee?: boolean + setup_fee_value?: number + setup_fee_type?: number + setup_fee_name?: string + setup_fee_text?: string + + use_delivery_shipping?: boolean + delivery_shipping_fee_type?: number + use_overseas_shipping?: boolean + use_delivery_shipping_bundle?: boolean + delivery_shipping_bundle_id?: string + + use_subscription?: boolean + use_subscription_times?: boolean + use_product_price?: boolean + + use_cancel?: boolean + use_able_refund?: boolean + use_able_cart?: boolean + + created_at?: string + updated_at?: string + + options?: CommerceProductOption[] + subscription_setting?: CommerceSubscriptionSetting +} + +export interface CommerceProductOption { + option_id?: string + name?: string + price?: number + stock?: number +} + +export interface CommerceSubscriptionSetting { + subscription_setting_id?: string + period_type?: string + period_value?: number + billing_day?: number + billing_count?: number +} + +export interface ProductListParams extends ListParams { + type?: number + period_type?: string + s_at?: string + e_at?: string + category_code?: string +} + +/** + * 상품 목록 조회 파라미터 (V1 Mall API) + * GET /v1/products + */ +export interface MallProductListParams extends ProductListParams { + category_id?: string + sort?: string + user_jwt?: string + idempotency_key?: string +} + +/** + * 상품 판매/노출 상태 변경 파라미터 (PUT /v1/products/{product_id}/status) + * 서버 _status_params 기준. ⚠️ 재고(stock)는 여기가 아니라 update 로 바꾼다. + */ +export interface ProductStatusParams { + product_id: string + status?: number + status_display?: boolean + status_sale?: boolean + status_frozen?: boolean + status_review?: boolean + use_display_period?: boolean + display_start_at?: string + display_end_at?: string + use_sale_period?: boolean + sale_start_at?: string + sale_end_at?: string + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string + [extra: string]: unknown +} diff --git a/src/lib/commerce/types/user-group.ts b/src/lib/commerce/types/user-group.ts new file mode 100644 index 0000000..2400440 --- /dev/null +++ b/src/lib/commerce/types/user-group.ts @@ -0,0 +1,91 @@ +import { ListParams } from './common' + +export interface CommerceUserGroup { + user_group_id?: string + seller_id?: string + project_id?: string + corporate_type?: number + + bank?: string + bank_code?: string + + count?: number + last_updated_at?: string + status?: number + + phone?: string + email?: string + zipcode?: string + address?: string + address_detail?: string + corporate_extension?: Record + auth_bank?: boolean + + company_name?: string + business_number?: string + registration_number?: string + corporate_established?: string + business_type?: string + business_category?: string + ceo_name?: string + auth_company?: boolean + + manager_name?: string + manager_phone?: string + manager_email?: string + + personal_customs_clearance_code?: string + + point?: number + accumulation?: number + marketing_accept_type?: number + marketing_accept_create_at?: string + marketing_accept_update_at?: string + + use_subscription_aggregate_transaction?: boolean + subscription_month_day?: number + subscription_week_day?: number + + use_limit?: boolean + purchase_limit?: number + subscribed_limit?: number + limit_message?: string + external_uid?: string + is_external?: string +} + +// Constants +export const CORPORATE_TYPE_INDIVIDUAL = 1 +export const CORPORATE_TYPE_CORPORATE = 2 + +export interface UserGroupListParams extends ListParams { + corporate_type?: number +} + +/** + * 그룹 구매한도 설정 파라미터 (PUT /v1/user-groups/{user_group_id}/limit) + * ⚠️ update 로는 반영되지 않는다 — 서버가 이 값들을 update 에서 제거하기 때문에 전용 라우트로만 바뀐다. + */ +export interface UserGroupLimitParams { + user_group_id: string + use_limit?: boolean + limit_month_purchase?: number + limit_week_purchase?: number + limit_message?: string + purchase_limit?: number + subscribed_limit?: number + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} + +/** + * 그룹 구독 합산청구(정산주기) 설정 파라미터 (PUT /v1/user-groups/{user_group_id}/aggregate-transaction) + */ +export interface UserGroupAggregateTransactionParams { + user_group_id: string + use_subscription_aggregate_transaction?: boolean + subscription_month_day?: number + subscription_week_day?: number + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} diff --git a/src/lib/commerce/types/user.ts b/src/lib/commerce/types/user.ts new file mode 100644 index 0000000..9c20e08 --- /dev/null +++ b/src/lib/commerce/types/user.ts @@ -0,0 +1,134 @@ +import { ListParams, CommerceAddress } from './common' + +export interface CommerceUserGroupRef { + user_group_id?: string + name?: string +} + +export interface CommerceUser { + user_id?: string + created_at?: string + updated_at?: string + + // 고객 유형 + membership_type?: number + + // 고객 정보 + name?: string + phone?: string + email?: string + tel?: string + nickname?: string + bank_username?: string + bank_account?: string + bank_code?: string + comment?: string + + // 최종상태 + count?: number + status?: number + + // 개인 고객 + gender?: number + birth?: string + individual_extension?: Record + + // 쇼핑몰 회원 + login_id?: string + login_pw?: string + login_type?: number + + group_tags?: string[] + metadata?: Record + + // 인증정보 + auth_sms?: boolean + auth_phone?: boolean + auth_email?: boolean + ci?: string + cd?: string + + join_at?: string + join_confirm_type?: number + lasted_at?: string + + // 약관 동의 + marketing_accept_type?: number + marketing_accept_create_at?: string + marketing_accept_update_at?: string + term_ids?: string[] + + group?: CommerceUserGroupRef + + external_uid?: string + is_external?: string + user_group_id?: string +} + +export interface UserListParams extends ListParams { + member_type?: number + type?: string +} + +export interface UserTokenResponse { + access_token?: string + expired_at?: string + user?: CommerceUser +} + +export interface UserLoginResponse { + access_token?: string + expired_at?: string + user?: CommerceUser +} + +/** + * 회원 로그인 파라미터 (V1 API) + * POST /v1/users/login + */ +export interface MallUserLoginParams { + login_id: string + password: string + // 0: 개인, 1: 사업자 + corporate_type?: number + idempotency_key?: string +} + +/** + * 회원가입 파라미터 (V1 API) + * POST /v1/users/join + */ +export interface MallUserJoinParams { + login_id: string + password: string + name: string + email?: string + phone?: string + nickname?: string + gender?: number + birth?: string + // 0: 개인, 1: 사업자 + corporate_type?: number + group?: Record + idempotency_key?: string +} + +/** + * 회원가입 중복 확인 타입 (V1 API) + * GET /v1/users/join/{type} + */ +export type MallUserJoinCheckType = + | 'email-exist' + | 'id-exist' + | 'phone-exist' + | 'uid-exist' + | 'group-business-number-exist' + +/** + * 회원 세션 조회 응답 (V1 API) + */ +export interface MallUserSessionResponse { + user?: CommerceUser + access_token?: string + expired_at?: string +} diff --git a/src/lib/commerce/types/webhook.ts b/src/lib/commerce/types/webhook.ts new file mode 100644 index 0000000..d04c608 --- /dev/null +++ b/src/lib/commerce/types/webhook.ts @@ -0,0 +1,9 @@ +/** + * 테스트 웹훅 발송 파라미터 (POST /v1/webhook/test) + */ +export interface SendTestWebhookParams { + /** 웹훅 본문 Content-Type (미지정시 서버 기본값) */ + header_content_type?: number + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ + idempotency_key?: string +} diff --git a/src/lib/resource.ts b/src/lib/resource.ts new file mode 100644 index 0000000..778cae4 --- /dev/null +++ b/src/lib/resource.ts @@ -0,0 +1,166 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' + +export interface BootpayRestApiErrorResponse { + error_code?: number + pg_error_code?: number + message?: string +} + +interface BootpayEntrypoints { + development: string + stage: string + production: string +} + +interface BootpayConfiguration { + application_id?: string + private_key?: string + client_key?: string + secret_key?: string + mode?: 'development' | 'production' | 'stage' +} + +export class BootpayBackendNodejsResource { + $http: AxiosInstance + $token?: string + mode: 'development' | 'production' | 'stage' + bootpayConfiguration: BootpayConfiguration + API_ENTRYPOINTS: BootpayEntrypoints + apiVersion: string = '5.0.0' + sdkVersion: string = '2.3.0' + + constructor() { + this.mode = 'production' + this.$http = axios.create({ + timeout: 60000 + }) + this.$token = undefined + this.bootpayConfiguration = { + application_id: '', + private_key: '', + client_key: '', + secret_key: '', + mode: 'production' + } + this.API_ENTRYPOINTS = { + development: 'https://dev-api.bootpay.co.kr/v2', + stage: 'https://stage-api.bootpay.co.kr/v2', + production: 'https://api.bootpay.co.kr/v2' + } + this.$http.interceptors.response.use((response: AxiosResponse): any => { + if (response.request !== undefined && response.headers !== undefined) { + return response.data + } else { + // 오류를 리턴 + return response.data as BootpayRestApiErrorResponse + } + }, (error: any) => { + if (error.response !== undefined) { + return Promise.reject(error.response.data as BootpayRestApiErrorResponse) + } else { + return Promise.reject({ + error_code: -100, + message: `Request Rest Api Failed to Bootpay Server, ${ error.message }` + }) as BootpayRestApiErrorResponse + } + }) + // @ts-expect-error + this.$http.interceptors.request.use((config: AxiosRequestConfig) => { + if (config.headers !== undefined) { + const { client_key, secret_key } = this.bootpayConfiguration + + // 인증 우선순위: + // 1) client_key/secret_key가 있으면 새 Basic Auth 사용 + // 2) 없으면 기존 application_id/private_key token 방식 유지 + if (client_key && secret_key) { + config.headers.authorization = `Basic ${Buffer.from(`${client_key}:${secret_key}`).toString('base64')}` + } else { + if (this.$token !== undefined) { + config.headers.authorization = `Bearer ${ this.$token }` + } + } + config.headers['Content-Type'] = 'application/json' + config.headers['Accept'] = 'application/json' + config.headers['BOOTPAY-SDK-VERSION'] = this.sdkVersion + config.headers['BOOTPAY-API-VERSION'] = this.apiVersion + config.headers['BOOTPAY-SDK-TYPE'] = 301 + + } + return config + }, (error: any) => { + return Promise.reject(error) + }) + } + + /** + * Environments + * Comment by GOSOMI + * @date: 2022-04-12 + * @param configuration: BootpayConfiguration + * @returns void + */ + setConfiguration(configuration: BootpayConfiguration): void { + if (configuration.mode === undefined) { + configuration.mode = 'production' + } + this.bootpayConfiguration = configuration + } + + /** + * SET API Version + * Comment by GOSOMI + * @date: 2022-07-29 + */ + setApiVersion(version: string) { + this.apiVersion = version + } + + /** + * Set Access Token + * Comment by GOSOMI + * @date: 2022-04-12 + */ + setToken(token: string): void { + this.$token = token + } + + entrypoints(url: string): string { + return [this.API_ENTRYPOINTS[this.bootpayConfiguration.mode === undefined ? 'production' : this.bootpayConfiguration.mode], url].join('/') + } + + async get(url: string, config?: AxiosRequestConfig): Promise { + try { + const response: T = await this.$http.get(this.entrypoints(url), config) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } + + async post(url: string, data?: D, config?: AxiosRequestConfig): Promise { + try { + const response: T = await this.$http.post(this.entrypoints(url), data, config) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } + + async put(url: string, data?: D, config?: AxiosRequestConfig): Promise { + try { + const response: T = await this.$http.put(this.entrypoints(url), data, config) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } + + async delete(url: string, config?: AxiosRequestConfig): Promise { + try { + const response: T = await this.$http.delete(this.entrypoints(url), config) + return Promise.resolve(response) + } catch (e) { + return Promise.reject(e) + } + } +} \ No newline at end of file diff --git a/src/lib/response.ts b/src/lib/response.ts new file mode 100644 index 0000000..d0bee9a --- /dev/null +++ b/src/lib/response.ts @@ -0,0 +1,485 @@ +export interface AccessTokenResponseParameters { + expire_in: number + access_token: string +} + +export interface ReceiptResponseParameters { + receipt_id: string + order_id: string + price: number + tax_free: number + cancelled_price: number + cancelled_tax_free: number + order_name: string + company_name: string + gateway_url: string + metadata: string + sandbox: boolean + pg: string + method: string + method_symbol: string // legacy method alias + method_origin: string + method_origin_symbol: string // legacy method origin alias + purchased_at?: Date + requested_at: Date + cancelled_at?: Date + status: number + status_locale: string + receipt_url?: string + card_data?: CardData, + phone_data?: PhoneData, + bank_data?: BankData + vbank_data?: BankData + escrow_data?: EscrowData + cash_receipt_data?: CashReceiptData + naver_point_data?: NaverPointData + kakao_moneny_data?: KakaoMoneyData + payco_point_data?: PaycoPointData + toss_point_data?: TossPointData + currency?: string +} + +export interface ExtraModel { + subscribe_test_payment: boolean +} + +export interface UserModel { + id?: string + username?: string + phone?: string + email?: string +} + +export interface CompanyModel { + name?: string + phone?: string + zipcode?: string + addr1?: string + addr2?: string +} + +export interface ItemModel { + id?: string + name?: string + qty?: number + price?: number +} + +export interface BillingData { + card_no: string + card_company: string + card_company_code: string + card_type: number + card_hash?: string + rtn_key_info?: string // KCP 전용 리턴값 +} + +export interface CardData { + tid: string + card_approve_no: string + card_no: string + card_quota: string + card_company_code: string + card_company: string + card_interest: string + receipt_url?: string + card_type?: string + card_owner_type?: string + point?: number +} + +export interface PhoneData { + tid: string + auth_no?: string + phone?: string + receipt_url?: string +} + +export interface BankData { + tid: string + bank_code: string + bank_name: string + bank_username: string + bank_account?: string + sender_name?: string + expired_at?: Date + cash_receipt_tid?: string + cash_receipt_type?: string + cash_receipt_no?: string + receipt_url?: string +} + +export interface EscrowData { + status: number + status_locale: string + shipping_started_at: Date + receipt_confirmed_at: Date | null +} + +export interface CashReceiptData { + tid?: string + cash_receipt_type?: number + cash_receipt_no?: string + receipt_url?: string +} + +export interface KakaoMoneyData { + tid?: string +} + +export interface NaverPointData { + tid?: string +} + +export interface PaycoPointData { + tid?: string +} + +export interface TossPointData { + tid?: string +} + +export interface CancelPaymentParameters { + receipt_id: string + cancel_price?: number + cancel_tax_free?: number + cancel_id?: string + cancel_username?: string + cancel_message?: string + refund?: Refund +} + +export interface Refund { + bank_account: string + bank_username: string + bank_code: string +} + +export interface CertificateResponseParameters { + receipt_id: string + authenticate_id: string + pg: string + method: string + method_origin: string + method_origin_symbol: string + authenticated_at: Date + requested_at: Date + status: number + status_locale: string + authenticate_data: AuthenticateData +} + +export interface AuthenticateData { + name?: string + phone?: string + unique?: string + birth?: Date + gender?: number + foreigner?: number + carrier?: string + number_of_realarms?: number + tid: string +} + +export interface SubscriptionBillingRequestParameters { + pg: string + method?: string + order_name: string + subscription_id: string + card_no: string + card_pw: string + card_identity_no: string + card_expire_year: string + card_expire_month: string + price?: number + tax_free?: number + extra: ExtraModel + user: UserModel + metadata?: object +} + +export interface SubscriptionBillingTransferRequestParameters { + pg: string + method?: string + order_name: string + subscription_id: string + + auth_type: 'ARS' | '간편인증' + username: string + bank_name: string + bank_account: string + identity_no: string + cash_receipt_type?: '소득공제' | '지출증빙' + cash_receipt_identity_no?: string + phone?: string + + price?: number + tax_free?: number + extra: ExtraModel + user: UserModel + metadata?: object +} + +export interface SubscriptionBillingResponseParameters { + billing_key: string + billing_data: BillingData + receipt_id: string + subscription_id: string + gateway_url?: string + metadata: object + pg: string + method: string + method_origin?: string + method_origin_symbol?: string + method_symbol?: string + published_at: Date + requested_at: Date + receipt_data: ReceiptResponseParameters + billing_expire_at: Date + status: number + status_locale?: string +} + +export interface SubscriptionCardPaymentRequestParameters { + billing_key: string + order_name: string + price: number + tax_free?: number + card_quota?: string + card_interest?: string + order_id: string + feedback_url?: string + content_type?: string + metadata?: any + items?: Array + user?: UserModel + extra?: ExtraModel +} + +export interface SubscriptionPaymentRequestParameters { + billing_key: string + order_name: string + price: number + tax_free?: number + card_quota?: string + card_interest?: string + order_id: string + feedback_url?: string + content_type?: string + metadata?: any + items?: Array + user?: UserModel + extra?: ExtraModel +} + +export interface DestroySubscribeResponseParameters { + billing_key: string +} + +export interface UserTokenRequestParameters { + user_id: string + email?: string + username?: string + gender?: number + birth?: string + phone?: string +} + +export interface UserTokenResponseParameters { + user_token: string + expired_at: Date +} + +export interface SubscribePaymentReserveParameters { + billing_key: string + order_name: string + price: number + tax_free?: number + order_id: string + reserve_execute_at: Date + user?: UserModel + items?: ItemModel + metadata?: any + feedback_url?: string + content_type?: 'application/json' | 'application/x-www-form-urlencoded' +} + +export interface ShippingRequestParameters { + receipt_id: string + receipt_url: string + tracking_number: string + delivery_corp: string + shipping_prepayment?: boolean + shipping_day?: number + user?: UserModel + company?: CompanyModel +} + +export interface SubscribePaymentReserveResponse { + reserve_id: string + reserve_execute_at: Date +} + +export interface CancelSubscribeReserveResponse { + reserve_id: string + success: boolean +} + +export interface CashReceiptPublishOnReceiptParameters { + receipt_id: string + username?: string + email?: string + phone?: string + identity_no: string + cash_receipt_type: '소득공제' | '지출증빙' + currency?: string +} + +export interface CashReceiptCancelOnReceiptParameters { + receipt_id: string + cancel_username?: string + cancel_message?: string +} + +export interface RequestCashReceiptParameters { + pg: string + price: number + tax_free?: number + order_name: string + cash_receipt_type: '소득공제' | '지출증빙' + identity_no: string + purchased_at?: Date + order_id: string + user?: UserModel + extra?: ExtraModel +} + +export interface CancelCashReceiptParameters { + receipt_id: string + cancel_username?: string + cancel_message?: string +} + +export interface RequestAuthenticateParameters { + authentication_id: string + pg: string + method: string + username: string + identity_no: string + carrier: string + phone: string + client_ip: string + site_url?: string + authenticate_type?: 'sms' | 'pass' + order_name: string + extra?: ExtraModel + user?: UserModel + metadata?: object +} + +export interface SubscribePaymentLookupResponse { + reserve_id: string + receipt_id?: string + order_id: string + price: number + tax_free: number + order_name: string + user: UserModel + feedback_url: string + metadata?: any, + content_type: 'application/json' | 'application/x-www-form-urlencoded', + version: number + extra: ExtraModel + reserve_requested_at: string + reserve_execute_at: string + reserve_started_at: string + reserve_finished_at: string + reserve_revoked_at: string + status: number +} + +/** @deprecated wallet 엔드포인트는 폐기 예정이며, 결제는 wallet_id + user_token 방식으로 전환 예정. 다음 메이저 버전에서 제거됩니다. */ +export interface WalletRequestParameters { + user_id: string + order_name: string + price: number + tax_free?: number + order_id: string + webhook_url?: string + content_type?: 'application/json' | 'application/x-www-form-urlencoded' + items?: ItemModel + user?: UserModel + extra?: ExtraModel + metadata?: object + sandbox: boolean +} + +/** @deprecated wallet 엔드포인트는 폐기 예정이며, 결제는 wallet_id + user_token 방식으로 전환 예정. 다음 메이저 버전에서 제거됩니다. */ +export interface WalletPaymentResponseParameters { + cancelled_price: number + wallet_data: WalletData + metadata: Record + cancelled_tax_free: number + method: string + card_data: CardData + sandbox: boolean + receipt_id: string + method_origin: string + order_name: string + method_origin_symbol: string + receipt_url: string + method_symbol: string + purchased_at: string + tax_free: number + price: number + company_name: string + pg: string + status_locale: string + currency: string + /** @deprecated HTTP status code 노출 type. 다음 메이저 버전에서 제거 예정. 성공 여부는 status 필드 사용. */ + http_status: number + order_id: string + requested_at: string + status: number +} + +export interface WalletData { + success: WalletDataPart + failure: any[] // 실패한 데이터가 리스트 형태로 존재 +} + +export interface WalletDataPart { + wallet_id: string + type: number + sandbox: number + order: number + payment_status: number + batch_data: BatchData + card_code: string + expired_at: string + latest_purchased_at: string +} + +export interface BatchData { + card_no: string + card_company: string + card_company_code: string + card_type: number + card_hash: string +} + +export interface CardData { + tid: string + card_approve_no: string + card_no: string + card_quota: string + card_company_code: string + card_company: string + card_type?: string +} + +// public class WalletResponseData { +// +// List data; +// int http_status; +// } \ No newline at end of file diff --git a/test/AUDIT.md b/test/AUDIT.md new file mode 100644 index 0000000..724b8fb --- /dev/null +++ b/test/AUDIT.md @@ -0,0 +1,290 @@ +# NodeJS SDK 테스트 감사 보고서 + +기준: `server/nodejs` 2.5.0, `BOOTPAY_ENV=production`, `BOOTPAY_AUTH_MODE=new`. +실행: `test/pg/*.js` (26), `test/commerce/*.js` (61). 최종 갱신: 2026-05-11. + +--- + +## 2026-05-11 진행 상황 (이후 섹션 읽기 전 필독) + +- **A 항목 #1~#5 (SDK URL 버그)**: 모두 해결됨. + - #1, #2 user-group URL 정정 → nodejs 커밋 `ac08d68`, 6 SDK 일괄 전파 (Go `df76c12` / Java `41d6f41` / Python `c225210` / PHP `e45386f` / .NET `1d0cf50` / Ruby `bba7237`). + - #3, #4, #5 죽은 endpoint (`coupon.preview`, `point.previewUsage`, `point.calculateLimit`) → 메서드 + 타입 + 테스트 파일 완전 삭제 (nodejs `ac08d68`). + - root 도 submodule pointer + 문서 갱신 (`69dba4d`). +- **B 항목 5 modules 일괄 추가**: category / coupon / point / cart / order-subscription-request → 6 server SDK 에 nodejs 기준으로 propagation 완료 (위 6 SDK 커밋과 동일). +- **새 발견**: dev 환경에서 5 modules endpoint 자체는 존재 확인. 그러나 production 미배포 (404). dev 에서도 `category.list` 만 200, 나머지는 `API_ROLE_NOT_SUPPORT` — user-token 인증 필요한 user-scoped endpoint 로 추정. **백엔드팀 확인 필요.** +- **새 anomaly**: `user.token('USER_ID_HERE')` 응답 메시지가 `INVOICE_TARGET_NOT_FOUND` — placeholder 가 invoice 도메인 에러로 매핑됨. 서버 측 매핑 버그 의심. + +--- + +## 요약 + +| 영역 | 정상 | 진짜 SDK/백엔드 버그 | 죽은 테스트 | 스테일 데이터 / placeholder | +|---|---:|---:|---:|---:| +| PG (26) | 4 | 2 | 3 | 17 | +| Commerce (61) | 7 | 9 list 타입 mismatch + 14 endpoint 404 (5 modules prod 미배포) + 4 SERVER_ERROR / 매핑 의심 | 0 | 9 role mismatch + 13종 fixture 누락 | + +--- + +## PG 결과 (26) + +### ✅ 정상 + +| 파일 | 비고 | +|---|---| +| `getAccessToken.js` | ck/sk + legacy 둘 다 동작 | +| `legacyCompatibility.js` | 두 모드 호환성 검증 통과 | +| `lookupBilling.js` | 정상 응답 (billing_key, billing_data 등) | +| `receiptPayment.js` | 정상 응답 (receipt 조회) | + +### 🐛 진짜 버그 — 백엔드 또는 SDK 수정 필요 + +| 파일 | ck/sk 결과 | legacy 결과 | 분석 | +|---|---|---|---| +| `requestUserToken.js` | `TOKEN_KEY_INVALID` | ✅ `user_token` 반환 | endpoint `POST request/user/token` 가 Basic Auth (ck/sk) 거부, Bearer 만 허용 | +| `getUserWallets.js` | `TOKEN_KEY_INVALID` | ✅ `[]` 반환 | 동일 — Bearer 만 허용 | + +→ **해결안 (택1)**: ① 백엔드가 ck/sk Basic Auth 받도록 수정 ② SDK 가 이 두 endpoint 만 자동으로 Bearer fallback ③ 이 두 endpoint 는 ck/sk 지원 불가로 문서화 후 legacy 강제. + +### 🧹 죽은/깨진 테스트 파일 — 삭제 또는 수정 + +| 파일 | 증상 | +|---|---| +| `form_payment_progress.js` | `ReferenceError: params is not defined` (line 19) | +| `request_payment.js` | 빈 출력 — entry point 없음 | +| `axios_test.js` | 하드코딩된 application_id (`-2410 not found`), config.js 우회 | + +### 🗓 스테일 데이터 (SDK 동작 정상, 픽스처 갱신 필요) + +| 파일 | 에러 코드 | +|---|---| +| `cancelPayment.js` | `RC_ALREADY_CANCELLED` | +| `confirmPayment.js` | `RC_NOT_CONFIRM_READY` | +| `cancelSubscribeReserve.js` | `SUBSCRIBE_BK_EXPIRED` | +| `destroySubscribeBillingKey.js` | `SUBSCRIBE_BK_EXPIRED` | +| `lookupSubscribeBilling.js` | `RC_NOT_SUBSCRIBE` | +| `subscribeCardPayment.js` | `SUBSCRIBE_BK_EXPIRED` | +| `subscribePayment.js` | `SUBSCRIBE_BK_EXPIRED` | +| `subscribePaymentReserve.js` | `SUBSCRIBE_BK_EXPIRED` | +| `cashReceiptPublishOnReceipt.js` | `RC_NOT_FOUND` | +| `shippingStart.js` | `RC_NOT_ESCROW` | +| `certificate.js` | `AUTH_EXPIRED` | +| `getBillingKey.js` | `RC_INVALID_EXP_MONTH` (테스트 카드 만료월 잘못됨) | +| `requestCashReceipt.js` | `RC_CASH_RECEIPT_FAILED` (PG MID 누락) | +| `publishAutomaticTransferBillingKey.js` | `SUBSCRIBE_PUBLISH_NOT_READY` | +| `requestSubscribeAutomaticTransferBillingKey.js` | `SUBSCRIBE_AT_LOOKUP_USER_FAILED` | +| `authenticateRequestRest.js` | `AUTH_CONFIRM_READY_FAILED` (통신사 정보 불일치) | +| `authenticateRealarmRest.js` | `AUTH_NOT_READY` | + +--- + +## Commerce 결과 (64) + +### ✅ 정상 200 OK (7) + +| 파일 | 비고 | +|---|---| +| `getAccessToken.js` | ck/sk 정상 발급 | +| `userList.js` | `{ list: [], count: 0 }` ← **타입 불일치** (SDK 선언은 `{ items, total }`) | +| `userGroupList.js` | 동일 | +| `productList.js` | 동일 (2건 데이터 있음) | +| `invoiceList.js` | `{ list, count, user }` ← **타입 불일치 + 추가 필드** | +| `orderList.js` | 동일 mismatch | +| `orderSubscriptionBillList.js` | 동일 mismatch | + +### 🐛 SDK 타입 계약 위반 — 모든 list endpoint + +| 모듈 | SDK 선언 (`modules/*.ts`) | 실제 서버 응답 | +|---|---|---| +| `user.list` | `Promise<{ items: CommerceUser[]; total: number }>` | `{ list, count }` | +| `userGroup.list` | `Promise<{ items: CommerceUserGroup[]; total: number }>` | `{ list, count }` | +| `product.list` | `Promise<{ items: CommerceProduct[]; total: number }>` | `{ list, count }` | +| `invoice.list` | `Promise<{ items: CommerceInvoice[]; total: number }>` | `{ list, count, user }` | +| `order.list` | `Promise<{ items: CommerceOrder[]; total: number }>` | `{ list, count }` | +| `orderCancel.list` | `Promise<{ items: ...; total }>` | (확인 필요) | +| `orderSubscription.list` | `Promise<{ items: ...; total }>` | `{ count, ing_pause, ing_purchase, ing_resume, ing_termination, ing_transfer, sum_active, sum_auto_end, sum_cancel_request, sum_first_payment_error, sum_hold_on, sum_pause, sum_pending, sum_terminated, sum_trial, total }` ← 통계 포함 풍부한 응답 | +| `orderSubscriptionBill.list` | `Promise<{ items: ...; total }>` | `{ list, count }` | +| `orderSubscriptionRequest.list` | `Promise<{ items: ...; total }>` | 404 | + +→ TypeScript 사용자가 `response.data.items` 로 접근 시 `undefined`. 기준 SDK 결정 후 6 SDK 전파 필요. + +### 🚫 404 Not Found — production 환경 (2026-05-11 재실행) + +| 모듈 | URL | dev 환경 결과 | 비고 | +|---|---|---|---| +| `category.list` | `GET categories` | ✅ 200 (`[]`) | 코드/URL OK. prod 미배포만 | +| `category.create/detail/update/delete` | `POST/GET/PUT/DELETE categories[/:id]` | (미테스트) | 동상 | +| `coupon.list` | `GET coupon` | ❌ `API_ROLE_NOT_SUPPORT` | dev 도 user-token 필요 추정 | +| `coupon.available` | `GET coupon/available` | ❌ 동상 | 동상 | +| `coupon.download` | `POST coupon/download` | (미테스트) | 동상 | +| `point.balance` | `GET point/balance` | ❌ `API_ROLE_NOT_SUPPORT` | user/manager/admin/partner/vendor/supervisor 전부 거부 | +| `point.transactions` | `GET point/transactions` | ❌ 동상 | 동상 | +| `cart.orderPreview` | `POST cart/order-preview` | ❌ 동상 | 동상 | +| `orderSubscriptionRequest.list` | `GET order-subscription-requests` | ❌ 동상 | 동상 | +| ~~`userGroup.userCreate`~~ | ~~`user-groups/{id}/add_user`~~ | — | ✅ 해결: `user-groups/{id}/user` | +| ~~`userGroup.userDelete`~~ | ~~`user-groups/{id}/remove_user`~~ | — | ✅ 해결: `user-groups/{id}/user/{userId}` | +| ~~`coupon.preview`~~ | ~~`POST coupon/preview`~~ | — | ✅ 해결: 메서드+타입+테스트 삭제 | +| ~~`point.previewUsage`~~ | ~~`POST point/preview_usage`~~ | — | ✅ 해결: 동상 | +| ~~`point.calculateLimit`~~ | ~~`POST point/calculate_limit`~~ | — | ✅ 해결: 동상 | + +→ **5 modules (category / coupon / point / cart / order-subscription-request)** routes.rb 에는 존재 (2026-04-28~04-29 추가). prod 는 아직 배포 안 됨 → 404. dev 는 endpoint 존재하나 ck/sk + BOOTPAY-ROLE 만으로는 인가 불가 — **user_token 컨텍스트 필요한 user-scoped endpoint 로 추정** (백엔드팀 확인 필요). + +### 🔐 API_ROLE_NOT_SUPPORT — manager+ role 필요 + +| 파일 | 모듈 | 필요 role 추정 | +|---|---|---| +| `orderCancelApprove.js` | `orderCancel.approve` | manager / supervisor | +| `orderCancelReject.js` | `orderCancel.reject` | manager / supervisor | +| `orderCancelRequest.js` | `orderCancel.request` | manager | +| `orderSubscriptionAdjustmentCreate.js` | `orderSubscriptionAdjustment.create` | manager | +| `orderSubscriptionAdjustmentUpdate.js` | `orderSubscriptionAdjustment.update` | manager | +| `orderSubscriptionAdjustmentDelete.js` | `orderSubscriptionAdjustment.delete` | manager | +| `orderSubscriptionUpdate.js` | `orderSubscription.update` | manager | +| `userGroupUserCreate.js` | `userGroup.userCreate` | manager (URL 정정 후 새로 노출됨) | +| `userGroupUserDelete.js` | `userGroup.userDelete` | manager (동상) | + +→ 테스트가 `commerce.asManager()` 또는 `.withRole('manager')` 호출해야 함. `config.js` 의 `BOOTPAY_TEST_COMMERCE_ROLE=manager` 환경변수로 일괄 토글 가능 (인프라 이미 있음). + +### 🧨 SERVER_ERROR 500 / 매핑 의심 / 검증 에러 + +| 파일 | error_code | 추정 원인 | +|---|---|---| +| `invoiceNotify.js` | `SERVER_ERROR` | `INVOICE_ID_HERE` placeholder 그대로 전송 | +| `productCreate.js` | `SERVER_ERROR` | multipart/form-data 처리 이슈 가능 | +| `orderSubscriptionList.js` | `SERVER_ERROR` | filter 에 placeholder 문자열 (`user_id: 'USER_ID_HERE'` 등) | +| `orderMonth.js` | `MONTHLY_BILLING_USER_GROUP_ID_INVALID` | `USER_GROUP_ID_HERE` placeholder | +| `userToken.js` | `INVOICE_TARGET_NOT_FOUND` | **🚨 서버 매핑 의심**: token endpoint 인데 invoice 도메인 에러 메시지. `user_id=USER_ID_HERE` placeholder 입력 시 응답. | +| `userJoin.js` | `USER_ID_INVALID` | 가입 가능한 user_id payload 필요 (현재 테스트 페이로드의 ID 형식 미스) | +| `userLogin.js` | `USER_LOGIN_FAILED` | 실제 login_id / login_pw 필요 | +| `userCheckExist.js` | `API_PARAM_INVALID` | param 형식 (`key=login_id&pk=value`) 백엔드 spec 재확인 | +| `userAuthenticationData.js` | `USER_STAND_BY_NOT_FOUND` | `STAND_ID_HERE` placeholder | +| `userGroupCreate.js` | `USER_BUSINESS_NUMBER_BLANK` | corporate_type=1 일 때 business_number 필수 — 테스트 payload 보완 | + +### 📦 픽스처 누락 (placeholder 13종) + +| Placeholder | 사용 파일 수 | +|---|---| +| `USER_ID_HERE` | 4+ | +| `USER_GROUP_ID_HERE` | 4+ | +| `PRODUCT_ID_HERE` | 5+ | +| `CATEGORY_ID_HERE` | 4 | +| `COUPON_TEMPLATE_ID_HERE` | 1 | +| `INVOICE_ID_HERE` | 2 | +| `ORDER_ID_HERE` | 3 | +| `ORDER_NUMBER_HERE` | 2 | +| `ORDER_SUBSCRIPTION_ID_HERE` | 7+ | +| `ORDER_SUBSCRIPTION_BILL_ID_HERE` | 2 | +| `ORDER_SUBSCRIPTION_ADJUSTMENT_ID_HERE` | 2 | +| `ORDER_CANCEL_REQUEST_HISTORY_ID_HERE` | 3 | +| `STAND_ID_HERE` | 1 | + +→ `test/config.js` 의 `COMMERCE_TEST_DATA` 에 env-driven 으로 추가됨 (이번 turn). `.env` 의 `BOOTPAY_TEST_COMMERCE_*` 값을 실제 데이터로 채우고 각 테스트 파일을 `COMMERCE_TEST_DATA.user_id` 식으로 치환해야 실제 통신. + +--- + +## 우선순위 권장 (2026-05-11 최신) + +| # | 작업 | 상태 | 영향 범위 | 비고 | +|---:|---|---|---|---| +| ~~1a~~ | A 항목 SDK URL 버그 5건 | ✅ 완료 | nodejs + 6 SDK | 커밋 해시 본문 참조 | +| ~~1b~~ | 5 modules 6 server SDK 일괄 추가 | ✅ 완료 | 6 SDK | nodejs reference parity | +| 2 | list 응답 타입 mismatch 정리 | 미해결 | 9 modules × 7 SDK | 기준 결정 후 6 server SDK 전파. (A) SDK 가 normalize ↔ (B) 타입을 `{ list, count }` 로 수정. | +| 3 | `requestUserToken` / `getUserWallets` ck/sk 거부 | 미해결 | 백엔드 또는 SDK | 백엔드 컨펌 필요 — 두 endpoint 의 Basic Auth 지원 여부. | +| 4 | 5 modules prod 배포 + 인증 모델 확인 | **백엔드 확인 대기** | 백엔드 | (a) prod 배포 일정 (b) user-scoped endpoint 인증 — ck/sk 만으로 가능한지 user_token 필요한지. | +| 5 | `user.token` 응답 매핑 (`INVOICE_TARGET_NOT_FOUND`) | 미해결 | 백엔드 | placeholder user_id 입력 시 invoice 도메인 에러로 매핑됨 — 서버 측 매핑 버그 의심. | +| 6 | role 자동 적용 (orderCancel·orderSubscriptionAdjustment·userGroup.user{Create,Delete}) | 미해결 | 테스트 패턴 | `.asManager()` 호출 또는 `BOOTPAY_TEST_COMMERCE_ROLE=manager` 자동 적용 (인프라 이미 있음). | +| 7 | Commerce 픽스처 ID 실데이터로 채우기 | 미해결 | `.env` only | `COMMERCE_TEST_DATA` 인프라 준비됨. 실 prod 데이터 ID 주입 별도. | +| 8 | 죽은 PG 테스트 파일 3개 정리 | 미해결 | nodejs only | 삭제 또는 수정. | +| 9 | PG 스테일 픽스처 갱신 | 미해결 | nodejs only | 17개 테스트 — billing_key/reserve_id/receipt_id 새 데이터로 교체. | + +--- + +## SDK ↔ commerce-api `config/routes.rb` 대조 (2026-05-11) + +대조 대상: `multi-manager/projects/commerce-api/config/routes.rb` 의 `namespace :v1` (base `/v1/`). +SDK 측: `server/nodejs/src/lib/commerce/modules/*.ts` 의 모든 호출 URL. + +### A. 실제 SDK URL 버그 (5건) — ✅ **2026-05-11 전체 해결됨** + +| # | SDK 모듈 / 메서드 | 이전 URL | 적용된 변경 | 전파 | +|---:|---|---|---|---| +| 1 | `userGroup.userCreate` | `POST user-groups/:id/add_user` | → `POST user-groups/:id/user` | nodejs `ac08d68` + 6 SDK | +| 2 | `userGroup.userDelete` | `DELETE user-groups/:id/remove_user?user_id=X` | → `DELETE user-groups/:id/user/:userId` | 동상 | +| 3 | `coupon.preview` | `POST coupon/preview` | 메서드 + 타입 + 테스트 완전 삭제 | nodejs only (다른 SDK 미구현) | +| 4 | `point.previewUsage` | `POST point/preview_usage` | 동상 | 동상 | +| 5 | `point.calculateLimit` | `POST point/calculate_limit` | 동상 | 동상 | + +전파 커밋: Go `df76c12` · Java `41d6f41` · Python `c225210` · PHP `e45386f` · .NET `1d0cf50` · Ruby `bba7237`. root `69dba4d`. + +### B. 프로덕션 배포 갭 (SDK 정상, 백엔드 prod 미배포) + +routes.rb 에는 존재 (2026-04-28 ~ 04-29 추가). prod 에서 404. **2026-05-11 dev 검증**: endpoint 존재 확인됨 (`category.list` 200 OK, 나머지 `API_ROLE_NOT_SUPPORT` — user-token 인증 필요 추정). + +5 modules 모두 **6 server SDK 에 일괄 추가됨 (2026-05-11)** — Go/Java/Python/PHP/.NET/Ruby. nodejs 기준 module 구조 그대로. + +| 모듈 | SDK URL | routes.rb 라인 | dev 검증 (2026-05-11) | 추가/변경 시점 | +|---|---|---:|---|---| +| `category.list` | `GET categories` | 320 | ✅ 200 (`[]`) | 2026-04-29 | +| `category.create/detail/update/delete` | `categories[/:id]` | 320 | 미테스트 | 동상 | +| `coupon.list` | `GET coupon` | 296 | ❌ `API_ROLE_NOT_SUPPORT` | V1 Phase A (2026-04-28~) | +| `coupon.available` | `GET coupon/available` | 298 | ❌ 동상 | 동상 | +| `coupon.download` | `POST coupon/download` | 299 | 미테스트 | 동상 | +| `point.balance` | `GET point/balance` | 290 | ❌ 동상 (user/manager/admin/partner/vendor/supervisor 전부 거부) | 동상 | +| `point.transactions` | `GET point/transactions` | 291 | ❌ 동상 | 동상 | +| `cart.orderPreview` | `POST cart/order-preview` | 276 | ❌ 동상 | 동상 | +| `orderSubscriptionRequest.list/detail/update` | `order-subscription-requests[/:id]` | 317 | ❌ 동상 | 동상 | + +→ **백엔드팀 확인 필요**: +1. prod 배포 일정 (코드는 6 SDK 전파 완료 — 배포만 되면 즉시 동작). +2. user-scoped endpoint 인증 모델 — `coupon/point/cart/order-subscription-requests` 가 ck/sk + BOOTPAY-ROLE 만으로 인가되는지, 아니면 `user_token` 컨텍스트가 추가로 필요한지. + +### C. 매칭 OK (참고) + +다음은 routes.rb 와 일치하여 SDK 변경 불필요: + +- `users/login`, `users/login/token`, `users/authenticate/:id`, `users` CRUD, `users/:id/token` +- `user-groups` CRUD, `user-groups/:id/limit`, `user-groups/:id/aggregate-transaction` +- `invoices` CRUD, `invoices/:id/notify` +- `products` CRUD, `products/:id/status` +- `orders` list/detail, `orders/month` +- `order/cancel` list/create + `:id/withdraw|approve|reject` +- `order_subscriptions` CRUD + `:id/approve|reject|terminate|pause|resume` +- `order_subscriptions/:id/adjustments` (POST/PUT/DELETE) +- `order_subscriptions/requests/ing/{pause,resume,termination,calculate_termination_fee}` +- `order_subscription_bills` list/detail/update +- `store`, `store/detail` + +### D. 의존 검증 필요 + +| 항목 | 현재 SDK 동작 | 확인 사항 | +|---|---|---| +| `user.checkExist` → `GET users/join/:key?pk=:value` | 정상 200 (테스트 시) | routes 의 `resources :join` 가 show route 만 제공 — `:key` 가 path param 으로 정상 해석되는지 컨트롤러 측 검증 권장. | +| `PG: requestUserToken` ck/sk 거부 | TOKEN_KEY_INVALID | PG 측 (`api.bootpay.co.kr/v2`) routes 는 별도. Commerce routes.rb 와 무관. PG 백엔드팀 별도 확인. | + +### 권장 조치 순서 + +1. **A 항목 (#1~5)** — SDK 코드 수정. 다른 6 server SDK 에도 동일 URL 수정 전파. +2. **B 항목** — 백엔드팀 prod 배포 확정 후 테스트 재실행. SDK 변경 불필요. +3. **D 항목** — 컨트롤러/PG routes 별도 확인. + +--- + +## 실행 환경 + +``` +node 25.6.1 +@bootpay/backend-js 2.5.0 +BOOTPAY_ENV=production (재실행 시 BOOTPAY_ENV=development 로 dev 검증 일부 포함) +BOOTPAY_AUTH_MODE=new (ck/sk Basic Auth) +실행일: 2026-05-11 (최종 갱신) +``` + +## 2026-05-11 commerce/*.js 재실행 결과 요약 + +총 61 파일. 모두 syntax/load OK. 실제 서버 응답 기준: + +- ✅ 200 success (7): `getAccessToken`, `invoiceList`, `orderList`, `orderSubscriptionBillList`, `productList`, `userGroupList`, `userList` +- 🚫 404 (prod 미배포, 14): `cartOrderPreview` · category 5건 · coupon 3건 · point 2건 · `orderSubscriptionRequestList` +- 🔐 `API_ROLE_NOT_SUPPORT` (9): orderCancel 3건 + orderSubscriptionAdjustment 3건 + `orderSubscriptionUpdate` + userGroup.user 2건 +- 📦 placeholder fixture (21): USER_NOT_FOUND / ORDER_NOT_FOUND / PRODUCT_NOT_FOUND / USER_GROUP_NOT_FOUND / ORDER_SUBSCRIPTION_*_NOT_FOUND / INVOICE_*_NOT_FOUND +- 🧨 기타 (10): SERVER_ERROR 3건 + 매핑 의심·검증 에러 7건 — 본문 D 표 참조 diff --git a/test/access_token.js b/test/access_token.js deleted file mode 100644 index 7eb3931..0000000 --- a/test/access_token.js +++ /dev/null @@ -1,20 +0,0 @@ -// import { BootpayRestClient } from 'bootpay-backend-nodejs' -// const Bootpay = require('bootpay-backend-nodejs') - - -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - // const Bootpay = require('bootpay-backend-nodejs').Bootpay - // const Bootpay = require('bootpay-backend-nodejs') - - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - try { - let response = await Bootpay.getAccessToken() - console.log(response) - } catch(e) { - console.log(e) - } -})() diff --git a/test/axios_test.js b/test/axios_test.js deleted file mode 100644 index eee43a1..0000000 --- a/test/axios_test.js +++ /dev/null @@ -1,20 +0,0 @@ - -(async () => { - const RestClient = require('../dist/bootpay').Bootpay - RestClient.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let response = await RestClient.getAccessToken() - // console.log(response) - const axios = require('axios') - try { - response = await axios.post("https://dev-api.bootpay.co.kr/request/token.json", { - application_id: '5b8f6a4d396fa665fdc2b5ea', - private_key: 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw==' - }) - console.log(response) - } catch(e) { - console.log(e.response.data) - } -})() \ No newline at end of file diff --git a/test/cancel.js b/test/cancel.js deleted file mode 100644 index 937b2f8..0000000 --- a/test/cancel.js +++ /dev/null @@ -1,24 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.cancel({ - receiptId: '5b0df1b8e13f332c6c83df6a', - price: 1000, - name: '취소자명', - reason: '취소합니다' - }) - } catch (e) { - console.log(e) - return - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/certificate.js b/test/certificate.js deleted file mode 100644 index 2e6162f..0000000 --- a/test/certificate.js +++ /dev/null @@ -1,17 +0,0 @@ -(async () => { - const RestClient = require('../dist/bootpay').Bootpay - RestClient.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await RestClient.getAccessToken() - if (token.status === 200) { - let response - try { - response = await RestClient.certificate('1234') - } catch (e) { - return console.log(e) - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/commerce/authorizationHeader.js b/test/commerce/authorizationHeader.js new file mode 100644 index 0000000..534ced7 --- /dev/null +++ b/test/commerce/authorizationHeader.js @@ -0,0 +1,73 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +// Commerce API - Authorization 헤더 선택 규칙 테스트 (네트워크 호출 없음) +// 1) 토큰이 없으면 client_key/secret_key Basic Auth +// 2) 토큰이 있으면 Bearer 우선 +// 3) Basic Auth 값이 토큰을 오염시키지 않아야 한다 (연속 요청에도 Basic 유지) + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: { access_token: 'commerce_access_token' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + const basic = `Basic ${Buffer.from('ck:sk').toString('base64')}`; + + // 1) 토큰 미발급 상태 — Basic Auth 부착 + await commerce.product.list(); + assert.strictEqual(header(requests[0], 'Authorization'), basic); + + // 2) 연속 요청에도 Basic 이 그대로 유지 (Basic 값이 $token 으로 새어나가면 안 된다) + await commerce.product.list(); + assert.strictEqual(header(requests[1], 'Authorization'), basic); + assert.strictEqual(commerce.getToken(), undefined, 'basic auth must not populate the token'); + assert.strictEqual(commerce.hasToken(), false); + + // 3) request/token 요청 자체는 Basic Auth 로 나가고, 발급된 토큰이 저장된다 + const tokenResponse = await commerce.getAccessToken(); + assert.strictEqual(tokenResponse.access_token, 'commerce_access_token'); + assert.strictEqual(header(requests[2], 'Authorization'), basic); + assert.strictEqual(commerce.getCurrentToken(), 'commerce_access_token'); + + // 4) 토큰 발급 이후에는 Bearer 가 우선한다 + await commerce.product.list(); + assert.strictEqual(header(requests[3], 'Authorization'), 'Bearer commerce_access_token'); + + // 5) setToken 으로 직접 넣은 토큰도 동일하게 Bearer 우선 + commerce.setToken('manual_token'); + await commerce.product.list(); + assert.strictEqual(header(requests[4], 'Authorization'), 'Bearer manual_token'); + + // 6) 키/토큰이 모두 없으면 Authorization 헤더를 붙이지 않는다 + const anonymous = new BootpayCommerce({ mode: 'development' }); + anonymous.$http.defaults.adapter = commerce.$http.defaults.adapter; + const beforeAnonymous = requests.length; + await anonymous.product.list(); + assert.ok(!header(requests[beforeAnonymous], 'Authorization'), 'no key/token must not send an Authorization header'); + + console.log('commerce authorization header: bearer takes precedence, basic auth stays stable'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/cartOrderPreview.js b/test/commerce/cartOrderPreview.js new file mode 100644 index 0000000..64d4b5c --- /dev/null +++ b/test/commerce/cartOrderPreview.js @@ -0,0 +1,42 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 장바구니 주문 미리보기 (V1 단일 결제 계산 endpoint) +// +// V1 결제 계산은 cart/order-preview 단일 호출로 처리: +// - cart_items + zipcode → 상품금액 + 배송비 권위 검증 +// - coupon_ids[] → 쿠폰 권위 검증 (coupon_discount_amount + applied_coupons[]) +// - point_amount → 적립금 권위 검증 (point_use_amount + point_max_usable + point_balance_after) +// - summary.total_order_price 가 결제 금액 (= product + delivery - coupon - point) +// +// member_mode='guest' 호출: cart_items 직접 전달 (서버 장바구니 불필요) +// member_mode='member' 호출: 회원 cart 사용 (cart_items 무시) + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.cart.orderPreview({ + member_mode: 'guest', + cart_items: [ + { + product_id: 'PRODUCT_ID_HERE', + quantity: 1 + } + ], + shipping_address: { zipcode: '63000' }, + coupon_ids: [], + point_amount: 0 + }) + console.log('Cart Order Preview:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/categoryCreate.js b/test/commerce/categoryCreate.js new file mode 100644 index 0000000..3a222cd --- /dev/null +++ b/test/commerce/categoryCreate.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Category 생성 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.category.create({ + name: 'SDK Test Category', + status_display: true, + status_best: false + }) + console.log('Category Create:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/categoryDelete.js b/test/commerce/categoryDelete.js new file mode 100644 index 0000000..37e3ba9 --- /dev/null +++ b/test/commerce/categoryDelete.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Category 삭제 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.category.destroy('CATEGORY_ID_HERE') + console.log('Category Delete:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/categoryDetail.js b/test/commerce/categoryDetail.js new file mode 100644 index 0000000..d05afdb --- /dev/null +++ b/test/commerce/categoryDetail.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Category 단건 조회 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.category.detail('CATEGORY_ID_HERE') + console.log('Category Detail:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/categoryList.js b/test/commerce/categoryList.js new file mode 100644 index 0000000..55e1b50 --- /dev/null +++ b/test/commerce/categoryList.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Category 트리 조회 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.category.list() + console.log('Category List:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/categoryUpdate.js b/test/commerce/categoryUpdate.js new file mode 100644 index 0000000..12d4b31 --- /dev/null +++ b/test/commerce/categoryUpdate.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Category 수정 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.category.update({ + category_id: 'CATEGORY_ID_HERE', + name: 'SDK Test Category (updated)', + status_display: true, + status_best: true + }) + console.log('Category Update:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/commerceRouteContract.js b/test/commerce/commerceRouteContract.js new file mode 100644 index 0000000..a8421a8 --- /dev/null +++ b/test/commerce/commerceRouteContract.js @@ -0,0 +1,299 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +// Commerce API - 라우트/동사/헤더 규약 테스트 (네트워크 호출 없음, 키 불필요) +// +// commerce-api v1 라우트 정본에 맞춘 회귀 테스트다. 특히 아래 함정들을 고정한다. +// · 회원 endpoint 는 복수 users/… 다 (단수 user/… 라우트는 존재하지 않는다) +// · requests/ing 중 resume 만 PUT, 나머지는 POST +// · order_subscriptions · order_subscription_bills 는 언더스코어, +// order-subscription-requests · user-groups 는 하이픈 +// · 조정항목 삭제 대상 ID 는 query 가 아니라 body +// · multipart 전송 시 boundary 가 살아 있어야 한다 (인터셉터가 Content-Type 을 덮어쓰면 안 된다) + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +function relative(config) { + return config.url.replace('https://dev-api.bootapi.com/v1/', ''); +} + +(async () => { + const commerce = new BootpayCommerce({ client_key: 'ck', secret_key: 'sk', mode: 'development' }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { data: {}, status: 200, statusText: 'OK', headers: {}, config, request: {} }; + }; + + // 직전 요청을 (method, uri) 로 검증한다. uri 는 query 를 제외한 경로만 비교한다. + const last = () => requests[requests.length - 1]; + async function expect(label, call, method, uri) { + await call(); + const config = last(); + assert.strictEqual(config.method.toLowerCase(), method, `${label}: method`); + assert.strictEqual(relative(config).split('?')[0], uri, `${label}: uri`); + return config; + } + + // ── 회원: 단수 user/… 는 죽은 경로다. 전부 복수 users/… 여야 한다 ── + await expect('userLogin', () => commerce.user.userLogin({ login_id: 'id', password: 'pw' }), 'post', 'users/login'); + await expect('userSession', () => commerce.user.userSession('jwt'), 'get', 'users/session'); + await expect('userLogout', () => commerce.user.userLogout('jwt'), 'delete', 'users/session'); + await expect('userJoin', () => commerce.user.userJoin({ login_id: 'id', password: 'pw', name: '홍길동' }), 'post', 'users/join'); + await expect('userJoinCheck', () => commerce.user.userJoinCheck('email-exist', 'a@b.com'), 'get', 'users/join/email-exist'); + + const uidExist = await expect('uidExist', () => commerce.user.uidExist('ex_uid_1'), 'get', 'users/join/uid-exist'); + assert.ok(relative(uidExist).includes('pk=ex_uid_1'), 'uidExist: pk in query'); + assert.strictEqual(header(uidExist, 'BOOTPAY-ROLE'), 'user'); + + // ── 청구서 ── + const invoiceList = await expect('invoice.list', () => commerce.invoice.list(), 'get', 'invoices'); + assert.ok(relative(invoiceList).includes('limit=24'), 'invoice.list: 서버 기본 limit 24 를 보낸다'); + assert.strictEqual(header(invoiceList, 'BOOTPAY-ROLE'), 'user'); + assert.ok(header(invoiceList, 'Idempotency-Key'), 'invoice.list: Idempotency-Key 자동 생성'); + + const invoiceFiltered = await expect( + 'invoice.list(params)', + () => commerce.invoice.list({ page: 2, limit: 10, cs_type: 'A', user_id: 'u1', product_type: 3, css_at: '2024-01-01', cse_at: '2024-12-31' }), + 'get', + 'invoices' + ); + ['page=2', 'limit=10', 'cs_type=A', 'user_id=u1', 'product_type=3', 'css_at=2024-01-01', 'cse_at=2024-12-31'].forEach((q) => + assert.ok(relative(invoiceFiltered).includes(q), `invoice.list: ${q}`) + ); + + await expect('invoice.detail', () => commerce.invoice.detail('inv1'), 'get', 'invoices/inv1'); + + // send_types 미전달시 body 에 키를 넣지 않는다 (서버가 빈 배열로 처리) + const notifyBare = await expect('invoice.notify', () => commerce.invoice.notify('inv1'), 'post', 'invoices/inv1/notify'); + assert.deepStrictEqual(JSON.parse(notifyBare.data), {}); + const notify = await expect('invoice.notify(send_types)', () => commerce.invoice.notify('inv1', [1, 3]), 'post', 'invoices/inv1/notify'); + assert.deepStrictEqual(JSON.parse(notify.data), { send_types: [1, 3] }); + + // ── 상품 쓰기: manager scope ── + const productCreate = await expect('product.create', () => commerce.product.create({ name: '상품', display_price: 1000 }), 'post', 'products'); + assert.strictEqual(header(productCreate, 'BOOTPAY-ROLE'), 'manager'); + assert.ok(String(header(productCreate, 'Content-Type')).includes('application/json'), 'product.create: 이미지 없으면 JSON'); + assert.deepStrictEqual(JSON.parse(productCreate.data), { name: '상품', display_price: 1000 }); + + const productUpdate = await expect('product.update', () => commerce.product.update({ product_id: 'p1', stock: 5 }), 'put', 'products/p1'); + assert.strictEqual(header(productUpdate, 'BOOTPAY-ROLE'), 'manager'); + + const productStatus = await expect( + 'product.status', + () => commerce.product.status({ product_id: 'p1', status_sale: true, status_display: false }), + 'put', + 'products/p1/status' + ); + assert.strictEqual(header(productStatus, 'BOOTPAY-ROLE'), 'manager'); + assert.deepStrictEqual(JSON.parse(productStatus.data), { status_sale: true, status_display: false }); + + const productDelete = await expect('product.delete', () => commerce.product.delete('p1'), 'delete', 'products/p1'); + assert.strictEqual(header(productDelete, 'BOOTPAY-ROLE'), 'manager'); + + // ── 상품 multipart: boundary 가 살아 있어야 한다 ── + const imagePath = path.join(os.tmpdir(), 'bootpay-commerce-route-contract.png'); + fs.writeFileSync(imagePath, Buffer.from('89504e470d0a1a0a', 'hex')); + try { + const multipart = await expect( + 'product.create(images)', + () => commerce.product.create({ name: '상품', search_tags: ['a', 'b'] }, [imagePath]), + 'post', + 'products' + ); + const contentType = String(header(multipart, 'Content-Type')); + assert.ok(contentType.startsWith('multipart/form-data'), 'multipart Content-Type 이 유지되어야 한다'); + assert.ok(contentType.includes('boundary='), 'boundary 가 유실되면 서버가 본문을 null 로 읽는다'); + assert.strictEqual(header(multipart, 'BOOTPAY-ROLE'), 'manager'); + } finally { + fs.unlinkSync(imagePath); + } + + // ── 주문 / 주문취소 ── + const orderList = await expect( + 'order.list', + () => commerce.order.list({ limit: 50, search_date_from: '2024-01-01', search_date_to: '2024-12-31' }), + 'get', + 'orders' + ); + ['limit=50', 'search_date_from=2024-01-01', 'search_date_to=2024-12-31'].forEach((q) => + assert.ok(relative(orderList).includes(q), `order.list: ${q}`) + ); + + const cancelList = await expect('orderCancel.list', () => commerce.orderCancel.list({ order_number: 'o1' }), 'get', 'order/cancel'); + assert.strictEqual(header(cancelList, 'BOOTPAY-ROLE'), 'user'); + + const withdraw = await expect('orderCancel.withdraw', () => commerce.orderCancel.withdraw('c1'), 'put', 'order/cancel/c1/withdraw'); + assert.strictEqual(header(withdraw, 'BOOTPAY-ROLE'), 'user'); + + const approve = await expect( + 'orderCancel.approve', + () => commerce.orderCancel.approve({ order_cancellation_request_id: 'c1', message: '승인' }), + 'put', + 'order/cancel/c1/approve' + ); + assert.strictEqual(header(approve, 'BOOTPAY-ROLE'), 'supervisor'); + assert.deepStrictEqual(JSON.parse(approve.data), { message: '승인' }); + + // 구 인자명도 계속 받는다 (하위호환) + await expect( + 'orderCancel.approve(legacy id)', + () => commerce.orderCancel.approve({ order_cancel_request_history_id: 'c2' }), + 'put', + 'order/cancel/c2/approve' + ); + await expect( + 'orderCancel.reject', + () => commerce.orderCancel.reject({ order_cancellation_request_id: 'c1', message: '반려' }), + 'put', + 'order/cancel/c1/reject' + ); + + // ── 구독 계약변경 / 조정항목 / 빌 ── + const subscriptionList = await expect( + 'orderSubscription.list', + () => commerce.orderSubscription.list({ limit: 30, status: 1, search_date_from: '2024-01-01' }), + 'get', + 'order_subscriptions' + ); + ['limit=30', 'status=1', 'search_date_from=2024-01-01'].forEach((q) => + assert.ok(relative(subscriptionList).includes(q), `orderSubscription.list: ${q}`) + ); + + const subscriptionUpdate = await expect( + 'orderSubscription.update', + () => commerce.orderSubscription.update({ order_subscription_id: 's1', quantity: 2, order_name: '변경' }), + 'put', + 'order_subscriptions/s1' + ); + assert.strictEqual(header(subscriptionUpdate, 'BOOTPAY-ROLE'), 'supervisor'); + assert.deepStrictEqual(JSON.parse(subscriptionUpdate.data), { quantity: 2, order_name: '변경' }); + + const adjustmentCreate = await expect( + 'adjustment.create', + () => commerce.orderSubscriptionAdjustment.create('s1', { name: '할인' }), + 'post', + 'order_subscriptions/s1/adjustments' + ); + assert.strictEqual(header(adjustmentCreate, 'BOOTPAY-ROLE'), 'supervisor'); + assert.deepStrictEqual(JSON.parse(adjustmentCreate.data), { price: 0, duration: 1, tax_free_price: 0, name: '할인' }); + + const adjustmentUpdate = await expect( + 'adjustment.update', + () => commerce.orderSubscriptionAdjustment.update({ order_subscription_id: 's1', duration: 2, adjustments: [{ name: '할인', price: -1000 }] }), + 'put', + 'order_subscriptions/s1/adjustments' + ); + assert.deepStrictEqual(JSON.parse(adjustmentUpdate.data), { duration: 2, adjustments: [{ name: '할인', price: -1000 }] }); + + // 삭제 대상 ID 는 query 가 아니라 body 로 간다 + const adjustmentDelete = await expect( + 'adjustment.delete', + () => commerce.orderSubscriptionAdjustment.delete('s1', 'a1'), + 'delete', + 'order_subscriptions/s1/adjustments' + ); + assert.ok(!relative(adjustmentDelete).includes('?'), 'adjustment.delete: query 로 보내면 안 된다'); + assert.deepStrictEqual(JSON.parse(adjustmentDelete.data), { order_subscription_adjustment_id: 'a1' }); + assert.strictEqual(header(adjustmentDelete, 'BOOTPAY-ROLE'), 'supervisor'); + + const billList = await expect( + 'orderSubscriptionBill.list', + () => commerce.orderSubscriptionBill.list({ order_subscription_id: 's1' }), + 'get', + 'order_subscription_bills' + ); + assert.ok(relative(billList).includes('limit=20'), 'orderSubscriptionBill.list: 서버 기본 limit 20'); + assert.strictEqual(header(billList, 'BOOTPAY-ROLE'), 'user'); + + // ── 구독 진행중 요청 (requests/ing) — resume 만 PUT ── + const ing = commerce.orderSubscription.requestIng; + await expect('ing.pause', () => ing.pause({ order_subscription_id: 's1' }), 'post', 'order_subscriptions/requests/ing/pause'); + await expect('ing.resume', () => ing.resume({ order_subscription_id: 's1' }), 'put', 'order_subscriptions/requests/ing/resume'); + await expect('ing.purchase', () => ing.purchase({ order_subscription_id: 's1', price: 1000 }), 'post', 'order_subscriptions/requests/ing/purchase'); + await expect('ing.termination', () => ing.termination({ order_subscription_id: 's1' }), 'post', 'order_subscriptions/requests/ing/termination'); + + const transfer = await expect( + 'ing.transfer', + () => ing.transfer({ order_subscription_id: 's1', new_user_id: 'u2' }), + 'post', + 'order_subscriptions/requests/ing/transfer' + ); + assert.strictEqual(header(transfer, 'BOOTPAY-ROLE'), 'user'); + assert.deepStrictEqual(JSON.parse(transfer.data), { order_subscription_id: 's1', new_user_id: 'u2' }); + + const calc = await expect( + 'ing.calculateTerminationFee', + () => ing.calculateTerminationFee('s1', 'o1'), + 'get', + 'order_subscriptions/requests/ing/calculate_termination_fee' + ); + assert.ok(relative(calc).includes('order_subscription_id=s1') && relative(calc).includes('order_number=o1')); + + // ── 구독 요청 리소스 (하이픈 경로) ── + const requestList = await expect('request.list', () => commerce.orderSubscriptionRequest.list(), 'get', 'order-subscription-requests'); + assert.strictEqual(header(requestList, 'BOOTPAY-ROLE'), 'user', 'project_id 가 없으면 user scope'); + + const supervisorList = await expect( + 'request.list(project_id)', + () => commerce.orderSubscriptionRequest.list({ project_id: 'prj1', order_subscription_id: 's1', user_id: 'u1', user_group_id: 'g1' }), + 'get', + 'order-subscription-requests' + ); + assert.strictEqual(header(supervisorList, 'BOOTPAY-ROLE'), 'supervisor', 'project_id 가 있으면 supervisor scope'); + ['project_id=prj1', 'order_subscription_id=s1', 'user_id=u1', 'user_group_id=g1'].forEach((q) => + assert.ok(relative(supervisorList).includes(q), `request.list: ${q}`) + ); + + await expect('request.detail', () => commerce.orderSubscriptionRequest.detail('r1'), 'get', 'order-subscription-requests/r1'); + + const requestUpdate = await expect( + 'request.update', + () => commerce.orderSubscriptionRequest.update({ order_subscription_request_history_id: 'r1', approval: 'approve', reason: '승인' }), + 'put', + 'order-subscription-requests/r1' + ); + assert.strictEqual(header(requestUpdate, 'BOOTPAY-ROLE'), 'supervisor'); + assert.deepStrictEqual(JSON.parse(requestUpdate.data), { approval: 'approve', reason: '승인' }); + + // ── 회원 그룹 (하이픈 경로) ── + const groupLimit = await expect( + 'userGroup.limit', + () => commerce.userGroup.limit({ user_group_id: 'g1', use_limit: true, limit_month_purchase: 1000, limit_week_purchase: 500 }), + 'put', + 'user-groups/g1/limit' + ); + assert.strictEqual(header(groupLimit, 'BOOTPAY-ROLE'), 'manager'); + assert.deepStrictEqual(JSON.parse(groupLimit.data), { + use_limit: true, + limit_month_purchase: 1000, + limit_week_purchase: 500 + }); + + const aggregate = await expect( + 'userGroup.aggregateTransaction', + () => commerce.userGroup.aggregateTransaction({ user_group_id: 'g1', use_subscription_aggregate_transaction: true, subscription_month_day: 5 }), + 'put', + 'user-groups/g1/aggregate-transaction' + ); + assert.strictEqual(header(aggregate, 'BOOTPAY-ROLE'), 'manager'); + + // ── 테스트 웹훅 ── + const webhookBare = await expect('webhook.sendTest', () => commerce.webhook.sendTest(), 'post', 'webhook/test'); + assert.deepStrictEqual(JSON.parse(webhookBare.data), {}); + const webhook = await expect('webhook.sendTest(params)', () => commerce.webhook.sendTest({ header_content_type: 1 }), 'post', 'webhook/test'); + assert.deepStrictEqual(JSON.parse(webhook.data), { header_content_type: 1 }); + + console.log(`commerce route contract: ${requests.length} requests verified (uri · method · role · payload)`); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/couponAvailable.js b/test/commerce/couponAvailable.js new file mode 100644 index 0000000..f8f6c40 --- /dev/null +++ b/test/commerce/couponAvailable.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 다운로드 가능한 쿠폰 목록 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.coupon.available() + console.log('Coupon Available:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/couponDownload.js b/test/commerce/couponDownload.js new file mode 100644 index 0000000..8753075 --- /dev/null +++ b/test/commerce/couponDownload.js @@ -0,0 +1,24 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 쿠폰 다운로드 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.coupon.download({ + coupon_template_id: 'COUPON_TEMPLATE_ID_HERE' + }) + console.log('Coupon Download:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/couponList.js b/test/commerce/couponList.js new file mode 100644 index 0000000..eb6114f --- /dev/null +++ b/test/commerce/couponList.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 사용자 보유 쿠폰 목록 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.coupon.list({ page: 1, limit: 10 }) + console.log('Coupon List:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/getAccessToken.js b/test/commerce/getAccessToken.js new file mode 100644 index 0000000..120b6cb --- /dev/null +++ b/test/commerce/getAccessToken.js @@ -0,0 +1,24 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - getAccessToken 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode // 'production' | 'development' | 'stage' + }) + + try { + const response = await commerce.getAccessToken() + console.log('Access Token Response:', JSON.stringify(response, null, 2)) + + // 토큰 확인 + console.log('Has Token:', commerce.hasToken()) + console.log('Current Token:', commerce.getCurrentToken()) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/invoiceCreate.js b/test/commerce/invoiceCreate.js new file mode 100644 index 0000000..b4a3e50 --- /dev/null +++ b/test/commerce/invoiceCreate.js @@ -0,0 +1,28 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Invoice Create (청구서 생성) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.invoice.create({ + user_id: 'USER_ID_HERE', + amount: 50000, + title: '테스트 청구서', + description: '테스트 청구서 설명' + }) + console.log('Invoice Create Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/invoiceDetail.js b/test/commerce/invoiceDetail.js new file mode 100644 index 0000000..8fce591 --- /dev/null +++ b/test/commerce/invoiceDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Invoice Detail (청구서 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.invoice.detail('INVOICE_ID_HERE') + console.log('Invoice Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/invoiceList.js b/test/commerce/invoiceList.js new file mode 100644 index 0000000..19d62a5 --- /dev/null +++ b/test/commerce/invoiceList.js @@ -0,0 +1,32 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Invoice List (청구서 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.invoice.list() + console.log('Invoice List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.invoice.list({ + page: 1, + limit: 10, + keyword: '청구서' + }) + console.log('Filtered Invoice List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/invoiceNotify.js b/test/commerce/invoiceNotify.js new file mode 100644 index 0000000..721e80e --- /dev/null +++ b/test/commerce/invoiceNotify.js @@ -0,0 +1,24 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Invoice Notify (청구서 알림 발송) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // send_types: 1=SMS, 2=Email 등 + const response = await commerce.invoice.notify('INVOICE_ID_HERE', [1, 2]) + console.log('Invoice Notify Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/mallSettingDetail.js b/test/commerce/mallSettingDetail.js new file mode 100644 index 0000000..b05b957 --- /dev/null +++ b/test/commerce/mallSettingDetail.js @@ -0,0 +1,25 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 몰 설정 조회 테스트 +// +// GET /v1/mall-setting (supervisor scope 전용) + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.mallSetting.getMallSetting() + console.log('Mall Setting:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/mallSettingRequest.js b/test/commerce/mallSettingRequest.js new file mode 100644 index 0000000..704b106 --- /dev/null +++ b/test/commerce/mallSettingRequest.js @@ -0,0 +1,78 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +function body(config) { + return typeof config.data === 'string' ? JSON.parse(config.data) : config.data; +} + +// Commerce API - 몰 설정 요청 규약 테스트 (네트워크 호출 없음) +// 1) 조회: GET mall-setting +// 2) 수정: PUT mall-setting, null/undefined 값은 전송하지 않는다 (Ruby SDK 의 payload.compact 동작) +// 3) supervisor role + Idempotency-Key 헤더 + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + // 1) 몰 설정 조회 + await commerce.mallSetting.getMallSetting(); + assert.strictEqual(requests[0].method.toLowerCase(), 'get'); + assert.strictEqual(requests[0].url, 'https://dev-api.bootapi.com/v1/mall-setting'); + assert.strictEqual(header(requests[0], 'BOOTPAY-ROLE'), 'supervisor'); + assert.ok(header(requests[0], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 2) detail() 은 getMallSetting() 의 alias + await commerce.mallSetting.detail(); + assert.strictEqual(requests[1].url, 'https://dev-api.bootapi.com/v1/mall-setting'); + + // 3) 몰 설정 수정 — 전달한 값만 flatten 바디로 전송 + await commerce.mallSetting.updateMallSetting({ + name: '테스트몰', + use_cart: true, + cart_max_limit: 100, + point_rate: 0, + description: undefined, + og_image: null + }); + assert.strictEqual(requests[2].method.toLowerCase(), 'put'); + assert.strictEqual(requests[2].url, 'https://dev-api.bootapi.com/v1/mall-setting'); + assert.deepStrictEqual(body(requests[2]), { + name: '테스트몰', + use_cart: true, + cart_max_limit: 100, + point_rate: 0 + }); + assert.strictEqual(header(requests[2], 'BOOTPAY-ROLE'), 'supervisor'); + + // 4) idempotency key 직접 지정 + await commerce.mallSetting.update({ name: '테스트몰' }, 'my-idempotency-key'); + assert.strictEqual(header(requests[3], 'Idempotency-Key'), 'my-idempotency-key'); + assert.deepStrictEqual(body(requests[3]), { name: '테스트몰' }); + + console.log('commerce mall setting: supervisor role, flatten payload compaction'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/mallSettingUpdate.js b/test/commerce/mallSettingUpdate.js new file mode 100644 index 0000000..09abd80 --- /dev/null +++ b/test/commerce/mallSettingUpdate.js @@ -0,0 +1,33 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 몰 설정 수정 테스트 +// +// PUT /v1/mall-setting (supervisor scope 전용) +// 요청 바디는 flatten 형식이며, 전달한 값(non-null)만 서버로 전송된다. + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.mallSetting.updateMallSetting({ + name: '부트페이 테스트몰', + description: '몰 설정 수정 테스트', + use_cart: true, + cart_max_limit: 100, + use_point: true, + point_rate: 1 + }) + console.log('Mall Setting Update:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderCancelApprove.js b/test/commerce/orderCancelApprove.js new file mode 100644 index 0000000..68681cf --- /dev/null +++ b/test/commerce/orderCancelApprove.js @@ -0,0 +1,28 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderCancel Approve (취소 승인) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 정식 인자명은 order_cancellation_request_id — orderCancel.list() 로 얻는다. + // 구 이름 order_cancel_request_history_id 도 하위호환으로 계속 동작한다. + const response = await commerce.orderCancel.approve({ + order_cancellation_request_id: 'ORDER_CANCELLATION_REQUEST_ID_HERE', + message: '취소 승인 완료' + }) + console.log('OrderCancel Approve Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderCancelList.js b/test/commerce/orderCancelList.js new file mode 100644 index 0000000..2ec8bb9 --- /dev/null +++ b/test/commerce/orderCancelList.js @@ -0,0 +1,36 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderCancel List (취소 요청 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.orderCancel.list() + console.log('OrderCancel List Response:', JSON.stringify(response, null, 2)) + + // order_id로 조회 + const byOrderId = await commerce.orderCancel.list({ + order_id: 'ORDER_ID_HERE' + }) + console.log('OrderCancel List by Order ID:', byOrderId) + + // order_number로 조회 + const byOrderNumber = await commerce.orderCancel.list({ + order_number: 'ORDER_NUMBER_HERE' + }) + console.log('OrderCancel List by Order Number:', byOrderNumber) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderCancelReject.js b/test/commerce/orderCancelReject.js new file mode 100644 index 0000000..f2f5df5 --- /dev/null +++ b/test/commerce/orderCancelReject.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderCancel Reject (취소 거절) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderCancel.reject({ + order_cancel_request_history_id: 'ORDER_CANCEL_REQUEST_HISTORY_ID_HERE', + reject_reason: '환불 불가 사유로 인한 거절' + }) + console.log('OrderCancel Reject Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderCancelRequest.js b/test/commerce/orderCancelRequest.js new file mode 100644 index 0000000..93bc171 --- /dev/null +++ b/test/commerce/orderCancelRequest.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderCancel Request (취소 요청) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderCancel.request({ + order_id: 'ORDER_ID_HERE', + cancel_reason: '고객 요청에 의한 취소', + cancel_amount: 10000 + }) + console.log('OrderCancel Request Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderCancelWithdraw.js b/test/commerce/orderCancelWithdraw.js new file mode 100644 index 0000000..0cedbff --- /dev/null +++ b/test/commerce/orderCancelWithdraw.js @@ -0,0 +1,29 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderCancel Withdraw (취소 요청 철회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 문자열로 바로 넘겨도 되고, 객체 형태로 idempotency_key 를 함께 줄 수도 있다. + const response = await commerce.orderCancel.withdraw('ORDER_CANCELLATION_REQUEST_ID_HERE') + console.log('OrderCancel Withdraw Response:', JSON.stringify(response, null, 2)) + + const byParams = await commerce.orderCancel.withdraw({ + order_cancellation_request_id: 'ORDER_CANCELLATION_REQUEST_ID_HERE' + }) + console.log('OrderCancel Withdraw (params) Response:', byParams) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderDetail.js b/test/commerce/orderDetail.js new file mode 100644 index 0000000..046ef7a --- /dev/null +++ b/test/commerce/orderDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Order Detail (주문 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.order.detail('25112678946009400157') + console.log('Order Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderList.js b/test/commerce/orderList.js new file mode 100644 index 0000000..a73219d --- /dev/null +++ b/test/commerce/orderList.js @@ -0,0 +1,36 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Order List (주문 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.order.list() + console.log('Order List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.order.list({ + page: 1, + limit: 10, + keyword: '주문', + user_id: 'USER_ID_HERE', + user_group_id: 'USER_GROUP_ID_HERE', + status: [1, 2], // 주문 상태 필터 + payment_status: [1] // 결제 상태 필터 + }) + console.log('Filtered Order List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderMonth.js b/test/commerce/orderMonth.js new file mode 100644 index 0000000..cbf9fca --- /dev/null +++ b/test/commerce/orderMonth.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Order Month (월별 주문 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.order.month('USER_GROUP_ID_HERE', '2024-12') + console.log('Order Month Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionAdjustmentCreate.js b/test/commerce/orderSubscriptionAdjustmentCreate.js new file mode 100644 index 0000000..9e050f7 --- /dev/null +++ b/test/commerce/orderSubscriptionAdjustmentCreate.js @@ -0,0 +1,30 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionAdjustment Create (정기구독 조정 생성) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscriptionAdjustment.create( + 'ORDER_SUBSCRIPTION_ID_HERE', + { + type: 1, // 조정 유형 + amount: 5000, + description: '할인 적용' + } + ) + console.log('OrderSubscriptionAdjustment Create Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionAdjustmentDelete.js b/test/commerce/orderSubscriptionAdjustmentDelete.js new file mode 100644 index 0000000..26e725b --- /dev/null +++ b/test/commerce/orderSubscriptionAdjustmentDelete.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionAdjustment Delete (정기구독 조정 삭제) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscriptionAdjustment.delete( + 'ORDER_SUBSCRIPTION_ID_HERE', + 'ORDER_SUBSCRIPTION_ADJUSTMENT_ID_HERE' + ) + console.log('OrderSubscriptionAdjustment Delete Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionAdjustmentUpdate.js b/test/commerce/orderSubscriptionAdjustmentUpdate.js new file mode 100644 index 0000000..8e7db46 --- /dev/null +++ b/test/commerce/orderSubscriptionAdjustmentUpdate.js @@ -0,0 +1,28 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionAdjustment Update (정기구독 조정 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscriptionAdjustment.update({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE', + order_subscription_adjustment_id: 'ORDER_SUBSCRIPTION_ADJUSTMENT_ID_HERE', + amount: 3000, + description: '조정 금액 수정' + }) + console.log('OrderSubscriptionAdjustment Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionBillDetail.js b/test/commerce/orderSubscriptionBillDetail.js new file mode 100644 index 0000000..e464b4a --- /dev/null +++ b/test/commerce/orderSubscriptionBillDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionBill Detail (정기구독 청구 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscriptionBill.detail('ORDER_SUBSCRIPTION_BILL_ID_HERE') + console.log('OrderSubscriptionBill Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionBillList.js b/test/commerce/orderSubscriptionBillList.js new file mode 100644 index 0000000..f8a6e7f --- /dev/null +++ b/test/commerce/orderSubscriptionBillList.js @@ -0,0 +1,34 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionBill List (정기구독 청구 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.orderSubscriptionBill.list() + console.log('OrderSubscriptionBill List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.orderSubscriptionBill.list({ + page: 1, + limit: 10, + keyword: '청구', + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE', + status: [1, 2] // 청구 상태 필터 + }) + console.log('Filtered OrderSubscriptionBill List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionBillUpdate.js b/test/commerce/orderSubscriptionBillUpdate.js new file mode 100644 index 0000000..32a41ea --- /dev/null +++ b/test/commerce/orderSubscriptionBillUpdate.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscriptionBill Update (정기구독 청구 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscriptionBill.update({ + order_subscription_bill_id: 'ORDER_SUBSCRIPTION_BILL_ID_HERE', + amount: 15000, + billing_date: '2025-02-01' + }) + console.log('OrderSubscriptionBill Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionCalculateTerminationFee.js b/test/commerce/orderSubscriptionCalculateTerminationFee.js new file mode 100644 index 0000000..0e4ec8a --- /dev/null +++ b/test/commerce/orderSubscriptionCalculateTerminationFee.js @@ -0,0 +1,32 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Calculate Termination Fee (해지 수수료 계산) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // order_subscription_id로 조회 + const response = await commerce.orderSubscription.requestIng.calculateTerminationFee( + 'ORDER_SUBSCRIPTION_ID_HERE' + ) + console.log('Calculate Termination Fee Response:', JSON.stringify(response, null, 2)) + + // order_number로 조회 + const responseByOrderNumber = await commerce.orderSubscription.requestIng.calculateTerminationFeeByOrderNumber( + 'ORDER_NUMBER_HERE' + ) + console.log('Calculate Termination Fee by Order Number Response:', responseByOrderNumber) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionCharge.js b/test/commerce/orderSubscriptionCharge.js new file mode 100644 index 0000000..382141f --- /dev/null +++ b/test/commerce/orderSubscriptionCharge.js @@ -0,0 +1,32 @@ +const { getCommerceKeys, COMMERCE_TEST_DATA } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 수시결제(온디맨드) charge_key 즉시 결제 테스트 +// +// POST /v1/order_subscriptions/charge (supervisor 전용) +// charge_key 는 body 로만 전송된다 (URL/query 금지 — 액세스 로그 노출 방지) + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.asSupervisor().orderSubscription.supervisorCharge({ + charge_key: COMMERCE_TEST_DATA.charge_key, + price: 1000, + tax_free_price: 0, + user: { id: COMMERCE_TEST_DATA.user_id }, + metadata: { memo: 'charge key 즉시 결제 테스트' } + }) + console.log('OrderSubscription Charge Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionChargeRequest.js b/test/commerce/orderSubscriptionChargeRequest.js new file mode 100644 index 0000000..bcf1b87 --- /dev/null +++ b/test/commerce/orderSubscriptionChargeRequest.js @@ -0,0 +1,107 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +function body(config) { + return typeof config.data === 'string' ? JSON.parse(config.data) : config.data; +} + +// Commerce API - 수시결제(온디맨드) charge_key 요청 규약 테스트 (네트워크 호출 없음) +// 1) 결제: POST order_subscriptions/charge, charge_key 는 body 로만 전송 +// 2) 해지: DELETE order_subscriptions/charge, body 에 charge_key/user +// 3) supervisor role + Idempotency-Key 헤더 (미지정시 매 호출마다 생성) + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + // 1) charge_key 즉시 결제 + await commerce.orderSubscription.supervisorCharge({ + charge_key: 'charge_key_1', + price: 1000, + tax_free_price: 0, + user: { id: 'user_1' }, + metadata: { memo: 'test' } + }); + assert.strictEqual(requests[0].method.toLowerCase(), 'post'); + assert.strictEqual(requests[0].url, 'https://dev-api.bootapi.com/v1/order_subscriptions/charge'); + assert.ok(!requests[0].url.includes('charge_key_1'), 'charge_key must never appear in the URL'); + assert.deepStrictEqual(body(requests[0]), { + charge_key: 'charge_key_1', + price: 1000, + tax_free_price: 0, + user: { id: 'user_1' }, + metadata: { memo: 'test' } + }); + assert.strictEqual(header(requests[0], 'BOOTPAY-ROLE'), 'supervisor'); + assert.ok(header(requests[0], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 2) 호출마다 Idempotency-Key 는 새로 생성된다 + await commerce.orderSubscription.supervisorCharge({ charge_key: 'charge_key_1', price: 1000 }); + assert.notStrictEqual(header(requests[1], 'Idempotency-Key'), header(requests[0], 'Idempotency-Key')); + assert.deepStrictEqual(body(requests[1]), { charge_key: 'charge_key_1', price: 1000 }); + + // 3) idempotency_key 를 직접 지정하면 헤더로만 전송되고 body 에는 포함되지 않는다 + await commerce.orderSubscription.supervisorCharge({ + charge_key: 'charge_key_1', + price: 1000, + idempotency_key: 'my-idempotency-key' + }); + assert.strictEqual(header(requests[2], 'Idempotency-Key'), 'my-idempotency-key'); + assert.deepStrictEqual(body(requests[2]), { charge_key: 'charge_key_1', price: 1000 }); + + // 4) charge_key 해지 — DELETE + body + await commerce.orderSubscription.supervisorChargeRevoke({ + charge_key: 'charge_key_1', + user: { id: 'user_1' } + }); + assert.strictEqual(requests[3].method.toLowerCase(), 'delete'); + assert.strictEqual(requests[3].url, 'https://dev-api.bootapi.com/v1/order_subscriptions/charge'); + assert.deepStrictEqual(body(requests[3]), { charge_key: 'charge_key_1', user: { id: 'user_1' } }); + assert.strictEqual(header(requests[3], 'BOOTPAY-ROLE'), 'supervisor'); + assert.ok(header(requests[3], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 5) role 을 user 로 두어도 charge endpoint 는 supervisor 로 나간다 + commerce.asUser(); + await commerce.orderSubscription.supervisorCharge({ charge_key: 'charge_key_1', price: 1000 }); + assert.strictEqual(header(requests[4], 'BOOTPAY-ROLE'), 'supervisor'); + + // 6) 일반 endpoint 는 설정된 role 을 그대로 사용한다 + await commerce.orderSubscription.list(); + assert.strictEqual(header(requests[5], 'BOOTPAY-ROLE'), 'user'); + + // 7) null/undefined 값은 body 에서 제거된다 (Ruby SDK 의 payload.compact 와 동일) + await commerce.orderSubscription.supervisorCharge({ + charge_key: 'charge_key_1', + price: 1000, + tax_free_price: null, + user: undefined + }); + assert.deepStrictEqual(body(requests[6]), { charge_key: 'charge_key_1', price: 1000 }); + + console.log('commerce order subscription charge: body-only charge_key, supervisor role, idempotency key'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/orderSubscriptionChargeRevoke.js b/test/commerce/orderSubscriptionChargeRevoke.js new file mode 100644 index 0000000..012e156 --- /dev/null +++ b/test/commerce/orderSubscriptionChargeRevoke.js @@ -0,0 +1,29 @@ +const { getCommerceKeys, COMMERCE_TEST_DATA } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 수시결제(온디맨드) charge_key 해지 테스트 +// +// DELETE /v1/order_subscriptions/charge (supervisor 전용) +// 해지 이후 해당 키로의 재결제는 불가능하다 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.asSupervisor().orderSubscription.supervisorChargeRevoke({ + charge_key: COMMERCE_TEST_DATA.charge_key, + user: { id: COMMERCE_TEST_DATA.user_id } + }) + console.log('OrderSubscription Charge Revoke Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionDetail.js b/test/commerce/orderSubscriptionDetail.js new file mode 100644 index 0000000..5f6f347 --- /dev/null +++ b/test/commerce/orderSubscriptionDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Detail (정기구독 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.detail('ORDER_SUBSCRIPTION_ID_HERE') + console.log('OrderSubscription Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionList.js b/test/commerce/orderSubscriptionList.js new file mode 100644 index 0000000..0c04db6 --- /dev/null +++ b/test/commerce/orderSubscriptionList.js @@ -0,0 +1,37 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription List (정기구독 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.orderSubscription.list() + console.log('OrderSubscription List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.orderSubscription.list({ + page: 1, + limit: 10, + keyword: '구독', + user_id: 'USER_ID_HERE', + user_group_id: 'USER_GROUP_ID_HERE', + s_at: '2024-01-01', + e_at: '2024-12-31', + request_type: 'active' + }) + console.log('Filtered OrderSubscription List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionPause.js b/test/commerce/orderSubscriptionPause.js new file mode 100644 index 0000000..f981e3e --- /dev/null +++ b/test/commerce/orderSubscriptionPause.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Pause (정기구독 일시정지) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.requestIng.pause({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE', + pause_reason: '일시 정지 사유' + }) + console.log('OrderSubscription Pause Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionPurchase.js b/test/commerce/orderSubscriptionPurchase.js new file mode 100644 index 0000000..65cb20f --- /dev/null +++ b/test/commerce/orderSubscriptionPurchase.js @@ -0,0 +1,29 @@ +const { getCommerceKeys, COMMERCE_TEST_DATA } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription 중도인수 요청 테스트 +// POST /v1/order_subscriptions/requests/ing/purchase + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.requestIng.purchase({ + order_subscription_id: COMMERCE_TEST_DATA.order_subscription_id, + price: 100000, + tax_free_price: 0, + reason: '중도인수 요청' + }) + console.log('OrderSubscription Purchase Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionRequestList.js b/test/commerce/orderSubscriptionRequestList.js new file mode 100644 index 0000000..e31e835 --- /dev/null +++ b/test/commerce/orderSubscriptionRequestList.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Request 조회 (본인 모드) + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.orderSubscriptionRequest.list({ page: 1, limit: 10 }) + console.log('OrderSubscription Request List:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionResume.js b/test/commerce/orderSubscriptionResume.js new file mode 100644 index 0000000..395243a --- /dev/null +++ b/test/commerce/orderSubscriptionResume.js @@ -0,0 +1,25 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Resume (정기구독 재개) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.requestIng.resume({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE' + }) + console.log('OrderSubscription Resume Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionTermination.js b/test/commerce/orderSubscriptionTermination.js new file mode 100644 index 0000000..93d091e --- /dev/null +++ b/test/commerce/orderSubscriptionTermination.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Termination (정기구독 해지) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.requestIng.termination({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE', + termination_reason: '해지 사유' + }) + console.log('OrderSubscription Termination Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionTransfer.js b/test/commerce/orderSubscriptionTransfer.js new file mode 100644 index 0000000..38c5f9a --- /dev/null +++ b/test/commerce/orderSubscriptionTransfer.js @@ -0,0 +1,31 @@ +const { getCommerceKeys, COMMERCE_TEST_DATA } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription 이전/승계 요청 테스트 +// POST /v1/order_subscriptions/requests/ing/transfer + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.requestIng.transfer({ + order_subscription_id: COMMERCE_TEST_DATA.order_subscription_id, + new_user_id: COMMERCE_TEST_DATA.user_id, + new_username: '홍길동', + new_user_email: 'test@example.com', + new_user_phone: '01000000000', + reason: '구독 승계 요청' + }) + console.log('OrderSubscription Transfer Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/orderSubscriptionUpdate.js b/test/commerce/orderSubscriptionUpdate.js new file mode 100644 index 0000000..0a8a62b --- /dev/null +++ b/test/commerce/orderSubscriptionUpdate.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - OrderSubscription Update (정기구독 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.orderSubscription.update({ + order_subscription_id: 'ORDER_SUBSCRIPTION_ID_HERE', + next_billing_date: '2025-01-15' + }) + console.log('OrderSubscription Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/pointBalance.js b/test/commerce/pointBalance.js new file mode 100644 index 0000000..41647ef --- /dev/null +++ b/test/commerce/pointBalance.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 적립금 잔액 조회 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.point.balance() + console.log('Point Balance:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/pointTransactions.js b/test/commerce/pointTransactions.js new file mode 100644 index 0000000..8f420cb --- /dev/null +++ b/test/commerce/pointTransactions.js @@ -0,0 +1,22 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 적립금 내역 조회 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + const response = await commerce.point.transactions({ page: 1, limit: 10 }) + console.log('Point Transactions:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productCreate.js b/test/commerce/productCreate.js new file mode 100644 index 0000000..debdad2 --- /dev/null +++ b/test/commerce/productCreate.js @@ -0,0 +1,42 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product Create (상품 생성) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 이미지 없이 상품 생성 + const response = await commerce.product.create({ + name: '테스트 상품', + price: 10000, + description: '테스트 상품 설명', + type: 1, // 상품 유형 + status: 1 // 활성 상태 + }) + console.log('Product Create Response:', JSON.stringify(response, null, 2)) + + // 이미지와 함께 상품 생성 + // const responseWithImages = await commerce.product.create( + // { + // name: '테스트 상품 (이미지 포함)', + // price: 20000, + // description: '테스트 상품 설명', + // type: 1 + // }, + // ['/path/to/image1.jpg', '/path/to/image2.jpg'] + // ) + // console.log('Product Create With Images Response:', responseWithImages) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productDelete.js b/test/commerce/productDelete.js new file mode 100644 index 0000000..dd19a7d --- /dev/null +++ b/test/commerce/productDelete.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product Delete (상품 삭제) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.product.delete('PRODUCT_ID_HERE') + console.log('Product Delete Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productDetail.js b/test/commerce/productDetail.js new file mode 100644 index 0000000..2e2b2ed --- /dev/null +++ b/test/commerce/productDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product Detail (상품 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.product.detail('PRODUCT_ID_HERE') + console.log('Product Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productList.js b/test/commerce/productList.js new file mode 100644 index 0000000..de23c4c --- /dev/null +++ b/test/commerce/productList.js @@ -0,0 +1,35 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product List (상품 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.product.list() + console.log('Product List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.product.list({ + page: 1, + limit: 10, + keyword: '상품', + type: 1, // 상품 유형 + period_type: 'monthly', + category_code: 'CATEGORY_CODE' + }) + console.log('Filtered Product List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productMallRequest.js b/test/commerce/productMallRequest.js new file mode 100644 index 0000000..d34bc7a --- /dev/null +++ b/test/commerce/productMallRequest.js @@ -0,0 +1,82 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +// Commerce API - 쇼핑몰(V1 Mall API) 상품 요청 규약 테스트 (네트워크 호출 없음) +// 1) 목록: GET products (page/limit 기본값 1/20, category_id/sort/keyword 는 지정된 것만) +// 2) 상세: GET products/{product_id} +// 3) 두 endpoint 모두 Idempotency-Key + (있을 때만) Bootpay-User-JWT 헤더를 붙인다 + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + // 1) 파라미터 없이 호출하면 page=1, limit=20 이 기본으로 붙는다 + await commerce.product.products(); + assert.strictEqual(requests[0].method.toLowerCase(), 'get'); + assert.strictEqual(requests[0].url, 'https://dev-api.bootapi.com/v1/products?page=1&limit=20'); + assert.ok(header(requests[0], 'Idempotency-Key'), 'Idempotency-Key header is required'); + assert.strictEqual(header(requests[0], 'Bootpay-User-JWT'), undefined); + + // 2) category_id / sort / keyword + 회원 JWT + await commerce.product.products({ + page: 2, + limit: 5, + category_id: 'CATEGORY_ID', + sort: 'created_at', + keyword: '테스트', + user_jwt: 'USER_JWT', + idempotency_key: 'products-key' + }); + assert.strictEqual( + requests[1].url, + 'https://dev-api.bootapi.com/v1/products?page=2&limit=5&category_id=CATEGORY_ID&sort=created_at&keyword=%ED%85%8C%EC%8A%A4%ED%8A%B8' + ); + assert.strictEqual(header(requests[1], 'Bootpay-User-JWT'), 'USER_JWT'); + assert.strictEqual(header(requests[1], 'Idempotency-Key'), 'products-key'); + + // 3) 상품 상세 — 회원 JWT 없이 + await commerce.product.productDetail('PRODUCT_ID'); + assert.strictEqual(requests[2].method.toLowerCase(), 'get'); + assert.strictEqual(requests[2].url, 'https://dev-api.bootapi.com/v1/products/PRODUCT_ID'); + assert.ok(header(requests[2], 'Idempotency-Key'), 'Idempotency-Key header is required'); + assert.strictEqual(header(requests[2], 'Bootpay-User-JWT'), undefined); + + // 4) 상품 상세 — 회원 JWT 지정 + await commerce.product.productDetail('PRODUCT_ID', 'USER_JWT', 'detail-key'); + assert.strictEqual(header(requests[3], 'Bootpay-User-JWT'), 'USER_JWT'); + assert.strictEqual(header(requests[3], 'Idempotency-Key'), 'detail-key'); + + // 5) 기존 list() / detail() 은 그대로 유지된다 (기본 page/limit 없음) + await commerce.product.list({ page: 1, limit: 10, type: 0 }); + assert.strictEqual(requests[4].url, 'https://dev-api.bootapi.com/v1/products?page=1&limit=10&type=0'); + + await commerce.product.detail('PRODUCT_ID'); + assert.strictEqual(requests[5].url, 'https://dev-api.bootapi.com/v1/products/PRODUCT_ID'); + + console.log('commerce mall product: products/product detail params + JWT header'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/productStatus.js b/test/commerce/productStatus.js new file mode 100644 index 0000000..8f7da2f --- /dev/null +++ b/test/commerce/productStatus.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product Status (상품 상태 변경) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.product.status({ + product_id: 'PRODUCT_ID_HERE', + status: 2 // 비활성 상태로 변경 + }) + console.log('Product Status Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/productUpdate.js b/test/commerce/productUpdate.js new file mode 100644 index 0000000..785e7ef --- /dev/null +++ b/test/commerce/productUpdate.js @@ -0,0 +1,28 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - Product Update (상품 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.product.update({ + product_id: 'PRODUCT_ID_HERE', + name: '수정된 상품명', + price: 15000, + description: '수정된 상품 설명' + }) + console.log('Product Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/storeRequest.js b/test/commerce/storeRequest.js new file mode 100644 index 0000000..ffbc87a --- /dev/null +++ b/test/commerce/storeRequest.js @@ -0,0 +1,61 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +// Commerce API - 가맹점 정보 요청 규약 테스트 (네트워크 호출 없음) +// 1) 기본 정보: GET store +// 2) 상세 정보: GET store/detail +// 3) 두 endpoint 모두 Idempotency-Key 헤더를 붙인다 + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + // 1) 가맹점 기본 정보 + await commerce.store.getStore(); + assert.strictEqual(requests[0].method.toLowerCase(), 'get'); + assert.strictEqual(requests[0].url, 'https://dev-api.bootapi.com/v1/store'); + assert.ok(header(requests[0], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 2) info() 는 getStore() 의 alias + await commerce.store.info('store-key'); + assert.strictEqual(requests[1].url, 'https://dev-api.bootapi.com/v1/store'); + assert.strictEqual(header(requests[1], 'Idempotency-Key'), 'store-key'); + + // 3) 가맹점 상세 정보 + await commerce.store.getStoreDetail(); + assert.strictEqual(requests[2].method.toLowerCase(), 'get'); + assert.strictEqual(requests[2].url, 'https://dev-api.bootapi.com/v1/store/detail'); + assert.ok(header(requests[2], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 4) detail() 은 getStoreDetail() 의 alias + await commerce.store.detail('store-detail-key'); + assert.strictEqual(requests[3].url, 'https://dev-api.bootapi.com/v1/store/detail'); + assert.strictEqual(header(requests[3], 'Idempotency-Key'), 'store-detail-key'); + + console.log('commerce store: store / store/detail + Idempotency-Key'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/userAuthenticationData.js b/test/commerce/userAuthenticationData.js new file mode 100644 index 0000000..e6ab316 --- /dev/null +++ b/test/commerce/userAuthenticationData.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Authentication Data (본인인증 데이터 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.authenticationData('STAND_ID_HERE') + console.log('User Authentication Data Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userCheckExist.js b/test/commerce/userCheckExist.js new file mode 100644 index 0000000..b1d4b56 --- /dev/null +++ b/test/commerce/userCheckExist.js @@ -0,0 +1,32 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Check Exist (중복 체크) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // login_id 중복 체크 + const loginIdCheck = await commerce.user.checkExist('login_id', 'test_user@example.com') + console.log('Login ID Exist Check:', JSON.stringify(loginIdCheck, null, 2)) + + // email 중복 체크 + const emailCheck = await commerce.user.checkExist('email', 'test_user@example.com') + console.log('Email Exist Check:', JSON.stringify(emailCheck, null, 2)) + + // phone 중복 체크 + const phoneCheck = await commerce.user.checkExist('phone', '010-1234-5678') + console.log('Phone Exist Check:', JSON.stringify(phoneCheck, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userDelete.js b/test/commerce/userDelete.js new file mode 100644 index 0000000..3fea90b --- /dev/null +++ b/test/commerce/userDelete.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Delete (회원탈퇴) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.delete('USER_ID_HERE') + console.log('User Delete Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userDetail.js b/test/commerce/userDetail.js new file mode 100644 index 0000000..fe52f39 --- /dev/null +++ b/test/commerce/userDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Detail (사용자 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.detail('USER_ID_HERE') + console.log('User Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupAggregateTransaction.js b/test/commerce/userGroupAggregateTransaction.js new file mode 100644 index 0000000..3bb1c29 --- /dev/null +++ b/test/commerce/userGroupAggregateTransaction.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup Aggregate Transaction (그룹 거래 집계 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.aggregateTransaction({ + user_group_id: 'USER_GROUP_ID_HERE', + s_at: '2024-01-01', + e_at: '2024-12-31' + }) + console.log('UserGroup Aggregate Transaction Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupCreate.js b/test/commerce/userGroupCreate.js new file mode 100644 index 0000000..c2ee59a --- /dev/null +++ b/test/commerce/userGroupCreate.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup Create (사용자 그룹 생성) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.create({ + name: '테스트 그룹', + corporate_type: 1, // 법인 유형 + description: '테스트용 사용자 그룹' + }) + console.log('UserGroup Create Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupDetail.js b/test/commerce/userGroupDetail.js new file mode 100644 index 0000000..af347dc --- /dev/null +++ b/test/commerce/userGroupDetail.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup Detail (사용자 그룹 상세 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.detail('USER_GROUP_ID_HERE') + console.log('UserGroup Detail Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupLimit.js b/test/commerce/userGroupLimit.js new file mode 100644 index 0000000..b16a1fa --- /dev/null +++ b/test/commerce/userGroupLimit.js @@ -0,0 +1,30 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup Limit (그룹 제한 설정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // ⚠️ userGroup.update 로는 한도가 반영되지 않는다 — 이 전용 라우트로만 바뀐다. + const response = await commerce.userGroup.limit({ + user_group_id: 'USER_GROUP_ID_HERE', + use_limit: true, + limit_month_purchase: 1000000, // 월 구매한도 + limit_week_purchase: 300000, // 주 구매한도 + limit_message: '월 구매한도를 초과했습니다.' + }) + console.log('UserGroup Limit Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupList.js b/test/commerce/userGroupList.js new file mode 100644 index 0000000..65d7523 --- /dev/null +++ b/test/commerce/userGroupList.js @@ -0,0 +1,32 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup List (사용자 그룹 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.userGroup.list() + console.log('UserGroup List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.userGroup.list({ + page: 1, + limit: 10, + keyword: '테스트' + }) + console.log('Filtered UserGroup List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupUpdate.js b/test/commerce/userGroupUpdate.js new file mode 100644 index 0000000..32b6d0a --- /dev/null +++ b/test/commerce/userGroupUpdate.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup Update (사용자 그룹 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.update({ + user_group_id: 'USER_GROUP_ID_HERE', + name: '수정된 그룹명', + description: '수정된 설명' + }) + console.log('UserGroup Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupUserCreate.js b/test/commerce/userGroupUserCreate.js new file mode 100644 index 0000000..456e745 --- /dev/null +++ b/test/commerce/userGroupUserCreate.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup User Create (그룹에 사용자 추가) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.userCreate( + 'USER_GROUP_ID_HERE', + 'USER_ID_HERE' + ) + console.log('UserGroup User Create Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userGroupUserDelete.js b/test/commerce/userGroupUserDelete.js new file mode 100644 index 0000000..ab5fb25 --- /dev/null +++ b/test/commerce/userGroupUserDelete.js @@ -0,0 +1,26 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - UserGroup User Delete (그룹에서 사용자 제거) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.userGroup.userDelete( + 'USER_GROUP_ID_HERE', + 'USER_ID_HERE' + ) + console.log('UserGroup User Delete Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userJoin.js b/test/commerce/userJoin.js new file mode 100644 index 0000000..42f33f8 --- /dev/null +++ b/test/commerce/userJoin.js @@ -0,0 +1,29 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Join (회원가입) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.join({ + login_id: 'test_user@example.com', + login_pw: 'password123', + name: '테스트 사용자', + email: 'test_user@example.com', + phone: '010-1234-5678' + }) + console.log('User Join Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userList.js b/test/commerce/userList.js new file mode 100644 index 0000000..3c62afc --- /dev/null +++ b/test/commerce/userList.js @@ -0,0 +1,32 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User List (사용자 목록 조회) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // 기본 목록 조회 + const response = await commerce.user.list() + console.log('User List Response:', JSON.stringify(response, null, 2)) + + // 파라미터로 조회 + const filteredResponse = await commerce.user.list({ + page: 1, + limit: 10, + keyword: '테스트' + }) + console.log('Filtered User List Response:', filteredResponse) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userLogin.js b/test/commerce/userLogin.js new file mode 100644 index 0000000..f78a3f0 --- /dev/null +++ b/test/commerce/userLogin.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Login 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.login('test_user@example.com', 'password123') + console.log('User Login Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userMallSessionRequest.js b/test/commerce/userMallSessionRequest.js new file mode 100644 index 0000000..f16ff91 --- /dev/null +++ b/test/commerce/userMallSessionRequest.js @@ -0,0 +1,150 @@ +const assert = require('assert'); +const { BootpayCommerce } = require('../../dist/bootpay-commerce.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +function body(config) { + return typeof config.data === 'string' ? JSON.parse(config.data) : config.data; +} + +// Commerce API - V1 회원 요청 규약 테스트 (네트워크 호출 없음) +// 1) 로그인: POST users/login (login_id / password / corporate_type) +// 2) 세션 조회: GET users/session (Bootpay-User-JWT) +// 3) 로그아웃: DELETE users/session (Bootpay-User-JWT) +// 4) 회원가입: POST users/join (null/undefined 값은 전송하지 않는다) +// 5) 중복 확인: GET users/join/{type}?pk={pk} +// ⚠️ 단수 user/... 는 commerce-api v1 에 존재하지 않는 죽은 경로다. 전부 복수 users/... 여야 한다. +// ⚠️ 로그인은 POST /v1/users/login 이다. POST /v1/users/session 은 라우트만 있고 create 액션이 없다. + +(async () => { + const commerce = new BootpayCommerce({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + + const requests = []; + commerce.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + // 1) 회원 로그인 — corporate_type 미지정시 0 + await commerce.user.userLogin({ + login_id: 'test_user@example.com', + password: 'password123' + }); + assert.strictEqual(requests[0].method.toLowerCase(), 'post'); + assert.strictEqual(requests[0].url, 'https://dev-api.bootapi.com/v1/users/login'); + assert.deepStrictEqual(body(requests[0]), { + login_id: 'test_user@example.com', + password: 'password123', + corporate_type: 0 + }); + assert.ok(header(requests[0], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 2) corporate_type 을 지정하면 그대로 전송한다 + await commerce.user.userLogin({ + login_id: 'biz@example.com', + password: 'password123', + corporate_type: 1, + idempotency_key: 'login-key' + }); + assert.deepStrictEqual(body(requests[1]), { + login_id: 'biz@example.com', + password: 'password123', + corporate_type: 1 + }); + assert.strictEqual(header(requests[1], 'Idempotency-Key'), 'login-key'); + + // 3) 회원 세션 조회 — Bootpay-User-JWT 헤더 + await commerce.user.userSession('USER_JWT'); + assert.strictEqual(requests[2].method.toLowerCase(), 'get'); + assert.strictEqual(requests[2].url, 'https://dev-api.bootapi.com/v1/users/session'); + assert.strictEqual(header(requests[2], 'Bootpay-User-JWT'), 'USER_JWT'); + + // 4) user_jwt 가 없으면 헤더를 붙이지 않는다 (Ruby SDK 의 headers.compact 와 동일 동작) + await commerce.user.userSession(); + assert.strictEqual(header(requests[3], 'Bootpay-User-JWT'), undefined); + assert.ok(header(requests[3], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + // 5) 회원 로그아웃 — DELETE users/session + await commerce.user.userLogout('USER_JWT', 'logout-key'); + assert.strictEqual(requests[4].method.toLowerCase(), 'delete'); + assert.strictEqual(requests[4].url, 'https://dev-api.bootapi.com/v1/users/session'); + assert.strictEqual(header(requests[4], 'Bootpay-User-JWT'), 'USER_JWT'); + assert.strictEqual(header(requests[4], 'Idempotency-Key'), 'logout-key'); + + // 6) 회원가입 — 전달한 값만 전송, corporate_type 기본값 0 + await commerce.user.userJoin({ + login_id: 'test_user@example.com', + password: 'password123', + name: '테스트 사용자', + email: 'test_user@example.com', + phone: '010-1234-5678', + nickname: undefined, + gender: null + }); + assert.strictEqual(requests[5].method.toLowerCase(), 'post'); + assert.strictEqual(requests[5].url, 'https://dev-api.bootapi.com/v1/users/join'); + assert.deepStrictEqual(body(requests[5]), { + login_id: 'test_user@example.com', + password: 'password123', + name: '테스트 사용자', + email: 'test_user@example.com', + phone: '010-1234-5678', + corporate_type: 0 + }); + + // 7) 회원가입 중복 확인 — pk 는 query 로 전송 + await commerce.user.userJoinCheck('email-exist', 'test_user@example.com'); + assert.strictEqual(requests[6].method.toLowerCase(), 'get'); + assert.strictEqual( + requests[6].url, + 'https://dev-api.bootapi.com/v1/users/join/email-exist?pk=test_user%40example.com' + ); + assert.ok(header(requests[6], 'Idempotency-Key'), 'Idempotency-Key header is required'); + + await commerce.user.userJoinCheck('group-business-number-exist', '123-45-67890', 'check-key'); + assert.strictEqual( + requests[7].url, + 'https://dev-api.bootapi.com/v1/users/join/group-business-number-exist?pk=123-45-67890' + ); + assert.strictEqual(header(requests[7], 'Idempotency-Key'), 'check-key'); + + // 8) 간편 헬퍼(login / checkExist)도 같은 경로를 쓴다 + await commerce.user.login('test_user@example.com', 'password123'); + assert.strictEqual(requests[8].url, 'https://dev-api.bootapi.com/v1/users/login'); + + await commerce.user.checkExist('email-exist', 'test_user@example.com'); + assert.strictEqual( + requests[9].url, + 'https://dev-api.bootapi.com/v1/users/join/email-exist?pk=test_user%40example.com' + ); + + // 9) uid-exist 전용형 — email/id/phone/group-business-number 와 같은 패턴 + await commerce.user.uidExist('external_uid_1234', 'uid-key'); + assert.strictEqual(requests[10].method.toLowerCase(), 'get'); + assert.strictEqual( + requests[10].url, + 'https://dev-api.bootapi.com/v1/users/join/uid-exist?pk=external_uid_1234' + ); + assert.strictEqual(header(requests[10], 'Idempotency-Key'), 'uid-key'); + assert.strictEqual(header(requests[10], 'BOOTPAY-ROLE'), 'user'); + + console.log('commerce v1 user: users/login, users/session, users/join endpoints + JWT header'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/commerce/userToken.js b/test/commerce/userToken.js new file mode 100644 index 0000000..290f839 --- /dev/null +++ b/test/commerce/userToken.js @@ -0,0 +1,23 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Token 발급 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.token('USER_ID_HERE') + console.log('User Token Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userUidExist.js b/test/commerce/userUidExist.js new file mode 100644 index 0000000..5aad741 --- /dev/null +++ b/test/commerce/userUidExist.js @@ -0,0 +1,28 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User uid-exist (외부 uid 중복검사) 테스트 +// GET /v1/users/join/uid-exist?pk={uid} + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.uidExist('external_uid_1234') + console.log('User uid-exist Response:', JSON.stringify(response, null, 2)) + + // 일반형(checkExist)으로도 같은 endpoint 를 호출할 수 있다. + const generic = await commerce.user.checkExist('uid-exist', 'external_uid_1234') + console.log('User checkExist(uid-exist) Response:', generic) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/userUpdate.js b/test/commerce/userUpdate.js new file mode 100644 index 0000000..1322759 --- /dev/null +++ b/test/commerce/userUpdate.js @@ -0,0 +1,27 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - User Update (사용자 정보 수정) 테스트 + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + const response = await commerce.user.update({ + user_id: 'USER_ID_HERE', + name: '수정된 이름', + phone: '010-9876-5432' + }) + console.log('User Update Response:', JSON.stringify(response, null, 2)) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/commerce/webhookSendTest.js b/test/commerce/webhookSendTest.js new file mode 100644 index 0000000..1764d7c --- /dev/null +++ b/test/commerce/webhookSendTest.js @@ -0,0 +1,29 @@ +const { getCommerceKeys } = require('../config.js'); +const keys = getCommerceKeys(); +// Commerce API - 테스트 웹훅 발송 테스트 +// POST /v1/webhook/test + +(async () => { + const { BootpayCommerce } = require('../../dist/bootpay-commerce.js') + + const commerce = new BootpayCommerce({ + client_key: keys.client_key, + secret_key: keys.secret_key, + mode: keys.mode + }) + + try { + // (legacy) application_id 방식에서만 필요. ck/sk 는 매 요청 Basic Auth 헤더로 직접 인증되므로 호출 불필요. + // await commerce.getAccessToken() + + // header_content_type 미지정 — 서버 기본값으로 발송 + const response = await commerce.webhook.sendTest() + console.log('Send Test Webhook Response:', JSON.stringify(response, null, 2)) + + // Content-Type 지정 발송 + const withContentType = await commerce.webhook.sendTest({ header_content_type: 1 }) + console.log('Send Test Webhook (content-type) Response:', withContentType) + } catch (e) { + console.error('Error:', e) + } +})() diff --git a/test/config.js b/test/config.js new file mode 100644 index 0000000..9ea6710 --- /dev/null +++ b/test/config.js @@ -0,0 +1,179 @@ +/** + * SDK 테스트용 설정 파일 + */ +const fs = require('fs'); +const path = require('path'); + +function loadDotEnv() { + const candidates = [ + path.resolve(__dirname, '..', '.env'), + path.resolve(__dirname, '.env') + ]; + for (const file of candidates) { + if (!fs.existsSync(file)) continue; + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + let value = trimmed.slice(idx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = value; + } + } +} + +loadDotEnv(); + +const env = (key, fallback) => process.env[key] || fallback; + +// 현재 환경: 'production' 또는 'development' +const CURRENT_ENV = env('BOOTPAY_ENV', 'production'); + +// PG 인증 방식: 'new' (client_key/secret_key) 또는 'legacy' (application_id/private_key) +// 매 실행 시 BOOTPAY_AUTH_MODE 환경변수로 토글한다. +const AUTH_MODE = (env('BOOTPAY_AUTH_MODE', 'new') || 'new').toLowerCase(); + +// PG API 키 (반드시 .env 또는 환경변수로 주입; .env.example 참고) +const PG_CREDENTIALS = { + production: { + mode: 'production', + client_key: env('BOOTPAY_PG_CLIENT_KEY_PROD', ''), + secret_key: env('BOOTPAY_PG_SECRET_KEY_PROD', '') + }, + development: { + mode: 'development', + client_key: env('BOOTPAY_PG_CLIENT_KEY_DEV', ''), + secret_key: env('BOOTPAY_PG_SECRET_KEY_DEV', '') + } +}; + +// PG API legacy application_id/private_key 인증 (호환성 검증용) +const PG_LEGACY_CREDENTIALS = { + production: { + mode: 'production', + application_id: env('BOOTPAY_PG_APPLICATION_ID_PROD', ''), + private_key: env('BOOTPAY_PG_PRIVATE_KEY_PROD', '') + }, + development: { + mode: 'development', + application_id: env('BOOTPAY_PG_APPLICATION_ID_DEV', ''), + private_key: env('BOOTPAY_PG_PRIVATE_KEY_DEV', '') + } +}; + +// Commerce API 키 (반드시 .env 또는 환경변수로 주입; .env.example 참고) +const COMMERCE_CREDENTIALS = { + production: { + mode: 'production', + client_key: env('BOOTPAY_COMMERCE_CLIENT_KEY_PROD', ''), + secret_key: env('BOOTPAY_COMMERCE_SECRET_KEY_PROD', '') + }, + development: { + mode: 'development', + client_key: env('BOOTPAY_COMMERCE_CLIENT_KEY_DEV', ''), + secret_key: env('BOOTPAY_COMMERCE_SECRET_KEY_DEV', '') + } +}; + +// PG 테스트 데이터 +const TEST_DATA = { + receipt_id: '628b2206d01c7e00209b6087', + receipt_id_confirm: '62876963d01c7e00209b6028', + // receipt_id_confirm: '69fd7187564d1f550535538c', + receipt_id_cash: '62e0f11f1fc192036b1b3c92', + receipt_id_escrow: '628ae7ffd01c7e001e9b6066', + receipt_id_billing: '62c7ccebcf9f6d001b3adcd4', + receipt_id_transfer: '66541bc4ca4517e69343e24c', + billing_key: '628b2644d01c7e00209b6092', + billing_key_2: '66542dfb4d18d5fc7b43e1b6', + reserve_id: '6490149ca575b40024f0b70d', + reserve_id_2: '628b316cd01c7e00219b6081', + user_id: '1234', + certificate_receipt_id: '69fd7187564d1f550535538c', + // 우선순위(순차) 결제 빌링키 조회용 위젯키 — .env 의 BOOTPAY_TEST_PG_WIDGET_KEY 로 주입 + widget_key: env('BOOTPAY_TEST_PG_WIDGET_KEY', 'WIDGET_KEY_HERE') +}; + +// Commerce 테스트 fixture — placeholder 였던 ID 들을 .env 로 주입. +// 빈 값이면 해당 endpoint 는 placeholder 문자열이 그대로 들어가서 ORDER_NOT_FOUND / USER_NOT_FOUND 등으로 실패하므로 +// 실제 통신 검증을 위해선 .env 의 BOOTPAY_TEST_COMMERCE_* 키들을 채워야 한다. +const COMMERCE_TEST_DATA = { + user_id: env('BOOTPAY_TEST_COMMERCE_USER_ID', 'USER_ID_HERE'), + user_group_id: env('BOOTPAY_TEST_COMMERCE_USER_GROUP_ID', 'USER_GROUP_ID_HERE'), + product_id: env('BOOTPAY_TEST_COMMERCE_PRODUCT_ID', 'PRODUCT_ID_HERE'), + category_id: env('BOOTPAY_TEST_COMMERCE_CATEGORY_ID', 'CATEGORY_ID_HERE'), + coupon_template_id: env('BOOTPAY_TEST_COMMERCE_COUPON_TEMPLATE_ID', 'COUPON_TEMPLATE_ID_HERE'), + invoice_id: env('BOOTPAY_TEST_COMMERCE_INVOICE_ID', 'INVOICE_ID_HERE'), + order_id: env('BOOTPAY_TEST_COMMERCE_ORDER_ID', 'ORDER_ID_HERE'), + order_number: env('BOOTPAY_TEST_COMMERCE_ORDER_NUMBER', 'ORDER_NUMBER_HERE'), + order_subscription_id: env('BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_ID', 'ORDER_SUBSCRIPTION_ID_HERE'), + order_subscription_bill_id: env('BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_BILL_ID', 'ORDER_SUBSCRIPTION_BILL_ID_HERE'), + order_subscription_adjustment_id: env('BOOTPAY_TEST_COMMERCE_ORDER_SUBSCRIPTION_ADJUSTMENT_ID', 'ORDER_SUBSCRIPTION_ADJUSTMENT_ID_HERE'), + order_cancel_request_history_id: env('BOOTPAY_TEST_COMMERCE_ORDER_CANCEL_REQUEST_HISTORY_ID', 'ORDER_CANCEL_REQUEST_HISTORY_ID_HERE'), + stand_id: env('BOOTPAY_TEST_COMMERCE_STAND_ID', 'STAND_ID_HERE'), + charge_key: env('BOOTPAY_TEST_COMMERCE_CHARGE_KEY', 'CHARGE_KEY_HERE'), + keyword: env('BOOTPAY_TEST_COMMERCE_KEYWORD', '테스트'), + s_at: env('BOOTPAY_TEST_COMMERCE_S_AT', '2024-01-01'), + e_at: env('BOOTPAY_TEST_COMMERCE_E_AT', '2099-12-31') +}; + +// Commerce default role — orderCancel.*, orderSubscriptionAdjustment.*, orderSubscription.update 류는 manager+ 필요. +// 테스트는 commerce.withRole(COMMERCE_ROLE) 또는 endpoint 별 .asManager() 직접 호출. +const COMMERCE_ROLE = (env('BOOTPAY_TEST_COMMERCE_ROLE', 'user') || 'user').toLowerCase(); + +// fixture 가 placeholder 그대로면 true — 테스트는 이걸 보고 skip 결정 가능. +function isCommercePlaceholder(value) { + return typeof value === 'string' && value.endsWith('_HERE'); +} + +function normalizeEnv(targetEnv) { + return targetEnv || CURRENT_ENV; +} + +function getPgKeys(targetEnv) { + return PG_CREDENTIALS[normalizeEnv(targetEnv)] || PG_CREDENTIALS.production; +} + +function getPgLegacyKeys(targetEnv) { + return PG_LEGACY_CREDENTIALS[normalizeEnv(targetEnv)] || PG_LEGACY_CREDENTIALS.production; +} + +function getCommerceKeys(targetEnv) { + return COMMERCE_CREDENTIALS[normalizeEnv(targetEnv)] || COMMERCE_CREDENTIALS.production; +} + +// AUTH_MODE 에 따라 setConfiguration 에 그대로 넘길 수 있는 PG config 를 반환한다. +// BOOTPAY_AUTH_MODE=new → { client_key, secret_key, mode } +// BOOTPAY_AUTH_MODE=legacy → { application_id, private_key, mode } +function getActivePgConfig(targetEnv) { + const env = normalizeEnv(targetEnv); + if (AUTH_MODE === 'legacy') { + console.log(`[BOOTPAY_AUTH_MODE=legacy] PG: application_id/private_key (Bearer) | env=${env}`); + const k = getPgLegacyKeys(env); + return { application_id: k.application_id, private_key: k.private_key, mode: k.mode }; + } + console.log(`[BOOTPAY_AUTH_MODE=new] PG: client_key/secret_key (Basic Auth) | env=${env}`); + const k = getPgKeys(env); + return { client_key: k.client_key, secret_key: k.secret_key, mode: k.mode }; +} + +module.exports = { + CURRENT_ENV, + AUTH_MODE, + PG_CREDENTIALS, + PG_LEGACY_CREDENTIALS, + COMMERCE_CREDENTIALS, + TEST_DATA, + COMMERCE_TEST_DATA, + COMMERCE_ROLE, + isCommercePlaceholder, + getPgKeys, + getPgLegacyKeys, + getActivePgConfig, + getCommerceKeys +}; diff --git a/test/destroy_subscribe_billing_key.js b/test/destroy_subscribe_billing_key.js deleted file mode 100644 index 2eb9b20..0000000 --- a/test/destroy_subscribe_billing_key.js +++ /dev/null @@ -1,17 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.destroySubscribeBillingKey('5f97b8a40f606f03e8ab32a0') - } catch (e) { - return console.log(e) - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/get_user_token.js b/test/get_user_token.js deleted file mode 100644 index 5fef3ed..0000000 --- a/test/get_user_token.js +++ /dev/null @@ -1,19 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - const token = await Bootpay.getAccessToken() - if (token.status === 200) { - let result - try { - result = await Bootpay.requestUserToken({ - userId: 'gosomi' - }) - } catch (e) { - return console.log(e) - } - console.log(result) - } -})() \ No newline at end of file diff --git a/test/pg/authenticateConfirmRest.mjs b/test/pg/authenticateConfirmRest.mjs new file mode 100644 index 0000000..88061be --- /dev/null +++ b/test/pg/authenticateConfirmRest.mjs @@ -0,0 +1,18 @@ +import config from '../config.js'; +const { getActivePgConfig, TEST_DATA } = config; +import { Bootpay } from "../../dist/bootpay.js" + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.confirmAuthentication( + TEST_DATA.receipt_id_confirm, + '457670' + ) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/authenticateRealarmRest.js b/test/pg/authenticateRealarmRest.js new file mode 100644 index 0000000..581b5d3 --- /dev/null +++ b/test/pg/authenticateRealarmRest.js @@ -0,0 +1,15 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // console.log(new Date((new Date()).getTime() + 5000)) + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.realarmAuthentication(TEST_DATA.receipt_id_confirm) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/authenticateRequestRest.js b/test/pg/authenticateRequestRest.js new file mode 100644 index 0000000..fd14530 --- /dev/null +++ b/test/pg/authenticateRequestRest.js @@ -0,0 +1,24 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.requestAuthentication({ + pg: '다날', + method: '본인인증', + order_name: '테스트 인증', + authentication_id: (new Date()).getTime(), + username: '이름', + identity_no: '생년월일', + phone: '전화번호', + carrier: '통신사', + authenticate_type: 'sms' + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/axios_test.js b/test/pg/axios_test.js new file mode 100644 index 0000000..465360d --- /dev/null +++ b/test/pg/axios_test.js @@ -0,0 +1,27 @@ +const { getPgKeys } = require('../config.js'); +const keys = getPgKeys('production'); + +(async () => { + const RestClient = require('../../dist/bootpay').Bootpay + RestClient.setConfiguration({ + client_key: keys.client_key, + secret_key: keys.secret_key, + }) + // Legacy fallback: + // RestClient.setConfiguration({ + // application_id: '5b8f6a4d396fa665fdc2b5ea', + // private_key: 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' + // }) + let response = await RestClient.getAccessToken() + // console.log(response) + const axios = require('axios') + try { + response = await axios.post("https://dev-api.bootpay.co.kr/request/token.json", { + client_key: keys.client_key, + secret_key: keys.secret_key, + }) + console.log(response) + } catch(e) { + console.log(e.response.data) + } +})() diff --git a/test/pg/cancelPayment.js b/test/pg/cancelPayment.js new file mode 100644 index 0000000..0241d97 --- /dev/null +++ b/test/pg/cancelPayment.js @@ -0,0 +1,19 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.cancelPayment({ + receipt_id: TEST_DATA.receipt_id, + cancel_price: 1000, + cancel_username: '테스트 사용자', + cancel_message: '테스트 취소입니다.' + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/cancelSubscribeReserve.js b/test/pg/cancelSubscribeReserve.js new file mode 100644 index 0000000..a88133f --- /dev/null +++ b/test/pg/cancelSubscribeReserve.js @@ -0,0 +1,27 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + // 예약 결제 등록 + const reserve = await Bootpay.subscribePaymentReserve({ + billing_key: TEST_DATA.billing_key, + order_name: '테스트결제', + price: 1000, + order_id: Date.now().toString(), + reserve_execute_at: new Date(Date.now() + 60000).toISOString() + }); + console.log('예약 등록:', reserve); + + if (reserve.data && reserve.data.reserve_id) { + // 예약 취소 + const cancel = await Bootpay.cancelSubscribeReserve(reserve.data.reserve_id); + console.log('예약 취소:', cancel); + } + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/cashReceiptPublishOnReceipt.js b/test/pg/cashReceiptPublishOnReceipt.js new file mode 100644 index 0000000..51731ea --- /dev/null +++ b/test/pg/cashReceiptPublishOnReceipt.js @@ -0,0 +1,21 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.cashReceiptPublishOnReceipt({ + receipt_id: TEST_DATA.receipt_id_cash, + username: '테스트', + email: 'test@bootpay.co.kr', + phone: '01000000000', + identity_no: '01000000000', + cash_receipt_type: '소득공제' + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/certificate.js b/test/pg/certificate.js new file mode 100644 index 0000000..6ccf857 --- /dev/null +++ b/test/pg/certificate.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.certificate(TEST_DATA.certificate_receipt_id); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/confirmPayment.js b/test/pg/confirmPayment.js new file mode 100644 index 0000000..da59e9a --- /dev/null +++ b/test/pg/confirmPayment.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.confirmPayment(TEST_DATA.receipt_id_confirm); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/destroySubscribeBillingKey.js b/test/pg/destroySubscribeBillingKey.js new file mode 100644 index 0000000..42bec1e --- /dev/null +++ b/test/pg/destroySubscribeBillingKey.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.destroyBillingKey(TEST_DATA.billing_key); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/form_payment_progress.js b/test/pg/form_payment_progress.js similarity index 72% rename from test/form_payment_progress.js rename to test/pg/form_payment_progress.js index 8b1669b..22ad367 100644 --- a/test/form_payment_progress.js +++ b/test/pg/form_payment_progress.js @@ -1,9 +1,17 @@ -const Bootpay = require('../lib/bootpay'); +// (legacy) 이 파일은 레거시 application_id/private_key + Promise then() 체인 예제다. +// ck/sk 모드에서는 getAccessToken() 호출이 불필요하며, 매 요청 Basic Auth 헤더로 +// 직접 인증된다. 신규 코드는 await 패턴으로 토큰 호출 없이 바로 결제/조회 API를 호출하면 된다. +const { Bootpay } = require('../../dist/bootpay.js'); -Bootpay.setConfig( - "[[ REST용 Application ID]]", - "[[ Private Key ]]" -); +Bootpay.setConfiguration({ + client_key: '[[ Client Key ]]', + secret_key: '[[ Server Key ]]' +}); +// Legacy fallback: +// Bootpay.setConfiguration({ +// application_id: '[[ REST용 Application ID ]]', +// private_key: '[[ Private Key ]]' +// }); // POST로 Params를 받아서 처리 // params가 POST로 전달된 Object라고 가정하면 @@ -42,4 +50,4 @@ switch (params.act) { }); break; -} \ No newline at end of file +} diff --git a/test/pg/getAccessToken.js b/test/pg/getAccessToken.js new file mode 100644 index 0000000..2d1a5b9 --- /dev/null +++ b/test/pg/getAccessToken.js @@ -0,0 +1,38 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getPgKeys, getPgLegacyKeys } = require('../config.js'); + +(async () => { + // 1) client_key/secret_key 경로 — Option A no-op 검증 (HTTP 호출 없이 합성 응답) + const ck = getPgKeys(); + Bootpay.setConfiguration({ + client_key: ck.client_key, + secret_key: ck.secret_key, + mode: ck.mode + }); + try { + const response = await Bootpay.getAccessToken(); + console.log('[ck/sk]', response); + if (response.access_token !== '' || response.expire_in !== 0) { + console.error('[ck/sk] expected synthetic empty response'); + } + } catch (e) { + console.log('[ck/sk] ERR', e); + } + + // 2) legacy application_id/private_key 경로 — 실제 request/token 호출 + Bearer 토큰 발급 검증 + const legacy = getPgLegacyKeys(); + Bootpay.setConfiguration({ + application_id: legacy.application_id, + private_key: legacy.private_key, + mode: legacy.mode + }); + try { + const response = await Bootpay.getAccessToken(); + console.log('[legacy]', response); + if (!response.access_token || response.expire_in <= 0) { + console.error('[legacy] expected real access_token + positive expire_in'); + } + } catch (e) { + console.log('[legacy] ERR', e); + } +})(); diff --git a/test/pg/getBillingKey.js b/test/pg/getBillingKey.js new file mode 100644 index 0000000..7189d11 --- /dev/null +++ b/test/pg/getBillingKey.js @@ -0,0 +1,27 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribeBillingKey({ + pg: '나이스페이', + order_name: '테스트결제', + subscription_id: (new Date()).getTime(), + card_no: '5570********1074', //카드번호 + card_pw: '**', //카드 비밀번호 2자리 + card_identity_no: '******', //카드 소유주 생년월일 6자리 + card_expire_year: '**', //카드 유효기간 년 2자리 + card_expire_month: '**', //카드 유효기간 월 2자리 + user: { + username: '홍길동', + phone: '01012345678' + } + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/getUserWallets.js b/test/pg/getUserWallets.js new file mode 100644 index 0000000..c010ede --- /dev/null +++ b/test/pg/getUserWallets.js @@ -0,0 +1,17 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.getUserWallets( + 'bootpay', + true + ) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/legacyCompatibility.js b/test/pg/legacyCompatibility.js new file mode 100644 index 0000000..1e452bd --- /dev/null +++ b/test/pg/legacyCompatibility.js @@ -0,0 +1,101 @@ +const assert = require('assert'); +const { Bootpay } = require('../../dist/bootpay.js'); + +function header(config, name) { + if (!config.headers) return undefined; + if (typeof config.headers.get === 'function') return config.headers.get(name); + return config.headers[name] || config.headers[name.toLowerCase()] || config.headers[name.toUpperCase()]; +} + +function body(data) { + return typeof data === 'string' ? JSON.parse(data) : data; +} + +(async () => { + const requests = []; + Bootpay.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: { access_token: 'legacy_access_token' }, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + Bootpay.setConfiguration({ + application_id: 'legacy_application_id', + private_key: 'legacy_private_key', + mode: 'development' + }); + + assert.strictEqual(Bootpay.bootpayConfiguration.application_id, 'legacy_application_id'); + assert.strictEqual(Bootpay.bootpayConfiguration.private_key, 'legacy_private_key'); + assert.strictEqual(Bootpay.bootpayConfiguration.client_key, undefined); + assert.strictEqual(Bootpay.bootpayConfiguration.secret_key, undefined); + assert.strictEqual(Bootpay.entrypoints('request/token'), 'https://dev-api.bootpay.co.kr/v2/request/token'); + + // 1) legacy: getAccessToken 은 request/token 으로 토큰 발급 + const legacyTokenRes = await Bootpay.getAccessToken(); + assert.strictEqual(legacyTokenRes.access_token, 'legacy_access_token'); + assert.deepStrictEqual(body(requests[0].data), { + application_id: 'legacy_application_id', + private_key: 'legacy_private_key' + }); + assert.strictEqual(header(requests[0], 'authorization'), undefined); + + // 2) legacy: 후속 요청에 Bearer 헤더 자동 부착 + await Bootpay.receiptPayment('receipt_id_for_header_check'); + assert.strictEqual(header(requests[1], 'authorization'), 'Bearer legacy_access_token'); + + // 3) ck/sk: getAccessToken 은 Option A 로 no-op — HTTP 호출 없이 합성 응답만 반환 + Bootpay.setConfiguration({ + client_key: 'ck', + secret_key: 'sk', + mode: 'development' + }); + const beforeCk = requests.length; + const ckTokenRes = await Bootpay.getAccessToken(); + assert.deepStrictEqual(ckTokenRes, { access_token: '', expire_in: 0 }); + assert.strictEqual(requests.length, beforeCk, 'ck/sk getAccessToken must not make HTTP call'); + + // 4) ck/sk: 실제 API 요청 시 매 요청 Basic Auth 헤더 부착 + await Bootpay.receiptPayment('receipt_id_for_basic_auth_check'); + assert.strictEqual( + header(requests[beforeCk], 'authorization'), + `Basic ${Buffer.from('ck:sk').toString('base64')}` + ); + + // 5) ck 만 있고 sk 없으면 즉시 에러 + Bootpay.setConfiguration({ + client_key: 'ck', + mode: 'production' + }); + await assert.rejects( + () => Bootpay.getAccessToken(), + (error) => error.error_code === -101 && error.message.includes('client_key/secret_key') + ); + + // 6) legacy + 부주의하게 sk 만 함께 들어와도 ck 없으면 legacy 경로 유지 + Bootpay.setConfiguration({ + application_id: 'legacy_application_id', + private_key: 'legacy_private_key', + secret_key: 'ignored_without_client_key', + mode: 'production' + }); + Bootpay.$token = undefined; + const beforeFallback = requests.length; + await Bootpay.getAccessToken(); + assert.deepStrictEqual(body(requests[beforeFallback].data), { + application_id: 'legacy_application_id', + private_key: 'legacy_private_key' + }); + assert.strictEqual(header(requests[beforeFallback], 'authorization'), undefined); + + console.log('legacy application_id/private_key and client_key/secret_key auth are compatible'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/pg/lookupBilling.js b/test/pg/lookupBilling.js new file mode 100644 index 0000000..09dbeb7 --- /dev/null +++ b/test/pg/lookupBilling.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.lookupBillingKey(TEST_DATA.billing_key_2); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/lookupSequentialBillingKey.js b/test/pg/lookupSequentialBillingKey.js new file mode 100644 index 0000000..f5b6585 --- /dev/null +++ b/test/pg/lookupSequentialBillingKey.js @@ -0,0 +1,16 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); +// PG API - 우선순위(순차) 결제 빌링키 조회 +// GET /v2/subscribe/sequential_billing_key/{billing_key}?widget_key={widget_key}&user_id={user_id} + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.lookupSequentialBillingKey(TEST_DATA.widget_key, TEST_DATA.billing_key_2, TEST_DATA.user_id); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/lookupSequentialBillingKeyRequest.js b/test/pg/lookupSequentialBillingKeyRequest.js new file mode 100644 index 0000000..4e02727 --- /dev/null +++ b/test/pg/lookupSequentialBillingKeyRequest.js @@ -0,0 +1,34 @@ +const assert = require('assert'); +const { Bootpay } = require('../../dist/bootpay.js'); + +// PG API - 우선순위(순차) 결제 빌링키 조회 URL 규약 테스트 (네트워크 호출 없음) +// GET subscribe/sequential_billing_key/{billing_key}?widget_key={widget_key}&user_id={user_id} + +(async () => { + Bootpay.setConfiguration({ client_key: 'ck', secret_key: 'sk', mode: 'development' }); + + const requests = []; + Bootpay.$http.defaults.adapter = async (config) => { + requests.push(config); + return { + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config, + request: {} + }; + }; + + await Bootpay.lookupSequentialBillingKey('widget_key_1', 'billing_key_1', 'user_id_1'); + assert.strictEqual(requests[0].method.toLowerCase(), 'get'); + assert.strictEqual( + requests[0].url, + 'https://dev-api.bootpay.co.kr/v2/subscribe/sequential_billing_key/billing_key_1?widget_key=widget_key_1&user_id=user_id_1' + ); + + console.log('pg lookupSequentialBillingKey: billing_key in path, widget_key/user_id in query'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/pg/lookupSubscribeBilling.js b/test/pg/lookupSubscribeBilling.js new file mode 100644 index 0000000..ba2996d --- /dev/null +++ b/test/pg/lookupSubscribeBilling.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.lookupSubscribeBillingKey(TEST_DATA.receipt_id_billing); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/publishAutomaticTransferBillingKey.js b/test/pg/publishAutomaticTransferBillingKey.js new file mode 100644 index 0000000..16fef23 --- /dev/null +++ b/test/pg/publishAutomaticTransferBillingKey.js @@ -0,0 +1,72 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.publishAutomaticTransferBillingKey(TEST_DATA.receipt_id_transfer) + console.log(response) + } catch (e) { + console.log(e) + } +})() + +/* +{ + receipt_id: '6655069ca691573f1bb9c28a', + subscription_id: '1716848284697', + gateway_url: 'https://gw.bootpay.co.kr', + metadata: {}, + pg: '나이스페이먼츠', + method: '계좌자동이체', + method_symbol: 'automatic_transfer_rest', + method_origin: '계좌자동이체', + method_origin_symbol: 'automatic_transfer_rest', + published_at: '2024-05-28T07:35:43+09:00', + requested_at: '2024-05-28T07:18:04+09:00', + status_locale: '빌링키발급완료', + status: 11, + receipt_data: { + receipt_id: '66550abf3324a61b141f9205', + order_id: '1716848284697', + price: 1000, + tax_free: 0, + cancelled_price: 0, + cancelled_tax_free: 0, + order_name: '테스트결제', + company_name: '윤태섭', + gateway_url: 'https://gw.bootpay.co.kr', + metadata: {}, + sandbox: true, + pg: '나이스페이먼츠', + method: '계좌이체', + method_symbol: 'bank', + method_origin: '계좌자동이체', + method_origin_symbol: 'automatic_transfer_rest', + purchased_at: '2024-05-28T07:35:43+09:00', + requested_at: '2024-05-28T07:18:04+09:00', + status_locale: '결제완료', + currency: 'KRW', + receipt_url: 'https://door.bootpay.co.kr/receipt/WjdGUkxNWE5KTngyWlJVdFFodzM1VEdiUGtsNzBzUlJWalU9LS02dDFRVjRl%0ARFdFS0I2T1cwLS1TTjNsUHFMenlTcTFwOUVCdVZGcTBnPT0%3D%0A', + status: 1, + bank_data: { + tid: '6073543098', + bank_code: '004', + bank_name: '국민', + bank_account: '0000000000000000', + bank_username: '윤태*' + } + }, + billing_key: '66550abf3324a61b141f9206', + billing_data: { + bank_name: '국민', + bank_code: '004', + bank_account: '0000000000000000', + username: '윤태*' + }, + billing_expire_at: '2099-12-31T23:59:59+09:00' +} + + */ diff --git a/test/pg/receiptPayment.js b/test/pg/receiptPayment.js new file mode 100644 index 0000000..1ce9d19 --- /dev/null +++ b/test/pg/receiptPayment.js @@ -0,0 +1,14 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.receiptPayment(TEST_DATA.receipt_id); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/requestCashReceipt.js b/test/pg/requestCashReceipt.js new file mode 100644 index 0000000..f51573d --- /dev/null +++ b/test/pg/requestCashReceipt.js @@ -0,0 +1,33 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.requestCashReceipt({ + pg: '나이스페이', + price: 1000, + tax_free: 0, + order_name: '테스트', + cash_receipt_type: '소득공제', + user: { + username: '부트페이', + phone: '01000000000', + email: 'bootpay@bootpay.co.kr' + }, + identity_no: '0100000000', + order_id: (new Date()).getTime(), + }); + console.log(response); + if (response.receipt_id !== undefined) { + const cancel = await Bootpay.cancelCashReceipt({ + receipt_id: response.receipt_id, + }); + console.log(cancel); + } + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/requestSubscribeAutomaticTransferBillingKey.js b/test/pg/requestSubscribeAutomaticTransferBillingKey.js new file mode 100644 index 0000000..ce24aef --- /dev/null +++ b/test/pg/requestSubscribeAutomaticTransferBillingKey.js @@ -0,0 +1,55 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribeAutomaticTransferBillingKey({ + pg: '나이스페이', + order_name: '테스트결제', + subscription_id: (new Date()).getTime(), + price: 1000, + username: '홍길동', + bank_name: '국민', + bank_account: '67561234123492472', + identity_no: '901014', + cash_receipt_identity_no: '01012341234', + phone: '01012341234', + user: { + username: '홍길동', + phone: '01012345678' + } + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() + +/* +{ + receipt_id: '6655069ca691573f1bb9c28a', + order_id: '1716848284697', + price: 1000, + tax_free: 0, + cancelled_price: 0, + cancelled_tax_free: 0, + order_name: '테스트결제', + company_name: '윤태섭', + gateway_url: 'https://gw.bootpay.co.kr', + metadata: {}, + sandbox: true, + pg: '나이스페이먼츠', + method: '계좌자동이체', + method_symbol: 'automatic_transfer_rest', + method_origin: '계좌자동이체', + method_origin_symbol: 'automatic_transfer_rest', + requested_at: '2024-05-28T07:18:04+09:00', + status_locale: '자동결제빌링키발급이전', + currency: 'KRW', + status: 41 +} + + */ \ No newline at end of file diff --git a/test/pg/requestUserToken.js b/test/pg/requestUserToken.js new file mode 100644 index 0000000..4d622e2 --- /dev/null +++ b/test/pg/requestUserToken.js @@ -0,0 +1,16 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.requestUserToken({ + user_id: TEST_DATA.user_id + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/request_payment.js b/test/pg/request_payment.js new file mode 100644 index 0000000..34db78a --- /dev/null +++ b/test/pg/request_payment.js @@ -0,0 +1,28 @@ +// @deprecated +// (async () => { +// const Bootpay = require('../../dist/bootpay').Bootpay +// Bootpay.setConfig( +// '5b8f6a4d396fa665fdc2b5ea', +// 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' +// ) +// const token = await Bootpay.getAccessToken() +// if (token.status === 200) { +// let result +// try { +// result = await Bootpay.requestPayment({ +// pg: 'kcp', +// method: 'card', +// orderId: (new Date).getTime(), +// price: 1000, +// itemName: '테스트 부트페이 상품', +// returnUrl: 'https://dev-api.bootpay.co.kr/callback', +// extra: { +// expire: 30 +// } +// }) +// } catch (e) { +// return console.log(e) +// } +// console.log(result) +// } +// })() \ No newline at end of file diff --git a/test/pg/shippingStart.js b/test/pg/shippingStart.js new file mode 100644 index 0000000..243d1e1 --- /dev/null +++ b/test/pg/shippingStart.js @@ -0,0 +1,24 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.shippingStart({ + receipt_id: TEST_DATA.receipt_id_escrow, + tracking_number: '123456', + delivery_corp: 'CJ대한통운', + user: { + username: '홍길동', + phone: '01000000000', + address: '서울특별시 구로구 디지털로 26길 61', + zipcode: '08882' + } + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/subscribeCardPayment.js b/test/pg/subscribeCardPayment.js new file mode 100644 index 0000000..012e235 --- /dev/null +++ b/test/pg/subscribeCardPayment.js @@ -0,0 +1,19 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.requestSubscribeCardPayment({ + billing_key: TEST_DATA.billing_key, + order_name: '테스트결제', + price: 1000, + order_id: Date.now().toString() + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/pg/subscribePayment.js b/test/pg/subscribePayment.js new file mode 100644 index 0000000..d78e99a --- /dev/null +++ b/test/pg/subscribePayment.js @@ -0,0 +1,20 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig('production')) + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken() + const response = await Bootpay.requestSubscribePayment({ + billing_key: TEST_DATA.billing_key, + order_name: '테스트 결제', + order_id: (new Date()).getTime(), + price: 100, + tax_free: 0 + }) + console.log(response) + } catch (e) { + console.log(e) + } +})() \ No newline at end of file diff --git a/test/pg/subscribePaymentReserve.js b/test/pg/subscribePaymentReserve.js new file mode 100644 index 0000000..6f18bbe --- /dev/null +++ b/test/pg/subscribePaymentReserve.js @@ -0,0 +1,20 @@ +const { Bootpay } = require('../../dist/bootpay.js'); +const { getActivePgConfig, TEST_DATA } = require('../config.js'); + +(async () => { + Bootpay.setConfiguration(getActivePgConfig()); + try { + // legacy 모드에서만 실제 토큰 발급. ck/sk 모드에서는 no-op. + await Bootpay.getAccessToken(); + const response = await Bootpay.subscribePaymentReserve({ + billing_key: TEST_DATA.billing_key, + order_name: '테스트결제', + price: 1000, + order_id: Date.now().toString(), + reserve_execute_at: new Date(Date.now() + 60000).toISOString() + }); + console.log(response); + } catch (e) { + console.log(e); + } +})(); diff --git a/test/remote_form.js b/test/remote_form.js deleted file mode 100644 index 937a4dd..0000000 --- a/test/remote_form.js +++ /dev/null @@ -1,34 +0,0 @@ -var BootpayRest = require('../lib/bootpay'); - -BootpayRest.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - -BootpayRest.remoteForm( - { - pg: 'danal', - fm: ['card', 'phone'], - n: '테스트 결제', //상품명 - o_key: 'unique_value_1234', //가맹점의 상품 고유 키 - is_r_n: false, //구매자가 상품명 입력 허용할지 말지 - is_r_p: false, //구매자가 가격 입력 허용할지 말지 - is_addr: false, //주소창 추가 할지 말지 - is_da: false, //배송비 추가 할지 말지 - is_memo: false, //구매자로부터 메모를 받을 지 - tfp: 0, //비과세 금액 - ip: 10000, //아이템 판매금액 - dp: 0, //디스플레이용 가격, 할인전 가격을 의미함, 쿠폰이나 프로모션에 의한 가격 디스카운트 개념 필요 - 페이코 때문에 생긴 개념 - dap: 0, //기본배송비 - dap_jj: 0, //제주 배송비 - dap_njj: 0 //제주 외 지역 도서산간 추가비용 - }, - { -// # st: 1, #1: sms, 2:lms, 3:mms, 4:알림톡, 5:친구톡 -// # rps: ['010-1234-5678', '010-1111-2222'], # 받는 사람 전화번호 -// # sp: '010-1234-1111', # 보내는 사람 전화번호 -// # msg: '테스트 문자입니다' - } -).then(function (data) { - console.log(data); -}); - diff --git a/test/request_payment.js b/test/request_payment.js deleted file mode 100644 index 94e01da..0000000 --- a/test/request_payment.js +++ /dev/null @@ -1,27 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - const token = await Bootpay.getAccessToken() - if (token.status === 200) { - let result - try { - result = await Bootpay.requestPayment({ - pg: 'kcp', - method: 'card', - orderId: (new Date).getTime(), - price: 1000, - itemName: '테스트 부트페이 상품', - returnUrl: 'https://dev-api.bootpay.co.kr/callback', - extra: { - expire: 30 - } - }) - } catch (e) { - return console.log(e) - } - console.log(result) - } -})() \ No newline at end of file diff --git a/test/request_subscribe_rest.js b/test/request_subscribe_rest.js deleted file mode 100644 index 4f5b744..0000000 --- a/test/request_subscribe_rest.js +++ /dev/null @@ -1,29 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestSubscribeBillingKey({ - orderId: (new Date()).getTime(), - pg: 'nicepay', - itemName: '정기결제 30일권', - cardNo: '[ 카드 번호 ]', - cardPw: '[ 카드 비밀번호 앞 2자리 ]', - expireYear: '[ 카드 만료 연도 ]', - expireMonth: '[ 카드 만료 월 ]', - identifyNumber: '[ 카드 소유주 생년월일 혹은 법인 번호 ]', - extra: { - subscribeTestPayment: 1 // 100원 결제 후 결제가 되면 billing key를 발행, 결제가 실패하면 에러 - } - }) - } catch (e) { - return console.log(e) - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/subscribe_billing.js b/test/subscribe_billing.js deleted file mode 100644 index 8d961d8..0000000 --- a/test/subscribe_billing.js +++ /dev/null @@ -1,24 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.requestSubscribeBillingPayment({ - billingKey: '5f97b8a40f606f03e8ab32a0', - itemName: '테스트', - price: 1000, - orderId: (new Date()).getTime(), - feedbackUrl: 'https://dev-api.bootpay.co.kr/callback', - feedbackContentType: 'json' - }) - } catch (e) { - return console.log(e) - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/subscribe_billing_reserve.js b/test/subscribe_billing_reserve.js deleted file mode 100644 index d410ce1..0000000 --- a/test/subscribe_billing_reserve.js +++ /dev/null @@ -1,30 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.reserveSubscribeBilling({ - billingKey: '5f97b8a40f606f03e8ab32a0', - itemName: '테스트', - price: 1000, - orderId: (new Date()).getTime(), - userInfo: { - username: '테스트', - phone: '01000000000' - }, - feedbackUrl: 'https://dev-api.bootpay.co.kr/callback', - feedbackContentType: 'json', - schedulerType: 'oneshot', - executeAt: ((new Date()).getTime() / 1000) + 5 - }) - } catch (e) { - return console.log(e) - } - console.log(response) - } -})() \ No newline at end of file diff --git a/test/subscribe_billing_reserve_cancel.js b/test/subscribe_billing_reserve_cancel.js deleted file mode 100644 index 177a3dc..0000000 --- a/test/subscribe_billing_reserve_cancel.js +++ /dev/null @@ -1,34 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - let token = await Bootpay.getAccessToken() - if (token.status === 200) { - let response - try { - response = await Bootpay.reserveSubscribeBilling({ - billingKey: '5f97b8a40f606f03e8ab32a0', - itemName: '테스트', - price: 1000, - orderId: (new Date()).getTime(), - userInfo: { - username: '테스트', - phone: '01000000000' - }, - feedbackUrl: 'https://dev-api.bootpay.co.kr/callback', - feedbackContentType: 'json', - schedulerType: 'oneshot', - executeAt: ((new Date()).getTime() / 1000) + 60 - }) - if (response.status === 200) { - response = await Bootpay.destroyReserveSubscribeBilling(response.data.reserve_id) - console.log(response) - } - } catch (e) { - return console.log(e) - } - // console.log(response) - } -})() \ No newline at end of file diff --git a/test/test.md b/test/test.md new file mode 100644 index 0000000..755ebee --- /dev/null +++ b/test/test.md @@ -0,0 +1,218 @@ +# NodeJS SDK 테스트 실행 가이드 + +## 환경 설정 + +`test/config.js` 파일에서 환경을 설정합니다: + +```javascript +// 'production' 또는 'development'로 설정 +const CURRENT_ENV = 'production'; +``` + +## 테스트 실행 + +### 빌드 먼저 실행 +```bash +cd /Users/taesupyoon/bootpay/server/sdk/nodejs +npm run build +``` + +### 개별 테스트 실행 (pg/ 폴더) +```bash +# 토큰 발급 +node test/pg/getAccessToken.js + +# 결제 조회 +node test/pg/receiptPayment.js + +# 결제 승인 +node test/pg/confirmPayment.js + +# 결제 취소 +node test/pg/cancelPayment.js + +# 본인인증 조회 +node test/pg/certificate.js + +# 빌링키 조회 (receipt_id) +node test/pg/lookupSubscribeBilling.js + +# 빌링키 조회 (billing_key) +node test/pg/lookupBilling.js + +# 우선순위(순차) 결제 빌링키 조회 (widget_key + billing_key) +node test/pg/lookupSequentialBillingKey.js + +# 우선순위 빌링키 조회 URL 규약 검증 (네트워크 호출 없음, 키 불필요) +node test/pg/lookupSequentialBillingKeyRequest.js + +# 빌링키 삭제 +node test/pg/destroySubscribeBillingKey.js + +# 카드 정기결제 실행 +node test/pg/subscribeCardPayment.js + +# 예약 결제 +node test/pg/subscribePaymentReserve.js + +# 예약 결제 취소 +node test/pg/cancelSubscribeReserve.js + +# 사용자 토큰 발급 +node test/pg/requestUserToken.js + +# 에스크로 배송시작 +node test/pg/shippingStart.js + +# 결제건 현금영수증 발행 +node test/pg/cashReceiptPublishOnReceipt.js +``` + +### Commerce API 테스트 +```bash +# Commerce 테스트 실행 +node test/commerce/[테스트파일].js + +# Authorization 헤더 선택 규칙 검증 (네트워크 호출 없음, 키 불필요) +node test/commerce/authorizationHeader.js + +# 수시결제(온디맨드) charge_key 즉시 결제 / 해지 (supervisor 전용) +node test/commerce/orderSubscriptionCharge.js +node test/commerce/orderSubscriptionChargeRevoke.js + +# 몰 설정 조회 / 수정 (supervisor 전용) +node test/commerce/mallSettingDetail.js +node test/commerce/mallSettingUpdate.js + +# charge_key / 몰 설정 요청 규약 검증 (네트워크 호출 없음, 키 불필요) +node test/commerce/orderSubscriptionChargeRequest.js +node test/commerce/mallSettingRequest.js + +# V1 회원 세션 / 상품 / 가맹점 요청 규약 검증 (네트워크 호출 없음, 키 불필요) +node test/commerce/userMallSessionRequest.js +node test/commerce/productMallRequest.js +node test/commerce/storeRequest.js + +# Commerce 라우트/동사/헤더 규약 일괄 검증 (네트워크 호출 없음, 키 불필요) +node test/commerce/commerceRouteContract.js + +# 외부 uid 중복검사 / 테스트 웹훅 발송 +node test/commerce/userUidExist.js +node test/commerce/webhookSendTest.js + +# 구독 중도인수 / 이전·승계 요청 +node test/commerce/orderSubscriptionPurchase.js +node test/commerce/orderSubscriptionTransfer.js +``` + +### V1 회원 endpoint 주의 + +`user.userLogin / userSession / userLogout / userJoin / userJoinCheck` 는 모두 복수형 `users/...` 경로를 사용한다. +단수형 `user/...` 는 commerce-api v1 에 존재하지 않는 죽은 경로다 — 예전 SDK 가 그리로 보내고 있었다. + +- 로그인은 `POST /v1/users/login` 이다. `POST /v1/users/session` 은 라우트만 있고 `create` 액션이 없으므로 쓰면 안 된다. +- `userJoin` 과 `join`, `userJoinCheck` 와 `checkExist` 는 같은 endpoint 를 부르지만 서버가 파라미터 조합으로 분기하므로 둘 다 유지한다. + +### 라우트 표기 주의 + +- 언더스코어: `order_subscriptions`, `order_subscription_bills` +- 하이픈: `order-subscription-requests`, `user-groups` +- `requests/ing` 계열은 `resume` 만 `PUT` 이고 나머지(`pause`/`purchase`/`termination`/`transfer`)는 `POST` 다. +세션이 필요한 호출에는 로그인시 받은 JWT 를 `Bootpay-User-JWT` 헤더로 전달한다. + +## 테스트 데이터 + +`test/config.js`에서 `TEST_DATA` 객체를 통해 테스트 데이터를 관리합니다: + +```javascript +const TEST_DATA = { + receipt_id: '628b2206d01c7e00209b6087', + receipt_id_confirm: '62876963d01c7e00209b6028', + receipt_id_cash: '62e0f11f1fc192036b1b3c92', + receipt_id_escrow: '628ae7ffd01c7e001e9b6066', + receipt_id_billing: '62c7ccebcf9f6d001b3adcd4', + receipt_id_transfer: '66541bc4ca4517e69343e24c', + billing_key: '628b2644d01c7e00209b6092', + billing_key_2: '66542dfb4d18d5fc7b43e1b6', + reserve_id: '6490149ca575b40024f0b70d', + reserve_id_2: '628b316cd01c7e00219b6081', + user_id: '1234', + certificate_receipt_id: '61b009aaec81b4057e7f6ecd' +}; +``` + +## 폴더 구조 + +``` +test/ +├── config.js # 환경 설정 및 테스트 데이터 +├── test.md # 테스트 가이드 +├── pg/ # PG API 테스트 (config 사용) +│ ├── getAccessToken.js +│ ├── receiptPayment.js +│ ├── confirmPayment.js +│ └── ... +├── commerce/ # Commerce API 테스트 +│ └── ... +└── [기존파일].js # 기존 테스트 파일 (레거시) +``` + +## PG 인증 방식 토글 (BOOTPAY_AUTH_MODE) + +PG 테스트는 기본적으로 신규 `client_key/secret_key` 방식으로 동작한다. 매 실행 시 환경변수로 레거시 `application_id/private_key` 방식으로 전환할 수 있다. + +### 토글 contract + +| `BOOTPAY_AUTH_MODE` | 동작 | +|---|---| +| `new` (기본, 미설정 시 동일) | `client_key` + `secret_key` Basic Auth 로 PG 인스턴스 생성. 토큰 발급 호출 불필요. | +| `legacy` | `application_id` + `private_key` 로 PG 인스턴스 생성. 토큰 발급 호출 후 `Bearer` 헤더 사용. | + +키 값은 모두 `.env` (또는 환경변수) 로 주입한다 — `.env.example` 참고. 토글만 바꾸고 키는 그대로 둬도 된다. + +### 사용법 + +```bash +# (1) 기본 — env var 생략 (= new) +node test/pg/receiptPayment.js + +# (2) 한 번만 legacy 로 전환 +BOOTPAY_AUTH_MODE=legacy node test/pg/receiptPayment.js + +# (3) 셸 세션 동안 legacy 고정 +export BOOTPAY_AUTH_MODE=legacy +node test/pg/receiptPayment.js +node test/pg/cancelPayment.js +unset BOOTPAY_AUTH_MODE # 끝나면 해제 + +# (4) 영구 전환 — .env 의 BOOTPAY_AUTH_MODE 값을 legacy 로 바꾸면 셸 export 없이도 동작 +``` + +### 진입 헬퍼 — 어디서 토글이 흡수되는가 + +`test/config.js` 의 `getActivePgConfig()` 가 `BOOTPAY_AUTH_MODE` 값에 따라 `Bootpay.setConfiguration(...)` 인자 dict 를 반환한다. PG 테스트 파일은 모두 한 줄로 두 모드를 모두 지원한다: + +```js +const { getActivePgConfig } = require('../config.js'); +Bootpay.setConfiguration(getActivePgConfig()); +``` + +두 모드 모두 `Bootpay.getAccessToken()` 호출은 안전하다 (ck/sk 모드에서는 SDK 내부에서 no-op). + +### 실행 시 인증 모드 표시 + +`getActivePgConfig()` 가 호출될 때마다 stdout 에 한 줄로 어떤 모드가 활성화됐는지 표시된다 — 어떤 키 set 으로 실행됐는지 로그에서 즉시 확인 가능: + +``` +[BOOTPAY_AUTH_MODE=new] PG: client_key/secret_key (Basic Auth) | env=production +[BOOTPAY_AUTH_MODE=legacy] PG: application_id/private_key (Bearer) | env=production +``` + +### 토글의 영향을 받지 않는 파일 + +다음은 한 스크립트 안에서 두 모드를 모두 검증하므로 환경변수에 무관하게 동일한 동작을 한다: + +- `test/pg/getAccessToken.js` +- `test/legacyCompatibility.js` +- `test/commerce/authorizationHeader.js` (Commerce — 실제 통신 없이 mock adapter 로 헤더만 검증) +- `test/commerce/commerceRouteContract.js` (Commerce — 실제 통신 없이 mock adapter 로 라우트/동사/role 검증) diff --git a/test/verify.js b/test/verify.js deleted file mode 100644 index cc7a53c..0000000 --- a/test/verify.js +++ /dev/null @@ -1,17 +0,0 @@ -(async () => { - const Bootpay = require('../dist/bootpay').Bootpay - Bootpay.setConfig( - '5b8f6a4d396fa665fdc2b5ea', - 'rm6EYECr6aroQVG2ntW0A6LpWnkTgP4uQ3H18sDDUYw=' - ) - const token = await Bootpay.getAccessToken() - if (token.status === 200) { - let result - try { - result = await Bootpay.verify('5f0d42a7d111902931bea5ff') - } catch (e) { - return console.log(e) - } - console.log(result) - } -})() \ No newline at end of file diff --git a/tests_basic_auth_product_info.mjs b/tests_basic_auth_product_info.mjs new file mode 100644 index 0000000..40c624f --- /dev/null +++ b/tests_basic_auth_product_info.mjs @@ -0,0 +1,66 @@ +/** + * Commerce Basic Auth smoke test — fetch 로 /products 한 번 호출하고 응답 확인. + * + * 키는 반드시 .env 또는 환경변수로 주입한다 (.env.example 참고). + * - BOOTPAY_ENV=production|development|stage + * - BOOTPAY_COMMERCE_CLIENT_KEY_{PROD|DEV} + * - BOOTPAY_COMMERCE_SECRET_KEY_{PROD|DEV} + */ +import { Buffer } from 'node:buffer'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadDotEnv() { + for (const file of [resolve(__dirname, '.env'), resolve(__dirname, 'test', '.env')]) { + if (!existsSync(file)) continue; + for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#') || !line.includes('=')) continue; + const idx = line.indexOf('='); + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) process.env[key] = value; + } + } +} + +loadDotEnv(); + +const env = (process.env.BOOTPAY_ENV || 'production').toLowerCase(); +const baseUrlMap = { + production: 'https://api.bootapi.com/v1', + stage: 'https://stage-api.bootapi.com/v1', + development: 'https://dev-api.bootapi.com/v1' +}; +const baseUrl = baseUrlMap[env] || baseUrlMap.production; + +const suffix = env === 'production' ? 'PROD' : 'DEV'; +const clientKey = process.env[`BOOTPAY_COMMERCE_CLIENT_KEY_${suffix}`] || ''; +const secretKey = process.env[`BOOTPAY_COMMERCE_SECRET_KEY_${suffix}`] || ''; + +if (!clientKey || !secretKey) { + console.error(`[smoke] missing BOOTPAY_COMMERCE_CLIENT_KEY_${suffix} / BOOTPAY_COMMERCE_SECRET_KEY_${suffix} — set them in .env (see .env.example)`); + process.exit(2); +} + +const basic = Buffer.from(`${clientKey}:${secretKey}`).toString('base64'); +const res = await fetch(`${baseUrl}/products?page=1&limit=1`, { + headers: { + 'Authorization': `Basic ${basic}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'bootpay_api_version': '5.0.0', + 'bootpay_sdk_version': '5.0.0', + 'bootpay_sdk_type': '300' + } +}); + +const body = await res.text(); +console.log(JSON.stringify({ env, status: res.status, ok: res.ok, preview: body.slice(0, 500) }, null, 2)); +if (!res.ok) process.exit(1); diff --git a/tsconfig.json b/tsconfig.json index bf25cbc..b1bf0a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,26 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "es6" - ], + "target": "es6", "module": "commonjs", "moduleResolution": "node", + "declarationMap": false, + "sourceMap": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "lib": [ + "esnext", + "DOM" + ], + "typeRoots": [ + "./node_modules/@types" + ], + "resolveJsonModule": true, + "esModuleInterop": true, "declaration": true, + "allowJs": true, "outDir": "./dist", - "strict": true, - "baseUrl": "./src", + "baseUrl": "./src/", "paths": { "*": [ "../node_modules/*", @@ -17,6 +28,9 @@ ] } }, + "include": [ + "src/**/*" + ], "exclude": [ "**/*.spec.ts", "node_modules",