diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f78517..b85b54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,68 @@ ### 3.3.0 -PG 와 Commerce 의 코드 스타일 통일 — **기존 표면은 아무것도 바뀌지 않았고 계속 동작합니다.** 신규 표면만 추가됩니다. +PG 와 Commerce 의 코드 스타일 통일 + Commerce 청구서/인증 정합성. **기존 표면은 그대로 동작한다.** + +#### 통일 API (신규 표면) - 공통 타입 추가 (`kr.co.bootpay.common`) - `BootpayMode` — 환경 enum (DEVELOPMENT / TEST / STAGE / PRODUCTION, 기본 PRODUCTION). 기존 `String devMode` 대체. - `BootpayRole` — Commerce `BOOTPAY-ROLE` 헤더 enum (user / manager / partner / vendor / supervisor). - - `BootpayResponse` — PG 와 Commerce 공용 응답 타입. `isSuccess()` / `getData()` / `getErrorCode()` / `getMessage()` / `asMap()`. `getData()` 는 응답 본문만 담고 `http_status` 를 제외한다. + - `BootpayResponse` — PG/Commerce 공용 응답. `isSuccess()` / `getData()` / `getErrorCode()` / `getMessage()` / `asMap()`. `getData()` 는 응답 본문만 담고 `http_status` 를 제외한다. - 생성 방식 통일 — `Bootpay.builder()`, `BootpayCommerce.builder()` - PG 는 client_key/secret_key 와 application_id/private_key 를 같은 빌더로 생성. 키가 짝을 이루지 않으면 `build()` 에서 즉시 `IllegalStateException`. - Commerce 는 `TokenPayload` 래퍼 없이 `clientKey`/`secretKey` 를 직접 지정. - - 환경 문자열 오타로 baseUrl 이 비던 문제 해소 (인식 불가 시 PRODUCTION 으로 fallback). -- PG 모듈 표면 추가 — `bootpay.payment` / `billing` / `auth` / `cash` / `escrow` / `user` / `wallet`. Commerce 와 같은 호출 형태이며, 기존 평면 메서드 31개와 **동일한 HTTP 요청**을 만든다 (테스트로 대조 검증). -- Commerce 진입점 추가 — `BootpayCommerce`. 기존 `BootpayStore` 를 상속하지 않고 위임하므로 기존 타입 계층에 영향이 없다. `unwrap()` 으로 내부 `BootpayStore` 접근 가능. + - 환경 문자열 오타로 Commerce baseUrl 이 null 로 남던 문제 해소. +- PG 모듈 표면 추가 — `bootpay.payment` / `billing` / `auth` / `cash` / `escrow` / `user` / `wallet`. 기존 평면 메서드 31개와 **동일한 HTTP 요청**을 만든다 (테스트로 전수 대조). +- Commerce 진입점 추가 — `BootpayCommerce`. `BootpayStore` 를 상속하지 않고 위임하므로 기존 타입 계층에 영향이 없다. `unwrap()` 으로 내부 인스턴스 접근 가능. - 이름 정리: `userLogin`→`mallLogin`, `userSession`→`mallSession`, `userLogout`→`mallLogout`, `userJoin`→`mallJoin`, `userJoinCheck`→`mallJoinCheck`, `product.products`→`product.mallList`, `product.productDetail`→`product.mallDetail`. - 중복 별칭 정리: `mallSetting.getMallSetting`/`updateMallSetting` 은 `detail`/`update` 하나로 노출. - - `subscriptionSetting` 모듈 노출 — 기존 `BootpayStore` 에는 배선되어 있지 않아 도달할 수 없었다. + - `subscriptionSetting` 모듈 노출 — 기존 `BootpayStore` 에는 배선 누락으로 도달할 수 없었다. - 토큰 발급 이름 통일 — 양쪽 모두 `issueAccessToken()` 이 `BootpayResponse` 를 반환. 기존 `getAccessToken()` 은 그대로 유지. -- 테스트: 신규 표면과 기존 표면이 같은 요청(method / path / query / body / role 헤더)을 만드는지 대조하는 동등성 테스트 추가 (`PgModuleParityTest`, `CommerceModuleParityTest`) 및 기존 표면 회귀 테스트 (`UnifiedSurfaceTest`). 네트워크 불필요. + +#### Commerce 인증 — 이번 릴리스에서는 바꾸지 않는다 + +Commerce 는 계속 client_key/secret_key 의 `Basic` 으로 인증한다. **v3.2.0 과 동일하며 기존 코드의 동작 변화가 없다.** + +작업 도중 기준 SDK(NodeJS · Ruby)의 "토큰이 있으면 Bearer" 규칙으로 전환했다가 되돌렸다. 이유는 다음과 같다. + +- 전환 근거로 삼았던 "Go / Python / PHP / .NET 도 Bearer" 가 사실이 아니었다. 넷 다 **항상 Basic** 이고 토큰 필드를 읽지 않는다. Java 가 맞춰야 할 다수파는 Basic 쪽이었다. +- Java 는 Commerce 토큰의 만료 시각을 알 수단이 없다. 서버가 내려주는 `expired_at` 을 `STokenResponse` 가 파싱하지 않고, `expire_in` 은 PG 형식이라 Commerce 응답에서 항상 0 이다. +- 401 폴백도 자동 재발급도 없어, 토큰 30분 만료 후 복구 수단 없이 인증이 끊긴다. 모든 Commerce 서비스가 토큰을 요구하므로 이 영향은 기존 사용자 전원에게 미쳤을 것이다. + +Bearer 전환은 `expired_at` 파싱 · 만료 기반 자동 재발급 · 401 시 Basic 폴백을 함께 갖춰 **다음 버전에서** 진행한다. + +#### Commerce scope(BOOTPAY-ROLE) 정합성 (동작 변경) + +- supervisor / manager scope 가 필요한 11개 엔드포인트가 `BOOTPAY-ROLE: user` 로 나가고 있었다. 서버(`scope_invalid!`)가 요구하는 scope 와 맞춘다. + - `orderSubscription` — `approve` / `reject` / `terminate` / `supervisorPause` / `supervisorResume` / `supervisorTerminate` → **supervisor** + - `category` — `create` / `update` / `delete` → **supervisor** + - `userGroup` — `userCreate` / `userDelete` → **manager** +- 원인은 `RequestContext` 없이 2-인자 HTTP 헬퍼를 호출한 것이다. `BootpayStoreObject.role` 기본값이 `"user"` 라 컨텍스트를 넘기지 않으면 조용히 user 로 전송된다. `SCategoryService` 에는 `supervisorContext()` 헬퍼를 신설했다. +- 부수 효과로 해당 11개 호출에 `Idempotency-Key` 헤더가 자동 부착된다 (다른 supervisor 메서드·ruby SDK 와 동일 규약). 요청 경로·바디는 변경 없다. +- ⚠️ 그동안 이 API 들은 supervisor 토큰으로도 scope 오류로 거절됐다. 우회하려고 role 을 직접 조작하던 코드가 있다면 제거해도 된다. + +#### Commerce 청구서 + +- 청구서 생성 파라미터 확장 (ruby SDK `request_checkout` parity). `SInvoice` 에 추가 — 기존 필드·시그니처는 그대로다. + - `user` (`SInvoiceUser`) — 구매자 정보. 가입 회원이면 `userId` 만으로 충분하고, 비회원 청구서는 `membershipType = "guest"` 와 이름·연락처를 함께 지정한다. + - `products` (`List`) — 등록된 상품을 참조해 청구한다 (`invoiceItems` 는 이름·금액을 직접 적는 기존 방식으로 그대로 유지). + - `SInvoiceProduct`: `productId` / `productOptionId` / `duration` / `quantity` / `priceAdjustments` + - `SInvoicePriceAdjustment`: `priceAdjustmentId` / `startAt` / `endAt` / `name` / `cycles` + - `SInvoicePriceAdjustmentCycle`: `duration` / `adjustmentType` / `name` / `value` / `minValue` / `maxValue` (`discount_percent` · `discount_price` · `setup_fee` 상수 제공) + - `deliveryPrice`, `useNotification`, `useAutoLogin`, `usageApiUrl`, `sdk` + - `extra` (`SInvoiceExtra`) — `separatelyConfirmed` / `createOrderImmediately` +- `invoice.create` 에 `Idempotency-Key` 헤더 부착 (ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가. role 은 고정하지 않는다 — `setRole("supervisor")` 로 지정해 둔 호출자가 조용히 user 로 강등되지 않도록 인스턴스 role 을 그대로 쓰고, 지정이 없을 때만 `user` 가 기본값이다. + +#### 브랜치 통합 (2-x-development) + +- `main` 을 `2-x-development` 로 통합하고 `2-x-development` 를 기준 브랜치로 삼는다 (nodejs · ruby 와 동일). +- `orderSubscription.supervisorTerminate(orderSubscriptionId, SupervisorTerminateParams)` 추가 — 기존 `terminate(id[, reason])` 는 그대로 두고, 위약금·마지막 청구 환불액·최종 정산액·서비스 종료일·해지 기준일까지 지정할 수 있다. +- `OrderSubscriptionRequestUpdateParams` 에 정산 필드 추가 (`price` / `taxFreePrice` / `terminationFee` / `lastBillRefundPrice` / `finalFee` / `serviceEndAt`) 및 `APPROVAL_APPROVE` / `APPROVAL_REJECT` 상수. 서비스가 body 에 실어 전송하도록 배선. +- `SUserJoinService` 에 중복확인 key 상수 추가 (`EMAIL_EXIST` / `ID_EXIST` / `PHONE_EXIST` / `UID_EXIST` / `GROUP_BUSINESS_NUMBER_EXIST`). + +#### 테스트 + +- 신규 표면과 기존 표면이 같은 요청(method / path / query / body / role 헤더)을 만드는지 대조하는 동등성 테스트 (`PgModuleParityTest`, `CommerceModuleParityTest`), 기존 표면 회귀 테스트 (`UnifiedSurfaceTest`), 인증 규칙 테스트 (`BootpayStoreObjectAuthTest`). 전부 네트워크 불필요. ### 3.2.0 diff --git a/app/src/main/java/com/example/bootpay/BootpayExample.java b/app/src/main/java/com/example/bootpay/BootpayExample.java index 2ed4790..ac1d3af 100644 --- a/app/src/main/java/com/example/bootpay/BootpayExample.java +++ b/app/src/main/java/com/example/bootpay/BootpayExample.java @@ -27,6 +27,7 @@ public static void main(String[] args) { // reserveSubscribe(); // reserveCancelSubscribe(); // destroyBillingKey(); +// lookupSequentialBillingKey(); // getUserToken(); // confirm(); // certificate(); @@ -290,6 +291,22 @@ public static void lookupBillingKeyByKey() { } } + public static void lookupSequentialBillingKey() { + String widgetKey = "66542dfb4d18d5fc7b43e1b7"; + String billingKey = "66542dfb4d18d5fc7b43e1b6"; + String userId = Config.TestData.USER_ID; + try { + HashMap res = bootpay.lookupSequentialBillingKey(widgetKey, billingKey, userId); + if(res.get("error_code") == null) { //success + System.out.println("lookupSequentialBillingKey success: " + res); + } else { + System.out.println("lookupSequentialBillingKey false: " + res); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + public static void destroyBillingKey() { try { HashMap res = bootpay.destroyBillingKey(Config.TestData.BILLING_KEY); diff --git a/app/src/main/java/com/example/bootpay/store/Invoice.java b/app/src/main/java/com/example/bootpay/store/Invoice.java index 5efd295..f18175c 100644 --- a/app/src/main/java/com/example/bootpay/store/Invoice.java +++ b/app/src/main/java/com/example/bootpay/store/Invoice.java @@ -7,6 +7,11 @@ import kr.co.bootpay.store.model.request.TokenPayload; import kr.co.bootpay.store.model.request.ListParams; import kr.co.bootpay.store.model.pojo.SInvoice; +import kr.co.bootpay.store.model.pojo.SInvoiceExtra; +import kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustment; +import kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustmentCycle; +import kr.co.bootpay.store.model.pojo.SInvoiceProduct; +import kr.co.bootpay.store.model.pojo.SInvoiceUser; import java.util.List; @@ -76,6 +81,136 @@ public static void create() { } } + // 상품·구매자를 지정해 청구서를 만든다 (ruby SDK request_checkout 과 같은 파라미터) + public static void createWithProducts() { + try { + SInvoice invoice = new SInvoice(); + invoice.name = "테스트 청구서"; + invoice.memo = "테스트 청구서 상세 메모"; + invoice.price = 1000.0; + invoice.redirectUrl = "https://example.com"; + invoice.requestId = "test1"; + invoice.useAutoLogin = true; // 청구서 링크에서 자동 로그인 + invoice.useNotification = true; // 생성과 동시에 안내 발송 + + // 구매자 — 이미 가입된 회원이면 userId 만으로 충분하다 + SInvoiceUser user = new SInvoiceUser(); + user.membershipType = SInvoiceUser.MEMBERSHIP_TYPE_GUEST; + user.userId = "test123"; + user.name = "부트페이"; + user.phone = "01095735114"; + invoice.user = user; + + // 등록된 상품을 참조해 청구한다 + SInvoiceProduct product = new SInvoiceProduct(); + product.productId = "66fa14954eac568eab4fc2d0"; + product.productOptionId = "68ede8c675febc5627363fb2"; + product.duration = 24; + product.quantity = 1; + invoice.products = List.of(product); + + BootpayStoreResponse res = bootpayStore.invoice.create(invoice); + if(res.isSuccess()) { + System.out.println("invoice createWithProducts success: " + res.getData()); + } else { + System.out.println("invoice createWithProducts false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 구독 상품에 프로모션(가격 조정)을 붙여 청구서를 만든다 + public static void createSubscriptionWithPromotion() { + try { + SInvoice invoice = new SInvoice(); + invoice.name = "과금 청구서"; + invoice.price = 1000.0; + invoice.useAutoLogin = true; + + SInvoiceUser user = new SInvoiceUser(); + user.userId = "gosomi85"; + invoice.user = user; + + // 첫달 20% 할인 (최소 100 ~ 최대 500) + SInvoicePriceAdjustmentCycle firstMonth = new SInvoicePriceAdjustmentCycle(); + firstMonth.duration = 1; + firstMonth.adjustmentType = SInvoicePriceAdjustmentCycle.ADJUSTMENT_TYPE_DISCOUNT_PERCENT; + firstMonth.name = "첫달 할인"; + firstMonth.value = 20.0; + firstMonth.minValue = 100.0; + firstMonth.maxValue = 500.0; + + // 도입비 500원 + SInvoicePriceAdjustmentCycle setupFee = new SInvoicePriceAdjustmentCycle(); + setupFee.duration = 1; + setupFee.adjustmentType = SInvoicePriceAdjustmentCycle.ADJUSTMENT_TYPE_SETUP_FEE; + setupFee.name = "도입비"; + setupFee.value = 500.0; + + SInvoicePriceAdjustment adjustment = new SInvoicePriceAdjustment(); + adjustment.priceAdjustmentId = "test1"; + adjustment.name = "첫 구매 할인 프로모션"; + adjustment.startAt = "2025-09-20 00:00:00"; + adjustment.endAt = "2025-12-30 23:59:59"; + adjustment.cycles = List.of(firstMonth, setupFee); + + SInvoiceProduct product = new SInvoiceProduct(); + product.productId = "66fa14954eac568eab4fc2d0"; + product.productOptionId = "68ede8c675febc5627363fb2"; + product.duration = 24; + product.quantity = 1; + product.priceAdjustments = List.of(adjustment); + invoice.products = List.of(product); + + SInvoiceExtra extra = new SInvoiceExtra(); + extra.separatelyConfirmed = false; + extra.createOrderImmediately = true; + invoice.extra = extra; + + BootpayStoreResponse res = bootpayStore.invoice.create(invoice); + if(res.isSuccess()) { + System.out.println("invoice createSubscriptionWithPromotion success: " + res.getData()); + } else { + System.out.println("invoice createSubscriptionWithPromotion false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 사용량 기반 과금 청구서 — usageApiUrl 로 사용량을 조회한다 + public static void createUsageBased() { + try { + SInvoice invoice = new SInvoice(); + invoice.name = "과금 청구서"; + invoice.memo = "과금 청구서입니다"; + invoice.price = 1000.0; + invoice.redirectUrl = "https://example.com"; + invoice.usageApiUrl = "https://dev-api.bootapi.com/v1/billing/usage"; + invoice.useAutoLogin = true; + invoice.requestId = "test1"; + + SInvoiceUser user = new SInvoiceUser(); + user.userId = "gosomi85"; + invoice.user = user; + + SInvoiceProduct product = new SInvoiceProduct(); + product.productId = "68dcee4c5614185fea14a0b7"; + product.quantity = 1; + invoice.products = List.of(product); + + BootpayStoreResponse res = bootpayStore.invoice.create(invoice); + if(res.isSuccess()) { + System.out.println("invoice createUsageBased success: " + res.getData()); + } else { + System.out.println("invoice createUsageBased false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + public static void _notify() { try { List sendTypes = List.of( diff --git a/app/src/main/java/com/example/bootpay/store/MallSetting.java b/app/src/main/java/com/example/bootpay/store/MallSetting.java new file mode 100644 index 0000000..1add928 --- /dev/null +++ b/app/src/main/java/com/example/bootpay/store/MallSetting.java @@ -0,0 +1,70 @@ +package com.example.bootpay.store; + +import kr.co.bootpay.store.BootpayStore; +import kr.co.bootpay.store.model.request.TokenPayload; +import kr.co.bootpay.store.model.pojo.SMallSetting; +import kr.co.bootpay.store.model.response.BootpayStoreResponse; + + +public class MallSetting { + + static BootpayStore bootpayStore; + public static void main(String[] args) { + try { + TokenPayload tokenPayload = new TokenPayload("hxS-Up--5RvT6oU6QJE0JA", "r5zxvDcQJiAP2PBQ0aJjSHQtblNmYFt6uFoEMhti_mg="); + bootpayStore = new BootpayStore(tokenPayload, "DEVELOPMENT"); + getToken(); + get(); +// update(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static void getToken() { + try { + BootpayStoreResponse res = bootpayStore.getAccessToken(); + if(res.isSuccess()) { + System.out.println("goGetToken success: " + res.getData()); + } else { + System.out.println("goGetToken false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 몰 설정 조회 (supervisor scope 전용) + public static void get() { + try { + BootpayStoreResponse res = bootpayStore.asSupervisor().mallSetting.detail(); + if(res.isSuccess()) { + System.out.println("mallSetting get success: " + res.getData()); + } else { + System.out.println("mallSetting get false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 몰 설정 수정 (supervisor scope 전용, 설정한 값만 전송된다) + public static void update() { + try { + SMallSetting params = new SMallSetting(); + params.name = "부트페이 스토어"; + params.sellerName = "부트페이"; + params.bizEmail = "help@bootpay.co.kr"; + params.useCart = true; + + BootpayStoreResponse res = bootpayStore.asSupervisor().mallSetting.update(params); + if(res.isSuccess()) { + System.out.println("mallSetting update success: " + res.getData()); + } else { + System.out.println("mallSetting update false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/java/com/example/bootpay/store/Webhook.java b/app/src/main/java/com/example/bootpay/store/Webhook.java new file mode 100644 index 0000000..4e17c50 --- /dev/null +++ b/app/src/main/java/com/example/bootpay/store/Webhook.java @@ -0,0 +1,64 @@ +package com.example.bootpay.store; + +import kr.co.bootpay.store.BootpayStore; +import kr.co.bootpay.store.model.request.TokenPayload; +import kr.co.bootpay.store.model.response.BootpayStoreResponse; + + +public class Webhook { + + static BootpayStore bootpayStore; + public static void main(String[] args) { + try { + TokenPayload tokenPayload = new TokenPayload("hxS-Up--5RvT6oU6QJE0JA", "r5zxvDcQJiAP2PBQ0aJjSHQtblNmYFt6uFoEMhti_mg="); + bootpayStore = new BootpayStore(tokenPayload, "DEVELOPMENT"); + getToken(); + sendTest(); +// sendTestWithContentType(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static void getToken() { + try { + BootpayStoreResponse res = bootpayStore.getAccessToken(); + if(res.isSuccess()) { + System.out.println("goGetToken success: " + res.getData()); + } else { + System.out.println("goGetToken false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 테스트 웹훅 발송 (서버 기본 content type 사용) + public static void sendTest() { + try { + BootpayStoreResponse res = bootpayStore.webhook.sendTest(); + if(res.isSuccess()) { + System.out.println("webhook sendTest success: " + res.getData()); + } else { + System.out.println("webhook sendTest false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 테스트 웹훅 발송 (수신 content type 지정) + public static void sendTestWithContentType() { + try { + // 1: application/json, 2: application/x-www-form-urlencoded + BootpayStoreResponse res = bootpayStore.webhook.sendTest(1); + if(res.isSuccess()) { + System.out.println("webhook sendTest success: " + res.getData()); + } else { + System.out.println("webhook sendTest false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/java/com/example/bootpay/store/order_subscription/request/Request.java b/app/src/main/java/com/example/bootpay/store/order_subscription/request/Request.java new file mode 100644 index 0000000..fc52c69 --- /dev/null +++ b/app/src/main/java/com/example/bootpay/store/order_subscription/request/Request.java @@ -0,0 +1,120 @@ +package com.example.bootpay.store.order_subscription.request; + +import com.example.bootpay.Config; + +import kr.co.bootpay.store.BootpayStore; +import kr.co.bootpay.store.model.request.TokenPayload; +import kr.co.bootpay.store.model.request.orderSubscriptionRequest.OrderSubscriptionRequestListParams; +import kr.co.bootpay.store.model.request.orderSubscriptionRequest.OrderSubscriptionRequestUpdateParams; +import kr.co.bootpay.store.model.response.BootpayStoreResponse; + +/** + * 구독 변경요청(order-subscription-requests) 예제. + */ +public class Request { + + static BootpayStore bootpayStore; + + public static void main(String[] args) { + try { + TokenPayload tokenPayload = new TokenPayload(Config.Commerce.getClientKey(), Config.Commerce.getSecretKey()); + bootpayStore = new BootpayStore(tokenPayload, Config.CURRENT_ENV); + getToken(); + list(); +// detail(); +// approve(); +// reject(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static void getToken() { + try { + BootpayStoreResponse res = bootpayStore.getAccessToken(); + if(res.isSuccess()) { + System.out.println("goGetToken success: " + res.getData()); + } else { + System.out.println("goGetToken false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 구독 변경요청 목록 조회 + // projectId를 지정하면 supervisor(프로젝트 전체 검색), 없으면 user(본인 요청)로 조회한다 + public static void list() { + try { + OrderSubscriptionRequestListParams params = new OrderSubscriptionRequestListParams(); + params.orderSubscriptionId = "686dc2f2b0eacea5cd974ca2"; + params.page = 1; + params.limit = 20; + + BootpayStoreResponse res = bootpayStore.orderSubscriptionRequest.list(params); + if(res.isSuccess()) { + System.out.println("orderSubscriptionRequest list success: " + res.getData()); + } else { + System.out.println("orderSubscriptionRequest list false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 구독 변경요청 상세 조회 + public static void detail() { + try { + BootpayStoreResponse res = bootpayStore.orderSubscriptionRequest.detail("686dc2f2b0eacea5cd974ca2"); + if(res.isSuccess()) { + System.out.println("orderSubscriptionRequest detail success: " + res.getData()); + } else { + System.out.println("orderSubscriptionRequest detail false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 구독 변경요청 승인 — 승인/반려는 approval 값으로 갈린다 + public static void approve() { + try { + OrderSubscriptionRequestUpdateParams params = new OrderSubscriptionRequestUpdateParams(); + params.orderSubscriptionRequestHistoryId = "686dc2f2b0eacea5cd974ca2"; + params.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_APPROVE; + params.reason = "승인 처리"; + // 정산 항목까지 확정할 때 함께 지정한다 +// params.price = 10000.0; +// params.terminationFee = 0.0; +// params.serviceEndAt = "2026-09-01T00:00:00+09:00"; + + BootpayStoreResponse res = bootpayStore.orderSubscriptionRequest.update(params); + if(res.isSuccess()) { + System.out.println("orderSubscriptionRequest approve success: " + res.getData()); + } else { + System.out.println("orderSubscriptionRequest approve false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + // 구독 변경요청 반려 + public static void reject() { + try { + OrderSubscriptionRequestUpdateParams params = new OrderSubscriptionRequestUpdateParams(); + params.orderSubscriptionRequestHistoryId = "686dc2f2b0eacea5cd974ca2"; + params.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_REJECT; + params.reason = "반려 처리"; + + BootpayStoreResponse res = bootpayStore.orderSubscriptionRequest.update(params); + if(res.isSuccess()) { + System.out.println("orderSubscriptionRequest reject success: " + res.getData()); + } else { + System.out.println("orderSubscriptionRequest reject false: " + res.getData()); + } + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java b/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java index eb2b6a4..e1fd0c3 100644 --- a/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java +++ b/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java @@ -86,6 +86,11 @@ public void setRole(String role) { this.role = role; } + /** + * client_key/secret_key 로 만든 Basic 인증 값을 반환한다. 키가 없으면 빈 문자열이다. + * + * @return "Basic {base64(client_key:secret_key)}", 키가 없으면 "" + */ public String requestAccessToken() { if((tokenPayload.clientKey == null || tokenPayload.clientKey.isEmpty()) && (tokenPayload.secretKey == null || tokenPayload.secretKey.isEmpty())) return ""; String credentials = tokenPayload.clientKey + ":" + tokenPayload.secretKey; @@ -93,6 +98,22 @@ public String requestAccessToken() { return "Basic " + encoded; } + /** + * Commerce 요청에 Authorization 헤더를 부착한다. client_key/secret_key 의 Basic 을 쓴다. + * + *

