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 발급받은 토큰({@code token})은 인증에 쓰지 않는다. Go / Python / PHP / .NET 도 같은
+ * 규칙이고, Basic 은 만료가 없어 장기 실행 프로세스에서 재발급이 필요 없다. 토큰 기반 Bearer 인증(기준 SDK 인 NodeJS · Ruby 의 규칙)으로의 전환은 만료 시각
+ * 파싱({@code expired_at})·자동 재발급·401 폴백을 함께 갖춘 뒤에 해야 한다. 그 준비 없이
+ * 전환하면 30분 만료 후 복구 수단 없이 401 이 나므로 이번 릴리스에서는 도입하지 않는다.
프로모션 단위로 묶이며, 실제 할인/부과 규칙은 {@link #cycles} 에 주기별로 담는다.
+ * + * @since 3.3.0 + */ +public class SInvoicePriceAdjustment { + + /** 가맹점이 관리하는 조정 식별자 */ + public String priceAdjustmentId; + /** 조정 적용 시작 일시 */ + public String startAt; + /** 조정 적용 종료 일시 */ + public String endAt; + /** 프로모션 명칭 */ + public String name; + /** 주기별 조정 규칙 */ + public List구독 상품에서 "첫 달 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이미 가입된 회원이면 {@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{@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) { - Listrole 을 고정하면 {@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) { - ListURL·쿼리 구성만 떼어내 서버 없이 검증할 수 있게 한 것이다.
+ */ + 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{@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@@ -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")); + } +}