From f745ba92cec022c3cde019963e33ff26671a7d3e Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Mon, 23 Feb 2026 17:50:09 +0900
Subject: [PATCH 01/17] feat(store): add supervisor order-subscription action
support
---
.../store/layer/OrderSubscription.java | 25 +++++++++++++
...ervisorOrderSubscriptionApproveParams.java | 5 +++
...upervisorOrderSubscriptionPauseParams.java | 7 ++++
...pervisorOrderSubscriptionRejectParams.java | 5 +++
...pervisorOrderSubscriptionResumeParams.java | 5 +++
...visorOrderSubscriptionTerminateParams.java | 10 ++++++
.../SOrderSubscriptionService.java | 36 +++++++++++++++++++
7 files changed, 93 insertions(+)
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionApproveParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionPauseParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionRejectParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionResumeParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionTerminateParams.java
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
index e304d34..34f83f3 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
@@ -5,6 +5,11 @@
import kr.co.bootpay.store.layer.order_subscription.request.OrderSubscriptionRequestIng;
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.SupervisorOrderSubscriptionApproveParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionRejectParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionTerminateParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionPauseParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionResumeParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.service.order_subscriptions.SOrderSubscriptionService;
@@ -30,4 +35,24 @@ public BootpayStoreResponse detail(String orderSubscriptionId) throws Exception
public BootpayStoreResponse update(OrderSubscriptionUpdateParams params) throws Exception {
return SOrderSubscriptionService.update(bootpay, params);
}
+
+ public BootpayStoreResponse supervisorApprove(String orderSubscriptionId, SupervisorOrderSubscriptionApproveParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorApprove(bootpay, orderSubscriptionId, params);
+ }
+
+ public BootpayStoreResponse supervisorReject(String orderSubscriptionId, SupervisorOrderSubscriptionRejectParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorReject(bootpay, orderSubscriptionId, params);
+ }
+
+ public BootpayStoreResponse supervisorTerminate(String orderSubscriptionId, SupervisorOrderSubscriptionTerminateParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorTerminate(bootpay, orderSubscriptionId, params);
+ }
+
+ public BootpayStoreResponse supervisorPause(String orderSubscriptionId, SupervisorOrderSubscriptionPauseParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorPause(bootpay, orderSubscriptionId, params);
+ }
+
+ public BootpayStoreResponse supervisorResume(String orderSubscriptionId, SupervisorOrderSubscriptionResumeParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorResume(bootpay, orderSubscriptionId, params);
+ }
}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionApproveParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionApproveParams.java
new file mode 100644
index 0000000..235eb8e
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionApproveParams.java
@@ -0,0 +1,5 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+public class SupervisorOrderSubscriptionApproveParams {
+ public String reason;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionPauseParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionPauseParams.java
new file mode 100644
index 0000000..38b6c69
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionPauseParams.java
@@ -0,0 +1,7 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+public class SupervisorOrderSubscriptionPauseParams {
+ public String reason;
+ public String pausedAt;
+ public String expectedResumeAt;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionRejectParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionRejectParams.java
new file mode 100644
index 0000000..b43d2a7
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionRejectParams.java
@@ -0,0 +1,5 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+public class SupervisorOrderSubscriptionRejectParams {
+ public String reason;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionResumeParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionResumeParams.java
new file mode 100644
index 0000000..0a7ad58
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionResumeParams.java
@@ -0,0 +1,5 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+public class SupervisorOrderSubscriptionResumeParams {
+ public String reason;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionTerminateParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionTerminateParams.java
new file mode 100644
index 0000000..7e54403
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionTerminateParams.java
@@ -0,0 +1,10 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+public class SupervisorOrderSubscriptionTerminateParams {
+ 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/service/order_subscriptions/SOrderSubscriptionService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java
index 6431971..51ad5d8 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
@@ -6,6 +6,11 @@
import kr.co.bootpay.store.BootpayStoreObject;
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.SupervisorOrderSubscriptionApproveParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionRejectParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionTerminateParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionPauseParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionResumeParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
@@ -85,4 +90,35 @@ static public BootpayStoreResponse update(BootpayStoreObject bootpay, OrderSubsc
return bootpay.responseToJsonObject(response);
}
+ static public BootpayStoreResponse supervisorApprove(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorOrderSubscriptionApproveParams params) throws Exception {
+ return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/approve", params == null ? new SupervisorOrderSubscriptionApproveParams() : params);
+ }
+
+ static public BootpayStoreResponse supervisorReject(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorOrderSubscriptionRejectParams params) throws Exception {
+ return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/reject", params == null ? new SupervisorOrderSubscriptionRejectParams() : params);
+ }
+
+ static public BootpayStoreResponse supervisorTerminate(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorOrderSubscriptionTerminateParams params) throws Exception {
+ return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/terminate", params == null ? new SupervisorOrderSubscriptionTerminateParams() : params);
+ }
+
+ static public BootpayStoreResponse supervisorPause(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorOrderSubscriptionPauseParams params) throws Exception {
+ return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/pause", params);
+ }
+
+ static public BootpayStoreResponse supervisorResume(BootpayStoreObject bootpay, String orderSubscriptionId, SupervisorOrderSubscriptionResumeParams params) throws Exception {
+ return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/resume", params == null ? new SupervisorOrderSubscriptionResumeParams() : params);
+ }
+
+ static private BootpayStoreResponse supervisorAction(BootpayStoreObject bootpay, String uri, Object params) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
+ throw new Exception("token 값이 비어있습니다.");
+ }
+ HttpClient client = HttpClientBuilder.create().build();
+ Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+ HttpPut put = bootpay.httpPut(uri, new StringEntity(gson.toJson(params), "UTF-8"));
+ HttpResponse response = client.execute(put);
+ return bootpay.responseToJsonObject(response);
+ }
+
}
From f943b2c87f186824111b0f39aeb4e100ecab9e39 Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Mon, 23 Feb 2026 18:18:25 +0900
Subject: [PATCH 02/17] chore: deprecate requestAccessToken marker
---
core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java | 1 +
1 file changed, 1 insertion(+)
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 9f20888..a4bfc8d 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
@@ -85,6 +85,7 @@ public void setRole(String role) {
this.role = role;
}
+ @Deprecated
public String requestAccessToken() {
if((tokenPayload.clientKey == null || tokenPayload.clientKey.isEmpty()) && (tokenPayload.secretKey == null || tokenPayload.secretKey.isEmpty())) return "";
String credentials = tokenPayload.clientKey + ":" + tokenPayload.secretKey;
From e13d6a6341e01a97d058b998868fe63c24060cf9 Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Mon, 23 Feb 2026 19:16:29 +0900
Subject: [PATCH 03/17] test: add basic-auth product info smoke test code
---
BasicAuthProductInfoTest.java | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 BasicAuthProductInfoTest.java
diff --git a/BasicAuthProductInfoTest.java b/BasicAuthProductInfoTest.java
new file mode 100644
index 0000000..8742646
--- /dev/null
+++ b/BasicAuthProductInfoTest.java
@@ -0,0 +1,2 @@
+// Java integration sample placeholder. Execute in app/core test harness with JDK.
+public class BasicAuthProductInfoTest {}
From 0cf97cdf77bb9437083bddc475527bf0d059f8f6 Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Mon, 23 Feb 2026 19:48:25 +0900
Subject: [PATCH 04/17] feat(store): add store module and mall aliases
---
.../kr/co/bootpay/store/BootpayStore.java | 2 ++
.../kr/co/bootpay/store/layer/Product.java | 10 +++++++
.../java/kr/co/bootpay/store/layer/Store.java | 21 ++++++++++++++
.../java/kr/co/bootpay/store/layer/User.java | 15 ++++++++++
.../store/service/store/SStoreService.java | 28 +++++++++++++++++++
5 files changed, 76 insertions(+)
create mode 100644 core/src/main/java/kr/co/bootpay/store/layer/Store.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
diff --git a/core/src/main/java/kr/co/bootpay/store/BootpayStore.java b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
index ddcfcd7..0b4b68b 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
@@ -13,6 +13,7 @@ public class BootpayStore extends BootpayStoreObject {
public User user;
public UserGroup userGroup;
public Product product;
+ public Store store;
public Invoice invoice;
public Order order;
public OrderCancel orderCancel;
@@ -41,6 +42,7 @@ private void initModules() {
this.user = new User(this);
this.userGroup = new UserGroup(this);
this.product = new Product(this);
+ this.store = new Store(this);
this.invoice = new Invoice(this);
this.order = new Order(this);
this.orderCancel = new OrderCancel(this);
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Product.java b/core/src/main/java/kr/co/bootpay/store/layer/Product.java
index f77a735..c7824c8 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/Product.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Product.java
@@ -47,6 +47,16 @@ public BootpayStoreResponse detail(String productId) throws Exception {
);
}
+ // Mall API alias
+ public BootpayStoreResponse products(ProductListParams params) throws Exception {
+ return this.list(params);
+ }
+
+ // Mall API alias
+ public BootpayStoreResponse productDetail(String productId) throws Exception {
+ return this.detail(productId);
+ }
+
public BootpayStoreResponse status(ProductStatusParams params) throws Exception {
return SProductService.status(
bootpay,
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Store.java b/core/src/main/java/kr/co/bootpay/store/layer/Store.java
new file mode 100644
index 0000000..6aba751
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Store.java
@@ -0,0 +1,21 @@
+package kr.co.bootpay.store.layer;
+
+import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import kr.co.bootpay.store.service.store.SStoreService;
+
+public class Store {
+ private final BootpayStore bootpay;
+
+ public Store(BootpayStore bootpay) {
+ this.bootpay = bootpay;
+ }
+
+ public BootpayStoreResponse info() throws Exception {
+ return SStoreService.info(bootpay);
+ }
+
+ public BootpayStoreResponse detail() throws Exception {
+ return SStoreService.detail(bootpay);
+ }
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/User.java b/core/src/main/java/kr/co/bootpay/store/layer/User.java
index f94c4b6..4859c23 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/User.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/User.java
@@ -49,6 +49,21 @@ public BootpayStoreResponse login(String loginId, String loginPw) throws Excepti
return SUserLoginService.login(bootpay, loginId, loginPw);
}
+ // Mall API alias
+ public BootpayStoreResponse userLogin(String loginId, String loginPw) throws Exception {
+ return this.login(loginId, loginPw);
+ }
+
+ // Mall API alias
+ public BootpayStoreResponse userJoin(SUser user) throws Exception {
+ return this.join(user);
+ }
+
+ // Mall API alias
+ public BootpayStoreResponse userJoinCheck(String key, String value) throws Exception {
+ return this.checkExist(key, value);
+ }
+
public BootpayStoreResponse list(UserListParams params) throws Exception {
return SUserService.list(bootpay, params);
}
diff --git a/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java b/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
new file mode 100644
index 0000000..fa63458
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
@@ -0,0 +1,28 @@
+package kr.co.bootpay.store.service.store;
+
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.HttpClientBuilder;
+
+public class SStoreService {
+ public static BootpayStoreResponse info(BootpayStoreObject bootpay) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
+
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = bootpay.httpGet("store");
+ HttpResponse response = client.execute(get);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ public static BootpayStoreResponse detail(BootpayStoreObject bootpay) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
+
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = bootpay.httpGet("store/detail");
+ HttpResponse response = client.execute(get);
+ return bootpay.responseToJsonObject(response);
+ }
+}
From 5ed9ef665eeee9b5c5a56dc286d6181331af9e40 Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Mon, 23 Feb 2026 19:52:32 +0900
Subject: [PATCH 05/17] chore(store): add get-store naming parity aliases
---
.../bootpay/store/service/store/SStoreService.java | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java b/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
index fa63458..ab23cc4 100644
--- a/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
+++ b/core/src/main/java/kr/co/bootpay/store/service/store/SStoreService.java
@@ -8,7 +8,7 @@
import org.apache.http.impl.client.HttpClientBuilder;
public class SStoreService {
- public static BootpayStoreResponse info(BootpayStoreObject bootpay) throws Exception {
+ public static BootpayStoreResponse getStore(BootpayStoreObject bootpay) throws Exception {
if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
HttpClient client = HttpClientBuilder.create().build();
@@ -17,7 +17,11 @@ public static BootpayStoreResponse info(BootpayStoreObject bootpay) throws Excep
return bootpay.responseToJsonObject(response);
}
- public static BootpayStoreResponse detail(BootpayStoreObject bootpay) throws Exception {
+ public static BootpayStoreResponse info(BootpayStoreObject bootpay) throws Exception {
+ return getStore(bootpay);
+ }
+
+ public static BootpayStoreResponse getStoreDetail(BootpayStoreObject bootpay) throws Exception {
if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
HttpClient client = HttpClientBuilder.create().build();
@@ -25,4 +29,8 @@ public static BootpayStoreResponse detail(BootpayStoreObject bootpay) throws Exc
HttpResponse response = client.execute(get);
return bootpay.responseToJsonObject(response);
}
+
+ public static BootpayStoreResponse detail(BootpayStoreObject bootpay) throws Exception {
+ return getStoreDetail(bootpay);
+ }
}
From 386a3b530f725852dfe8c4c01b4ad41aa5e259dc Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Tue, 17 Mar 2026 14:35:44 +0900
Subject: [PATCH 06/17] feat(pg): support basic auth fallback with client
credentials
---
.../java/kr/co/bootpay/pg/BootpayObject.java | 35 ++++++++++++++++---
1 file changed, 30 insertions(+), 5 deletions(-)
diff --git a/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java b/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
index 70a2281..ba37f2e 100644
--- a/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
+++ b/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
@@ -21,7 +21,9 @@
import org.apache.http.impl.client.HttpClientBuilder;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.HashMap;
import java.util.List;
@@ -30,6 +32,8 @@ public class BootpayObject {
public String token;
public String application_id;
public String private_key;
+ public String client_key;
+ public String secret_key;
public String baseUrl;
public final String DEVELOPMENT = "https://dev-api.bootpay.co.kr/v2/";
@@ -44,21 +48,25 @@ public class BootpayObject {
public BootpayObject() {}
public BootpayObject(String rest_application_id, String private_key) {
- this.application_id = rest_application_id;
- this.private_key = private_key;
- this.baseUrl = PRODUCTION;
+ this(rest_application_id, private_key, null, null, "PRODUCTION");
}
public BootpayObject(String rest_application_id, String private_key, String devMode) {
+ this(rest_application_id, private_key, null, null, devMode);
+ }
+
+ public BootpayObject(String rest_application_id, String private_key, String client_key, String secret_key, String devMode) {
this.application_id = rest_application_id;
this.private_key = private_key;
+ this.client_key = client_key;
+ this.secret_key = secret_key;
if("DEVELOPMENT".equals(devMode)) {
this.baseUrl = DEVELOPMENT;
} else if("TEST".equalsIgnoreCase(devMode)) {
this.baseUrl = TEST;
} else if("STAGE".equalsIgnoreCase(devMode)) {
this.baseUrl = STAGE;
- } else if("PRODUCTION".equalsIgnoreCase(devMode)) {
+ } else {
this.baseUrl = PRODUCTION;
}
}
@@ -84,9 +92,26 @@ protected void setCommonHeaders(HttpRequestBase request) {
}
protected void setAuthHeader(HttpRequestBase request) {
+ String authorization = getAuthorizationHeader();
+ if (authorization != null && !authorization.isEmpty()) {
+ request.setHeader("Authorization", authorization);
+ }
+ }
+
+ protected String getAuthorizationHeader() {
if (this.token != null && !this.token.isEmpty()) {
- request.setHeader("Authorization", getTokenValue());
+ return getTokenValue();
+ }
+
+ if (this.client_key != null && !this.client_key.isEmpty() && this.secret_key != null && !this.secret_key.isEmpty()) {
+ return "Basic " + Base64.getEncoder().encodeToString((this.client_key + ":" + this.secret_key).getBytes(StandardCharsets.UTF_8));
}
+
+ if (this.application_id != null && !this.application_id.isEmpty() && this.private_key != null && !this.private_key.isEmpty()) {
+ return "Basic " + Base64.getEncoder().encodeToString((this.application_id + ":" + this.private_key).getBytes(StandardCharsets.UTF_8));
+ }
+
+ return null;
}
// ========================================
From 202024f3ba9c9475a64865ebcc36b54484063bc0 Mon Sep 17 00:00:00 2001
From: openclaw-bot
Date: Tue, 17 Mar 2026 14:50:37 +0900
Subject: [PATCH 07/17] fix(auth): use bearer only for app config when token
exists
---
core/src/main/java/kr/co/bootpay/pg/BootpayObject.java | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java b/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
index ba37f2e..9db45c2 100644
--- a/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
+++ b/core/src/main/java/kr/co/bootpay/pg/BootpayObject.java
@@ -99,16 +99,14 @@ protected void setAuthHeader(HttpRequestBase request) {
}
protected String getAuthorizationHeader() {
- if (this.token != null && !this.token.isEmpty()) {
- return getTokenValue();
- }
-
if (this.client_key != null && !this.client_key.isEmpty() && this.secret_key != null && !this.secret_key.isEmpty()) {
return "Basic " + Base64.getEncoder().encodeToString((this.client_key + ":" + this.secret_key).getBytes(StandardCharsets.UTF_8));
}
- if (this.application_id != null && !this.application_id.isEmpty() && this.private_key != null && !this.private_key.isEmpty()) {
- return "Basic " + Base64.getEncoder().encodeToString((this.application_id + ":" + this.private_key).getBytes(StandardCharsets.UTF_8));
+ if (this.application_id != null && !this.application_id.isEmpty()) {
+ if (this.token != null && !this.token.isEmpty()) {
+ return getTokenValue();
+ }
}
return null;
From ddea5940510e873ec552f9e58061bdc40a8f7f04 Mon Sep 17 00:00:00 2001
From: Bootpay SDK Bot
Date: Fri, 14 Aug 2026 05:53:38 +0000
Subject: [PATCH 08/17] =?UTF-8?q?sync:=20817dbe80=20=EB=B2=84=EA=B7=B8=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: git.bootpay.co.kr/bootpay-sdk/backend-ruby@817dbe804b30af68817b912c2fde28b787040ff2
---
.../com/example/bootpay/BootpayExample.java | 16 +++++
.../bootpay/store/OrderSubscription.java | 47 ++++++++++++++
.../main/java/kr/co/bootpay/pg/Bootpay.java | 5 ++
.../co/bootpay/pg/service/BillingService.java | 16 +++++
.../store/layer/OrderSubscription.java | 10 +++
...pervisorOrderSubscriptionChargeParams.java | 13 ++++
...orOrderSubscriptionChargeRevokeParams.java | 10 +++
.../SOrderSubscriptionService.java | 62 +++++++++++++++++++
8 files changed, 179 insertions(+)
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeRevokeParams.java
diff --git a/app/src/main/java/com/example/bootpay/BootpayExample.java b/app/src/main/java/com/example/bootpay/BootpayExample.java
index 51b7d2f..b75dbaa 100644
--- a/app/src/main/java/com/example/bootpay/BootpayExample.java
+++ b/app/src/main/java/com/example/bootpay/BootpayExample.java
@@ -26,6 +26,7 @@ public static void main(String[] args) {
// reserveSubscribe();
// reserveCancelSubscribe();
// destroyBillingKey();
+// lookupSequentialBillingKey();
// getUserToken();
// confirm();
// certificate();
@@ -297,6 +298,21 @@ public static void lookupBillingKeyByKey() {
}
}
+ public static void lookupSequentialBillingKey() {
+ String widgetKey = "66542dfb4d18d5fc7b43e1b7";
+ String billingKey = "66542dfb4d18d5fc7b43e1b6";
+ try {
+ HashMap res = bootpay.lookupSequentialBillingKey(widgetKey, billingKey);
+ 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() {
String receiptId = "628b2644d01c7e00209b6092";
try {
diff --git a/app/src/main/java/com/example/bootpay/store/OrderSubscription.java b/app/src/main/java/com/example/bootpay/store/OrderSubscription.java
index 6adf427..a4daa5b 100644
--- a/app/src/main/java/com/example/bootpay/store/OrderSubscription.java
+++ b/app/src/main/java/com/example/bootpay/store/OrderSubscription.java
@@ -3,9 +3,14 @@
import kr.co.bootpay.store.BootpayStore;
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.SupervisorOrderSubscriptionChargeParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionChargeRevokeParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.model.request.TokenPayload;
+import java.util.HashMap;
+import java.util.Map;
+
public class OrderSubscription {
@@ -18,6 +23,8 @@ public static void main(String[] args) {
list();
// detail();
// update();
+// supervisorCharge();
+// supervisorChargeRevoke();
} catch (Exception e) {
e.printStackTrace();
}
@@ -82,6 +89,46 @@ public static void update() {
}
}
+ // 수시결제(온디맨드) charge_key 즉시 결제
+ public static void supervisorCharge() {
+ try {
+ SupervisorOrderSubscriptionChargeParams params = new SupervisorOrderSubscriptionChargeParams();
+ params.chargeKey = "6d1f1a2b3c4d5e6f70819200";
+ params.price = 1000d;
+ params.taxFreePrice = 0d;
+
+ Map metadata = new HashMap<>();
+ metadata.put("memo", "수시결제 테스트");
+ params.metadata = metadata;
+
+ BootpayStoreResponse res = bootpayStore.asSupervisor().orderSubscription.supervisorCharge(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription supervisorCharge success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription supervisorCharge false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // 수시결제(온디맨드) charge_key 해지
+ public static void supervisorChargeRevoke() {
+ try {
+ SupervisorOrderSubscriptionChargeRevokeParams params = new SupervisorOrderSubscriptionChargeRevokeParams();
+ params.chargeKey = "6d1f1a2b3c4d5e6f70819200";
+
+ BootpayStoreResponse res = bootpayStore.asSupervisor().orderSubscription.supervisorChargeRevoke(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription supervisorChargeRevoke success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription supervisorChargeRevoke false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
public static void approve() {
}
diff --git a/core/src/main/java/kr/co/bootpay/pg/Bootpay.java b/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
index 569db24..f75dc39 100644
--- a/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
+++ b/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
@@ -29,6 +29,11 @@ public HashMap lookupBillingKeyByKey(String billingKey) throws E
return BillingService.lookupBillingKeyByKey(this, billingKey);
}
+ // 우선순위 결제 빌링키 조회
+ public HashMap lookupSequentialBillingKey(String widgetKey, String billingKey) throws Exception {
+ return BillingService.lookupSequentialBillingKey(this, widgetKey, billingKey);
+ }
+
public HashMap lookupPaymentMethods() throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java b/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
index f2a774a..43ae31c 100644
--- a/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
+++ b/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
@@ -3,8 +3,12 @@
import kr.co.bootpay.pg.BootpayObject;
import kr.co.bootpay.pg.model.request.Subscribe;
import kr.co.bootpay.pg.model.request.SubscribePayload;
+import org.apache.http.NameValuePair;
+import org.apache.http.message.BasicNameValuePair;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
public class BillingService {
@@ -39,6 +43,18 @@ static public HashMap lookupBillingKeyByKey(BootpayObject bootpa
return bootpay.doGet("billing_key/" + billingKey);
}
+ // 우선순위 결제 빌링키 조회
+ static public HashMap lookupSequentialBillingKey(BootpayObject bootpay, String widgetKey, String billingKey) throws Exception {
+ validateToken(bootpay);
+ if (widgetKey == null || widgetKey.isEmpty()) throw new Exception("widgetKey 값이 비어있습니다.");
+ if (billingKey == null || billingKey.isEmpty()) throw new Exception("billingKey 값이 비어있습니다.");
+
+ List nameValuePairList = new ArrayList<>();
+ nameValuePairList.add(new BasicNameValuePair("widget_key", widgetKey));
+
+ return bootpay.doGet("subscribe/sequential_billing_key/" + billingKey, nameValuePairList);
+ }
+
static public HashMap destroyBillingKey(BootpayObject bootpay, String billingKey) throws Exception {
validateToken(bootpay);
if (billingKey == null || billingKey.isEmpty()) throw new Exception("billingKey 값이 비어있습니다.");
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
index 34f83f3..f3306bf 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
@@ -10,6 +10,8 @@
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionTerminateParams;
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionPauseParams;
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionResumeParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionChargeParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionChargeRevokeParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.service.order_subscriptions.SOrderSubscriptionService;
@@ -55,4 +57,12 @@ public BootpayStoreResponse supervisorPause(String orderSubscriptionId, Supervis
public BootpayStoreResponse supervisorResume(String orderSubscriptionId, SupervisorOrderSubscriptionResumeParams params) throws Exception {
return SOrderSubscriptionService.supervisorResume(bootpay, orderSubscriptionId, params);
}
+
+ public BootpayStoreResponse supervisorCharge(SupervisorOrderSubscriptionChargeParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorCharge(bootpay, params);
+ }
+
+ public BootpayStoreResponse supervisorChargeRevoke(SupervisorOrderSubscriptionChargeRevokeParams params) throws Exception {
+ return SOrderSubscriptionService.supervisorChargeRevoke(bootpay, params);
+ }
}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeParams.java
new file mode 100644
index 0000000..9111a31
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeParams.java
@@ -0,0 +1,13 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+import java.util.Map;
+
+public class SupervisorOrderSubscriptionChargeParams {
+ // Idempotency-Key 헤더로 전송되므로 body에는 포함하지 않는다
+ public transient String idempotencyKey;
+ public String chargeKey;
+ public Double price;
+ public Double taxFreePrice;
+ public Map user;
+ public Map metadata;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeRevokeParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeRevokeParams.java
new file mode 100644
index 0000000..a38dd85
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/SupervisorOrderSubscriptionChargeRevokeParams.java
@@ -0,0 +1,10 @@
+package kr.co.bootpay.store.model.request.orderSubscription;
+
+import java.util.Map;
+
+public class SupervisorOrderSubscriptionChargeRevokeParams {
+ // Idempotency-Key 헤더로 전송되므로 body에는 포함하지 않는다
+ public transient String idempotencyKey;
+ public String chargeKey;
+ public Map user;
+}
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 51ad5d8..8c93ff7 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
@@ -11,10 +11,14 @@
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionTerminateParams;
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionPauseParams;
import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionResumeParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionChargeParams;
+import kr.co.bootpay.store.model.request.orderSubscription.SupervisorOrderSubscriptionChargeRevokeParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import kr.co.bootpay.http.HttpDeleteWithBody;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
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.apache.http.impl.client.HttpClientBuilder;
@@ -22,7 +26,10 @@
import org.apache.http.NameValuePair;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.UUID;
public class SOrderSubscriptionService {
@@ -110,6 +117,61 @@ static public BootpayStoreResponse supervisorResume(BootpayStoreObject bootpay,
return supervisorAction(bootpay, "order_subscriptions/" + orderSubscriptionId + "/resume", params == null ? new SupervisorOrderSubscriptionResumeParams() : params);
}
+ // 수시결제(온디맨드) charge_key 즉시 결제
+ // charge_key는 body로만 전송한다 (URL/query 금지 - 액세스 로그 노출 방지)
+ static public BootpayStoreResponse supervisorCharge(BootpayStoreObject bootpay, SupervisorOrderSubscriptionChargeParams params) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
+ throw new Exception("token 값이 비어있습니다.");
+ }
+ if (params == null) {
+ throw new Exception("params 값이 비어있습니다");
+ }
+ if (params.chargeKey == null || params.chargeKey.isEmpty()) {
+ throw new Exception("charge_key 값이 비어있습니다");
+ }
+ if (params.price == null) {
+ throw new Exception("price 금액을 설정을 해주세요.");
+ }
+ HttpClient client = HttpClientBuilder.create().build();
+ Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+ HttpPost post = bootpay.httpPost(
+ "order_subscriptions/charge",
+ new StringEntity(gson.toJson(params), "UTF-8"),
+ supervisorHeaders(params.idempotencyKey)
+ );
+ HttpResponse response = client.execute(post);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ // 수시결제(온디맨드) charge_key 해지
+ // 해지 이후 해당 키로의 재결제는 불가능하다
+ static public BootpayStoreResponse supervisorChargeRevoke(BootpayStoreObject bootpay, SupervisorOrderSubscriptionChargeRevokeParams params) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
+ throw new Exception("token 값이 비어있습니다.");
+ }
+ if (params == null) {
+ throw new Exception("params 값이 비어있습니다");
+ }
+ if (params.chargeKey == null || params.chargeKey.isEmpty()) {
+ throw new Exception("charge_key 값이 비어있습니다");
+ }
+ HttpClient client = HttpClientBuilder.create().build();
+ Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+ HttpDeleteWithBody delete = bootpay.httpDeleteWithBody("order_subscriptions/charge", new StringEntity(gson.toJson(params), "UTF-8"));
+ for (Map.Entry entry : supervisorHeaders(params.idempotencyKey).entrySet()) {
+ delete.setHeader(entry.getKey(), entry.getValue());
+ }
+ HttpResponse response = client.execute(delete);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static private Map supervisorHeaders(String idempotencyKey) {
+ Map headers = new HashMap<>();
+ headers.put("Idempotency-Key", (idempotencyKey == null || idempotencyKey.isEmpty()) ? UUID.randomUUID().toString() : idempotencyKey);
+ headers.put("BOOTPAY-ROLE", "supervisor");
+ return headers;
+ }
+
static private BootpayStoreResponse supervisorAction(BootpayStoreObject bootpay, String uri, Object params) throws Exception {
if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
throw new Exception("token 값이 비어있습니다.");
From 38ce1a6eddf5346ab1a9594b73c9ab2e03877a51 Mon Sep 17 00:00:00 2001
From: Bootpay SDK Bot
Date: Fri, 14 Aug 2026 07:09:19 +0000
Subject: [PATCH 09/17] =?UTF-8?q?sync:=20817dbe80=20=EB=B2=84=EA=B7=B8=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: git.bootpay.co.kr/bootpay-sdk/backend-ruby@817dbe804b30af68817b912c2fde28b787040ff2
---
.../co/bootpay/store/BootpayStoreObject.java | 74 ++++++------
.../store/BootpayStoreObjectAuthTest.java | 105 ++++++++++++++++++
2 files changed, 144 insertions(+), 35 deletions(-)
create mode 100644 core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
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 a4bfc8d..051aa41 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
@@ -22,6 +22,7 @@
import java.io.File;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -87,9 +88,28 @@ public void setRole(String role) {
@Deprecated
public String requestAccessToken() {
- if((tokenPayload.clientKey == null || tokenPayload.clientKey.isEmpty()) && (tokenPayload.secretKey == null || tokenPayload.secretKey.isEmpty())) return "";
- String credentials = tokenPayload.clientKey + ":" + tokenPayload.secretKey;
- String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
+ String encoded = basicAuthentification();
+ if(encoded == null) return "";
+ return "Basic " + encoded;
+ }
+
+ // basic authenticate 값을 생성한다 (토큰으로 저장하지 않는다)
+ public String basicAuthentification() {
+ if(tokenPayload == null) return null;
+ String clientKey = tokenPayload.clientKey == null ? "" : tokenPayload.clientKey;
+ String secretKey = tokenPayload.secretKey == null ? "" : tokenPayload.secretKey;
+ if(clientKey.isEmpty() && secretKey.isEmpty()) return null;
+ String credentials = clientKey + ":" + secretKey;
+ return Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ }
+
+ // 토큰이 있으면 Bearer, 없으면 client key / secret key 기반의 Basic 인증을 사용한다
+ public String getAuthorizationHeader(RequestContext context) {
+ String tokenToUse = (context != null && context.getToken() != null) ? context.getToken() : this.getToken();
+ if(tokenToUse != null && !tokenToUse.isEmpty()) return "Bearer " + tokenToUse;
+
+ String encoded = basicAuthentification();
+ if(encoded == null) return null;
return "Basic " + encoded;
}
@@ -114,8 +134,8 @@ public HttpGet httpGet(String url, RequestContext context) throws Exception {
}
get.setHeader("BOOTPAY-ROLE", roleToUse);
- String tokenToUse = (context != null && context.getToken() != null) ? context.getToken() : this.getToken();
- if(tokenToUse != null) get.setHeader("Authorization", "Bearer " + tokenToUse);
+ String authorization = getAuthorizationHeader(context);
+ if(authorization != null) get.setHeader("Authorization", authorization);
get.setURI(uri);
return get;
@@ -141,8 +161,8 @@ public HttpGet httpGet(String url, List nameValuePairList, Reques
}
get.setHeader("BOOTPAY-ROLE", roleToUse);
- String tokenToUse = (context != null && context.getToken() != null) ? context.getToken() : this.getToken();
- if(tokenToUse != null) get.setHeader("Authorization", "Bearer " + tokenToUse);
+ String authorization = getAuthorizationHeader(context);
+ if(authorization != null) get.setHeader("Authorization", authorization);
URI uri = new URIBuilder(get.getURI()).addParameters(nameValuePairList).build();
get.setURI(uri);
@@ -170,12 +190,8 @@ public HttpPost httpPost(String url, StringEntity entity, RequestContext context
}
post.setHeader("BOOTPAY-ROLE", roleToUse);
- String tokenToUse = (context != null && context.getToken() != null) ? context.getToken() : this.getToken();
- if(tokenToUse != null) {
- post.setHeader("Authorization", "Bearer " + tokenToUse);
- } else { //토큰 발급
- post.setHeader("Authorization", requestAccessToken());
- }
+ String authorization = getAuthorizationHeader(context);
+ if(authorization != null) post.setHeader("Authorization", authorization);
post.setEntity(entity);
return post;
@@ -202,12 +218,8 @@ public HttpPost httpPost(String url, StringEntity entity, Map he
}
post.setHeader("BOOTPAY-ROLE", roleToUse);
- String tokenToUse = (context != null && context.getToken() != null) ? context.getToken() : this.getToken();
- if(tokenToUse != null) {
- post.setHeader("Authorization", getTokenValue());
- } else { //토큰 발급
- post.setHeader("Authorization", requestAccessToken());
- }
+ String authorization = getAuthorizationHeader(context);
+ if(authorization != null) post.setHeader("Authorization", authorization);
// 사용자 정의 헤더 추가
if (header != null) {
@@ -239,10 +251,8 @@ public HttpPost httpPostMultipart(String url, List files, HashMap
Date: Fri, 14 Aug 2026 14:13:49 +0000
Subject: [PATCH 10/17] =?UTF-8?q?sync:=20817dbe80=20=EB=B2=84=EA=B7=B8=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: git.bootpay.co.kr/bootpay-sdk/backend-ruby@817dbe804b30af68817b912c2fde28b787040ff2
---
.../example/bootpay/store/MallSetting.java | 70 ++++++
.../kr/co/bootpay/store/BootpayStore.java | 2 +
.../co/bootpay/store/layer/MallSetting.java | 40 ++++
.../mallSetting/MallSettingUpdateParams.java | 222 ++++++++++++++++++
.../mall_setting/SMallSettingService.java | 85 +++++++
.../mall_setting/SMallSettingServiceTest.java | 103 ++++++++
6 files changed, 522 insertions(+)
create mode 100644 app/src/main/java/com/example/bootpay/store/MallSetting.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/layer/MallSetting.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/mallSetting/MallSettingUpdateParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/service/mall_setting/SMallSettingService.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/mall_setting/SMallSettingServiceTest.java
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..8414e52
--- /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.request.mallSetting.MallSettingUpdateParams;
+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.get();
+ 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 {
+ MallSettingUpdateParams params = new MallSettingUpdateParams();
+ 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/core/src/main/java/kr/co/bootpay/store/BootpayStore.java b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
index 0b4b68b..6acc0a4 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
@@ -14,6 +14,7 @@ public class BootpayStore extends BootpayStoreObject {
public UserGroup userGroup;
public Product product;
public Store store;
+ public MallSetting mallSetting;
public Invoice invoice;
public Order order;
public OrderCancel orderCancel;
@@ -43,6 +44,7 @@ private void initModules() {
this.userGroup = new UserGroup(this);
this.product = new Product(this);
this.store = new Store(this);
+ this.mallSetting = new MallSetting(this);
this.invoice = new Invoice(this);
this.order = new Order(this);
this.orderCancel = new OrderCancel(this);
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/MallSetting.java b/core/src/main/java/kr/co/bootpay/store/layer/MallSetting.java
new file mode 100644
index 0000000..2afc22c
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/layer/MallSetting.java
@@ -0,0 +1,40 @@
+package kr.co.bootpay.store.layer;
+
+import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.model.request.mallSetting.MallSettingUpdateParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import kr.co.bootpay.store.service.mall_setting.SMallSettingService;
+
+public class MallSetting {
+ private final BootpayStore bootpay;
+
+ public MallSetting(BootpayStore bootpay) {
+ this.bootpay = bootpay;
+ }
+
+ public BootpayStoreResponse get() throws Exception {
+ return SMallSettingService.getMallSetting(bootpay, null);
+ }
+
+ public BootpayStoreResponse get(String idempotencyKey) throws Exception {
+ return SMallSettingService.getMallSetting(bootpay, idempotencyKey);
+ }
+
+ // Mall API alias
+ public BootpayStoreResponse getMallSetting() throws Exception {
+ return get();
+ }
+
+ public BootpayStoreResponse getMallSetting(String idempotencyKey) throws Exception {
+ return get(idempotencyKey);
+ }
+
+ public BootpayStoreResponse update(MallSettingUpdateParams params) throws Exception {
+ return SMallSettingService.updateMallSetting(bootpay, params);
+ }
+
+ // Mall API alias
+ public BootpayStoreResponse updateMallSetting(MallSettingUpdateParams params) throws Exception {
+ return update(params);
+ }
+}
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/service/mall_setting/SMallSettingService.java b/core/src/main/java/kr/co/bootpay/store/service/mall_setting/SMallSettingService.java
new file mode 100644
index 0000000..242b7f5
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/service/mall_setting/SMallSettingService.java
@@ -0,0 +1,85 @@
+package kr.co.bootpay.store.service.mall_setting;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.mallSetting.MallSettingUpdateParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClientBuilder;
+
+import java.util.UUID;
+
+public class SMallSettingService {
+ // 몰 설정 조회 (GET mall-setting)
+ // supervisor scope 토큰 전용
+ static public BootpayStoreResponse getMallSetting(BootpayStoreObject bootpay, String idempotencyKey) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = getRequest(bootpay, idempotencyKey);
+ HttpResponse response = client.execute(get);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static public BootpayStoreResponse getMallSetting(BootpayStoreObject bootpay) throws Exception {
+ return getMallSetting(bootpay, null);
+ }
+
+ static public BootpayStoreResponse get(BootpayStoreObject bootpay, String idempotencyKey) throws Exception {
+ return getMallSetting(bootpay, idempotencyKey);
+ }
+
+ static public BootpayStoreResponse get(BootpayStoreObject bootpay) throws Exception {
+ return getMallSetting(bootpay, null);
+ }
+
+ // 몰 설정 수정 (PUT mall-setting)
+ // supervisor scope 토큰 전용
+ // 요청 바디는 flatten 형식이며 값이 설정된(non-null) 필드만 서버로 전송된다
+ static public BootpayStoreResponse updateMallSetting(BootpayStoreObject bootpay, MallSettingUpdateParams params) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpPut put = updateRequest(bootpay, params);
+ HttpResponse response = client.execute(put);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static public BootpayStoreResponse update(BootpayStoreObject bootpay, MallSettingUpdateParams params) throws Exception {
+ return updateMallSetting(bootpay, params);
+ }
+
+ static HttpGet getRequest(BootpayStoreObject bootpay, String idempotencyKey) throws Exception {
+ validateAuthorization(bootpay);
+
+ HttpGet get = bootpay.httpGet("mall-setting");
+ setSupervisorHeaders(get, idempotencyKey);
+ return get;
+ }
+
+ static HttpPut updateRequest(BootpayStoreObject bootpay, MallSettingUpdateParams params) throws Exception {
+ validateAuthorization(bootpay);
+ if (params == null) throw new Exception("params 값이 비어있습니다");
+
+ Gson gson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ HttpPut put = bootpay.httpPut("mall-setting", new StringEntity(gson.toJson(params), "UTF-8"));
+ setSupervisorHeaders(put, params.idempotencyKey);
+ return put;
+ }
+
+ // 토큰이 없다면 client key / secret key 기반의 Basic 인증을 사용한다
+ static private void validateAuthorization(BootpayStoreObject bootpay) throws Exception {
+ if (bootpay.getAuthorizationHeader(null) == null) throw new Exception("token 값이 비어있습니다.");
+ }
+
+ static private void setSupervisorHeaders(HttpRequestBase request, String idempotencyKey) {
+ request.setHeader("Idempotency-Key", (idempotencyKey == null || idempotencyKey.isEmpty()) ? UUID.randomUUID().toString() : idempotencyKey);
+ request.setHeader("BOOTPAY-ROLE", "supervisor");
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/service/mall_setting/SMallSettingServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/mall_setting/SMallSettingServiceTest.java
new file mode 100644
index 0000000..9569783
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/service/mall_setting/SMallSettingServiceTest.java
@@ -0,0 +1,103 @@
+package kr.co.bootpay.store.service.mall_setting;
+
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.TokenPayload;
+import kr.co.bootpay.store.model.request.mallSetting.MallSettingUpdateParams;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.util.EntityUtils;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SMallSettingServiceTest {
+ private static final String CLIENT_KEY = "test_client_key";
+ private static final String SECRET_KEY = "test_secret_key";
+
+ private BootpayStoreObject bootpay() {
+ return new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION");
+ }
+
+ @Test
+ public void 몰설정_조회는_supervisor_role로_요청한다() throws Exception {
+ HttpGet get = SMallSettingService.getRequest(bootpay(), null);
+
+ assertEquals("https://api.bootapi.com/v1/mall-setting", get.getURI().toString());
+ assertEquals("supervisor", get.getFirstHeader("BOOTPAY-ROLE").getValue());
+ assertNotNull(get.getFirstHeader("Idempotency-Key").getValue());
+ }
+
+ @Test
+ public void 전달된_idempotency_key를_헤더로_사용한다() throws Exception {
+ HttpGet get = SMallSettingService.getRequest(bootpay(), "my-idempotency-key");
+
+ assertEquals("my-idempotency-key", get.getFirstHeader("Idempotency-Key").getValue());
+ }
+
+ @Test
+ public void 몰설정_수정은_PUT으로_supervisor_role로_요청한다() throws Exception {
+ MallSettingUpdateParams params = new MallSettingUpdateParams();
+ params.name = "부트페이 스토어";
+
+ HttpPut put = SMallSettingService.updateRequest(bootpay(), params);
+
+ assertEquals("PUT", put.getMethod());
+ assertEquals("https://api.bootapi.com/v1/mall-setting", put.getURI().toString());
+ assertEquals("supervisor", put.getFirstHeader("BOOTPAY-ROLE").getValue());
+ assertNotNull(put.getFirstHeader("Idempotency-Key").getValue());
+ }
+
+ @Test
+ public void 수정_요청은_설정된_값만_snake_case로_전송한다() throws Exception {
+ MallSettingUpdateParams params = new MallSettingUpdateParams();
+ params.idempotencyKey = "my-idempotency-key";
+ params.normalWidgetKey = "widget_key_value";
+ params.sellerName = "부트페이";
+ params.addr1 = "서울시 강남구";
+ params.addr2 = "1층";
+ params.useAgeAccept19 = true;
+ params.useAgeAccept14 = false;
+ params.useOrderCancelApproval = true;
+ params.pointCalcType1 = 1;
+ params.catalogViewTypePc = 2;
+ params.restDay = Arrays.asList("saturday", "sunday");
+
+ String body = EntityUtils.toString(SMallSettingService.updateRequest(bootpay(), params).getEntity(), "UTF-8");
+
+ assertTrue(body.contains("\"normal_widget_key\":\"widget_key_value\""));
+ assertTrue(body.contains("\"seller_name\":\"부트페이\""));
+ assertTrue(body.contains("\"addr_1\":\"서울시 강남구\""));
+ assertTrue(body.contains("\"addr_2\":\"1층\""));
+ assertTrue(body.contains("\"use_age_accept_19\":true"));
+ assertTrue(body.contains("\"use_age_accept_14\":false"));
+ assertTrue(body.contains("\"use_oder_cancel_approval\":true"));
+ assertTrue(body.contains("\"point_calc_type1\":1"));
+ assertTrue(body.contains("\"catalog_view_type_pc\":2"));
+ assertTrue(body.contains("\"rest_day\":[\"saturday\",\"sunday\"]"));
+
+ // 값이 설정되지 않은 필드는 전송하지 않는다 (ruby의 compact 동작)
+ assertFalse(body.contains("biz_email"));
+ assertFalse(body.contains("use_cart"));
+ // idempotency key는 헤더로만 전송한다
+ assertFalse(body.contains("idempotency"));
+ }
+
+ @Test
+ public void 인증정보가_없으면_예외를_발생시킨다() {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(), "PRODUCTION");
+
+ assertThrows(Exception.class, () -> SMallSettingService.getRequest(bootpay, null));
+ assertThrows(Exception.class, () -> SMallSettingService.updateRequest(bootpay, new MallSettingUpdateParams()));
+ }
+
+ @Test
+ public void params가_없으면_예외를_발생시킨다() {
+ assertThrows(Exception.class, () -> SMallSettingService.updateRequest(bootpay(), null));
+ }
+}
From e182c5788b891522581a67ccf6946650ee3a87a8 Mon Sep 17 00:00:00 2001
From: Bootpay SDK Bot
Date: Tue, 18 Aug 2026 10:18:22 +0000
Subject: [PATCH 11/17] =?UTF-8?q?sync:=203b22da9d=20*=20github-ruby=20?=
=?UTF-8?q?=EC=A0=84=EC=9A=A9=EB=B6=84=20=EB=B0=98=EC=98=81=20(webhook=20?=
=?UTF-8?q?=C2=B7=20image=5Fdestroy=20=C2=B7=20billing=5Fkey=20user=5Fid)?=
=?UTF-8?q?=20*=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20API=2027=EC=A2=85=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EC=A3=BD=EC=9D=80=20=EA=B2=BD?=
=?UTF-8?q?=EB=A1=9C=205=EA=B1=B4=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: git.bootpay.co.kr/bootpay-sdk/backend-ruby@3b22da9d52c64cc943de9c5105bd411e9415c76f
---
.../com/example/bootpay/store/Webhook.java | 67 +++++++++
.../store/order_subscription/request/Ing.java | 47 ++++++-
.../order_subscription/request/Request.java | 109 +++++++++++++++
.../main/java/kr/co/bootpay/pg/Bootpay.java | 5 +
.../co/bootpay/pg/service/BillingService.java | 14 +-
.../kr/co/bootpay/store/BootpayStore.java | 2 +
.../kr/co/bootpay/store/layer/Invoice.java | 1 +
.../store/layer/OrderSubscription.java | 3 +
.../kr/co/bootpay/store/layer/Product.java | 8 ++
.../kr/co/bootpay/store/layer/Webhook.java | 22 +++
.../request/OrderSubscriptionRequest.java | 42 ++++++
.../request/OrderSubscriptionRequestIng.java | 16 +++
.../request/invoice/InvoiceListParams.java | 17 ++-
.../order/cancel/OrderCancelActionParams.java | 13 ++
.../OrderSubscriptionRequestListParams.java | 16 +++
.../OrderSubscriptionRequestUpdateParams.java | 20 +++
.../ing/OrderSubscriptionTransferParams.java | 12 ++
.../request/webhook/TestWebhookParams.java | 7 +
.../service/invoices/SInvoiceService.java | 42 ++++--
.../SOrderSubscriptionRequestIngService.java | 37 ++++-
.../SOrderSubscriptionRequestService.java | 128 +++++++++++++++++
.../service/orders/SOrderCancelService.java | 12 +-
.../service/products/SProductService.java | 17 +++
.../service/webhook/SWebhookService.java | 46 ++++++
.../pg/service/BillingServiceTest.java | 47 +++++++
.../bootpay/store/BootpayStoreModuleTest.java | 26 ++++
.../cancel/OrderCancelActionParamsTest.java | 31 ++++
.../service/invoices/SInvoiceServiceTest.java | 77 ++++++++++
.../SOrderSubscriptionRequestServiceTest.java | 132 ++++++++++++++++++
.../service/webhook/SWebhookServiceTest.java | 55 ++++++++
30 files changed, 1042 insertions(+), 29 deletions(-)
create mode 100644 app/src/main/java/com/example/bootpay/store/Webhook.java
create mode 100644 app/src/main/java/com/example/bootpay/store/order_subscription/request/Request.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/layer/Webhook.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequest.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestListParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestUpdateParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/ing/OrderSubscriptionTransferParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/request/webhook/TestWebhookParams.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestService.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/service/webhook/SWebhookService.java
create mode 100644 core/src/test/java/kr/co/bootpay/pg/service/BillingServiceTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/BootpayStoreModuleTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParamsTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/invoices/SInvoiceServiceTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestServiceTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/webhook/SWebhookServiceTest.java
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..5ddf037
--- /dev/null
+++ b/app/src/main/java/com/example/bootpay/store/Webhook.java
@@ -0,0 +1,67 @@
+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.request.webhook.TestWebhookParams;
+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 {
+ TestWebhookParams params = new TestWebhookParams();
+ params.headerContentType = "application/json";
+
+ BootpayStoreResponse res = bootpayStore.webhook.sendTest(params);
+ 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/Ing.java b/app/src/main/java/com/example/bootpay/store/order_subscription/request/Ing.java
index 9a5b411..a28d3d9 100644
--- a/app/src/main/java/com/example/bootpay/store/order_subscription/request/Ing.java
+++ b/app/src/main/java/com/example/bootpay/store/order_subscription/request/Ing.java
@@ -6,8 +6,10 @@
import kr.co.bootpay.store.BootpayStore;
import kr.co.bootpay.store.model.request.TokenPayload;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPauseParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPurchaseParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionResumeParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTerminationParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTransferParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.model.response.orderSubscription.request.ing.CalcTerminateFeeResponse;
@@ -142,5 +144,48 @@ public static void termination(CalcTerminateFeeResponse calcResponse) {
e.printStackTrace();
}
}
-}
+ // 중도인수 요청
+ public static void purchase() {
+ try {
+ OrderSubscriptionPurchaseParams params = new OrderSubscriptionPurchaseParams();
+
+ params.orderSubscriptionId = "686dc2f2b0eacea5cd974ca2";
+ params.price = 10000.0;
+ params.taxFreePrice = 0.0;
+ params.reason = "중도 인수 요청";
+
+ BootpayStoreResponse res = bootpayStore.orderSubscription.requestIng.purchase(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription purchase success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription purchase false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // 구독 이전/승계 요청
+ public static void transfer() {
+ try {
+ OrderSubscriptionTransferParams params = new OrderSubscriptionTransferParams();
+
+ params.orderSubscriptionId = "686dc2f2b0eacea5cd974ca2";
+ params.newUserId = "6870a0c1b0eacea5cd974f3e";
+ params.newUsername = "홍길동";
+ params.newUserEmail = "help@bootpay.co.kr";
+ params.newUserPhone = "01000000000";
+ params.reason = "구독 승계 요청";
+
+ BootpayStoreResponse res = bootpayStore.orderSubscription.requestIng.transfer(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription transfer success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription transfer 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..4618663
--- /dev/null
+++ b/app/src/main/java/com/example/bootpay/store/order_subscription/request/Request.java
@@ -0,0 +1,109 @@
+package com.example.bootpay.store.order_subscription.request;
+
+import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.model.request.TokenPayload;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestListParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestUpdateParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+
+
+public class Request {
+
+ 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();
+ 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.orderSubscription.request.list(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription request list success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription request list false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // 구독 변경요청 상세 조회
+ public static void detail() {
+ try {
+ BootpayStoreResponse res = bootpayStore.orderSubscription.request.detail("686dc2f2b0eacea5cd974ca2");
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription request detail success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription request detail false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // 구독 변경요청 승인
+ public static void approve() {
+ try {
+ OrderSubscriptionRequestUpdateParams params = new OrderSubscriptionRequestUpdateParams();
+ params.requestHistoryId = "686dc2f2b0eacea5cd974ca2";
+ params.reason = "승인 처리";
+
+ BootpayStoreResponse res = bootpayStore.orderSubscription.request.approve(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription request approve success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription request approve false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ // 구독 변경요청 반려
+ public static void reject() {
+ try {
+ OrderSubscriptionRequestUpdateParams params = new OrderSubscriptionRequestUpdateParams();
+ params.requestHistoryId = "686dc2f2b0eacea5cd974ca2";
+ params.reason = "반려 처리";
+
+ BootpayStoreResponse res = bootpayStore.orderSubscription.request.reject(params);
+ if(res.isSuccess()) {
+ System.out.println("orderSubscription request reject success: " + res.getData());
+ } else {
+ System.out.println("orderSubscription request reject false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/core/src/main/java/kr/co/bootpay/pg/Bootpay.java b/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
index f75dc39..c480515 100644
--- a/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
+++ b/core/src/main/java/kr/co/bootpay/pg/Bootpay.java
@@ -34,6 +34,11 @@ public HashMap lookupSequentialBillingKey(String widgetKey, Stri
return BillingService.lookupSequentialBillingKey(this, widgetKey, billingKey);
}
+ // 우선순위 결제 빌링키 조회 (user_id 로 조회 대상을 한정한다)
+ public HashMap lookupSequentialBillingKey(String widgetKey, String billingKey, String userId) throws Exception {
+ return BillingService.lookupSequentialBillingKey(this, widgetKey, billingKey, userId);
+ }
+
public HashMap lookupPaymentMethods() throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java b/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
index 43ae31c..52c6a0b 100644
--- a/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
+++ b/core/src/main/java/kr/co/bootpay/pg/service/BillingService.java
@@ -45,14 +45,24 @@ static public HashMap lookupBillingKeyByKey(BootpayObject bootpa
// 우선순위 결제 빌링키 조회
static public HashMap lookupSequentialBillingKey(BootpayObject bootpay, String widgetKey, String billingKey) throws Exception {
+ return lookupSequentialBillingKey(bootpay, widgetKey, billingKey, null);
+ }
+
+ // 우선순위 결제 빌링키 조회 (user_id 로 조회 대상을 한정한다)
+ static public HashMap lookupSequentialBillingKey(BootpayObject bootpay, String widgetKey, String billingKey, String userId) throws Exception {
validateToken(bootpay);
if (widgetKey == null || widgetKey.isEmpty()) throw new Exception("widgetKey 값이 비어있습니다.");
if (billingKey == null || billingKey.isEmpty()) throw new Exception("billingKey 값이 비어있습니다.");
+ return bootpay.doGet("subscribe/sequential_billing_key/" + billingKey, sequentialBillingKeyParams(widgetKey, userId));
+ }
+
+ // 값이 설정되지 않은 필드는 전송하지 않는다
+ static List sequentialBillingKeyParams(String widgetKey, String userId) {
List nameValuePairList = new ArrayList<>();
nameValuePairList.add(new BasicNameValuePair("widget_key", widgetKey));
-
- return bootpay.doGet("subscribe/sequential_billing_key/" + billingKey, nameValuePairList);
+ if (userId != null && !userId.isEmpty()) nameValuePairList.add(new BasicNameValuePair("user_id", userId));
+ return nameValuePairList;
}
static public HashMap destroyBillingKey(BootpayObject bootpay, String billingKey) throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/store/BootpayStore.java b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
index 6acc0a4..8c76b21 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStore.java
@@ -21,6 +21,7 @@ public class BootpayStore extends BootpayStoreObject {
public OrderSubscription orderSubscription;
public OrderSubscriptionBill orderSubscriptionBill;
public OrderSubscriptionAdjustment orderSubscriptionAdjustment;
+ public Webhook webhook;
@@ -51,6 +52,7 @@ private void initModules() {
this.orderSubscription = new OrderSubscription(this);
this.orderSubscriptionBill = new OrderSubscriptionBill(this);
this.orderSubscriptionAdjustment = new OrderSubscriptionAdjustment(this);
+ this.webhook = new Webhook(this);
}
//token
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java b/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
index d0864fd..53450d8 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
@@ -16,6 +16,7 @@ public Invoice(BootpayStore bootpay) {
this.bootpay = bootpay;
}
+ // InvoiceListParams를 넘기면 cs_type / user_id / product_type / css_at / cse_at 필터도 함께 전송한다
public BootpayStoreResponse list(ListParams params) throws Exception {
return SInvoiceService.list(
bootpay,
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
index f3306bf..07589f6 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/OrderSubscription.java
@@ -2,6 +2,7 @@
import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.layer.order_subscription.request.OrderSubscriptionRequest;
import kr.co.bootpay.store.layer.order_subscription.request.OrderSubscriptionRequestIng;
import kr.co.bootpay.store.model.request.orderSubscription.OrderSubscriptionListParams;
import kr.co.bootpay.store.model.request.orderSubscription.OrderSubscriptionUpdateParams;
@@ -18,10 +19,12 @@
public class OrderSubscription {
private final BootpayStore bootpay;
public final OrderSubscriptionRequestIng requestIng;
+ public final OrderSubscriptionRequest request;
public OrderSubscription(BootpayStore bootpay) {
this.bootpay = bootpay;
this.requestIng = new OrderSubscriptionRequestIng(bootpay);
+ this.request = new OrderSubscriptionRequest(bootpay);
}
public BootpayStoreResponse list(OrderSubscriptionListParams params) throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Product.java b/core/src/main/java/kr/co/bootpay/store/layer/Product.java
index c7824c8..c2393d8 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/Product.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Product.java
@@ -25,6 +25,14 @@ public BootpayStoreResponse list(ProductListParams params) throws Exception {
);
}
+ // 이미지가 있으면 multipart, 없으면 JSON으로 전송한다
+ public BootpayStoreResponse create(SProduct product) throws Exception {
+ return SProductService.create(
+ bootpay,
+ product
+ );
+ }
+
public BootpayStoreResponse create(SProduct product, List imagePaths) throws Exception {
return SProductService.create(
bootpay,
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Webhook.java b/core/src/main/java/kr/co/bootpay/store/layer/Webhook.java
new file mode 100644
index 0000000..12d3814
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Webhook.java
@@ -0,0 +1,22 @@
+package kr.co.bootpay.store.layer;
+
+import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.model.request.webhook.TestWebhookParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import kr.co.bootpay.store.service.webhook.SWebhookService;
+
+public class Webhook {
+ private final BootpayStore bootpay;
+
+ public Webhook(BootpayStore bootpay) {
+ this.bootpay = bootpay;
+ }
+
+ public BootpayStoreResponse sendTest() throws Exception {
+ return SWebhookService.sendTestWebhook(bootpay);
+ }
+
+ public BootpayStoreResponse sendTest(TestWebhookParams params) throws Exception {
+ return SWebhookService.sendTestWebhook(bootpay, params);
+ }
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequest.java b/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequest.java
new file mode 100644
index 0000000..902600e
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequest.java
@@ -0,0 +1,42 @@
+package kr.co.bootpay.store.layer.order_subscription.request;
+
+import kr.co.bootpay.store.BootpayStore;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestListParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestUpdateParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import kr.co.bootpay.store.service.order_subscriptions.request.SOrderSubscriptionRequestService;
+
+public class OrderSubscriptionRequest {
+ private final BootpayStore bootpay;
+
+ public OrderSubscriptionRequest(BootpayStore bootpay) {
+ this.bootpay = bootpay;
+ }
+
+ public BootpayStoreResponse list(OrderSubscriptionRequestListParams params) throws Exception {
+ return SOrderSubscriptionRequestService.list(
+ bootpay,
+ params
+ );
+ }
+
+ public BootpayStoreResponse detail(String requestHistoryId) throws Exception {
+ return SOrderSubscriptionRequestService.detail(bootpay, requestHistoryId);
+ }
+
+ public BootpayStoreResponse detail(String requestHistoryId, String projectId) throws Exception {
+ return SOrderSubscriptionRequestService.detail(bootpay, requestHistoryId, projectId);
+ }
+
+ public BootpayStoreResponse update(OrderSubscriptionRequestUpdateParams params) throws Exception {
+ return SOrderSubscriptionRequestService.update(bootpay, params);
+ }
+
+ public BootpayStoreResponse approve(OrderSubscriptionRequestUpdateParams params) throws Exception {
+ return SOrderSubscriptionRequestService.approve(bootpay, params);
+ }
+
+ public BootpayStoreResponse reject(OrderSubscriptionRequestUpdateParams params) throws Exception {
+ return SOrderSubscriptionRequestService.reject(bootpay, params);
+ }
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequestIng.java b/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequestIng.java
index 534f572..025cea6 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequestIng.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/order_subscription/request/OrderSubscriptionRequestIng.java
@@ -2,8 +2,10 @@
import kr.co.bootpay.store.BootpayStore;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPauseParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPurchaseParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionResumeParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTerminationParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTransferParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.service.order_subscriptions.request.SOrderSubscriptionRequestIngService;
@@ -59,6 +61,20 @@ public BootpayStoreResponse termination(OrderSubscriptionTerminationParams param
);
}
+ public BootpayStoreResponse purchase(OrderSubscriptionPurchaseParams params) throws Exception {
+ return SOrderSubscriptionRequestIngService.purchase(
+ bootpay,
+ params
+ );
+ }
+
+ public BootpayStoreResponse transfer(OrderSubscriptionTransferParams params) throws Exception {
+ return SOrderSubscriptionRequestIngService.transfer(
+ bootpay,
+ params
+ );
+ }
+
// public BootpayStoreResponse detail(String orderSubscriptionId) throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/invoice/InvoiceListParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/invoice/InvoiceListParams.java
index b92a6a7..3879c4c 100644
--- a/core/src/main/java/kr/co/bootpay/store/model/request/invoice/InvoiceListParams.java
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/invoice/InvoiceListParams.java
@@ -1,12 +1,15 @@
package kr.co.bootpay.store.model.request.invoice;
-public class InvoiceListParams {
+import kr.co.bootpay.store.model.request.ListParams;
+
+// 청구서 목록 조회 파라미터 (GET invoices)
+// 응답은 { list: [...], count: N } 구조이며 서버 기본 limit은 24다
+public class InvoiceListParams extends ListParams {
public Integer type;
- public String keyword;
- // public String csType;
-// public String cssAt; //검색 시작일
-// public String cseAt; //검색 종료일
- public Integer page;
- public Integer limit;
+ public String csType;
+ public String userId;
+ public Integer productType;
+ public String cssAt; //검색 시작일
+ public String cseAt; //검색 종료일
}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParams.java
index 9e26a56..1318a17 100644
--- a/core/src/main/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParams.java
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParams.java
@@ -1,6 +1,19 @@
package kr.co.bootpay.store.model.request.order.cancel;
public class OrderCancelActionParams {
+ // 서버(v1/order/cancel_controller)는 approve/reject/withdraw 셋 다 params[:id]를
+ // order_cancellation_request_id로 동일하게 취급한다. reject와 이름이 달라 다른 값처럼 보였던
+ // 문제를 orderCancellationRequestId로 맞춘다. 구 이름(orderCancelRequestHistoryId)도 계속 받는다.
+ public String orderCancellationRequestId;
+
+ @Deprecated
public String orderCancelRequestHistoryId;
+
public String message; // 승인/거절 메시지
+
+ // 신규 인자명을 우선하고, 없으면 구 인자명으로 폴백한다 (하위호환)
+ public String resolveOrderCancellationRequestId() {
+ if (orderCancellationRequestId != null && !orderCancellationRequestId.isEmpty()) return orderCancellationRequestId;
+ return orderCancelRequestHistoryId;
+ }
}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestListParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestListParams.java
new file mode 100644
index 0000000..53a8816
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestListParams.java
@@ -0,0 +1,16 @@
+package kr.co.bootpay.store.model.request.orderSubscription.request;
+
+import kr.co.bootpay.store.model.request.ListParams;
+
+// 구독 변경요청 목록 조회 파라미터 (GET order-subscription-requests)
+// projectId를 지정하면 supervisor role(프로젝트 전체 검색)로, 없으면 user role(본인 요청)로 요청한다
+public class OrderSubscriptionRequestListParams extends ListParams {
+ public String projectId;
+ public String orderSubscriptionId;
+ public String sAt;
+ public String eAt;
+ public Integer status;
+ public Integer requestType;
+ public String userId;
+ public String userGroupId;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestUpdateParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestUpdateParams.java
new file mode 100644
index 0000000..b34a288
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/OrderSubscriptionRequestUpdateParams.java
@@ -0,0 +1,20 @@
+package kr.co.bootpay.store.model.request.orderSubscription.request;
+
+// 구독 변경요청 승인/반려 파라미터 (PUT order-subscription-requests/:id)
+// 승인과 반려는 별도 액션이 아니라 approval 값으로 갈린다
+// (서버가 params[:action]을 Rails 예약어로 사용하기 때문에 키 이름이 approval이다)
+public class OrderSubscriptionRequestUpdateParams {
+ public static final String APPROVAL_APPROVE = "approve";
+ public static final String APPROVAL_REJECT = "reject";
+
+ public transient String requestHistoryId; // URL path로만 사용하며 body에는 담지 않는다
+
+ public String approval; // approve | reject
+ public String reason;
+ public Double price;
+ public Double taxFreePrice;
+ public Double terminationFee;
+ public Double lastBillRefundPrice;
+ public Double finalFee;
+ public String serviceEndAt;
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/ing/OrderSubscriptionTransferParams.java b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/ing/OrderSubscriptionTransferParams.java
new file mode 100644
index 0000000..f53f5ab
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/model/request/orderSubscription/request/ing/OrderSubscriptionTransferParams.java
@@ -0,0 +1,12 @@
+package kr.co.bootpay.store.model.request.orderSubscription.request.ing;
+
+public class OrderSubscriptionTransferParams {
+ public String orderSubscriptionId;
+ public String newUserId; //승계받을 회원 id
+ public String newUsername; //승계받을 회원 이름
+ public String newUserEmail; //승계받을 회원 이메일
+ public String newUserPhone; //승계받을 회원 연락처
+ public String newUserAddress; //승계받을 회원 주소
+ public String walletId; //승계 이후 사용할 결제수단 id
+ public String reason;
+}
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/service/invoices/SInvoiceService.java b/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java
index fcc08fa..501ff54 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
@@ -7,6 +7,7 @@
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import kr.co.bootpay.store.model.pojo.SInvoice;
import kr.co.bootpay.store.model.request.ListParams;
+import kr.co.bootpay.store.model.request.invoice.InvoiceListParams;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
@@ -44,27 +45,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);
}
static public BootpayStoreResponse notify(BootpayStoreObject bootpay, String invoiceId, List sendTypes) throws Exception {
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 5efa0f9..1592311 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
@@ -7,8 +7,10 @@
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.request.ing.OrderSubscriptionPauseParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionPurchaseParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionResumeParams;
import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTerminationParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.ing.OrderSubscriptionTransferParams;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
@@ -121,10 +123,39 @@ static public BootpayStoreResponse termination(BootpayStoreObject bootpay, Order
}
+ // 중도인수 요청 (POST order_subscriptions/requests/ing/purchase)
+ static public BootpayStoreResponse purchase(BootpayStoreObject bootpay, OrderSubscriptionPurchaseParams params) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
+ throw new Exception("token 값이 비어있습니다.");
+ }
+ HttpClient client = HttpClientBuilder.create().build();
-// static public BootpayStoreResponse purchase(BootpayStoreObject bootpay, OrderSubscriptionPauseParams params) throws Exception {
-//
-// }
+ Gson gson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ HttpPost post = bootpay.httpPost("order_subscriptions/requests/ing/purchase", new StringEntity(gson.toJson(params), "UTF-8"));
+
+ HttpResponse response = client.execute(post);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ // 구독 이전/승계 요청 (POST order_subscriptions/requests/ing/transfer)
+ static public BootpayStoreResponse transfer(BootpayStoreObject bootpay, OrderSubscriptionTransferParams params) throws Exception {
+ if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
+ throw new Exception("token 값이 비어있습니다.");
+ }
+ HttpClient client = HttpClientBuilder.create().build();
+
+ Gson gson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ HttpPost post = bootpay.httpPost("order_subscriptions/requests/ing/transfer", new StringEntity(gson.toJson(params), "UTF-8"));
+
+ HttpResponse response = client.execute(post);
+ return bootpay.responseToJsonObject(response);
+ }
// static public BootpayStoreResponse calculatePurchasePrice(BootpayStoreObject bootpay, String orderSubscriptionId) throws Exception {
diff --git a/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestService.java
new file mode 100644
index 0000000..954ab0f
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestService.java
@@ -0,0 +1,128 @@
+package kr.co.bootpay.store.service.order_subscriptions.request;
+
+import com.google.gson.FieldNamingPolicy;
+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.orderSubscription.request.OrderSubscriptionRequestListParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestUpdateParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.message.BasicNameValuePair;
+
+import java.util.ArrayList;
+import java.util.List;
+
+// 구독 변경요청 리소스 (order-subscription-requests)
+// ⚠️ 하이픈 경로다. order_subscriptions · order_subscription_bills 는 언더스코어이므로 복사시 주의할 것.
+public class SOrderSubscriptionRequestService {
+
+ // 구독 변경요청 목록 (GET order-subscription-requests)
+ static public BootpayStoreResponse list(BootpayStoreObject bootpay, OrderSubscriptionRequestListParams params) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = listRequest(bootpay, params);
+ HttpResponse response = client.execute(get);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ // 구독 변경요청 상세 (GET order-subscription-requests/:id)
+ static public BootpayStoreResponse detail(BootpayStoreObject bootpay, String requestHistoryId) throws Exception {
+ return detail(bootpay, requestHistoryId, null);
+ }
+
+ static public BootpayStoreResponse detail(BootpayStoreObject bootpay, String requestHistoryId, String projectId) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = detailRequest(bootpay, requestHistoryId, projectId);
+ HttpResponse response = client.execute(get);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ // 구독 변경요청 승인/반려 (PUT order-subscription-requests/:id)
+ static public BootpayStoreResponse update(BootpayStoreObject bootpay, OrderSubscriptionRequestUpdateParams params) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpPut put = updateRequest(bootpay, params);
+ HttpResponse response = client.execute(put);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static public BootpayStoreResponse approve(BootpayStoreObject bootpay, OrderSubscriptionRequestUpdateParams params) throws Exception {
+ if (params == null) throw new Exception("params 값이 비어있습니다");
+ params.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_APPROVE;
+ return update(bootpay, params);
+ }
+
+ static public BootpayStoreResponse reject(BootpayStoreObject bootpay, OrderSubscriptionRequestUpdateParams params) throws Exception {
+ if (params == null) throw new Exception("params 값이 비어있습니다");
+ params.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_REJECT;
+ return update(bootpay, params);
+ }
+
+ static HttpGet listRequest(BootpayStoreObject bootpay, OrderSubscriptionRequestListParams params) throws Exception {
+ validateAuthorization(bootpay);
+
+ String url = "order-subscription-requests";
+ if (params == null) return bootpay.httpGet(url, new RequestContext("user"));
+
+ List nameValuePairList = new ArrayList<>();
+ if (params.projectId != null) nameValuePairList.add(new BasicNameValuePair("project_id", params.projectId));
+ if (params.orderSubscriptionId != null) nameValuePairList.add(new BasicNameValuePair("order_subscription_id", params.orderSubscriptionId));
+ 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.keyword != null) nameValuePairList.add(new BasicNameValuePair("keyword", params.keyword));
+ 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.status != null) nameValuePairList.add(new BasicNameValuePair("status", params.status.toString()));
+ if (params.requestType != null) nameValuePairList.add(new BasicNameValuePair("request_type", params.requestType.toString()));
+ if (params.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", params.userId));
+ if (params.userGroupId != null) nameValuePairList.add(new BasicNameValuePair("user_group_id", params.userGroupId));
+
+ return bootpay.httpGet(url, nameValuePairList, new RequestContext(roleFor(params.projectId)));
+ }
+
+ static HttpGet detailRequest(BootpayStoreObject bootpay, String requestHistoryId, String projectId) throws Exception {
+ validateAuthorization(bootpay);
+ if (requestHistoryId == null || requestHistoryId.isEmpty()) throw new Exception("request_history_id 값이 비어있습니다");
+
+ String url = "order-subscription-requests/" + requestHistoryId;
+ RequestContext context = new RequestContext(roleFor(projectId));
+ if (projectId == null || projectId.isEmpty()) return bootpay.httpGet(url, context);
+
+ List nameValuePairList = new ArrayList<>();
+ nameValuePairList.add(new BasicNameValuePair("project_id", projectId));
+ return bootpay.httpGet(url, nameValuePairList, context);
+ }
+
+ static HttpPut updateRequest(BootpayStoreObject bootpay, OrderSubscriptionRequestUpdateParams params) throws Exception {
+ validateAuthorization(bootpay);
+ if (params == null) throw new Exception("params 값이 비어있습니다");
+ if (params.requestHistoryId == null || params.requestHistoryId.isEmpty()) throw new Exception("request_history_id 값이 비어있습니다");
+ if (params.approval == null || params.approval.isEmpty()) throw new Exception("approval 값이 비어있습니다");
+
+ Gson gson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ return bootpay.httpPut(
+ "order-subscription-requests/" + params.requestHistoryId,
+ new StringEntity(gson.toJson(params), "UTF-8"),
+ new RequestContext("supervisor")
+ );
+ }
+
+ // project_id를 주면 supervisor(프로젝트 전체 검색), 없으면 user(본인 요청)로 조회한다
+ static private String roleFor(String projectId) {
+ return (projectId == null || projectId.isEmpty()) ? "user" : "supervisor";
+ }
+
+ // 토큰이 없다면 client key / secret key 기반의 Basic 인증을 사용한다
+ static private void validateAuthorization(BootpayStoreObject bootpay) throws Exception {
+ if (bootpay.getAuthorizationHeader(null) == null) throw new Exception("token 값이 비어있습니다.");
+ }
+}
diff --git a/core/src/main/java/kr/co/bootpay/store/service/orders/SOrderCancelService.java b/core/src/main/java/kr/co/bootpay/store/service/orders/SOrderCancelService.java
index b6cc8ea..24c342c 100644
--- a/core/src/main/java/kr/co/bootpay/store/service/orders/SOrderCancelService.java
+++ b/core/src/main/java/kr/co/bootpay/store/service/orders/SOrderCancelService.java
@@ -102,7 +102,7 @@ static public BootpayStoreResponse approve(BootpayStoreObject bootpay, OrderCanc
.create();
// 파일 업로드 요청 (여러 파일)
- HttpPut put = bootpay.httpPut("order/cancel/" + params.orderCancelRequestHistoryId + "/approve", new StringEntity(gson.toJson(params), "UTF-8"));
+ HttpPut put = bootpay.httpPut("order/cancel/" + resolveCancellationId(params) + "/approve", new StringEntity(gson.toJson(params), "UTF-8"));
HttpResponse response = client.execute(put);
return bootpay.responseToJsonObject(response);
@@ -121,7 +121,7 @@ static public BootpayStoreResponse reject(BootpayStoreObject bootpay, OrderCance
.create();
// 파일 업로드 요청 (여러 파일)
- HttpPut put = bootpay.httpPut("order/cancel/" + params.orderCancelRequestHistoryId + "/reject", new StringEntity(gson.toJson(params), "UTF-8"));
+ HttpPut put = bootpay.httpPut("order/cancel/" + resolveCancellationId(params) + "/reject", new StringEntity(gson.toJson(params), "UTF-8"));
HttpResponse response = client.execute(put);
return bootpay.responseToJsonObject(response);
@@ -129,4 +129,12 @@ static public BootpayStoreResponse reject(BootpayStoreObject bootpay, OrderCance
// String str = IOUtils.toString(response.getEntity().getContent(), "UTF-8");
// return responseJson(gson, str, response.getStatusLine().getStatusCode());
}
+
+ // 승인/반려 모두 order_cancellation_request_id를 :id로 사용한다 (구 인자명도 하위호환으로 받는다)
+ static private String resolveCancellationId(OrderCancelActionParams params) throws Exception {
+ if (params == null) throw new Exception("params 값이 비어있습니다");
+ String cancellationId = params.resolveOrderCancellationRequestId();
+ if (cancellationId == null || cancellationId.isEmpty()) throw new Exception("order_cancellation_request_id 값이 비어있습니다");
+ return cancellationId;
+ }
}
diff --git a/core/src/main/java/kr/co/bootpay/store/service/products/SProductService.java b/core/src/main/java/kr/co/bootpay/store/service/products/SProductService.java
index 04f98bd..b189475 100644
--- a/core/src/main/java/kr/co/bootpay/store/service/products/SProductService.java
+++ b/core/src/main/java/kr/co/bootpay/store/service/products/SProductService.java
@@ -26,10 +26,27 @@
public class SProductService {
+ // 이미지 없이 상품을 등록한다 (POST products)
+ // 이미지가 있으면 multipart, 없으면 JSON으로 전송한다
+ static public BootpayStoreResponse create(BootpayStoreObject bootpay, SProduct product) throws Exception {
+ return create(bootpay, product, null);
+ }
+
static public BootpayStoreResponse create(BootpayStoreObject bootpay, SProduct product, List imagePaths) throws Exception {
if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
HttpClient client = HttpClientBuilder.create().build();
+ // 이미지가 없으면 multipart 대신 JSON으로 전송한다
+ if (imagePaths == null || imagePaths.isEmpty()) {
+ Gson jsonGson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ HttpPost jsonPost = bootpay.httpPost("products", new StringEntity(jsonGson.toJson(product), "UTF-8"));
+ HttpResponse jsonResponse = client.execute(jsonPost);
+ return bootpay.responseToJsonObject(jsonResponse);
+ }
+
// URL 리스트를 파일 리스트로 변환
List fileList = new ArrayList<>();
for (URL imageUrl : imagePaths) {
diff --git a/core/src/main/java/kr/co/bootpay/store/service/webhook/SWebhookService.java b/core/src/main/java/kr/co/bootpay/store/service/webhook/SWebhookService.java
new file mode 100644
index 0000000..ba575b8
--- /dev/null
+++ b/core/src/main/java/kr/co/bootpay/store/service/webhook/SWebhookService.java
@@ -0,0 +1,46 @@
+package kr.co.bootpay.store.service.webhook;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.webhook.TestWebhookParams;
+import kr.co.bootpay.store.model.response.BootpayStoreResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClientBuilder;
+
+public class SWebhookService {
+ // 테스트 웹훅을 발송한다 (POST webhook/test)
+ // header_content_type 을 지정하지 않으면 서버 기본값으로 발송된다
+ static public BootpayStoreResponse sendTestWebhook(BootpayStoreObject bootpay, TestWebhookParams params) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpPost post = sendTestWebhookRequest(bootpay, params);
+ HttpResponse response = client.execute(post);
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static public BootpayStoreResponse sendTestWebhook(BootpayStoreObject bootpay) throws Exception {
+ return sendTestWebhook(bootpay, new TestWebhookParams());
+ }
+
+ static HttpPost sendTestWebhookRequest(BootpayStoreObject bootpay, TestWebhookParams params) throws Exception {
+ validateAuthorization(bootpay);
+
+ Gson gson = new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .create();
+
+ // 값이 설정되지 않은 필드는 전송하지 않는다 (ruby의 compact 동작)
+ String body = gson.toJson(params == null ? new TestWebhookParams() : params);
+
+ return bootpay.httpPost("webhook/test", new StringEntity(body, "UTF-8"));
+ }
+
+ // 토큰이 없다면 client key / secret key 기반의 Basic 인증을 사용한다
+ static private void validateAuthorization(BootpayStoreObject bootpay) throws Exception {
+ if (bootpay.getAuthorizationHeader(null) == null) throw new Exception("token 값이 비어있습니다.");
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/pg/service/BillingServiceTest.java b/core/src/test/java/kr/co/bootpay/pg/service/BillingServiceTest.java
new file mode 100644
index 0000000..5ea80d6
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/pg/service/BillingServiceTest.java
@@ -0,0 +1,47 @@
+package kr.co.bootpay.pg.service;
+
+import kr.co.bootpay.pg.BootpayObject;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.methods.HttpGet;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+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 BillingServiceTest {
+
+ private BootpayObject bootpay() {
+ BootpayObject bootpay = new BootpayObject("test_application_id", "test_private_key", "PRODUCTION");
+ bootpay.token = "test_access_token";
+ return bootpay;
+ }
+
+ @Test
+ public void 우선순위_빌링키_조회는_widget_key와_user_id를_쿼리로_전송한다() throws Exception {
+ List params = BillingService.sequentialBillingKeyParams("widget_key_value", "user_id_value");
+ String query = bootpay().httpGet("subscribe/sequential_billing_key/billing_key_value", params).getURI().getQuery();
+
+ assertTrue(query.contains("widget_key=widget_key_value"));
+ assertTrue(query.contains("user_id=user_id_value"));
+ }
+
+ @Test
+ public void user_id가_없으면_widget_key만_전송한다() throws Exception {
+ List params = BillingService.sequentialBillingKeyParams("widget_key_value", null);
+ HttpGet get = bootpay().httpGet("subscribe/sequential_billing_key/billing_key_value", params);
+
+ assertEquals("https://api.bootpay.co.kr/v2/subscribe/sequential_billing_key/billing_key_value?widget_key=widget_key_value",
+ get.getURI().toString());
+ assertFalse(get.getURI().toString().contains("user_id"));
+ }
+
+ @Test
+ public void 필수값이_없으면_예외를_발생시킨다() {
+ assertThrows(Exception.class, () -> BillingService.lookupSequentialBillingKey(bootpay(), null, "billing_key_value", "user_id_value"));
+ assertThrows(Exception.class, () -> BillingService.lookupSequentialBillingKey(bootpay(), "widget_key_value", null, "user_id_value"));
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/BootpayStoreModuleTest.java b/core/src/test/java/kr/co/bootpay/store/BootpayStoreModuleTest.java
new file mode 100644
index 0000000..dfc4e8b
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/BootpayStoreModuleTest.java
@@ -0,0 +1,26 @@
+package kr.co.bootpay.store;
+
+import kr.co.bootpay.store.model.request.TokenPayload;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class BootpayStoreModuleTest {
+
+ private BootpayStore bootpay() {
+ return new BootpayStore(new TokenPayload("test_client_key", "test_secret_key"), "PRODUCTION");
+ }
+
+ @Test
+ public void 웹훅_모듈이_초기화된다() {
+ assertNotNull(bootpay().webhook);
+ }
+
+ @Test
+ public void 구독_변경요청_모듈이_초기화된다() {
+ BootpayStore bootpay = bootpay();
+
+ assertNotNull(bootpay.orderSubscription.request);
+ assertNotNull(bootpay.orderSubscription.requestIng);
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParamsTest.java b/core/src/test/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParamsTest.java
new file mode 100644
index 0000000..67b7ade
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/model/request/order/cancel/OrderCancelActionParamsTest.java
@@ -0,0 +1,31 @@
+package kr.co.bootpay.store.model.request.order.cancel;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class OrderCancelActionParamsTest {
+
+ @Test
+ public void 신규_인자명을_우선한다() {
+ OrderCancelActionParams params = new OrderCancelActionParams();
+ params.orderCancellationRequestId = "new_id";
+ params.orderCancelRequestHistoryId = "legacy_id";
+
+ assertEquals("new_id", params.resolveOrderCancellationRequestId());
+ }
+
+ @Test
+ public void 신규_인자명이_없으면_구_인자명으로_폴백한다() {
+ OrderCancelActionParams params = new OrderCancelActionParams();
+ params.orderCancelRequestHistoryId = "legacy_id";
+
+ assertEquals("legacy_id", params.resolveOrderCancellationRequestId());
+ }
+
+ @Test
+ public void 둘_다_없으면_null을_반환한다() {
+ assertNull(new OrderCancelActionParams().resolveOrderCancellationRequestId());
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/service/invoices/SInvoiceServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/invoices/SInvoiceServiceTest.java
new file mode 100644
index 0000000..9ef3275
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/service/invoices/SInvoiceServiceTest.java
@@ -0,0 +1,77 @@
+package kr.co.bootpay.store.service.invoices;
+
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.ListParams;
+import kr.co.bootpay.store.model.request.TokenPayload;
+import kr.co.bootpay.store.model.request.invoice.InvoiceListParams;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SInvoiceServiceTest {
+ private BootpayStoreObject bootpay() throws Exception {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload("test_client_key", "test_secret_key"), "PRODUCTION");
+ bootpay.setTokenFromAPI("test_access_token");
+ return bootpay;
+ }
+
+ @Test
+ public void 목록은_GET_invoices로_요청한다() throws Exception {
+ HttpGet get = SInvoiceService.listRequest(bootpay(), null);
+
+ assertEquals("https://api.bootapi.com/v1/invoices", get.getURI().toString());
+ assertNull(get.getURI().getQuery());
+ }
+
+ @Test
+ public void 기본_ListParams도_그대로_사용할_수_있다() throws Exception {
+ ListParams params = new ListParams();
+ params.keyword = "청구서";
+ params.page = 1;
+ params.limit = 24;
+
+ String query = SInvoiceService.listRequest(bootpay(), params).getURI().getQuery();
+
+ assertTrue(query.contains("keyword=청구서"));
+ assertTrue(query.contains("page=1"));
+ assertTrue(query.contains("limit=24"));
+ }
+
+ @Test
+ public void InvoiceListParams는_추가_필터도_snake_case로_전송한다() throws Exception {
+ InvoiceListParams params = new InvoiceListParams();
+ params.page = 2;
+ params.limit = 24;
+ params.csType = "cancel";
+ params.userId = "user_id";
+ params.productType = 1;
+ params.cssAt = "2026-08-01";
+ params.cseAt = "2026-08-31";
+
+ String query = SInvoiceService.listRequest(bootpay(), params).getURI().getQuery();
+
+ assertTrue(query.contains("page=2"));
+ assertTrue(query.contains("limit=24"));
+ assertTrue(query.contains("cs_type=cancel"));
+ assertTrue(query.contains("user_id=user_id"));
+ assertTrue(query.contains("product_type=1"));
+ assertTrue(query.contains("css_at=2026-08-01"));
+ assertTrue(query.contains("cse_at=2026-08-31"));
+
+ // 값이 설정되지 않은 필드는 전송하지 않는다 (ruby의 compact 동작)
+ assertFalse(query.contains("keyword"));
+ assertFalse(query.contains("&type="));
+ }
+
+ @Test
+ public void 토큰이_없으면_예외를_발생시킨다() {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload("test_client_key", "test_secret_key"), "PRODUCTION");
+
+ assertThrows(Exception.class, () -> SInvoiceService.listRequest(bootpay, new InvoiceListParams()));
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestServiceTest.java
new file mode 100644
index 0000000..81f7dfc
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestServiceTest.java
@@ -0,0 +1,132 @@
+package kr.co.bootpay.store.service.order_subscriptions.request;
+
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.TokenPayload;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestListParams;
+import kr.co.bootpay.store.model.request.orderSubscription.request.OrderSubscriptionRequestUpdateParams;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.util.EntityUtils;
+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 SOrderSubscriptionRequestServiceTest {
+ private static final String CLIENT_KEY = "test_client_key";
+ private static final String SECRET_KEY = "test_secret_key";
+
+ private BootpayStoreObject bootpay() {
+ return new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION");
+ }
+
+ @Test
+ public void 목록은_하이픈_경로로_요청한다() throws Exception {
+ HttpGet get = SOrderSubscriptionRequestService.listRequest(bootpay(), null);
+
+ assertEquals("https://api.bootapi.com/v1/order-subscription-requests", get.getURI().toString());
+ assertEquals("user", get.getFirstHeader("BOOTPAY-ROLE").getValue());
+ }
+
+ @Test
+ public void 목록은_설정된_값만_snake_case_쿼리로_전송한다() throws Exception {
+ OrderSubscriptionRequestListParams params = new OrderSubscriptionRequestListParams();
+ params.orderSubscriptionId = "686dc2f2b0eacea5cd974ca2";
+ params.page = 2;
+ params.limit = 20;
+ params.sAt = "2026-08-01";
+ params.eAt = "2026-08-31";
+ params.status = 1;
+ params.requestType = 3;
+ params.userGroupId = "group_id";
+
+ String query = SOrderSubscriptionRequestService.listRequest(bootpay(), params).getURI().getQuery();
+
+ assertTrue(query.contains("order_subscription_id=686dc2f2b0eacea5cd974ca2"));
+ assertTrue(query.contains("page=2"));
+ assertTrue(query.contains("limit=20"));
+ assertTrue(query.contains("s_at=2026-08-01"));
+ assertTrue(query.contains("e_at=2026-08-31"));
+ assertTrue(query.contains("status=1"));
+ assertTrue(query.contains("request_type=3"));
+ assertTrue(query.contains("user_group_id=group_id"));
+
+ // 값이 설정되지 않은 필드는 전송하지 않는다 (ruby의 compact 동작)
+ assertFalse(query.contains("project_id"));
+ assertFalse(query.contains("keyword"));
+ assertFalse(query.contains("user_id="));
+ }
+
+ @Test
+ public void project_id가_있으면_supervisor_role로_요청한다() throws Exception {
+ OrderSubscriptionRequestListParams params = new OrderSubscriptionRequestListParams();
+ params.projectId = "project_id";
+
+ HttpGet get = SOrderSubscriptionRequestService.listRequest(bootpay(), params);
+
+ assertEquals("supervisor", get.getFirstHeader("BOOTPAY-ROLE").getValue());
+ assertTrue(get.getURI().getQuery().contains("project_id=project_id"));
+ }
+
+ @Test
+ public void 상세는_project_id가_없으면_user_role로_요청한다() throws Exception {
+ HttpGet get = SOrderSubscriptionRequestService.detailRequest(bootpay(), "request_history_id", null);
+
+ assertEquals("https://api.bootapi.com/v1/order-subscription-requests/request_history_id", get.getURI().toString());
+ assertEquals("user", get.getFirstHeader("BOOTPAY-ROLE").getValue());
+ }
+
+ @Test
+ public void 상세는_project_id가_있으면_supervisor_role로_요청한다() throws Exception {
+ HttpGet get = SOrderSubscriptionRequestService.detailRequest(bootpay(), "request_history_id", "project_id");
+
+ assertEquals("supervisor", get.getFirstHeader("BOOTPAY-ROLE").getValue());
+ assertTrue(get.getURI().getQuery().contains("project_id=project_id"));
+ }
+
+ @Test
+ public void 승인반려는_PUT으로_supervisor_role로_요청한다() throws Exception {
+ OrderSubscriptionRequestUpdateParams params = new OrderSubscriptionRequestUpdateParams();
+ params.requestHistoryId = "request_history_id";
+ params.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_APPROVE;
+ params.reason = "승인 처리";
+ params.terminationFee = 1000.0;
+
+ HttpPut put = SOrderSubscriptionRequestService.updateRequest(bootpay(), params);
+
+ assertEquals("PUT", put.getMethod());
+ assertEquals("https://api.bootapi.com/v1/order-subscription-requests/request_history_id", put.getURI().toString());
+ assertEquals("supervisor", put.getFirstHeader("BOOTPAY-ROLE").getValue());
+
+ String body = EntityUtils.toString(put.getEntity(), "UTF-8");
+ assertTrue(body.contains("\"approval\":\"approve\""));
+ assertTrue(body.contains("\"reason\":\"승인 처리\""));
+ assertTrue(body.contains("\"termination_fee\":1000"));
+
+ // request_history_id는 URL path로만 사용하며 body에는 담지 않는다
+ assertFalse(body.contains("request_history_id"));
+ }
+
+ @Test
+ public void 승인반려는_필수값이_없으면_예외를_발생시킨다() {
+ OrderSubscriptionRequestUpdateParams noId = new OrderSubscriptionRequestUpdateParams();
+ noId.approval = OrderSubscriptionRequestUpdateParams.APPROVAL_REJECT;
+
+ OrderSubscriptionRequestUpdateParams noApproval = new OrderSubscriptionRequestUpdateParams();
+ noApproval.requestHistoryId = "request_history_id";
+
+ assertThrows(Exception.class, () -> SOrderSubscriptionRequestService.updateRequest(bootpay(), null));
+ assertThrows(Exception.class, () -> SOrderSubscriptionRequestService.updateRequest(bootpay(), noId));
+ assertThrows(Exception.class, () -> SOrderSubscriptionRequestService.updateRequest(bootpay(), noApproval));
+ }
+
+ @Test
+ public void 인증정보가_없으면_예외를_발생시킨다() {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(), "PRODUCTION");
+
+ assertThrows(Exception.class, () -> SOrderSubscriptionRequestService.listRequest(bootpay, null));
+ assertThrows(Exception.class, () -> SOrderSubscriptionRequestService.detailRequest(bootpay, "request_history_id", null));
+ }
+}
diff --git a/core/src/test/java/kr/co/bootpay/store/service/webhook/SWebhookServiceTest.java b/core/src/test/java/kr/co/bootpay/store/service/webhook/SWebhookServiceTest.java
new file mode 100644
index 0000000..d8bc003
--- /dev/null
+++ b/core/src/test/java/kr/co/bootpay/store/service/webhook/SWebhookServiceTest.java
@@ -0,0 +1,55 @@
+package kr.co.bootpay.store.service.webhook;
+
+import kr.co.bootpay.store.BootpayStoreObject;
+import kr.co.bootpay.store.model.request.TokenPayload;
+import kr.co.bootpay.store.model.request.webhook.TestWebhookParams;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.util.EntityUtils;
+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 SWebhookServiceTest {
+ private static final String CLIENT_KEY = "test_client_key";
+ private static final String SECRET_KEY = "test_secret_key";
+
+ private BootpayStoreObject bootpay() {
+ return new BootpayStoreObject(new TokenPayload(CLIENT_KEY, SECRET_KEY), "PRODUCTION");
+ }
+
+ @Test
+ public void 테스트_웹훅은_POST_webhook_test로_요청한다() throws Exception {
+ HttpPost post = SWebhookService.sendTestWebhookRequest(bootpay(), null);
+
+ assertEquals("POST", post.getMethod());
+ assertEquals("https://api.bootapi.com/v1/webhook/test", post.getURI().toString());
+ }
+
+ @Test
+ public void header_content_type을_snake_case로_전송한다() throws Exception {
+ TestWebhookParams params = new TestWebhookParams();
+ params.headerContentType = "application/json";
+
+ String body = EntityUtils.toString(SWebhookService.sendTestWebhookRequest(bootpay(), params).getEntity(), "UTF-8");
+
+ assertTrue(body.contains("\"header_content_type\":\"application/json\""));
+ }
+
+ @Test
+ public void 값이_설정되지_않으면_빈_payload로_전송한다() throws Exception {
+ String body = EntityUtils.toString(SWebhookService.sendTestWebhookRequest(bootpay(), new TestWebhookParams()).getEntity(), "UTF-8");
+
+ assertEquals("{}", body);
+ assertFalse(body.contains("header_content_type"));
+ }
+
+ @Test
+ public void 인증정보가_없으면_예외를_발생시킨다() {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(), "PRODUCTION");
+
+ assertThrows(Exception.class, () -> SWebhookService.sendTestWebhookRequest(bootpay, new TestWebhookParams()));
+ }
+}
From b31aea9ecd459b4eed5b91dc0a0f415a1f60b521 Mon Sep 17 00:00:00 2001
From: Bootpay SDK Bot
Date: Thu, 20 Aug 2026 01:30:53 +0000
Subject: [PATCH 12/17] =?UTF-8?q?sync:=203b22da9d=20*=20github-ruby=20?=
=?UTF-8?q?=EC=A0=84=EC=9A=A9=EB=B6=84=20=EB=B0=98=EC=98=81=20(webhook=20?=
=?UTF-8?q?=C2=B7=20image=5Fdestroy=20=C2=B7=20billing=5Fkey=20user=5Fid)?=
=?UTF-8?q?=20*=20=EC=BB=A4=EB=A8=B8=EC=8A=A4=20API=2027=EC=A2=85=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EC=A3=BD=EC=9D=80=20=EA=B2=BD?=
=?UTF-8?q?=EB=A1=9C=205=EA=B1=B4=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Source: git.bootpay.co.kr/bootpay-sdk/backend-ruby@3b22da9d52c64cc943de9c5105bd411e9415c76f
---
.../java/com/example/bootpay/store/User.java | 15 ++++
.../java/kr/co/bootpay/store/layer/User.java | 6 ++
.../request/product/ProductListParams.java | 4 +
.../SOrderSubscriptionService.java | 49 ++++++------
.../SOrderSubscriptionRequestIngService.java | 30 ++++----
.../store/service/users/SUserJoinService.java | 31 ++++++--
.../SOrderSubscriptionServiceTest.java | 76 +++++++++++++++++++
...rderSubscriptionRequestIngServiceTest.java | 61 +++++++++++++++
.../service/users/SUserJoinServiceTest.java | 56 ++++++++++++++
9 files changed, 286 insertions(+), 42 deletions(-)
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionServiceTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/order_subscriptions/request/SOrderSubscriptionRequestIngServiceTest.java
create mode 100644 core/src/test/java/kr/co/bootpay/store/service/users/SUserJoinServiceTest.java
diff --git a/app/src/main/java/com/example/bootpay/store/User.java b/app/src/main/java/com/example/bootpay/store/User.java
index a17ba74..0f3ae1c 100644
--- a/app/src/main/java/com/example/bootpay/store/User.java
+++ b/app/src/main/java/com/example/bootpay/store/User.java
@@ -24,6 +24,7 @@ public static void main(String[] args) {
// idExist();
// phoneExist();
// groupBusinessNumberExist();
+// uidExist();
// update();
} catch (Exception e) {
e.printStackTrace();
@@ -166,6 +167,20 @@ public static void groupBusinessNumberExist() {
}
}
+ // 외부 uid(ex_uid) 중복 확인 - checkExist("uid-exist", uid)와 동일하다
+ public static void uidExist() {
+ try {
+ BootpayStoreResponse res = bootpay.user.uidExist("ex_uid_1234");
+ if (res.isSuccess()) {
+ System.out.println("uidExist success: " + res.getData());
+ } else {
+ System.out.println("uidExist false: " + res.getData());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
// {count=5.0, http_status=200, list=[{login_id=ehowlsla5, name=홍길동, phone=null, email=null, membership_type=1.0, gender=null, birth=null, comment=null, group_tags=[], metadata=null, auth_sms=false, auth_phone=false, auth_email=false, count=0.0, status=1.0, individual_extension=null, updated_at=2025-03-24T16:13:52+09:00, created_at=2025-03-24T16:12:47+09:00, user_id=67e105ef03d0cb4e4117b0a1}, {login_id=ehowlsla4, name=홍길동, phone=null, email=null, membership_type=1.0, gender=null, birth=null, comment=null, group_tags=[], metadata=null, auth_sms=false, auth_phone=false, auth_email=false, count=0.0, status=1.0, individual_extension=null, updated_at=2025-03-24T16:09:07+09:00, created_at=2025-03-24T16:09:07+09:00, user_id=67e1051303d0cb4e4117b09b}, {login_id=ehowlsla3, name=홍길동, phone=null, email=null, membership_type=1.0, gender=null, birth=null, comment=null, group_tags=[], metadata=null, auth_sms=false, auth_phone=false, auth_email=false, count=0.0, status=1.0, individual_extension=null, updated_at=2025-03-24T14:58:21+09:00, created_at=2025-03-24T14:58:21+09:00, user_id=67e0f47d03d0cb4e4117b083}, {login_id=ehowlsla2, name=홍길동, phone=null, email=null, membership_type=1.0, gender=null, birth=null, comment=null, group_tags=[], metadata=null, auth_sms=false, auth_phone=false, auth_email=false, count=0.0, status=1.0, individual_extension=null, updated_at=2025-03-24T14:35:26+09:00, created_at=2025-03-24T14:35:26+09:00, user_id=67e0ef1e03d0cb4e4117b07d}, {login_id=ehowlsla, name=윤태섭, phone=null, email=null, membership_type=1.0, gender=null, birth=null, comment=null, group_tags=[], metadata=null, auth_sms=false, auth_phone=false, auth_email=false, count=0.0, status=1.0, individual_extension=null, updated_at=2025-03-07T13:03:37+09:00, created_at=2025-03-06T15:37:03+09:00, user_id=67c9428f7b47af25bee631e7}]}
public static void list() {
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/User.java b/core/src/main/java/kr/co/bootpay/store/layer/User.java
index 4859c23..b13d7a7 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/User.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/User.java
@@ -25,10 +25,16 @@ public BootpayStoreResponse join(SUser user) throws Exception {
return SUserJoinService.join(bootpay, user);
}
+ // key: email-exist, id-exist, phone-exist, uid-exist, group-business-number-exist
public BootpayStoreResponse checkExist(String key, String value) throws Exception {
return SUserJoinService.checkExist(bootpay, key, value);
}
+ // 외부 uid(ex_uid) 중복 확인 (GET users/join/uid-exist)
+ public BootpayStoreResponse uidExist(String uid) throws Exception {
+ return SUserJoinService.uidExist(bootpay, uid);
+ }
+
// public HashMap idExist(String pk) throws Exception {
// return SUserJoinService.idExist(bootpay, pk);
// }
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/service/order_subscriptions/SOrderSubscriptionService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java
index 8c93ff7..d69ca81 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
@@ -33,34 +33,39 @@
public class SOrderSubscriptionService {
+ // 구독 목록 조회 (GET order_subscriptions)
+ // 서버는 limit 을 `params[:limit].presence || 20` 으로 받는다 (미전송시 서버 기본값 20)
static public BootpayStoreResponse list(BootpayStoreObject bootpay, OrderSubscriptionListParams 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, OrderSubscriptionListParams params) throws Exception {
if (bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
throw new Exception("token 값이 비어있습니다.");
}
- HttpClient client = HttpClientBuilder.create().build();
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.requestType != null) nameValuePairList.add(new BasicNameValuePair("request_type", params.eAt));
- if(params.userGroupId != null) nameValuePairList.add(new BasicNameValuePair("user_group_id", params.userGroupId));
- if(params.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", params.userId));
-
- 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 동작)
+ // ⚠️ 날짜 키는 s_at/e_at 이다. orders 의 css_at/cse_at 와 다르다.
+ 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.requestType != null) nameValuePairList.add(new BasicNameValuePair("request_type", params.requestType.toString()));
+ if(params.status != null) nameValuePairList.add(new BasicNameValuePair("status", params.status.toString()));
+ if(params.userGroupId != null) nameValuePairList.add(new BasicNameValuePair("user_group_id", params.userGroupId));
+ if(params.userId != null) nameValuePairList.add(new BasicNameValuePair("user_id", params.userId));
+
+ 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);
}
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 1592311..0fd623d 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
@@ -22,8 +22,6 @@
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@@ -64,7 +62,17 @@ static public BootpayStoreResponse resume(BootpayStoreObject bootpay, OrderSubsc
}
+ // 중도해지 수수료 사전계산 (GET order_subscriptions/requests/ing/calculate_termination_fee)
+ // 해지 요청 전에 얼마가 나오는지 미리 보여줄 때 쓴다
static public BootpayStoreResponse calculateTerminationFee(BootpayStoreObject bootpay, String orderSubscriptionId, String orderNumber) throws Exception {
+ HttpClient client = HttpClientBuilder.create().build();
+ HttpGet get = calculateTerminationFeeRequest(bootpay, orderSubscriptionId, orderNumber);
+ HttpResponse response = client.execute(get);
+
+ return bootpay.responseToJsonObject(response);
+ }
+
+ static HttpGet calculateTerminationFeeRequest(BootpayStoreObject bootpay, String orderSubscriptionId, String orderNumber) throws Exception {
// 토큰 유효성 검증
if (bootpay == null || bootpay.getToken() == null || bootpay.getToken().isEmpty()) {
throw new IllegalArgumentException("Bootpay 토큰이 비어있습니다.");
@@ -78,20 +86,12 @@ static public BootpayStoreResponse calculateTerminationFee(BootpayStoreObject bo
throw new IllegalArgumentException("orderSubscriptionId 또는 orderNumber 중 하나는 필수입니다.");
}
- // URL 구성
- 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));
- } else {
- url.append("order_number=").append(URLEncoder.encode(orderNumber, StandardCharsets.UTF_8));
- }
+ // 값이 설정되지 않은 필드는 전송하지 않는다 (둘 다 있으면 둘 다 전송한다)
+ List nameValuePairList = new ArrayList<>();
+ if (hasOrderSubscriptionId) nameValuePairList.add(new BasicNameValuePair("order_subscription_id", orderSubscriptionId));
+ if (hasOrderNumber) nameValuePairList.add(new BasicNameValuePair("order_number", orderNumber));
- // 요청 실행
- HttpClient client = HttpClientBuilder.create().build();
- HttpGet get = bootpay.httpGet(url.toString());
- HttpResponse response = client.execute(get);
-
- return bootpay.responseToJsonObject(response);
+ return bootpay.httpGet("order_subscriptions/requests/ing/calculate_termination_fee", nameValuePairList);
}
// 오버로드: orderNumber만 전달하는 경우
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 f8eec4a..4498c4e 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
@@ -17,6 +17,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";
+
static public BootpayStoreResponse join(BootpayStoreObject bootpay, SUser user) throws Exception {
if(bootpay.getToken() == null || bootpay.getToken().isEmpty()) throw new Exception("token 값이 비어있습니다.");
// if(user.group == null) throw new Exception("group 값이 비었습니다.");
@@ -33,21 +40,35 @@ static public BootpayStoreResponse join(BootpayStoreObject bootpay, SUser user)
return bootpay.responseToJsonObject(response);
}
+ // 회원가입 중복 확인 (GET users/join/:path?pk=:pk)
+ // path: email-exist, id-exist, phone-exist, uid-exist, group-business-number-exist
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);
+ }
+
+ // 외부 uid(ex_uid) 중복 확인 (GET users/join/uid-exist)
+ // email-exist / id-exist / phone-exist / group-business-number-exist 와 같은 패턴이다
+ static public BootpayStoreResponse uidExist(BootpayStoreObject bootpay, String uid) throws Exception {
+ return checkExist(bootpay, UID_EXIST, uid);
+ }
+
+ 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 값이 비어있습니다.");
// URL 인코딩 처리
// String encodedPk = URLEncoder.encode(pk, StandardCharsets.UTF_8);
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/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"));
+ }
+}
From d82341b59e8ac50ed31843df6ca1c7cf1ebf0c22 Mon Sep 17 00:00:00 2001
From: rupy1014
Date: Thu, 20 Aug 2026 10:46:33 +0900
Subject: [PATCH 13/17] =?UTF-8?q?feat(commerce):=20=EC=B2=AD=EA=B5=AC?=
=?UTF-8?q?=EC=84=9C=20=EC=83=9D=EC=84=B1=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?=
=?UTF-8?q?=ED=84=B0=20=ED=99=95=EC=9E=A5=20+=20supervisorTerminate=20(v3.?=
=?UTF-8?q?4.0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ruby SDK 의 request_checkout 에는 있는데 Java 에는 없던 청구서 생성 파라미터를
반영한다. 기존 SInvoice 필드와 create(invoice) 시그니처는 그대로 두고 추가만 했다.
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 헤더와 user role 을 붙인다. list/detail/notify 는 이미 그렇게
동작하고 있었고 create 만 빠져 있었다 (ruby SDK 와도 어긋나 있었다)
- create(invoice, idempotencyKey) 오버로드 추가
검증 (네트워크 불필요, 로컬 HttpServer 대조)
- user/products/delivery_price/use_*/usage_api_url/extra 직렬화와 role·Idempotency-Key
- price_adjustments → cycles 중첩 직렬화
- 미지정 필드 미전송 (기존 사용 패턴 회귀)
- idempotencyKey 직접 지정
예제: createWithProducts / createSubscriptionWithPromotion / createUsageBased 추가
./gradlew build --offline → 200 tests, failures 0, errors 0
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 16 ++
.../com/example/bootpay/store/Invoice.java | 135 +++++++++++++++++
core/src/main/java/kr/co/bootpay/Version.java | 2 +-
.../kr/co/bootpay/store/layer/Invoice.java | 10 ++
.../co/bootpay/store/model/pojo/SInvoice.java | 28 ++++
.../store/model/pojo/SInvoiceExtra.java | 14 ++
.../model/pojo/SInvoicePriceAdjustment.java | 24 +++
.../pojo/SInvoicePriceAdjustmentCycle.java | 29 ++++
.../store/model/pojo/SInvoiceProduct.java | 26 ++++
.../store/model/pojo/SInvoiceUser.java | 26 ++++
.../bootpay/store/module/InvoiceModule.java | 12 ++
.../service/invoices/SInvoiceService.java | 14 +-
.../commerce/CommerceWireFormatTest.java | 139 ++++++++++++++++++
publish.gradle | 2 +-
14 files changed, 474 insertions(+), 3 deletions(-)
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceExtra.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustment.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoicePriceAdjustmentCycle.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceProduct.java
create mode 100644 core/src/main/java/kr/co/bootpay/store/model/pojo/SInvoiceUser.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f78517..16e134b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+### 3.4.0
+
+- 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`
+- Commerce: `invoice.create` 에 `Idempotency-Key` 헤더와 user role 부착 (list/detail/notify 와 동일한 규약, ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가.
+- Commerce: `orderSubscription.supervisorTerminate(orderSubscriptionId, SupervisorTerminateParams)` 추가 — 기존 `terminate(id[, reason])` 는 그대로 두고, 위약금·마지막 청구 환불액·최종 정산액·서비스 종료일·해지 기준일까지 지정할 수 있다.
+- Commerce: `OrderSubscriptionRequestUpdateParams` 에 정산 필드 추가 (`price` / `taxFreePrice` / `terminationFee` / `lastBillRefundPrice` / `finalFee` / `serviceEndAt`) 및 `APPROVAL_APPROVE` / `APPROVAL_REJECT` 상수. 서비스가 body 에 실어 전송하도록 배선.
+- Commerce: `SUserJoinService` 에 중복확인 key 상수 추가 (`EMAIL_EXIST` / `ID_EXIST` / `PHONE_EXIST` / `UID_EXIST` / `GROUP_BUSINESS_NUMBER_EXIST`).
+- 브랜치 정리: `main` 을 `2-x-development` 로 통합하고 `2-x-development` 를 기준 브랜치로 삼는다 (nodejs · ruby 와 동일).
+
### 3.3.0
PG 와 Commerce 의 코드 스타일 통일 — **기존 표면은 아무것도 바뀌지 않았고 계속 동작합니다.** 신규 표면만 추가됩니다.
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/core/src/main/java/kr/co/bootpay/Version.java b/core/src/main/java/kr/co/bootpay/Version.java
index 9f5a8a0..52f6f9a 100644
--- a/core/src/main/java/kr/co/bootpay/Version.java
+++ b/core/src/main/java/kr/co/bootpay/Version.java
@@ -4,7 +4,7 @@
public class Version {
// PG API
public static final String API_VERSION = "5.1.0"; //부트페이 JS 버전
- public static final String SDK_VERSION = "3.3.0"; //JAVA SDK 버전
+ public static final String SDK_VERSION = "3.4.0"; //JAVA SDK 버전
public static final String SDK_TYPE = "304"; // JAVA 304 고정
// Commerce API
diff --git a/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java b/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
index 3b010ac..883bcdb 100644
--- a/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
+++ b/core/src/main/java/kr/co/bootpay/store/layer/Invoice.java
@@ -38,6 +38,16 @@ public BootpayStoreResponse create(SInvoice invoice) throws Exception {
return SInvoiceService.create(bootpay, invoice);
}
+ /**
+ * 청구서 생성.
+ *
+ * @param invoice 청구서 정보
+ * @param idempotencyKey 미지정시 자동 생성 (Idempotency-Key 헤더로 전송)
+ */
+ public BootpayStoreResponse create(SInvoice invoice, String idempotencyKey) throws Exception {
+ return SInvoiceService.create(bootpay, invoice, idempotencyKey);
+ }
+
/**
* 청구서 알림 재발송 — sendTypes 미전달시 서버가 빈 배열로 처리한다.
* ⚠️ 실제 고객에게 알림이 발송되므로 테스트 호출 주의.
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..78980da 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.4.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..9df39d5
--- /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.4.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..ad38fb9
--- /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.4.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..f7d877d
--- /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.4.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..e7316ee
--- /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.4.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..4d0babc
--- /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.4.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/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/service/invoices/SInvoiceService.java b/core/src/main/java/kr/co/bootpay/store/service/invoices/SInvoiceService.java
index 1e7832c..2d9aada 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"),
+ invoiceContext(idempotencyKey));
HttpResponse response = client.execute(post);
return bootpay.responseToJsonObject(response);
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 a602dcd0..d042e4f 100644
--- a/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java
+++ b/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java
@@ -1083,4 +1083,143 @@ void testOrderSubscriptionRequestUpdateSettlementFields() throws Exception {
assertFalse(lastBody.contains("order_subscription_request_history_id"), "ID 는 URL 에만: " + lastBody);
}
+ // ══════════════════════════════════════════════════════════
+ // invoice.create — ruby SDK request_checkout parity (3.4.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/publish.gradle b/publish.gradle
index cfe0d63..ae49782 100644
--- a/publish.gradle
+++ b/publish.gradle
@@ -1,6 +1,6 @@
ext {
PUBLISH_GROUP_ID = 'io.github.bootpay'
- PUBLISH_VERSION = '3.3.0'
+ PUBLISH_VERSION = '3.4.0'
PUBLISH_ARTIFACT_ID = 'backend'
PUBLISH_DESCRIPTION = 'Bootpay Backend Library'
PUBLISH_URL = 'https://github.com/bootpay/android'
From d70c18fb4a0f556b40e8ccacde9087f01e823cff Mon Sep 17 00:00:00 2001
From: rupy1014
Date: Thu, 20 Aug 2026 13:48:00 +0900
Subject: [PATCH 14/17] =?UTF-8?q?fix(commerce):=20Authorization=20?=
=?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EA=B8=B0=EC=A4=80=20SDK=20?=
=?UTF-8?q?=EC=99=80=20=EC=9D=BC=EC=B9=98=20+=20=EB=B2=84=EC=A0=84=203.3.0?=
=?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Commerce 인증만 다른 SDK 와 어긋나 있었다. 다른 구현을 대조해 맞춘다.
NodeJS authorizationHeader(): token 있으면 Bearer, 없으면 Basic
Ruby rest.rb: @token.present? ? "Bearer #{@token}" : "Basic #{...}"
Go / Python / PHP / .NET: 토큰이 있으면 Bearer
Java(기존): 항상 requestAccessToken() = Basic ← 유일한 이탈
변경
- BootpayStoreObject.authorizationHeader([RequestContext]) 추가. 우선순위는
RequestContext 토큰 → 인스턴스 토큰(Bearer) → client_key/secret_key(Basic) → 없음
- 인증 정보가 하나도 없으면 Authorization 헤더를 붙이지 않는다
(기존에는 빈 문자열을 실어 보냈다)
- RequestContext.token 을 실제로 사용한다 (필드는 있는데 무시되고 있었다)
- 8곳의 헤더 부착을 applyAuthHeader(request, context) 로 일원화
- requestAccessToken() 은 Basic 값 계산으로 동작·시그니처 그대로 유지.
NodeJS 와 같은 주의(이 값을 토큰으로 저장하면 다음 요청부터 인증이 깨진다)를 문서화
⚠️ getAccessToken() 으로 토큰을 발급받은 코드는 이제 Basic 이 아니라 Bearer 로
전송된다. 기준 SDK 와 같은 동작이며, 토큰 만료(30분) 시 재발급이 필요하다.
버전
- 아직 배포 전이므로 3.4.0 을 되돌려 3.3.0 하나로 합쳤다.
통일 API + 청구서 파라미터 + 인증 정합성 + 브랜치 통합이 모두 3.3.0 이다.
- CHANGELOG 의 3.4.0 / 3.3.0 항목을 3.3.0 하나로 병합
검증: ./gradlew build --offline → 202 tests, failures 0, errors 0
(인증 규칙 7건: Bearer 우선 / context 토큰 우선 / 토큰 없으면 Basic /
자격 없으면 헤더 미부착 / Basic 값이 토큰으로 저장되지 않음 회귀)
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 62 ++++++++++-------
core/src/main/java/kr/co/bootpay/Version.java | 2 +-
.../co/bootpay/store/BootpayStoreObject.java | 66 +++++++++++++++---
.../co/bootpay/store/model/pojo/SInvoice.java | 2 +-
.../store/model/pojo/SInvoiceExtra.java | 2 +-
.../model/pojo/SInvoicePriceAdjustment.java | 2 +-
.../pojo/SInvoicePriceAdjustmentCycle.java | 2 +-
.../store/model/pojo/SInvoiceProduct.java | 2 +-
.../store/model/pojo/SInvoiceUser.java | 2 +-
.../commerce/CommerceWireFormatTest.java | 2 +-
.../store/BootpayStoreObjectAuthTest.java | 68 ++++++++++++++-----
publish.gradle | 2 +-
12 files changed, 157 insertions(+), 57 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 16e134b..5f5bfff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,38 +1,54 @@
-### 3.4.0
-
-- 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`
-- Commerce: `invoice.create` 에 `Idempotency-Key` 헤더와 user role 부착 (list/detail/notify 와 동일한 규약, ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가.
-- Commerce: `orderSubscription.supervisorTerminate(orderSubscriptionId, SupervisorTerminateParams)` 추가 — 기존 `terminate(id[, reason])` 는 그대로 두고, 위약금·마지막 청구 환불액·최종 정산액·서비스 종료일·해지 기준일까지 지정할 수 있다.
-- Commerce: `OrderSubscriptionRequestUpdateParams` 에 정산 필드 추가 (`price` / `taxFreePrice` / `terminationFee` / `lastBillRefundPrice` / `finalFee` / `serviceEndAt`) 및 `APPROVAL_APPROVE` / `APPROVAL_REJECT` 상수. 서비스가 body 에 실어 전송하도록 배선.
-- Commerce: `SUserJoinService` 에 중복확인 key 상수 추가 (`EMAIL_EXIST` / `ID_EXIST` / `PHONE_EXIST` / `UID_EXIST` / `GROUP_BUSINESS_NUMBER_EXIST`).
-- 브랜치 정리: `main` 을 `2-x-development` 로 통합하고 `2-x-development` 를 기준 브랜치로 삼는다 (nodejs · ruby 와 동일).
-
### 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 인증 정합성 (동작 변경)
+
+- `Authorization` 헤더 규칙을 기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 일치시켰다.
+ - **토큰이 발급되어 있으면 `Bearer {token}`**, 없으면 client_key/secret_key `Basic`, 둘 다 없으면 **헤더를 붙이지 않는다** (기존에는 항상 Basic 을 보냈고, 인증 정보가 없으면 빈 문자열을 보냈다).
+ - `RequestContext` 의 토큰이 인스턴스 토큰보다 우선한다 (`RequestContext.token` 필드가 그동안 무시되고 있었다).
+ - `authorizationHeader()` / `authorizationHeader(RequestContext)` 추가. `requestAccessToken()` 은 Basic 값 계산으로 그대로 유지된다.
+ - ⚠️ `getAccessToken()` 을 호출해 토큰을 발급받은 코드는 이제 Basic 이 아니라 Bearer 로 전송된다. 토큰 만료(30분) 시 재발급이 필요하다.
+
+#### 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` 헤더와 user role 부착 (list/detail/notify 와 동일한 규약, ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가.
+
+#### 브랜치 통합 (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/core/src/main/java/kr/co/bootpay/Version.java b/core/src/main/java/kr/co/bootpay/Version.java
index 52f6f9a..9f5a8a0 100644
--- a/core/src/main/java/kr/co/bootpay/Version.java
+++ b/core/src/main/java/kr/co/bootpay/Version.java
@@ -4,7 +4,7 @@
public class Version {
// PG API
public static final String API_VERSION = "5.1.0"; //부트페이 JS 버전
- public static final String SDK_VERSION = "3.4.0"; //JAVA SDK 버전
+ public static final String SDK_VERSION = "3.3.0"; //JAVA SDK 버전
public static final String SDK_TYPE = "304"; // JAVA 304 고정
// Commerce API
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..004c2d9 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,14 @@ public void setRole(String role) {
this.role = role;
}
+ /**
+ * client_key/secret_key 로 만든 Basic 인증 값을 반환한다. 키가 없으면 빈 문자열이다.
+ *
+ * ⚠️ 이 값을 {@code token} 으로 저장하면 안 된다. 저장하면 다음 요청부터 Basic 값이
+ * Bearer 토큰으로 오인되어 인증이 깨진다 (NodeJS SDK 에 같은 주의가 적혀 있다).
+ *
+ * @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 +101,48 @@ public String requestAccessToken() {
return "Basic " + encoded;
}
+ /**
+ * 요청에 실을 Authorization 값을 결정한다.
+ *
+ * 기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 같은 규칙이다.
+ *
+ * - {@link RequestContext} 에 토큰이 지정되어 있으면 그 토큰으로 Bearer
+ * - 발급받은 토큰이 있으면 그 토큰으로 Bearer
+ * - client_key/secret_key 가 있으면 Basic
+ * - 아무것도 없으면 null — 헤더를 붙이지 않는다
+ *
+ *
+ * @param context 요청별 컨텍스트 (없으면 null)
+ * @return Authorization 헤더 값, 없으면 null
+ */
+ public String authorizationHeader(RequestContext context) {
+ if (context != null && context.getToken() != null && !context.getToken().isEmpty()) {
+ return "Bearer " + context.getToken();
+ }
+ if (this.token != null && !this.token.isEmpty()) {
+ return "Bearer " + this.token;
+ }
+ String basic = requestAccessToken();
+ return basic.isEmpty() ? null : basic;
+ }
+
+ /**
+ * @return Authorization 헤더 값, 없으면 null
+ */
+ public String authorizationHeader() {
+ return authorizationHeader(null);
+ }
+
+ /**
+ * Authorization 헤더를 부착한다. 인증 정보가 하나도 없으면 헤더 자체를 붙이지 않는다.
+ */
+ private void applyAuthHeader(HttpRequestBase request, RequestContext context) {
+ String authorization = authorizationHeader(context);
+ if (authorization != null) {
+ request.setHeader("Authorization", authorization);
+ }
+ }
+
// RequestContext에 지정된 부가 헤더 부착 — Idempotency-Key, Bootpay-User-JWT는 값이 있을 때만 붙는다
private void applyContextHeaders(HttpRequestBase request, RequestContext context) {
if (context == null) return;
@@ -125,7 +175,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 +202,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 +231,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 +259,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 +292,7 @@ public HttpPost httpPostMultipart(String url, List files, HashMap selectedUsers;
// ========================================
- // 청구서 생성 파라미터 (3.4.0~, ruby SDK request_checkout parity)
+ // 청구서 생성 파라미터 (3.3.0~, ruby SDK request_checkout parity)
// ========================================
/** SDK 를 통한 생성인지 여부 */
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
index 9df39d5..57336aa 100644
--- 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
@@ -3,7 +3,7 @@
/**
* 청구서 생성 부가 옵션 ({@code extra}).
*
- * @since 3.4.0
+ * @since 3.3.0
*/
public class SInvoiceExtra {
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
index ad38fb9..ff0f105 100644
--- 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
@@ -7,7 +7,7 @@
*
* 프로모션 단위로 묶이며, 실제 할인/부과 규칙은 {@link #cycles} 에 주기별로 담는다.
*
- * @since 3.4.0
+ * @since 3.3.0
*/
public class SInvoicePriceAdjustment {
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
index f7d877d..0f3896c 100644
--- 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
@@ -6,7 +6,7 @@
* 구독 상품에서 "첫 달 20% 할인, 둘째 달 100원 할인, 도입비 500원" 같은 규칙을
* 주기 단위로 기술한다.
*
- * @since 3.4.0
+ * @since 3.3.0
*/
public class SInvoicePriceAdjustmentCycle {
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
index e7316ee..aad3093 100644
--- 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
@@ -9,7 +9,7 @@
* 참조하는 방식이다. 구독 상품이면 {@link #duration} 으로 계약 기간을,
* {@link #priceAdjustments} 로 프로모션을 지정한다.
*
- * @since 3.4.0
+ * @since 3.3.0
*/
public class SInvoiceProduct {
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
index 4d0babc..a48cc4c 100644
--- 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
@@ -6,7 +6,7 @@
* 이미 가입된 회원이면 {@link #userId} 만으로 충분하고, 비회원 청구서라면
* {@link #membershipType} 을 {@code "guest"} 로 두고 이름·연락처를 함께 넘긴다.
*
- * @since 3.4.0
+ * @since 3.3.0
*/
public class SInvoiceUser {
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 d042e4f..30f4d40 100644
--- a/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java
+++ b/core/src/test/java/kr/co/bootpay/commerce/CommerceWireFormatTest.java
@@ -1084,7 +1084,7 @@ void testOrderSubscriptionRequestUpdateSettlementFields() throws Exception {
}
// ══════════════════════════════════════════════════════════
- // invoice.create — ruby SDK request_checkout parity (3.4.0)
+ // invoice.create — ruby SDK request_checkout parity (3.3.0)
// ══════════════════════════════════════════════════════════
@Test
diff --git a/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
index 72eceec..5e21028 100644
--- a/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
+++ b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
@@ -17,9 +17,10 @@
import static org.junit.jupiter.api.Assertions.assertNull;
/**
- * Commerce 인증 헤더 회귀 검증.
+ * Commerce 인증 헤더 규칙 검증.
*
- * 2-x-development 브랜치에 있던 검증을 현행 동작 기준으로 옮긴 것이다.
+ * 기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 동일하게
+ * 토큰이 있으면 Bearer, 없으면 client_key/secret_key Basic, 둘 다 없으면 헤더 미부착 이다.
*/
@DisplayName("Commerce API - 인증 헤더")
class BootpayStoreObjectAuthTest {
@@ -27,7 +28,7 @@ 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 = Base64.getEncoder()
+ private static final String BASIC_VALUE = "Basic " + Base64.getEncoder()
.encodeToString((CLIENT_KEY + ":" + SECRET_KEY).getBytes(StandardCharsets.UTF_8));
private BootpayStoreObject bootpay() {
@@ -39,13 +40,14 @@ private BootpayStoreObject bootpay() {
void basicAuthDoesNotStoreToken() {
BootpayStoreObject bootpay = bootpay();
- assertEquals("Basic " + BASIC_VALUE, bootpay.requestAccessToken());
+ assertEquals(BASIC_VALUE, bootpay.requestAccessToken());
+ assertEquals(BASIC_VALUE, bootpay.authorizationHeader());
assertNull(bootpay.getToken());
}
@Test
- @DisplayName("GET/POST/PUT/DELETE 모두 Basic 인증 헤더를 붙인다")
- void allVerbsCarryBasicAuth() throws Exception {
+ @DisplayName("토큰이 없으면 GET/POST/PUT/DELETE 모두 Basic 인증을 쓴다")
+ void allVerbsUseBasicWithoutToken() throws Exception {
BootpayStoreObject bootpay = bootpay();
HttpGet get = bootpay.httpGet("products");
@@ -53,10 +55,10 @@ void allVerbsCarryBasicAuth() throws Exception {
HttpPut put = bootpay.httpPut("products/1", new StringEntity("{}", "UTF-8"));
HttpDelete delete = bootpay.httpDelete("products/1");
- assertEquals("Basic " + BASIC_VALUE, get.getFirstHeader("Authorization").getValue());
- assertEquals("Basic " + BASIC_VALUE, post.getFirstHeader("Authorization").getValue());
- assertEquals("Basic " + BASIC_VALUE, put.getFirstHeader("Authorization").getValue());
- assertEquals("Basic " + BASIC_VALUE, delete.getFirstHeader("Authorization").getValue());
+ 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
@@ -69,24 +71,56 @@ void basicAuthSurvivesRepeatedRequests() throws Exception {
// basic 인증 값이 토큰으로 저장되면 두번째 요청부터 Bearer 로 전송되는 버그가 있었다
assertNull(bootpay.getToken());
- assertEquals("Basic " + BASIC_VALUE, get.getFirstHeader("Authorization").getValue());
+ assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue());
}
@Test
- @DisplayName("RequestContext 의 role 은 인스턴스 기본 role 을 덮는다")
- void contextRoleOverridesInstanceRole() throws Exception {
+ @DisplayName("토큰이 발급되어 있으면 Bearer 인증을 쓴다")
+ void tokenTakesPrecedenceOverBasic() throws Exception {
BootpayStoreObject bootpay = bootpay();
- RequestContext context = RequestContext.builder().role("manager").build();
+ 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");
+
+ assertEquals("Bearer access_token_value", get.getFirstHeader("Authorization").getValue());
+ assertEquals("Bearer access_token_value", post.getFirstHeader("Authorization").getValue());
+ assertEquals("Bearer access_token_value", put.getFirstHeader("Authorization").getValue());
+ assertEquals("Bearer access_token_value", delete.getFirstHeader("Authorization").getValue());
+ }
+
+ @Test
+ @DisplayName("RequestContext 의 토큰이 인스턴스 토큰보다 우선한다")
+ void contextTokenOverridesInstanceToken() 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("Bearer context_token", get.getFirstHeader("Authorization").getValue());
assertEquals("manager", get.getFirstHeader("BOOTPAY-ROLE").getValue());
- assertEquals("Basic " + BASIC_VALUE, get.getFirstHeader("Authorization").getValue());
}
@Test
- @DisplayName("키가 없으면 Basic 값이 빈 문자열이다")
- void noKeysYieldEmptyAuthValue() {
+ @DisplayName("키와 토큰이 모두 없으면 Authorization 헤더를 붙이지 않는다")
+ void noCredentialsMeansNoAuthorizationHeader() throws Exception {
+ BootpayStoreObject bootpay = new BootpayStoreObject(new TokenPayload(), "PRODUCTION");
+
+ HttpGet get = bootpay.httpGet("products");
+ HttpPost post = bootpay.httpPost("products", new StringEntity("{}", "UTF-8"));
+
+ assertNull(bootpay.authorizationHeader());
+ assertNull(get.getFirstHeader("Authorization"), "빈 Authorization 헤더를 보내면 안 된다");
+ assertNull(post.getFirstHeader("Authorization"), "빈 Authorization 헤더를 보내면 안 된다");
+ }
+
+ @Test
+ @DisplayName("requestAccessToken 은 기존 동작(Basic 값 계산)을 유지한다")
+ void requestAccessTokenKeepsLegacyBehaviour() {
+ assertEquals(BASIC_VALUE, bootpay().requestAccessToken());
assertEquals("", new BootpayStoreObject(new TokenPayload(), "PRODUCTION").requestAccessToken());
}
}
diff --git a/publish.gradle b/publish.gradle
index ae49782..cfe0d63 100644
--- a/publish.gradle
+++ b/publish.gradle
@@ -1,6 +1,6 @@
ext {
PUBLISH_GROUP_ID = 'io.github.bootpay'
- PUBLISH_VERSION = '3.4.0'
+ PUBLISH_VERSION = '3.3.0'
PUBLISH_ARTIFACT_ID = 'backend'
PUBLISH_DESCRIPTION = 'Bootpay Backend Library'
PUBLISH_URL = 'https://github.com/bootpay/android'
From e9ea775bfd4342077f85be1825936454ef59eb97 Mon Sep 17 00:00:00 2001
From: alfredhot
Date: Thu, 20 Aug 2026 14:18:14 +0900
Subject: [PATCH 15/17] =?UTF-8?q?fix(commerce):=20supervisor/manager=20sco?=
=?UTF-8?q?pe=20=EB=AF=B8=EB=B6=80=EC=B0=A9=2011=EA=B3=B3=20=EC=88=98?=
=?UTF-8?q?=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
서버(commerce-api)가 scope_invalid! 로 supervisor/manager 를 요구하는
엔드포인트 11개가 BOOTPAY-ROLE: user 로 나가고 있었다. 해당 API 들은
올바른 토큰으로도 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 로 전송된다(예외도 경고도 없다).
SOrderSubscriptionService 와 SUserGroupService 는 이미 있던
supervisorContext() / managerContext() 를 붙이기만 했고,
SCategoryService 는 헬퍼가 없어 신설했다.
부수 효과로 이 11개 호출에 Idempotency-Key 가 자동 부착된다
(다른 supervisor 메서드·ruby SDK 와 동일 규약).
요청 경로와 바디는 변경 없다.
검증: 루프백 HTTP 서버로 실제 전송 헤더를 캡처해 17개 메서드의
BOOTPAY-ROLE 을 서버 요구 scope 와 대조 — 전부 일치 확인.
requestIng.* 는 서버(ing_controller)가 role: 'user' 를 요구하므로
user 유지가 정상이며 건드리지 않았다.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 10 ++++++++++
.../service/categories/SCategoryService.java | 19 ++++++++++++++++---
.../SOrderSubscriptionService.java | 18 ++++++++++++------
.../user_groups/SUserGroupService.java | 5 +++--
4 files changed, 41 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f5bfff..593c383 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,16 @@ PG 와 Commerce 의 코드 스타일 통일 + Commerce 청구서/인증 정합
- `authorizationHeader()` / `authorizationHeader(RequestContext)` 추가. `requestAccessToken()` 은 Basic 값 계산으로 그대로 유지된다.
- ⚠️ `getAccessToken()` 을 호출해 토큰을 발급받은 코드는 이제 Basic 이 아니라 Bearer 로 전송된다. 토큰 만료(30분) 시 재발급이 필요하다.
+#### 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` 에 추가 — 기존 필드·시그니처는 그대로다.
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/order_subscriptions/SOrderSubscriptionService.java b/core/src/main/java/kr/co/bootpay/store/service/order_subscriptions/SOrderSubscriptionService.java
index 83cc700..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
@@ -200,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);
@@ -232,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);
@@ -264,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);
@@ -291,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);
@@ -318,7 +322,8 @@ 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);
@@ -349,7 +354,8 @@ static public BootpayStoreResponse supervisorTerminate(BootpayStoreObject bootpa
.create();
String json = (params != null) ? gson.toJson(params) : "{}";
- HttpPut put = bootpay.httpPut("order_subscriptions/" + orderSubscriptionId + "/terminate", new StringEntity(json, "UTF-8"));
+ 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/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);
From 424bac1daabcff2462e65924024505f86e33cdfe Mon Sep 17 00:00:00 2001
From: rupy1014
Date: Thu, 20 Aug 2026 15:13:30 +0900
Subject: [PATCH 16/17] =?UTF-8?q?fix(commerce):=20=EC=9D=B8=EC=A6=9D=20Bea?=
=?UTF-8?q?rer=20=EC=A0=84=ED=99=98=EC=9D=84=20=EB=90=98=EB=8F=8C=EB=A6=B0?=
=?UTF-8?q?=EB=8B=A4=20=E2=80=94=20v3.2.0=20=EB=8F=99=EC=9E=91(Basic)=20?=
=?UTF-8?q?=EC=9C=A0=EC=A7=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
전환 근거였던 '다른 SDK 도 토큰이 있으면 Bearer' 가 사실이 아니었다.
Go / Python / PHP / .NET 은 전부 항상 Basic 이고 토큰 필드를 읽지 않는다
(commerce.go:195, commerce_resource.py:81, BootpayCommerceApi.php:190,
BootpayCommerceObject.cs:180). Bearer 는 NodeJS · Ruby 둘뿐이라 Java 가
맞춰야 할 다수파는 Basic 이었다.
되돌리는 실제 이유는 사용자 피해다.
- 모든 Commerce 서비스에 토큰 가드가 있어(99곳) 기존 사용자는 전원 토큰 보유
상태다. 따라서 Bearer 전환은 조건부가 아니라 Commerce 사용자 전체에 적용된다.
- 401 폴백도 자동 재발급도 없다. 토큰 30분 만료 후 복구 수단이 없다.
- Java 는 만료 시각을 알 수도 없다. 서버가 주는 expired_at 을 STokenResponse 가
파싱하지 않고, expire_in 은 PG 형식이라 Commerce 응답에서 항상 0 이다.
- 예외가 아니라 success=false 라 스택트레이스도 남지 않는다. 배포 직후 스모크는
통과하고 30분 뒤 조용히 전면 실패한다.
변경
- applyAuthHeader 를 v3.2.0 과 동일하게 requestAccessToken() (Basic) 부착으로 환원.
Bearer 우선 authorizationHeader() 2종은 제거 (3.3.0 미배포분이라 외부 영향 없음)
- invoice.create 가 role 을 'user' 로 고정하던 것을 제거 — setRole('supervisor') 로
지정해 둔 호출자가 조용히 강등됐다. Idempotency-Key 부착은 유지 (v3.2.0 대비 추가)
- 인증 테스트 7건을 v3.2.0 동작 단언으로 전환 (회귀 방지)
- CHANGELOG: 인증 절을 '이번 릴리스에서 바꾸지 않는다' 로 교체하고 사유 기록
Bearer 전환은 expired_at 파싱 + 만료 기반 재발급 + 401 Basic 폴백을 갖춰 다음
버전에서 진행한다.
검증: compileJava/compileTestJava 통과, 202 tests failures 0 errors 0,
v3.2.0 테스트 소스 15개 javac error 0
Co-Authored-By: Claude Fable 5
---
CHANGELOG.md | 16 ++++---
.../co/bootpay/store/BootpayStoreObject.java | 47 ++++---------------
.../service/invoices/SInvoiceService.java | 14 +++++-
.../store/BootpayStoreObjectAuthTest.java | 30 ++++++------
4 files changed, 47 insertions(+), 60 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 593c383..93402de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,13 +19,17 @@ PG 와 Commerce 의 코드 스타일 통일 + Commerce 청구서/인증 정합
- `subscriptionSetting` 모듈 노출 — 기존 `BootpayStore` 에는 배선 누락으로 도달할 수 없었다.
- 토큰 발급 이름 통일 — 양쪽 모두 `issueAccessToken()` 이 `BootpayResponse` 를 반환. 기존 `getAccessToken()` 은 그대로 유지.
-#### Commerce 인증 정합성 (동작 변경)
+#### Commerce 인증 — 이번 릴리스에서는 바꾸지 않는다
-- `Authorization` 헤더 규칙을 기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 일치시켰다.
- - **토큰이 발급되어 있으면 `Bearer {token}`**, 없으면 client_key/secret_key `Basic`, 둘 다 없으면 **헤더를 붙이지 않는다** (기존에는 항상 Basic 을 보냈고, 인증 정보가 없으면 빈 문자열을 보냈다).
- - `RequestContext` 의 토큰이 인스턴스 토큰보다 우선한다 (`RequestContext.token` 필드가 그동안 무시되고 있었다).
- - `authorizationHeader()` / `authorizationHeader(RequestContext)` 추가. `requestAccessToken()` 은 Basic 값 계산으로 그대로 유지된다.
- - ⚠️ `getAccessToken()` 을 호출해 토큰을 발급받은 코드는 이제 Basic 이 아니라 Bearer 로 전송된다. 토큰 만료(30분) 시 재발급이 필요하다.
+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) 정합성 (동작 변경)
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 004c2d9..e1fd0c3 100644
--- a/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
+++ b/core/src/main/java/kr/co/bootpay/store/BootpayStoreObject.java
@@ -89,9 +89,6 @@ public void setRole(String role) {
/**
* client_key/secret_key 로 만든 Basic 인증 값을 반환한다. 키가 없으면 빈 문자열이다.
*
- * ⚠️ 이 값을 {@code token} 으로 저장하면 안 된다. 저장하면 다음 요청부터 Basic 값이
- * Bearer 토큰으로 오인되어 인증이 깨진다 (NodeJS SDK 에 같은 주의가 적혀 있다).
- *
* @return "Basic {base64(client_key:secret_key)}", 키가 없으면 ""
*/
public String requestAccessToken() {
@@ -102,45 +99,19 @@ public String requestAccessToken() {
}
/**
- * 요청에 실을 Authorization 값을 결정한다.
+ * Commerce 요청에 Authorization 헤더를 부착한다. client_key/secret_key 의 Basic 을 쓴다.
*
- * 기준 SDK(NodeJS) 및 Ruby / Go / Python / PHP / .NET 과 같은 규칙이다.
- *
- * - {@link RequestContext} 에 토큰이 지정되어 있으면 그 토큰으로 Bearer
- * - 발급받은 토큰이 있으면 그 토큰으로 Bearer
- * - client_key/secret_key 가 있으면 Basic
- * - 아무것도 없으면 null — 헤더를 붙이지 않는다
- *
+ * 발급받은 토큰({@code token})은 인증에 쓰지 않는다. Go / Python / PHP / .NET 도 같은
+ * 규칙이고, Basic 은 만료가 없어 장기 실행 프로세스에서 재발급이 필요 없다.
*
- * @param context 요청별 컨텍스트 (없으면 null)
- * @return Authorization 헤더 값, 없으면 null
- */
- public String authorizationHeader(RequestContext context) {
- if (context != null && context.getToken() != null && !context.getToken().isEmpty()) {
- return "Bearer " + context.getToken();
- }
- if (this.token != null && !this.token.isEmpty()) {
- return "Bearer " + this.token;
- }
- String basic = requestAccessToken();
- return basic.isEmpty() ? null : basic;
- }
-
- /**
- * @return Authorization 헤더 값, 없으면 null
- */
- public String authorizationHeader() {
- return authorizationHeader(null);
- }
-
- /**
- * Authorization 헤더를 부착한다. 인증 정보가 하나도 없으면 헤더 자체를 붙이지 않는다.
+ * 토큰 기반 Bearer 인증(기준 SDK 인 NodeJS · Ruby 의 규칙)으로의 전환은 만료 시각
+ * 파싱({@code expired_at})·자동 재발급·401 폴백을 함께 갖춘 뒤에 해야 한다. 그 준비 없이
+ * 전환하면 30분 만료 후 복구 수단 없이 401 이 나므로 이번 릴리스에서는 도입하지 않는다.
+ *
+ * @param context 요청별 컨텍스트 (현재 인증에는 사용하지 않는다)
*/
private void applyAuthHeader(HttpRequestBase request, RequestContext context) {
- String authorization = authorizationHeader(context);
- if (authorization != null) {
- request.setHeader("Authorization", authorization);
- }
+ request.setHeader("Authorization", requestAccessToken());
}
// RequestContext에 지정된 부가 헤더 부착 — Idempotency-Key, Bootpay-User-JWT는 값이 있을 때만 붙는다
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 2d9aada..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
@@ -49,7 +49,7 @@ static public BootpayStoreResponse create(BootpayStoreObject bootpay, SInvoice i
.create();
HttpPost post = bootpay.httpPost("invoices", new StringEntity(gson.toJson(invoice), "UTF-8"),
- invoiceContext(idempotencyKey));
+ invoiceCreateContext(idempotencyKey));
HttpResponse response = client.execute(post);
return bootpay.responseToJsonObject(response);
@@ -187,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/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
index 5e21028..5a28a0f 100644
--- a/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
+++ b/core/src/test/java/kr/co/bootpay/store/BootpayStoreObjectAuthTest.java
@@ -41,7 +41,6 @@ void basicAuthDoesNotStoreToken() {
BootpayStoreObject bootpay = bootpay();
assertEquals(BASIC_VALUE, bootpay.requestAccessToken());
- assertEquals(BASIC_VALUE, bootpay.authorizationHeader());
assertNull(bootpay.getToken());
}
@@ -75,8 +74,8 @@ void basicAuthSurvivesRepeatedRequests() throws Exception {
}
@Test
- @DisplayName("토큰이 발급되어 있으면 Bearer 인증을 쓴다")
- void tokenTakesPrecedenceOverBasic() throws Exception {
+ @DisplayName("토큰이 발급되어 있어도 Commerce 인증은 Basic 을 쓴다")
+ void issuedTokenDoesNotSwitchToBearer() throws Exception {
BootpayStoreObject bootpay = bootpay();
bootpay.setTokenFromAPI("access_token_value");
@@ -85,36 +84,37 @@ void tokenTakesPrecedenceOverBasic() throws Exception {
HttpPut put = bootpay.httpPut("products/1", new StringEntity("{}", "UTF-8"));
HttpDelete delete = bootpay.httpDelete("products/1");
- assertEquals("Bearer access_token_value", get.getFirstHeader("Authorization").getValue());
- assertEquals("Bearer access_token_value", post.getFirstHeader("Authorization").getValue());
- assertEquals("Bearer access_token_value", put.getFirstHeader("Authorization").getValue());
- assertEquals("Bearer access_token_value", delete.getFirstHeader("Authorization").getValue());
+ // 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 의 토큰이 인스턴스 토큰보다 우선한다")
- void contextTokenOverridesInstanceToken() throws Exception {
+ @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("Bearer context_token", get.getFirstHeader("Authorization").getValue());
+ assertEquals(BASIC_VALUE, get.getFirstHeader("Authorization").getValue());
assertEquals("manager", get.getFirstHeader("BOOTPAY-ROLE").getValue());
}
@Test
- @DisplayName("키와 토큰이 모두 없으면 Authorization 헤더를 붙이지 않는다")
- void noCredentialsMeansNoAuthorizationHeader() throws Exception {
+ @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"));
- assertNull(bootpay.authorizationHeader());
- assertNull(get.getFirstHeader("Authorization"), "빈 Authorization 헤더를 보내면 안 된다");
- assertNull(post.getFirstHeader("Authorization"), "빈 Authorization 헤더를 보내면 안 된다");
+ assertEquals("", get.getFirstHeader("Authorization").getValue());
+ assertEquals("", post.getFirstHeader("Authorization").getValue());
}
@Test
From 6a6ec8d9b0637d6ff2c26fd9bf751d650d472b31 Mon Sep 17 00:00:00 2001
From: rupy1014
Date: Thu, 20 Aug 2026 15:14:32 +0900
Subject: [PATCH 17/17] =?UTF-8?q?docs:=20CHANGELOG=20=E2=80=94=20invoice.c?=
=?UTF-8?q?reate=20=EC=9D=98=20role=20=EA=B3=A0=EC=A0=95=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93402de..b85b54a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,7 +51,7 @@ Bearer 전환은 `expired_at` 파싱 · 만료 기반 자동 재발급 · 401
- `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` 헤더와 user role 부착 (list/detail/notify 와 동일한 규약, ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가.
+- `invoice.create` 에 `Idempotency-Key` 헤더 부착 (ruby SDK 와 parity). `create(invoice, idempotencyKey)` 오버로드 추가. role 은 고정하지 않는다 — `setRole("supervisor")` 로 지정해 둔 호출자가 조용히 user 로 강등되지 않도록 인스턴스 role 을 그대로 쓰고, 지정이 없을 때만 `user` 가 기본값이다.
#### 브랜치 통합 (2-x-development)