발급받은 토큰({@code token})은 인증에 쓰지 않는다. Go / Python / PHP / .NET 도 같은 + * 규칙이고, Basic 은 만료가 없어 장기 실행 프로세스에서 재발급이 필요 없다.

+ * + *

토큰 기반 Bearer 인증(기준 SDK 인 NodeJS · Ruby 의 규칙)으로의 전환은 만료 시각 + * 파싱({@code expired_at})·자동 재발급·401 폴백을 함께 갖춘 뒤에 해야 한다. 그 준비 없이 + * 전환하면 30분 만료 후 복구 수단 없이 401 이 나므로 이번 릴리스에서는 도입하지 않는다.

+ * + * @param context 요청별 컨텍스트 (현재 인증에는 사용하지 않는다) + */ + private void applyAuthHeader(HttpRequestBase request, RequestContext context) { + request.setHeader("Authorization", requestAccessToken()); + } + // RequestContext에 지정된 부가 헤더 부착 — Idempotency-Key, Bootpay-User-JWT는 값이 있을 때만 붙는다 private void applyContextHeaders(HttpRequestBase request, RequestContext context) { if (context == null) return; @@ -125,7 +146,7 @@ public HttpGet httpGet(String url, RequestContext context) throws Exception { } get.setHeader("BOOTPAY-ROLE", roleToUse); - get.setHeader("Authorization", requestAccessToken()); + applyAuthHeader(get, context); applyContextHeaders(get, context); get.setURI(uri); @@ -152,7 +173,7 @@ public HttpGet httpGet(String url, List nameValuePairList, Reques } get.setHeader("BOOTPAY-ROLE", roleToUse); - get.setHeader("Authorization", requestAccessToken()); + applyAuthHeader(get, context); applyContextHeaders(get, context); URI uri = new URIBuilder(get.getURI()).addParameters(nameValuePairList).build(); @@ -181,7 +202,7 @@ public HttpPost httpPost(String url, StringEntity entity, RequestContext context } post.setHeader("BOOTPAY-ROLE", roleToUse); - post.setHeader("Authorization", requestAccessToken()); + applyAuthHeader(post, context); applyContextHeaders(post, context); post.setEntity(entity); @@ -209,7 +230,7 @@ public HttpPost httpPost(String url, StringEntity entity, Map he } post.setHeader("BOOTPAY-ROLE", roleToUse); - post.setHeader("Authorization", requestAccessToken()); + applyAuthHeader(post, context); applyContextHeaders(post, context); // 사용자 정의 헤더 추가 @@ -242,7 +263,7 @@ public HttpPost httpPostMultipart(String url, List files, HashMap{@link #terminate(String, String)} 와 같은 엔드포인트지만 위약금·환불액·서비스 종료일 등 + * 정산 항목을 함께 전달할 수 있다.

+ * + * @param orderSubscriptionId 구독 ID 또는 external_uid + * @param params 해지 파라미터 (전부 선택) + * @return BootpayStoreResponse + */ + public BootpayStoreResponse supervisorTerminate(String orderSubscriptionId, SupervisorTerminateParams params) throws Exception { + return SOrderSubscriptionService.supervisorTerminate(bootpay, orderSubscriptionId, params); + } + } diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoice.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoice.java index c4df2f1..4837850 100644 --- a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoice.java +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoice.java @@ -87,5 +87,33 @@ public class SInvoice { public List invoiceItems; public List selectedUsers; + // ======================================== + // 청구서 생성 파라미터 (3.3.0~, ruby SDK request_checkout parity) + // ======================================== + + /** SDK 를 통한 생성인지 여부 */ + public Boolean sdk; + + /** 구매자 정보 — 회원이면 userId 만으로 충분하다 */ + public SInvoiceUser user; + + /** 청구할 상품 목록 — 등록된 상품을 참조한다 (invoiceItems 는 이름·금액 직접 기입 방식) */ + public List products; + + /** 배송비 */ + public Double deliveryPrice; + + /** 생성과 동시에 구매자에게 안내를 발송할지 여부 */ + public Boolean useNotification; + + /** 청구서 링크 진입 시 자동 로그인 처리 여부 */ + public Boolean useAutoLogin; + + /** 사용량 기반 과금 시 사용량을 조회할 API 주소 */ + public String usageApiUrl; + + /** 부가 옵션 (결제·승인 분리, 주문 즉시 생성 등) */ + public SInvoiceExtra extra; + } diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceExtra.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceExtra.java new file mode 100644 index 0000000..57336aa --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceExtra.java @@ -0,0 +1,14 @@ +package kr.co.bootpay.store.model.pojo; + +/** + * 청구서 생성 부가 옵션 ({@code extra}). + * + * @since 3.3.0 + */ +public class SInvoiceExtra { + + /** 결제와 승인을 분리할지 여부 */ + public Boolean separatelyConfirmed; + /** 청구서 생성과 동시에 주문을 만들지 여부 */ + public Boolean createOrderImmediately; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustment.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustment.java new file mode 100644 index 0000000..ff0f105 --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustment.java @@ -0,0 +1,24 @@ +package kr.co.bootpay.store.model.pojo; + +import java.util.List; + +/** + * 청구서 상품의 가격 조정 ({@code products[].price_adjustments[]}). + * + *

프로모션 단위로 묶이며, 실제 할인/부과 규칙은 {@link #cycles} 에 주기별로 담는다.

+ * + * @since 3.3.0 + */ +public class SInvoicePriceAdjustment { + + /** 가맹점이 관리하는 조정 식별자 */ + public String priceAdjustmentId; + /** 조정 적용 시작 일시 */ + public String startAt; + /** 조정 적용 종료 일시 */ + public String endAt; + /** 프로모션 명칭 */ + public String name; + /** 주기별 조정 규칙 */ + public List cycles; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustmentCycle.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustmentCycle.java new file mode 100644 index 0000000..0f3896c --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustmentCycle.java @@ -0,0 +1,29 @@ +package kr.co.bootpay.store.model.pojo; + +/** + * 청구서 상품의 가격 조정 주기 ({@code products[].price_adjustments[].cycles[]}). + * + *

구독 상품에서 "첫 달 20% 할인, 둘째 달 100원 할인, 도입비 500원" 같은 규칙을 + * 주기 단위로 기술한다.

+ * + * @since 3.3.0 + */ +public class SInvoicePriceAdjustmentCycle { + + public static final String ADJUSTMENT_TYPE_DISCOUNT_PERCENT = "discount_percent"; + public static final String ADJUSTMENT_TYPE_DISCOUNT_PRICE = "discount_price"; + public static final String ADJUSTMENT_TYPE_SETUP_FEE = "setup_fee"; + + /** 이 조정이 적용될 기간 (개월 수) */ + public Integer duration; + /** 조정 유형 — "discount_percent" / "discount_price" / "setup_fee" */ + public String adjustmentType; + /** 조정 명칭 (예: "첫달 할인") */ + public String name; + /** 조정 값 — percent 면 비율, 그 외에는 금액 */ + public Double value; + /** 조정 금액 하한 */ + public Double minValue; + /** 조정 금액 상한 */ + public Double maxValue; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceProduct.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceProduct.java new file mode 100644 index 0000000..aad3093 --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceProduct.java @@ -0,0 +1,26 @@ +package kr.co.bootpay.store.model.pojo; + +import java.util.List; + +/** + * 청구서에 담는 상품 ({@code products[]}). + * + *

{@code invoice_items} 가 이름·금액을 직접 적어 넣는 방식이라면, 이쪽은 이미 등록된 상품을 + * 참조하는 방식이다. 구독 상품이면 {@link #duration} 으로 계약 기간을, + * {@link #priceAdjustments} 로 프로모션을 지정한다.

+ * + * @since 3.3.0 + */ +public class SInvoiceProduct { + + /** 상품 id */ + public String productId; + /** 상품 옵션 id */ + public String productOptionId; + /** 계약 기간 (개월 수) */ + public Integer duration; + /** 수량 */ + public Integer quantity; + /** 가격 조정 (프로모션) */ + public List priceAdjustments; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceUser.java b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceUser.java new file mode 100644 index 0000000..a48cc4c --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceUser.java @@ -0,0 +1,26 @@ +package kr.co.bootpay.store.model.pojo; + +/** + * 청구서 생성 시 지정하는 구매자 정보 ({@code user}). + * + *

이미 가입된 회원이면 {@link #userId} 만으로 충분하고, 비회원 청구서라면 + * {@link #membershipType} 을 {@code "guest"} 로 두고 이름·연락처를 함께 넘긴다.

+ * + * @since 3.3.0 + */ +public class SInvoiceUser { + + public static final String MEMBERSHIP_TYPE_GUEST = "guest"; + public static final String MEMBERSHIP_TYPE_MEMBER = "member"; + + /** 회원 식별자 (user_id, ex_uid, login_id 중 하나) */ + public String userId; + /** 회원 유형 — "guest" 또는 "member" */ + public String membershipType; + /** 구매자 이름 */ + public String name; + /** 구매자 연락처 */ + public String phone; + /** 구매자 이메일 */ + public String email; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/mallSetting/MallSettingUpdateParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/mallSetting/MallSettingUpdateParams.java new file mode 100644 index 0000000..5ec7376 --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/request/mallSetting/MallSettingUpdateParams.java @@ -0,0 +1,222 @@ +package kr.co.bootpay.store.model.request.mallSetting; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +// 몰 설정 수정 요청 파라미터 +// 요청 바디는 flatten 형식이며 값이 설정된(non-null) 필드만 서버로 전송된다 +public class MallSettingUpdateParams { + // Idempotency-Key 헤더로 전송되므로 body에는 포함하지 않는다 + public transient String idempotencyKey; + + public String normalWidgetKey; + public String subscriptionWidgetKey; + + public String sellerName; + public String sellerNameEn; + public String bizEmail; + public String bizTel; + public String bizFax; + public String registrationNo; + public String corpRegNo; + public String mailOrderSalesNumber; + public String ownerName; + public String zip; + @SerializedName("addr_1") + public String addr1; + @SerializedName("addr_2") + public String addr2; + public String privacyName; + public String privacyEmail; + + public String name; + public String description; + public Integer status; + public String invoiceTitle; + + public Boolean useLogo; + public String logo; + public Boolean useFavicon; + public String favicon; + public Boolean useOpenGraph; + public String ogImage; + public Boolean useSignature; + public String signature; + + public Boolean useOperationTime; + public Map customerServiceCenterOperationTime; + + public Integer restStartHour; + public Integer restStartMinute; + public Integer restEndHour; + public Integer restEndMinute; + public List restDay; + + public String hostingService; + public Boolean useNonMemberOrder; + + @SerializedName("use_age_accept_19") + public Boolean useAgeAccept19; + @SerializedName("use_age_accept_14") + public Boolean useAgeAccept14; + public Boolean useAgeAcceptParentName; + public Boolean useAgeAcceptParentBirth; + public Boolean useAgeAcceptParentEmail; + + public Boolean useMembershipCollectPhone; + public Boolean useMembershipCollectTel; + public Boolean useMembershipCollectEmail; + public Boolean useMembershipCollectAddress; + public Boolean useMembershipCollectBank; + public Boolean useMembershipCollectBirth; + public Boolean useMembershipCollectGender; + public Boolean useMembershipCollectInterest; + public Integer membershipCollectInterestNumber; + public Boolean useMembershipCollectCustoms; + public Boolean useMembershipCollectNickname; + public Boolean useMembershipCollectRecommendId; + public Double recommendIdPointTo; + public Double recommendIdPointFrom; + + public Boolean useMembershipCollectBusiness; + public Boolean useMembershipCollectRegister; + public Boolean membershipOnlyBusiness; + + public Boolean useCorporateDepartment; + public Integer subGroupType; + public Boolean useCorporateSignupApproval; + public List corporateEmailDomains; + public Boolean useCorporateAutoApprove; + public Boolean useCorporateInviteOnly; + + public Boolean useMemberInfoPhone; + public Boolean useMemberInfoTel; + public Boolean useMemberInfoEmail; + public Boolean useMemberInfoAddress; + public Boolean useMemberInfoBank; + public Boolean useMemberInfoBirth; + public Boolean useMemberInfoGender; + public Boolean useMemberInfoCustoms; + public Boolean useMemberInfoNickname; + public Boolean useMemberInfoRegister; + + public Boolean ordererCollectPhone; + public Boolean ordererCollectTel; + public Boolean ordererCollectEmail; + + public String orderPrefix; + public Boolean useOrderCancel; + // 서버 필드명이 use_oder_cancel_approval 이므로 그대로 전송한다 + @SerializedName("use_oder_cancel_approval") + public Boolean useOrderCancelApproval; + public List orderCancelReasons; + public Integer orderCancelReasonRequiredType; + public String orderCancelRequestMessage; + public String orderCancelDoneMessage; + + public Boolean useGeneralMembership; + public List generalMembershipDuplication; + public Boolean useCertification; + public Integer certificationType; + public List generalMembershipIdType; + + public Boolean useMembershipDuplicationEmail; + public Boolean useMembershipDuplicationPhone; + + public Boolean useSocialMembership; + public List socialMembershipType; + + public Boolean usePoint; + public Boolean usePointTransaction; + public String pointDisplayName; + public Double pointMinBalance; + public List pointNotCondition; + public Integer pointCondition; + public Boolean usePointMaxRate; + public Double pointMaxRate; + public Boolean usePointMaxAmount; + public Double pointMaxAmount; + public Double pointRate; + public Integer pointCalcType1; + public Integer pointCalcType2; + public Boolean usePointAdvanceDiscount; + public Double pointAdvanceDiscountRate; + public Boolean usePointExpire; + public Integer pointExpireType; + public Integer pointIssueEventType; + public Integer pointIssueDelayDays; + + public Boolean useOpenMarket; + public Boolean useProductApproval; + public Boolean useProductReview; + public Boolean useProductReviewPoint; + public Double productReviewPoint; + public Double productReviewPhotoPoint; + public Boolean useProductReviewAnswer; + public Boolean useProductReviewAutoAnswer; + public Integer productReviewAutoAnswerMinute; + public String productReviewAutoAnswerText; + + public Boolean useProductQna; + public Integer productQnaMemberAuth; + public List useProductQnaAnswerOption; + + public Boolean useNotice; + public Boolean useQna; + public Boolean useFaq; + + public Boolean useChatSupport; + public Integer chatSupportType; + public Map chatSupportKey; + + public Boolean useDormant; + public Integer dormantYear; + public Integer dormantRestore; + + public Boolean useWithdrawal; + public Boolean useWithdrawalGuideMessage; + public Boolean useWithdrawalGuideMessageAfter; + public String withdrawalGuideMessageAfter; + public Boolean useWithdrawalAuto; + public Integer withdrawalAutoYear; + + public Boolean useSubscriptionAggregateTransaction; + public Integer subscriptionMonthDay; + public Integer subscriptionWeekDay; + + public Boolean useLimit; + public Double limitMonthPurchase; + public Double limitWeekPurchase; + public Boolean useLimitPayment; + public Boolean useLimitMessage; + + public String termsOfService; + public String termsOfPrivacyPolicy; + public String termsOfPrivacyCollect; + public String termsOfPrivacyThird; + + public Integer paymentTimeout; + public Integer productSortType; + public Integer mallThemeType; + + public Integer catalogDisplayType; + public String catalogHeadline; + public String catalogBgColor; + public Integer catalogViewTypePc; + public Integer catalogViewTypeMobile; + public Integer catalogProductSortType; + + public Boolean useCart; + public Integer cartStoragePeriod; + public Integer cartMaxLimit; + public Integer cartAddAction; + public Boolean cartDirectPurchase; + public Boolean cartOptionChange; + public Boolean cartDiscountDisplay; + + public Boolean useWishlist; + public Integer wishlistMaxLimit; + public Boolean cartWishlistDisplay; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorTerminateParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorTerminateParams.java new file mode 100644 index 0000000..c9bd36d --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorTerminateParams.java @@ -0,0 +1,22 @@ +package kr.co.bootpay.store.model.request.orderSubscription; + +/** + * 관리자 구독 해지 파라미터 (PUT order_subscriptions/:id/terminate, supervisor 권한 필요). + * + *

{@code terminate(orderSubscriptionId[, reason])} 는 사유만 전달합니다. 위약금·환불액·서비스 종료일 + * 같은 정산 항목까지 지정해야 할 때 이 파라미터를 사용하세요.

+ */ +public class SupervisorTerminateParams { + /** 해지 사유 */ + public String reason; + /** 해지 위약금 */ + public Double terminationFee; + /** 마지막 청구 건 환불 금액 */ + public Double lastBillRefundPrice; + /** 최종 정산 금액 */ + public Double finalFee; + /** 서비스 종료 일시 */ + public String serviceEndAt; + /** 해지 처리 기준일 */ + public String cancelDate; +} diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscriptionRequest/OrderSubscriptionRequestUpdateParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscriptionRequest/OrderSubscriptionRequestUpdateParams.java index 611293b..6cdd2f0 100644 --- a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscriptionRequest/OrderSubscriptionRequestUpdateParams.java +++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscriptionRequest/OrderSubscriptionRequestUpdateParams.java @@ -1,10 +1,34 @@ package kr.co.bootpay.store.model.request.orderSubscriptionRequest; +/** + * 구독 변경요청 승인/반려 파라미터 (PUT order-subscription-requests/:id). + * + *

승인과 반려는 별도 액션이 아니라 {@link #approval} 값으로 갈린다 + * (서버가 {@code params[:action]} 을 Rails 예약어로 사용하기 때문에 키 이름이 approval 이다).

+ */ public class OrderSubscriptionRequestUpdateParams { + + public static final String APPROVAL_APPROVE = "approve"; + public static final String APPROVAL_REJECT = "reject"; + public String orderSubscriptionRequestHistoryId; /** "approve" 또는 "reject" */ public String approval; public String reason; + + /** 승인 시 확정 금액 */ + public Double price; + /** 승인 시 확정 비과세 금액 */ + public Double taxFreePrice; + /** 해지 위약금 */ + public Double terminationFee; + /** 마지막 청구 건 환불 금액 */ + public Double lastBillRefundPrice; + /** 최종 정산 금액 */ + public Double finalFee; + /** 서비스 종료 일시 */ + public String serviceEndAt; + /** 미지정시 자동 생성 (Idempotency-Key 헤더로 전송, body 에는 포함되지 않는다) */ public transient String idempotencyKey; } diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/product/ProductListParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/product/ProductListParams.java index 7189d40..bca79fd 100644 --- a/core/src/main/java/kr/co/bootpay/store/model/request/product/ProductListParams.java +++ b/core/src/main/java/kr/co/bootpay/store/model/request/product/ProductListParams.java @@ -3,6 +3,10 @@ import kr.co.bootpay.store.model.request.ListParams; +// 상품 목록 조회 파라미터 (GET products) +// ⚠️ ListParams의 keyword는 서버(v1/products_controller#index)가 읽지 않는다. +// 컨트롤러는 page/limit/category_id/ex_uid/sort 만 사용하므로 keyword를 보내도 조용히 무시된다. +// 하위호환 때문에 인자는 남겨두되, 검색이 필요하면 서버 지원 추가가 선행되어야 한다. public class ProductListParams extends ListParams { // public Integer corporateType; //1: 개인, 2: 기업 diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/webhook/TestWebhookParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/webhook/TestWebhookParams.java new file mode 100644 index 0000000..acf500c --- /dev/null +++ b/core/src/main/java/kr/co/bootpay/store/model/request/webhook/TestWebhookParams.java @@ -0,0 +1,7 @@ +package kr.co.bootpay.store.model.request.webhook; + +public class TestWebhookParams { + // 웹훅 수신시 사용할 Content-Type (application/json 또는 application/x-www-form-urlencoded) + // null이면 전송하지 않으며 서버 기본값으로 발송된다 + public String headerContentType; +} diff --git a/core/src/main/java/kr/co/bootpay/store/module/InvoiceModule.java b/core/src/main/java/kr/co/bootpay/store/module/InvoiceModule.java index a8e60b3..2b7fa65 100644 --- a/core/src/main/java/kr/co/bootpay/store/module/InvoiceModule.java +++ b/core/src/main/java/kr/co/bootpay/store/module/InvoiceModule.java @@ -55,6 +55,18 @@ public BootpayResponse create(SInvoice invoice) throws Exception { return CommerceResponses.of(delegate.create(invoice)); } + /** + * 청구서 생성. + * + * @param invoice 생성할 청구서 + * @param idempotencyKey 미지정 시 자동 생성 (Idempotency-Key 헤더) + * @return 생성된 청구서 + * @throws Exception 통신 실패 또는 인증 정보 누락 + */ + public BootpayResponse create(SInvoice invoice, String idempotencyKey) throws Exception { + return CommerceResponses.of(delegate.create(invoice, idempotencyKey)); + } + /** * 청구서 발송. * diff --git a/core/src/main/java/kr/co/bootpay/store/module/OrderSubscriptionModule.java b/core/src/main/java/kr/co/bootpay/store/module/OrderSubscriptionModule.java index 706e705..415ecf0 100644 --- a/core/src/main/java/kr/co/bootpay/store/module/OrderSubscriptionModule.java +++ b/core/src/main/java/kr/co/bootpay/store/module/OrderSubscriptionModule.java @@ -9,6 +9,7 @@ import kr.co.bootpay.store.model.request.orderSubscription.SupervisorChargeRevokeParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorPauseParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorResumeParams; +import kr.co.bootpay.store.model.request.orderSubscription.SupervisorTerminateParams; /** * 정기구독 모듈. @@ -174,4 +175,17 @@ public BootpayResponse supervisorCharge(SupervisorChargeParams params) throws Ex public BootpayResponse supervisorChargeRevoke(SupervisorChargeRevokeParams params) throws Exception { return CommerceResponses.of(delegate.supervisorChargeRevoke(params)); } + + /** + * 구독 해지 (supervisor 권한) — 위약금·환불액·서비스 종료일 등 정산 항목 지정. + * + * @param orderSubscriptionId 구독 id + * @param params 해지 파라미터 (전부 선택) + * @return 처리 결과 + * @throws Exception 통신 실패 또는 인증 정보 누락 + */ + public BootpayResponse supervisorTerminate(String orderSubscriptionId, SupervisorTerminateParams params) throws Exception { + return CommerceResponses.of(delegate.supervisorTerminate(orderSubscriptionId, params)); + } + } diff --git a/core/src/main/java/kr/co/bootpay/store/service/categories/SCategoryService.java b/core/src/main/java/kr/co/bootpay/store/service/categories/SCategoryService.java index d26e686..b514992 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/categories/SCategoryService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/categories/SCategoryService.java @@ -4,6 +4,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import kr.co.bootpay.store.BootpayStoreObject; +import kr.co.bootpay.store.context.RequestContext; import kr.co.bootpay.store.model.request.category.CategoryCreateParams; import kr.co.bootpay.store.model.request.category.CategoryUpdateParams; import kr.co.bootpay.store.model.response.BootpayStoreResponse; @@ -56,7 +57,8 @@ static public BootpayStoreResponse create(BootpayStoreObject bootpay, CategoryCr .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create(); - HttpPost post = bootpay.httpPost("categories", new StringEntity(gson.toJson(params), "UTF-8")); + HttpPost post = bootpay.httpPost("categories", new StringEntity(gson.toJson(params), "UTF-8"), + supervisorContext()); HttpResponse response = client.execute(post); return bootpay.responseToJsonObject(response); } @@ -86,7 +88,8 @@ static public BootpayStoreResponse update(BootpayStoreObject bootpay, CategoryUp body.filterColor = params.filterColor; body.filterSize = params.filterSize; - HttpPut put = bootpay.httpPut("categories/" + params.categoryId, new StringEntity(gson.toJson(body), "UTF-8")); + HttpPut put = bootpay.httpPut("categories/" + params.categoryId, new StringEntity(gson.toJson(body), "UTF-8"), + supervisorContext()); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); } @@ -100,8 +103,18 @@ static public BootpayStoreResponse delete(BootpayStoreObject bootpay, String cat } HttpClient client = HttpClientBuilder.create().build(); - HttpDelete delete = bootpay.httpDelete("categories/" + categoryId); + HttpDelete delete = bootpay.httpDelete("categories/" + categoryId, supervisorContext()); HttpResponse response = client.execute(delete); return bootpay.responseToJsonObject(response); } + + /** + * 카테고리 쓰기(등록/수정/삭제) 요청 컨텍스트 — 서버가 supervisor scope 를 요구한다. + */ + private static RequestContext supervisorContext() { + return RequestContext.builder() + .role("supervisor") + .idempotencyKey(RequestContext.idempotencyKeyOrGenerate(null)) + .build(); + } } diff --git a/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java b/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java index f827fa5..7100b15 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java @@ -28,6 +28,17 @@ public class SInvoiceService { static public BootpayStoreResponse create(BootpayStoreObject bootpay, SInvoice invoice) throws Exception { + return create(bootpay, invoice, null); + } + + /** + * 청구서 생성 (POST invoices). + * + * @param invoice 청구서 정보. name 과 price 외에 user / products / deliveryPrice / + * useNotification / useAutoLogin / usageApiUrl / extra 를 지정할 수 있다. + * @param idempotencyKey 미지정시 자동 생성 (Idempotency-Key 헤더로 전송) + */ + static public BootpayStoreResponse create(BootpayStoreObject bootpay, SInvoice invoice, String idempotencyKey) throws Exception { if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) { throw new Exception("token 값이 비어있습니다."); } @@ -37,7 +48,8 @@ static public BootpayStoreResponse create(BootpayStoreObject bootpay, SInvoice i .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create(); - HttpPost post = bootpay.httpPost("invoices", new StringEntity(gson.toJson(invoice), "UTF-8")); + HttpPost post = bootpay.httpPost("invoices", new StringEntity(gson.toJson(invoice), "UTF-8"), + invoiceCreateContext(idempotencyKey)); HttpResponse response = client.execute(post); return bootpay.responseToJsonObject(response); @@ -46,27 +58,40 @@ static public BootpayStoreResponse create(BootpayStoreObject bootpay, SInvoice i // return responseJson(gson, str, response.getStatusLine().getStatusCode()); } + // 청구서 목록 조회 (GET invoices) + // InvoiceListParams를 넘기면 cs_type / user_id / product_type / css_at / cse_at 필터도 함께 전송한다 static public BootpayStoreResponse list(BootpayStoreObject bootpay, ListParams params) throws Exception { + HttpClient client = HttpClientBuilder.create().build(); + HttpGet get = listRequest(bootpay, params); + HttpResponse response = client.execute(get); + return bootpay.responseToJsonObject(response); + } + + static HttpGet listRequest(BootpayStoreObject bootpay, ListParams params) throws Exception { if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) { throw new Exception("token 값이 비어있습니다."); } - HttpClient client = HttpClientBuilder.create().build(); String url = "invoices"; - if(params != null) { - List nameValuePairList = new ArrayList<>(); - if (params.keyword != null) nameValuePairList.add(new BasicNameValuePair("keyword", params.keyword)); - if (params.page != null) nameValuePairList.add(new BasicNameValuePair("page", params.page.toString())); - if (params.limit != null) nameValuePairList.add(new BasicNameValuePair("limit", params.limit.toString())); - - HttpGet get = bootpay.httpGet(url, nameValuePairList); - HttpResponse response = client.execute(get); - return bootpay.responseToJsonObject(response); - } else { - HttpGet get = bootpay.httpGet(url); - HttpResponse response = client.execute(get); - return bootpay.responseToJsonObject(response); + if (params == null) return bootpay.httpGet(url); + + // 값이 설정되지 않은 필드는 전송하지 않는다 (ruby의 compact 동작) + List nameValuePairList = new ArrayList<>(); + if (params.keyword != null) nameValuePairList.add(new BasicNameValuePair("keyword", params.keyword)); + if (params.page != null) nameValuePairList.add(new BasicNameValuePair("page", params.page.toString())); + if (params.limit != null) nameValuePairList.add(new BasicNameValuePair("limit", params.limit.toString())); + + if (params instanceof InvoiceListParams) { + InvoiceListParams invoiceParams = (InvoiceListParams) params; + if (invoiceParams.type != null) nameValuePairList.add(new BasicNameValuePair("type", invoiceParams.type.toString())); + if (invoiceParams.csType != null) nameValuePairList.add(new BasicNameValuePair("cs_type", invoiceParams.csType)); + if (invoiceParams.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", invoiceParams.userId)); + if (invoiceParams.productType != null) nameValuePairList.add(new BasicNameValuePair("product_type", invoiceParams.productType.toString())); + if (invoiceParams.cssAt != null) nameValuePairList.add(new BasicNameValuePair("css_at", invoiceParams.cssAt)); + if (invoiceParams.cseAt != null) nameValuePairList.add(new BasicNameValuePair("cse_at", invoiceParams.cseAt)); } + + return bootpay.httpGet(url, nameValuePairList); } /** @@ -162,4 +187,16 @@ private static RequestContext invoiceContext(String idempotencyKey) { .idempotencyKey(RequestContext.idempotencyKeyOrGenerate(idempotencyKey)) .build(); } + + /** + * 청구서 생성 요청 컨텍스트 — Idempotency-Key 만 싣고 role 은 지정하지 않는다. + * + *

role 을 고정하면 {@code setRole("supervisor")} 로 지정해 둔 호출자가 조용히 user 로 + * 강등된다. role 미지정 시 인스턴스 role 이 쓰이고, 그마저 없으면 "user" 가 기본값이다.

+ */ + private static RequestContext invoiceCreateContext(String idempotencyKey) { + return RequestContext.builder() + .idempotencyKey(RequestContext.idempotencyKeyOrGenerate(idempotencyKey)) + .build(); + } } diff --git a/core/src/main/java/kr/co/bootpay/store/service/order_subscription_requests/SOrderSubscriptionRequestService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscription_requests/SOrderSubscriptionRequestService.java index 66011aa..bf50a0f 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/order_subscription_requests/SOrderSubscriptionRequestService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/order_subscription_requests/SOrderSubscriptionRequestService.java @@ -96,6 +96,12 @@ static public BootpayStoreResponse update(BootpayStoreObject bootpay, OrderSubsc OrderSubscriptionRequestUpdateParams body = new OrderSubscriptionRequestUpdateParams(); body.approval = params.approval; body.reason = params.reason; + body.price = params.price; + body.taxFreePrice = params.taxFreePrice; + body.terminationFee = params.terminationFee; + body.lastBillRefundPrice = params.lastBillRefundPrice; + body.finalFee = params.finalFee; + body.serviceEndAt = params.serviceEndAt; HttpPut put = bootpay.httpPut( "order-subscription-requests/" + params.orderSubscriptionRequestHistoryId, diff --git a/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java index 5873523..344abae 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java @@ -12,6 +12,7 @@ import kr.co.bootpay.store.model.request.orderSubscription.SupervisorChargeRevokeParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorPauseParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorResumeParams; +import kr.co.bootpay.store.model.request.orderSubscription.SupervisorTerminateParams; import kr.co.bootpay.store.model.response.BootpayStoreResponse; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; @@ -33,42 +34,50 @@ static public BootpayStoreResponse list(BootpayStoreObject bootpay, OrderSubscri throw new Exception("token 값이 비어있습니다."); } HttpClient client = HttpClientBuilder.create().build(); + HttpGet get = listRequest(bootpay, params); + HttpResponse response = client.execute(get); + return bootpay.responseToJsonObject(response); + } - String url = "order_subscriptions"; - if(params != null) { - List nameValuePairList = new ArrayList<>(); - if(params.sAt != null) nameValuePairList.add(new BasicNameValuePair("s_at", params.sAt)); - if(params.eAt != null) nameValuePairList.add(new BasicNameValuePair("e_at", params.eAt)); - if(params.searchDateFrom != null) nameValuePairList.add(new BasicNameValuePair("search_date_from", params.searchDateFrom)); - if(params.searchDateTo != null) nameValuePairList.add(new BasicNameValuePair("search_date_to", params.searchDateTo)); - - if(params.requestType != null) nameValuePairList.add(new BasicNameValuePair("request_type", params.requestType.toString())); - if(params.status != null) nameValuePairList.add(new BasicNameValuePair("status", params.status.toString())); - - // user_group_id 또는 ex_uid 지원 - if(params.userGroupId != null) nameValuePairList.add(new BasicNameValuePair("user_group_id", params.userGroupId)); - if(params.userGroupExUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_ex_uid", params.userGroupExUid)); - if(params.userGroupExternalUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_external_uid", params.userGroupExternalUid)); - if(params.userGroupUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_uid", params.userGroupUid)); - - // user_id 또는 ex_uid 지원 - if(params.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", params.userId)); - if(params.userExUid != null) nameValuePairList.add(new BasicNameValuePair("user_ex_uid", params.userExUid)); - if(params.userExternalUid != null) nameValuePairList.add(new BasicNameValuePair("user_external_uid", params.userExternalUid)); - if(params.userUid != null) nameValuePairList.add(new BasicNameValuePair("user_uid", params.userUid)); - - if(params.keyword != null) nameValuePairList.add(new BasicNameValuePair("keyword", params.keyword)); - if(params.page != null) nameValuePairList.add(new BasicNameValuePair("page", params.page.toString())); - if(params.limit != null) nameValuePairList.add(new BasicNameValuePair("limit", params.limit.toString())); - - HttpGet get = bootpay.httpGet(url, nameValuePairList); - HttpResponse response = client.execute(get); - return bootpay.responseToJsonObject(response); - } else { - HttpGet get = bootpay.httpGet(url); - HttpResponse response = client.execute(get); - return bootpay.responseToJsonObject(response); + /** + * 목록 조회 요청을 구성한다 (전송하지 않는다). + * + *

URL·쿼리 구성만 떼어내 서버 없이 검증할 수 있게 한 것이다.

+ */ + static HttpGet listRequest(BootpayStoreObject bootpay, OrderSubscriptionListParams params) throws Exception { + if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) { + throw new Exception("token 값이 비어있습니다."); } + + String url = "order_subscriptions"; + if(params == null) return bootpay.httpGet(url); + + List nameValuePairList = new ArrayList<>(); + if(params.sAt != null) nameValuePairList.add(new BasicNameValuePair("s_at", params.sAt)); + if(params.eAt != null) nameValuePairList.add(new BasicNameValuePair("e_at", params.eAt)); + if(params.searchDateFrom != null) nameValuePairList.add(new BasicNameValuePair("search_date_from", params.searchDateFrom)); + if(params.searchDateTo != null) nameValuePairList.add(new BasicNameValuePair("search_date_to", params.searchDateTo)); + + if(params.requestType != null) nameValuePairList.add(new BasicNameValuePair("request_type", params.requestType.toString())); + if(params.status != null) nameValuePairList.add(new BasicNameValuePair("status", params.status.toString())); + + // user_group_id 또는 ex_uid 지원 + if(params.userGroupId != null) nameValuePairList.add(new BasicNameValuePair("user_group_id", params.userGroupId)); + if(params.userGroupExUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_ex_uid", params.userGroupExUid)); + if(params.userGroupExternalUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_external_uid", params.userGroupExternalUid)); + if(params.userGroupUid != null) nameValuePairList.add(new BasicNameValuePair("user_group_uid", params.userGroupUid)); + + // user_id 또는 ex_uid 지원 + if(params.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", params.userId)); + if(params.userExUid != null) nameValuePairList.add(new BasicNameValuePair("user_ex_uid", params.userExUid)); + if(params.userExternalUid != null) nameValuePairList.add(new BasicNameValuePair("user_external_uid", params.userExternalUid)); + if(params.userUid != null) nameValuePairList.add(new BasicNameValuePair("user_uid", params.userUid)); + + if(params.keyword != null) nameValuePairList.add(new BasicNameValuePair("keyword", params.keyword)); + if(params.page != null) nameValuePairList.add(new BasicNameValuePair("page", params.page.toString())); + if(params.limit != null) nameValuePairList.add(new BasicNameValuePair("limit", params.limit.toString())); + + return bootpay.httpGet(url, nameValuePairList); } @@ -191,7 +200,8 @@ static public BootpayStoreResponse approve(BootpayStoreObject bootpay, String or params.put("reason", reason); } - HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/approve", new StringEntity(gson.toJson(params), "UTF-8")); + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/approve", new StringEntity(gson.toJson(params), "UTF-8"), + supervisorContext(null)); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); @@ -223,7 +233,8 @@ static public BootpayStoreResponse reject(BootpayStoreObject bootpay, String ord java.util.Map params = new java.util.HashMap<>(); params.put("reason", reason); - HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/reject", new StringEntity(gson.toJson(params), "UTF-8")); + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/reject", new StringEntity(gson.toJson(params), "UTF-8"), + supervisorContext(null)); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); @@ -255,7 +266,8 @@ static public BootpayStoreResponse terminate(BootpayStoreObject bootpay, String params.put("reason", reason); } - HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/terminate", new StringEntity(gson.toJson(params), "UTF-8")); + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/terminate", new StringEntity(gson.toJson(params), "UTF-8"), + supervisorContext(null)); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); @@ -282,7 +294,8 @@ static public BootpayStoreResponse supervisorPause(BootpayStoreObject bootpay, S .create(); String json = (params != null) ? gson.toJson(params) : "{}"; - HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/pause", new StringEntity(json, "UTF-8")); + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/pause", new StringEntity(json, "UTF-8"), + supervisorContext(null)); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); @@ -309,7 +322,40 @@ static public BootpayStoreResponse supervisorResume(BootpayStoreObject bootpay, .create(); String json = (params != null) ? gson.toJson(params) : "{}"; - HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/resume", new StringEntity(json, "UTF-8")); + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/resume", new StringEntity(json, "UTF-8"), + supervisorContext(null)); + + HttpResponse response = client.execute(put); + return bootpay.responseToJsonObject(response); + } + + /** + * 관리자 구독 해지 (supervisor 권한 필요) + * + *

{@code terminate(...)} 와 같은 엔드포인트지만 위약금·환불액·서비스 종료일 등 정산 항목을 함께 + * 전달할 수 있다. 지정하지 않은 항목은 서버 기본 처리에 맡긴다.

+ * + * @param bootpay BootpayStoreObject + * @param orderSubscriptionId 구독 ID 또는 external_uid + * @param params 해지 파라미터 (전부 선택) + * @return BootpayStoreResponse + */ + static public BootpayStoreResponse supervisorTerminate(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorTerminateParams params) throws Exception { + if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) { + throw new Exception("token 값이 비어있습니다."); + } + if(orderSubscriptionId == null || orderSubscriptionId.isEmpty()) { + throw new Exception("order_subscription_id 값이 비어있습니다"); + } + HttpClient client = HttpClientBuilder.create().build(); + + Gson gson = new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + + String json = (params != null) ? gson.toJson(params) : "{}"; + HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/terminate", new StringEntity(json, "UTF-8"), + supervisorContext(null)); HttpResponse response = client.execute(put); return bootpay.responseToJsonObject(response); diff --git a/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngService.java index a613c3d..dce8c96 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngService.java @@ -141,6 +141,36 @@ static public BootpayStoreResponse calculateTerminationFee(BootpayStoreObject bo return bootpay.responseToJsonObject(response); } + /** + * 해지 위약금 계산 요청을 구성한다 (전송하지 않는다). + * + *

URL·쿼리 구성만 떼어내 서버 없이 검증할 수 있게 한 것이다.

+ */ + static HttpGet calculateTerminationFeeRequest(BootpayStoreObject bootpay, String orderSubscriptionId, String orderNumber) throws Exception { + if (bootpay == null || bootpay.getToken() == null || bootpay.getToken().isEmpty()) { + throw new IllegalArgumentException("Bootpay 토큰이 비어있습니다."); + } + + boolean hasOrderSubscriptionId = orderSubscriptionId != null && !orderSubscriptionId.trim().isEmpty(); + boolean hasOrderNumber = orderNumber != null && !orderNumber.trim().isEmpty(); + + if (!hasOrderSubscriptionId && !hasOrderNumber) { + throw new IllegalArgumentException("orderSubscriptionId 또는 orderNumber 중 하나는 필수입니다."); + } + + // 둘 다 주어지면 둘 다 전송한다 (else 로 묶으면 order_number 가 조용히 유실된다) + StringBuilder url = new StringBuilder("order_subscriptions/requests/ing/calculate_termination_fee?"); + if (hasOrderSubscriptionId) { + url.append("order_subscription_id=").append(URLEncoder.encode(orderSubscriptionId, StandardCharsets.UTF_8)); + } + if (hasOrderNumber) { + if (hasOrderSubscriptionId) url.append("&"); + url.append("order_number=").append(URLEncoder.encode(orderNumber, StandardCharsets.UTF_8)); + } + + return bootpay.httpGet(url.toString(), userContext(null)); + } + // 오버로드: orderNumber만 전달하는 경우 static public BootpayStoreResponse calculateTerminationFeeByOrderNumber(BootpayStoreObject bootpay, String orderNumber) throws Exception { return calculateTerminationFee(bootpay, null, orderNumber); diff --git a/core/src/main/java/kr/co/bootpay/store/service/user_groups/SUserGroupService.java b/core/src/main/java/kr/co/bootpay/store/service/user_groups/SUserGroupService.java index 09b52fb..eb034e3 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/user_groups/SUserGroupService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/user_groups/SUserGroupService.java @@ -112,7 +112,8 @@ static public BootpayStoreResponse userCreate(BootpayStoreObject bootpay, String Map params = new HashMap<>(); params.put("user_id", userId); - HttpPost post = bootpay.httpPost("user-groups/" + userGroupId + "/user", new StringEntity(gson.toJson(params), "UTF-8")); + HttpPost post = bootpay.httpPost("user-groups/" + userGroupId + "/user", new StringEntity(gson.toJson(params), "UTF-8"), + managerContext(null)); HttpResponse response = client.execute(post); return bootpay.responseToJsonObject(response); @@ -124,7 +125,7 @@ static public BootpayStoreResponse userDelete(BootpayStoreObject bootpay, String } HttpClient client = HttpClientBuilder.create().build(); - HttpDelete delete = bootpay.httpDelete("user-groups/" + userGroupId + "/user/" + userId); + HttpDelete delete = bootpay.httpDelete("user-groups/" + userGroupId + "/user/" + userId, managerContext(null)); HttpResponse response = client.execute(delete); return bootpay.responseToJsonObject(response); diff --git a/core/src/main/java/kr/co/bootpay/store/service/users/SUserJoinService.java b/core/src/main/java/kr/co/bootpay/store/service/users/SUserJoinService.java index 365f820..a6dea3b 100644 --- a/core/src/main/java/kr/co/bootpay/store/service/users/SUserJoinService.java +++ b/core/src/main/java/kr/co/bootpay/store/service/users/SUserJoinService.java @@ -20,6 +20,13 @@ public class SUserJoinService { + // 중복 확인 key 목록 (서버 라우트 GET users/join/:id 의 :id 값) + public static final String EMAIL_EXIST = "email-exist"; + public static final String ID_EXIST = "id-exist"; + public static final String PHONE_EXIST = "phone-exist"; + public static final String UID_EXIST = "uid-exist"; + public static final String GROUP_BUSINESS_NUMBER_EXIST = "group-business-number-exist"; + /** * 회원가입 *

@@ -64,18 +71,30 @@ static public BootpayStoreResponse join(BootpayStoreObject bootpay, SUser user) * @return BootpayStoreResponse { exists: boolean } */ static public BootpayStoreResponse checkExist(BootpayStoreObject bootpay, String path, String pk) throws Exception { + HttpClient client = HttpClientBuilder.create().build(); + HttpGet get = checkExistRequest(bootpay, path, pk); + HttpResponse response = client.execute(get); + + return bootpay.responseToJsonObject(response); + } + + /** + * 중복 확인 요청을 구성한다 (전송하지 않는다). + * + *

URL·쿼리 구성만 떼어내 서버 없이 검증할 수 있게 한 것이다.

+ */ + static HttpGet checkExistRequest(BootpayStoreObject bootpay, String path, String pk) throws Exception { if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) { throw new Exception("token 값이 비어있습니다."); } + if (path == null || path.isEmpty()) throw new Exception("path 값이 비어있습니다."); + if (pk == null || pk.isEmpty()) throw new Exception("pk 값이 비어있습니다."); String encodedPk = URLEncoder.encode(pk, "UTF-8"); - HttpClient client = HttpClientBuilder.create().build(); + // URL 구조: users/join/:path?pk=:pk String url = String.format("users/join/%s?pk=%s", path, encodedPk); - HttpGet get = bootpay.httpGet(url); - HttpResponse response = client.execute(get); - - return bootpay.responseToJsonObject(response); + return bootpay.httpGet(url); } /** diff --git a/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java b/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java index ffd9ae6..30f4d40 100644 --- a/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java +++ b/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java @@ -13,6 +13,7 @@ import kr.co.bootpay.store.model.request.orderSubscription.OrderSubscriptionListParams; import kr.co.bootpay.store.model.request.orderSubscription.OrderSubscriptionUpdateParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorChargeParams; +import kr.co.bootpay.store.model.request.orderSubscription.SupervisorTerminateParams; import kr.co.bootpay.store.model.request.orderSubscription.SupervisorChargeRevokeParams; import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPurchaseParams; import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionResumeParams; @@ -1016,4 +1017,209 @@ void testDefaultRolePreservedWithoutContext() throws Exception { store.clearRole(); } } + + // ══════════════════════════════════════════════════════════ + // orderSubscription.supervisorTerminate (2-x-development 에서 통합) + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("supervisorTerminate - PUT order_subscriptions/{id}/terminate, 정산 항목 전부 body 전송") + void testSupervisorTerminate() throws Exception { + SupervisorTerminateParams params = new SupervisorTerminateParams(); + params.reason = "고객 요청"; + params.terminationFee = 5000.0; + params.lastBillRefundPrice = 1200.0; + params.finalFee = 3800.0; + params.serviceEndAt = "2026-09-01T00:00:00+09:00"; + params.cancelDate = "2026-08-20"; + + store.orderSubscription.supervisorTerminate("subscription_1", params); + + assertEquals("PUT", lastMethod); + assertEquals("/v1/order_subscriptions/subscription_1/terminate", lastPath); + assertTrue(lastBody.contains("\"reason\":\"고객 요청\""), lastBody); + assertTrue(lastBody.contains("\"termination_fee\":5000"), lastBody); + assertTrue(lastBody.contains("\"last_bill_refund_price\":1200"), lastBody); + assertTrue(lastBody.contains("\"final_fee\":3800"), lastBody); + assertTrue(lastBody.contains("\"service_end_at\":\"2026-09-01T00:00:00+09:00\""), lastBody); + assertTrue(lastBody.contains("\"cancel_date\":\"2026-08-20\""), lastBody); + } + + @Test + @DisplayName("supervisorTerminate - params 가 null 이면 빈 바디, 기존 terminate 와 같은 엔드포인트") + void testSupervisorTerminateNullParams() throws Exception { + store.orderSubscription.supervisorTerminate("subscription_1", null); + + assertEquals("PUT", lastMethod); + assertEquals("/v1/order_subscriptions/subscription_1/terminate", lastPath); + assertEquals("{}", lastBody); + } + + @Test + @DisplayName("orderSubscriptionRequest.update - 정산 항목(price/termination_fee 등) body 전송") + void testOrderSubscriptionRequestUpdateSettlementFields() throws Exception { + kr.co.bootpay.store.model.request.orderSubscriptionRequest.OrderSubscriptionRequestUpdateParams params = + new kr.co.bootpay.store.model.request.orderSubscriptionRequest.OrderSubscriptionRequestUpdateParams(); + params.orderSubscriptionRequestHistoryId = "history_1"; + params.approval = kr.co.bootpay.store.model.request.orderSubscriptionRequest.OrderSubscriptionRequestUpdateParams.APPROVAL_APPROVE; + params.reason = "승인"; + params.price = 10000.0; + params.taxFreePrice = 0.0; + params.terminationFee = 500.0; + params.lastBillRefundPrice = 100.0; + params.finalFee = 9600.0; + params.serviceEndAt = "2026-09-01T00:00:00+09:00"; + + store.orderSubscriptionRequest.update(params); + + assertEquals("PUT", lastMethod); + assertEquals("/v1/order-subscription-requests/history_1", lastPath); + assertTrue(lastBody.contains("\"approval\":\"approve\""), lastBody); + assertTrue(lastBody.contains("\"price\":10000"), lastBody); + assertTrue(lastBody.contains("\"termination_fee\":500"), lastBody); + assertTrue(lastBody.contains("\"last_bill_refund_price\":100"), lastBody); + assertTrue(lastBody.contains("\"final_fee\":9600"), lastBody); + assertTrue(lastBody.contains("\"service_end_at\":"), lastBody); + assertFalse(lastBody.contains("order_subscription_request_history_id"), "ID 는 URL 에만: " + lastBody); + } + + // ══════════════════════════════════════════════════════════ + // invoice.create — ruby SDK request_checkout parity (3.3.0) + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("invoice.create - user/products/delivery_price/use_* /usage_api_url/extra 전송, user role + Idempotency-Key") + void testInvoiceCreateRubyParity() throws Exception { + kr.co.bootpay.store.model.pojo.SInvoice invoice = new kr.co.bootpay.store.model.pojo.SInvoice(); + invoice.name = "테스트 청구서"; + invoice.memo = "테스트 청구서 상세 메모"; + invoice.price = 1000.0; + invoice.taxFreePrice = 0.0; + invoice.deliveryPrice = 2500.0; + invoice.redirectUrl = "https://example.com"; + invoice.requestId = "test1"; + invoice.useNotification = true; + invoice.useAutoLogin = true; + invoice.usageApiUrl = "https://dev-api.bootapi.com/v1/billing/usage"; + invoice.sdk = false; + + kr.co.bootpay.store.model.pojo.SInvoiceUser user = new kr.co.bootpay.store.model.pojo.SInvoiceUser(); + user.membershipType = kr.co.bootpay.store.model.pojo.SInvoiceUser.MEMBERSHIP_TYPE_GUEST; + user.name = "부트페이"; + user.userId = "test123"; + user.phone = "01095735114"; + invoice.user = user; + + kr.co.bootpay.store.model.pojo.SInvoiceProduct product = new kr.co.bootpay.store.model.pojo.SInvoiceProduct(); + product.productId = "66fa14954eac568eab4fc2d0"; + product.productOptionId = "68ede8c675febc5627363fb2"; + product.duration = 24; + product.quantity = 1; + invoice.products = java.util.Collections.singletonList(product); + + kr.co.bootpay.store.model.pojo.SInvoiceExtra extra = new kr.co.bootpay.store.model.pojo.SInvoiceExtra(); + extra.separatelyConfirmed = false; + extra.createOrderImmediately = true; + invoice.extra = extra; + + store.invoice.create(invoice); + + assertEquals("POST", lastMethod); + assertEquals("/v1/invoices", lastPath); + assertEquals("user", lastRole); + assertNotNull(lastIdempotencyKey, "Idempotency-Key 자동 생성"); + + assertTrue(lastBody.contains("\"delivery_price\":2500"), lastBody); + assertTrue(lastBody.contains("\"use_notification\":true"), lastBody); + assertTrue(lastBody.contains("\"use_auto_login\":true"), lastBody); + assertTrue(lastBody.contains("\"usage_api_url\":\"https://dev-api.bootapi.com/v1/billing/usage\""), lastBody); + assertTrue(lastBody.contains("\"sdk\":false"), lastBody); + + assertTrue(lastBody.contains("\"user\":{"), lastBody); + assertTrue(lastBody.contains("\"membership_type\":\"guest\""), lastBody); + assertTrue(lastBody.contains("\"user_id\":\"test123\""), lastBody); + + assertTrue(lastBody.contains("\"products\":[{"), lastBody); + assertTrue(lastBody.contains("\"product_id\":\"66fa14954eac568eab4fc2d0\""), lastBody); + assertTrue(lastBody.contains("\"product_option_id\":\"68ede8c675febc5627363fb2\""), lastBody); + assertTrue(lastBody.contains("\"duration\":24"), lastBody); + assertTrue(lastBody.contains("\"quantity\":1"), lastBody); + + assertTrue(lastBody.contains("\"extra\":{"), lastBody); + assertTrue(lastBody.contains("\"separately_confirmed\":false"), lastBody); + assertTrue(lastBody.contains("\"create_order_immediately\":true"), lastBody); + } + + @Test + @DisplayName("invoice.create - price_adjustments/cycles 중첩 직렬화") + void testInvoiceCreatePriceAdjustments() throws Exception { + kr.co.bootpay.store.model.pojo.SInvoice invoice = new kr.co.bootpay.store.model.pojo.SInvoice(); + invoice.name = "과금 청구서"; + invoice.price = 1000.0; + + kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustmentCycle cycle = + new kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustmentCycle(); + cycle.duration = 1; + cycle.adjustmentType = kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustmentCycle.ADJUSTMENT_TYPE_DISCOUNT_PERCENT; + cycle.name = "첫달 할인"; + cycle.value = 20.0; + cycle.minValue = 100.0; + cycle.maxValue = 500.0; + + kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustment adjustment = + new kr.co.bootpay.store.model.pojo.SInvoicePriceAdjustment(); + adjustment.priceAdjustmentId = "test1"; + adjustment.startAt = "2025-09-20 00:00:00"; + adjustment.endAt = "2025-12-30 23:59:59"; + adjustment.name = "첫 구매 할인 프로모션"; + adjustment.cycles = java.util.Collections.singletonList(cycle); + + kr.co.bootpay.store.model.pojo.SInvoiceProduct product = new kr.co.bootpay.store.model.pojo.SInvoiceProduct(); + product.productId = "66fa14954eac568eab4fc2d0"; + product.quantity = 1; + product.priceAdjustments = java.util.Collections.singletonList(adjustment); + invoice.products = java.util.Collections.singletonList(product); + + store.invoice.create(invoice); + + assertEquals("POST", lastMethod); + assertTrue(lastBody.contains("\"price_adjustments\":[{"), lastBody); + assertTrue(lastBody.contains("\"price_adjustment_id\":\"test1\""), lastBody); + assertTrue(lastBody.contains("\"start_at\":\"2025-09-20 00:00:00\""), lastBody); + assertTrue(lastBody.contains("\"cycles\":[{"), lastBody); + assertTrue(lastBody.contains("\"adjustment_type\":\"discount_percent\""), lastBody); + assertTrue(lastBody.contains("\"min_value\":100"), lastBody); + assertTrue(lastBody.contains("\"max_value\":500"), lastBody); + } + + @Test + @DisplayName("invoice.create - 미지정 필드는 전송하지 않는다 (기존 사용 패턴 회귀)") + void testInvoiceCreateOmitsUnsetFields() throws Exception { + kr.co.bootpay.store.model.pojo.SInvoice invoice = new kr.co.bootpay.store.model.pojo.SInvoice(); + invoice.name = "테스트 청구서"; + invoice.price = 3000.0; + + store.invoice.create(invoice); + + assertEquals("POST", lastMethod); + assertEquals("/v1/invoices", lastPath); + assertFalse(lastBody.contains("user"), "지정하지 않은 user 는 전송되면 안 된다: " + lastBody); + assertFalse(lastBody.contains("products"), "지정하지 않은 products 는 전송되면 안 된다: " + lastBody); + assertFalse(lastBody.contains("delivery_price"), lastBody); + assertFalse(lastBody.contains("sdk"), lastBody); + } + + @Test + @DisplayName("invoice.create - idempotencyKey 직접 지정 가능") + void testInvoiceCreateExplicitIdempotencyKey() throws Exception { + kr.co.bootpay.store.model.pojo.SInvoice invoice = new kr.co.bootpay.store.model.pojo.SInvoice(); + invoice.name = "테스트 청구서"; + invoice.price = 1000.0; + + store.invoice.create(invoice, "my-idem-key"); + + assertEquals("my-idem-key", lastIdempotencyKey); + assertFalse(lastBody.contains("idempotency"), "idempotencyKey 는 body 에 포함되면 안 된다: " + lastBody); + } + } diff --git a/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java new file mode 100644 index 0000000..5a28a0f --- /dev/null +++ b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java @@ -0,0 +1,126 @@ +package kr.co.bootpay.store; + +import kr.co.bootpay.store.context.RequestContext; +import kr.co.bootpay.store.model.request.TokenPayload; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.StringEntity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Commerce 인증 헤더 규칙 검증. + * + *

기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 동일하게 + * 토큰이 있으면 Bearer, 없으면 client_key/secret_key Basic, 둘 다 없으면 헤더 미부착 이다.

+ */ +@DisplayName("Commerce API - 인증 헤더") +class BootpayStoreObjectAuthTest { + + private static final String CLIENT_KEY = "test_client_key"; + private static final String SECRET_KEY = "test_secret_key"; + + private static final String BASIC_VALUE = "Basic " + Base64.getEncoder() + .encodeToString((CLIENT_KEY + ":" + SECRET_KEY).getBytes(StandardCharsets.UTF_8)); + + private BootpayStoreObject bootpay() { + return new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + } + + @Test + @DisplayName("Basic 인증 값 계산은 토큰을 저장하지 않는다") + void basicAuthDoesNotStoreToken() { + BootpayStoreObject bootpay = bootpay(); + + assertEquals(BASIC_VALUE, bootpay.requestAccessToken()); + assertNull(bootpay.getToken()); + } + + @Test + @DisplayName("토큰이 없으면 GET/POST/PUT/DELETE 모두 Basic 인증을 쓴다") + void allVerbsUseBasicWithoutToken() throws Exception { + BootpayStoreObject bootpay = bootpay(); + + HttpGet get = bootpay.httpGet("products"); + HttpPost post = bootpay.httpPost("products", new StringEntity("{}", "UTF-8")); + HttpPut put = bootpay.httpPut("products/1", new StringEntity("{}", "UTF-8")); + HttpDelete delete = bootpay.httpDelete("products/1"); + + assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, post.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, put.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, delete.getFirstHeader("Authorization").getValue()); + } + + @Test + @DisplayName("Basic 인증은 반복 요청에도 유지된다 (토큰으로 저장되면 안 된다)") + void basicAuthSurvivesRepeatedRequests() throws Exception { + BootpayStoreObject bootpay = bootpay(); + + bootpay.httpPost("products", new StringEntity("{}", "UTF-8")); + HttpGet get = bootpay.httpGet("products"); + + // basic 인증 값이 토큰으로 저장되면 두번째 요청부터 Bearer 로 전송되는 버그가 있었다 + assertNull(bootpay.getToken()); + assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue()); + } + + @Test + @DisplayName("토큰이 발급되어 있어도 Commerce 인증은 Basic 을 쓴다") + void issuedTokenDoesNotSwitchToBearer() throws Exception { + BootpayStoreObject bootpay = bootpay(); + bootpay.setTokenFromAPI("access_token_value"); + + HttpGet get = bootpay.httpGet("products"); + HttpPost post = bootpay.httpPost("products", new StringEntity("{}", "UTF-8")); + HttpPut put = bootpay.httpPut("products/1", new StringEntity("{}", "UTF-8")); + HttpDelete delete = bootpay.httpDelete("products/1"); + + // Bearer 로 전환하면 토큰 30분 만료 후 복구 수단이 없다. 만료 파싱·재발급·401 폴백을 + // 갖추기 전까지는 Go / Python / PHP / .NET 과 같이 Basic 을 유지한다. + assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, post.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, put.getFirstHeader("Authorization").getValue()); + assertEquals(BASIC_VALUE, delete.getFirstHeader("Authorization").getValue()); + } + + @Test + @DisplayName("RequestContext 의 토큰은 인증에 쓰이지 않고 role 만 적용된다") + void contextTokenDoesNotAffectAuthorization() throws Exception { + BootpayStoreObject bootpay = bootpay(); + bootpay.setTokenFromAPI("access_token_value"); + RequestContext context = RequestContext.builder().role("manager").token("context_token").build(); + + HttpGet get = bootpay.httpGet("products", context); + + assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue()); + assertEquals("manager", get.getFirstHeader("BOOTPAY-ROLE").getValue()); + } + + @Test + @DisplayName("키가 없으면 Authorization 값이 비어 있다 (v3.2.0 과 동일)") + void noCredentialsKeepsLegacyEmptyHeader() throws Exception { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(), "PRODUCTION"); + + HttpGet get = bootpay.httpGet("products"); + HttpPost post = bootpay.httpPost("products", new StringEntity("{}", "UTF-8")); + + assertEquals("", get.getFirstHeader("Authorization").getValue()); + assertEquals("", post.getFirstHeader("Authorization").getValue()); + } + + @Test + @DisplayName("requestAccessToken 은 기존 동작(Basic 값 계산)을 유지한다") + void requestAccessTokenKeepsLegacyBehaviour() { + assertEquals(BASIC_VALUE, bootpay().requestAccessToken()); + assertEquals("", new BootpayStoreObject(new TokenPayload(), "PRODUCTION").requestAccessToken()); + } +} diff --git a/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionServiceTest.java new file mode 100644 index 0000000..41e41db --- /dev/null +++ b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionServiceTest.java @@ -0,0 +1,76 @@ +package kr.co.bootpay.store.service.order_subscriptions; + +import kr.co.bootpay.store.BootpayStoreObject; +import kr.co.bootpay.store.model.request.TokenPayload; +import kr.co.bootpay.store.model.request.orderSubscription.OrderSubscriptionListParams; +import org.apache.http.client.methods.HttpGet; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SOrderSubscriptionServiceTest { + private static final String CLIENT_KEY = "test_client_key"; + private static final String SECRET_KEY = "test_secret_key"; + + private BootpayStoreObject bootpay() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + bootpay.setTokenFromAPI("test_token"); + return bootpay; + } + + @Test + public void 구독목록은_GET_order_subscriptions로_요청한다() throws Exception { + HttpGet get = SOrderSubscriptionService.listRequest(bootpay(), null); + + assertEquals("GET", get.getMethod()); + assertEquals("https://api.bootapi.com/v1/order_subscriptions", get.getURI().toString()); + } + + @Test + public void page와_limit을_함께_전송한다() throws Exception { + OrderSubscriptionListParams params = new OrderSubscriptionListParams(); + params.page = 2; + params.limit = 50; + + String query = SOrderSubscriptionService.listRequest(bootpay(), params).getURI().getQuery(); + + assertTrue(query.contains("page=2")); + assertTrue(query.contains("limit=50")); + } + + @Test + public void request_type과_status를_각자의_값으로_전송한다() throws Exception { + OrderSubscriptionListParams params = new OrderSubscriptionListParams(); + params.requestType = 3; + params.status = 1; + params.eAt = "2026-08-14"; + + String query = SOrderSubscriptionService.listRequest(bootpay(), params).getURI().getQuery(); + + assertTrue(query.contains("request_type=3")); + assertTrue(query.contains("status=1")); + assertTrue(query.contains("e_at=2026-08-14")); + } + + @Test + public void 값이_설정되지_않은_필드는_전송하지_않는다() throws Exception { + OrderSubscriptionListParams params = new OrderSubscriptionListParams(); + params.userId = "user_id_value"; + + String query = SOrderSubscriptionService.listRequest(bootpay(), params).getURI().getQuery(); + + assertEquals("user_id=user_id_value", query); + assertFalse(query.contains("limit")); + assertFalse(query.contains("status")); + } + + @Test + public void 토큰이_없으면_예외를_발생시킨다() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + + assertThrows(Exception.class, () -> SOrderSubscriptionService.listRequest(bootpay, new OrderSubscriptionListParams())); + } +} diff --git a/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngServiceTest.java new file mode 100644 index 0000000..7f1950e --- /dev/null +++ b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngServiceTest.java @@ -0,0 +1,61 @@ +package kr.co.bootpay.store.service.order_subscriptions.request; + +import kr.co.bootpay.store.BootpayStoreObject; +import kr.co.bootpay.store.model.request.TokenPayload; +import org.apache.http.client.methods.HttpGet; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SOrderSubscriptionRequestIngServiceTest { + private static final String CLIENT_KEY = "test_client_key"; + private static final String SECRET_KEY = "test_secret_key"; + + private BootpayStoreObject bootpay() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + bootpay.setTokenFromAPI("test_token"); + return bootpay; + } + + @Test + public void 중도해지_수수료는_GET_calculate_termination_fee로_요청한다() throws Exception { + HttpGet get = SOrderSubscriptionRequestIngService.calculateTerminationFeeRequest(bootpay(), "order_subscription_id_value", null); + + assertEquals("GET", get.getMethod()); + assertEquals("/v1/order_subscriptions/requests/ing/calculate_termination_fee", get.getURI().getPath()); + assertEquals("order_subscription_id=order_subscription_id_value", get.getURI().getQuery()); + } + + @Test + public void order_number만_전달하면_order_number로_조회한다() throws Exception { + HttpGet get = SOrderSubscriptionRequestIngService.calculateTerminationFeeRequest(bootpay(), null, "order_number_value"); + + assertEquals("order_number=order_number_value", get.getURI().getQuery()); + } + + @Test + public void 둘_다_전달하면_둘_다_전송한다() throws Exception { + String query = SOrderSubscriptionRequestIngService + .calculateTerminationFeeRequest(bootpay(), "order_subscription_id_value", "order_number_value") + .getURI().getQuery(); + + assertTrue(query.contains("order_subscription_id=order_subscription_id_value")); + assertTrue(query.contains("order_number=order_number_value")); + } + + @Test + public void 둘_다_없으면_예외를_발생시킨다() { + assertThrows(IllegalArgumentException.class, + () -> SOrderSubscriptionRequestIngService.calculateTerminationFeeRequest(bootpay(), null, null)); + } + + @Test + public void 토큰이_없으면_예외를_발생시킨다() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + + assertThrows(IllegalArgumentException.class, + () -> SOrderSubscriptionRequestIngService.calculateTerminationFeeRequest(bootpay, "order_subscription_id_value", null)); + } +} diff --git a/core/src/test/java/kr/co/bootpay/store/service/users/SUserJoinServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/users/SUserJoinServiceTest.java new file mode 100644 index 0000000..09b81d5 --- /dev/null +++ b/core/src/test/java/kr/co/bootpay/store/service/users/SUserJoinServiceTest.java @@ -0,0 +1,56 @@ +package kr.co.bootpay.store.service.users; + +import kr.co.bootpay.store.BootpayStoreObject; +import kr.co.bootpay.store.model.request.TokenPayload; +import org.apache.http.client.methods.HttpGet; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SUserJoinServiceTest { + private static final String CLIENT_KEY = "test_client_key"; + private static final String SECRET_KEY = "test_secret_key"; + + private BootpayStoreObject bootpay() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + bootpay.setTokenFromAPI("test_token"); + return bootpay; + } + + @Test + public void uid_중복확인은_GET_users_join_uid_exist로_요청한다() throws Exception { + HttpGet get = SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.UID_EXIST, "ex_uid_1234"); + + assertEquals("GET", get.getMethod()); + assertEquals("/v1/users/join/uid-exist", get.getURI().getPath()); + assertEquals("pk=ex_uid_1234", get.getURI().getQuery()); + } + + @Test + public void 중복확인_key는_users_join_경로에_그대로_사용한다() throws Exception { + assertEquals("/v1/users/join/email-exist", + SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.EMAIL_EXIST, "test@bootpay.co.kr").getURI().getPath()); + assertEquals("/v1/users/join/id-exist", + SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.ID_EXIST, "ehowlsla").getURI().getPath()); + assertEquals("/v1/users/join/phone-exist", + SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.PHONE_EXIST, "01000000000").getURI().getPath()); + assertEquals("/v1/users/join/group-business-number-exist", + SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.GROUP_BUSINESS_NUMBER_EXIST, "1088603663").getURI().getPath()); + } + + @Test + public void pk는_URL_인코딩해서_전송한다() throws Exception { + HttpGet get = SUserJoinService.checkExistRequest(bootpay(), SUserJoinService.EMAIL_EXIST, "test+1@bootpay.co.kr"); + + assertEquals("pk=test+1@bootpay.co.kr", get.getURI().getQuery()); + assertEquals("pk=test%2B1%40bootpay.co.kr", get.getURI().getRawQuery()); + } + + @Test + public void 토큰이_없으면_예외를_발생시킨다() { + BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION"); + + assertThrows(Exception.class, () -> SUserJoinService.checkExistRequest(bootpay, SUserJoinService.UID_EXIST, "ex_uid_1234")); + } +}