From b05c61a65ac80af53a4825782d717ebba97a35d4 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 17:19:37 +0900 Subject: [PATCH 01/37] =?UTF-8?q?refactor:=2064=EC=9E=90=EB=A6=AC=20PK=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=EC=97=90=EC=84=9C=EC=9D=98?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95(number=20->=20string?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/member/memberTypes.ts | 4 ++-- .../src/api/reservation/reservationAPI.ts | 8 +++---- .../src/api/reservation/reservationTypes.ts | 22 +++++++++---------- frontend/src/api/theme/themeAPI.ts | 2 +- frontend/src/api/theme/themeTypes.ts | 4 ++-- frontend/src/api/time/timeAPI.ts | 4 ++-- frontend/src/api/time/timeTypes.ts | 6 ++--- frontend/src/pages/MyReservationPage.tsx | 6 ++--- frontend/src/pages/ReservationPage.tsx | 4 ++-- frontend/src/pages/admin/ReservationPage.tsx | 2 +- frontend/src/pages/admin/ThemePage.tsx | 2 +- frontend/src/pages/admin/TimePage.tsx | 2 +- frontend/src/pages/admin/WaitingPage.tsx | 4 ++-- src/main/resources/test.http | 4 ++++ 14 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 src/main/resources/test.http diff --git a/frontend/src/api/member/memberTypes.ts b/frontend/src/api/member/memberTypes.ts index 45d90e86..32d4ab17 100644 --- a/frontend/src/api/member/memberTypes.ts +++ b/frontend/src/api/member/memberTypes.ts @@ -1,5 +1,5 @@ export interface MemberRetrieveResponse { - id: number; + id: string; name: string; } @@ -14,6 +14,6 @@ export interface SignupRequest { } export interface SignupResponse { - id: number; + id: string; name: string; } diff --git a/frontend/src/api/reservation/reservationAPI.ts b/frontend/src/api/reservation/reservationAPI.ts index 276ce06f..a3f23db9 100644 --- a/frontend/src/api/reservation/reservationAPI.ts +++ b/frontend/src/api/reservation/reservationAPI.ts @@ -30,7 +30,7 @@ export const searchReservations = async (params: ReservationSearchQuery): Promis }; // DELETE /reservations/{id} -export const cancelReservationByAdmin = async (id: number): Promise => { +export const cancelReservationByAdmin = async (id: string): Promise => { return await apiClient.del(`/reservations/${id}`, true); }; @@ -55,16 +55,16 @@ export const createWaiting = async (data: WaitingCreateRequest): Promise => { +export const cancelWaiting = async (id: string): Promise => { return await apiClient.del(`/reservations/waiting/${id}`, true); }; // POST /reservations/waiting/{id}/confirm -export const confirmWaiting = async (id: number): Promise => { +export const confirmWaiting = async (id: string): Promise => { return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true); }; // POST /reservations/waiting/{id}/reject -export const rejectWaiting = async (id: number): Promise => { +export const rejectWaiting = async (id: string): Promise => { return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true); }; diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts index 5bffd87f..1ab96518 100644 --- a/frontend/src/api/reservation/reservationTypes.ts +++ b/frontend/src/api/reservation/reservationTypes.ts @@ -14,7 +14,7 @@ export type ReservationStatus = | typeof ReservationStatus.WAITING; export interface MyReservationRetrieveResponse { - id: number; + id: string; themeName: string; date: string; time: string; @@ -29,7 +29,7 @@ export interface MyReservationRetrieveListResponse { } export interface ReservationRetrieveResponse { - id: number; + id: string; date: string; member: MemberRetrieveResponse; time: TimeRetrieveResponse; @@ -43,15 +43,15 @@ export interface ReservationRetrieveListResponse { export interface AdminReservationCreateRequest { date: string; - timeId: number; - themeId: number; - memberId: number; + timeId: string; + themeId: string; + memberId: string; } export interface ReservationCreateWithPaymentRequest { date: string; - timeId: number; - themeId: number; + timeId: string; + themeId: string; paymentKey: string; orderId: string; amount: number; @@ -60,13 +60,13 @@ export interface ReservationCreateWithPaymentRequest { export interface WaitingCreateRequest { date: string; - timeId: number; - themeId: number; + timeId: string; + themeId: string; } export interface ReservationSearchQuery { - themeId?: number; - memberId?: number; + themeId?: string; + memberId?: string; dateFrom?: string; dateTo?: string; } diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index c653a9e8..6cbe5c3d 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -13,6 +13,6 @@ export const mostReservedThemes = async (count: number = 10): Promise(`/themes/most-reserved-last-week?count=${count}`, false); }; -export const delTheme = async (id: number): Promise => { +export const delTheme = async (id: string): Promise => { return await apiClient.del(`/themes/${id}`, true); }; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index d1721953..129f1fc1 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -5,14 +5,14 @@ export interface ThemeCreateRequest { } export interface ThemeCreateResponse { - id: number; + id: string; name: string; description: string; thumbnail: string; } export interface ThemeRetrieveResponse { - id: number; + id: string; name: string; description: string; thumbnail: string; diff --git a/frontend/src/api/time/timeAPI.ts b/frontend/src/api/time/timeAPI.ts index 2a2d6ac2..656f90e9 100644 --- a/frontend/src/api/time/timeAPI.ts +++ b/frontend/src/api/time/timeAPI.ts @@ -9,10 +9,10 @@ export const fetchTimes = async (): Promise => { return await apiClient.get('/times', true); }; -export const delTime = async (id: number): Promise => { +export const delTime = async (id: string): Promise => { return await apiClient.del(`/times/${id}`, true); }; -export const fetchTimesWithAvailability = async (date: string, themeId: number): Promise => { +export const fetchTimesWithAvailability = async (date: string, themeId: string): Promise => { return await apiClient.get(`/times/search?date=${date}&themeId=${themeId}`, true); }; diff --git a/frontend/src/api/time/timeTypes.ts b/frontend/src/api/time/timeTypes.ts index 408e8f7f..acb7c350 100644 --- a/frontend/src/api/time/timeTypes.ts +++ b/frontend/src/api/time/timeTypes.ts @@ -3,12 +3,12 @@ export interface TimeCreateRequest { } export interface TimeCreateResponse { - id: number; + id: string; startAt: string; } export interface TimeRetrieveResponse { - id: number; + id: string; startAt: string; } @@ -17,7 +17,7 @@ export interface TimeRetrieveListResponse { } export interface TimeWithAvailabilityResponse { - id: number; + id: string; startAt: string; isAvailable: boolean; } diff --git a/frontend/src/pages/MyReservationPage.tsx b/frontend/src/pages/MyReservationPage.tsx index a599a2e3..96102c63 100644 --- a/frontend/src/pages/MyReservationPage.tsx +++ b/frontend/src/pages/MyReservationPage.tsx @@ -26,11 +26,11 @@ const MyReservationPage: React.FC = () => { .catch(handleError); }, []); - const _cancelWaiting = (id: number) => { + const _cancelWaiting = (id: string) => { cancelWaiting(id) .then(() => { alert('예약 대기가 취소되었습니다.'); - setReservations(reservations.filter(r => r.id !== id)); + setReservations(reservations.filter(r => r.id.toString() !== id)); }) .catch(handleError); }; @@ -74,7 +74,7 @@ const MyReservationPage: React.FC = () => { {getStatusText(r.status, r.rank)} {r.status === ReservationStatus.WAITING && - } + } {r.paymentKey} {r.amount} diff --git a/frontend/src/pages/ReservationPage.tsx b/frontend/src/pages/ReservationPage.tsx index 00d3e87a..77471955 100644 --- a/frontend/src/pages/ReservationPage.tsx +++ b/frontend/src/pages/ReservationPage.tsx @@ -18,9 +18,9 @@ declare global { const ReservationPage: React.FC = () => { const [selectedDate, setSelectedDate] = useState(new Date()); const [themes, setThemes] = useState([]); - const [selectedTheme, setSelectedTheme] = useState(null); + const [selectedTheme, setSelectedTheme] = useState(null); const [times, setTimes] = useState([]); - const [selectedTime, setSelectedTime] = useState<{ id: number, isAvailable: boolean } | null>(null); + const [selectedTime, setSelectedTime] = useState<{ id: string, isAvailable: boolean } | null>(null); const paymentWidgetRef = useRef(null); const paymentMethodsRef = useRef(null); const navigate = useNavigate(); diff --git a/frontend/src/pages/admin/ReservationPage.tsx b/frontend/src/pages/admin/ReservationPage.tsx index 5f5d9b60..b4c916e3 100644 --- a/frontend/src/pages/admin/ReservationPage.tsx +++ b/frontend/src/pages/admin/ReservationPage.tsx @@ -90,7 +90,7 @@ const AdminReservationPage: React.FC = () => { .catch(handleError); }; - const deleteReservation = async(id: number) => { + const deleteReservation = async(id: string) => { if (!window.confirm('정말 삭제하시겠어요?')) { return; } diff --git a/frontend/src/pages/admin/ThemePage.tsx b/frontend/src/pages/admin/ThemePage.tsx index 2d8dc313..a47256ca 100644 --- a/frontend/src/pages/admin/ThemePage.tsx +++ b/frontend/src/pages/admin/ThemePage.tsx @@ -49,7 +49,7 @@ const AdminThemePage: React.FC = () => { .catch(handleError); } - const deleteTheme = async (id: number) => { + const deleteTheme = async (id: string) => { if (!window.confirm('정말 삭제하시겠어요?')) { return; } diff --git a/frontend/src/pages/admin/TimePage.tsx b/frontend/src/pages/admin/TimePage.tsx index 3912dc93..6772452e 100644 --- a/frontend/src/pages/admin/TimePage.tsx +++ b/frontend/src/pages/admin/TimePage.tsx @@ -62,7 +62,7 @@ const AdminTimePage: React.FC = () => { .catch(handleError); }; - const deleteTime = async (id: number) => { + const deleteTime = async (id: string) => { if (!window.confirm('정말 삭제하시겠어요?')) { return; } diff --git a/frontend/src/pages/admin/WaitingPage.tsx b/frontend/src/pages/admin/WaitingPage.tsx index 3a784377..de90dfff 100644 --- a/frontend/src/pages/admin/WaitingPage.tsx +++ b/frontend/src/pages/admin/WaitingPage.tsx @@ -29,7 +29,7 @@ const AdminWaitingPage: React.FC = () => { fetchData(); }, []); - const approveWaiting = async (id: number) => { + const approveWaiting = async (id: string) => { await confirmWaiting(id) .then(() => { alert('대기 중인 예약을 승인했어요. 결제는 별도로 진행해주세요.'); @@ -38,7 +38,7 @@ const AdminWaitingPage: React.FC = () => { .catch(handleError); }; - const denyWaiting = async (id: number) => { + const denyWaiting = async (id: string) => { await rejectWaiting(id) .then(() => { alert('대기 중인 예약을 거절했어요.'); diff --git a/src/main/resources/test.http b/src/main/resources/test.http new file mode 100644 index 00000000..193be221 --- /dev/null +++ b/src/main/resources/test.http @@ -0,0 +1,4 @@ +### GET request to example server +POST localhost:8080/savetest + +### \ No newline at end of file -- 2.47.2 From 56ada7730cc615137282ab593a2605f10cfe21d8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 17:49:09 +0900 Subject: [PATCH 02/37] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/time/implement/TimeFinder.kt | 2 +- .../time/implement/TimeFinderTest.kt | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/time/implement/TimeFinder.kt b/src/main/kotlin/roomescape/time/implement/TimeFinder.kt index 609a0fa3..60911a89 100644 --- a/src/main/kotlin/roomescape/time/implement/TimeFinder.kt +++ b/src/main/kotlin/roomescape/time/implement/TimeFinder.kt @@ -51,7 +51,7 @@ class TimeFinder( val allTimes: List = findAll() return allTimes.map { time -> - val isReservable: Boolean = reservations.any { reservation -> time.id == reservation.id } + val isReservable: Boolean = reservations.none { reservation -> time.id == reservation.time.id } TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable) }.also { log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" } diff --git a/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt b/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt index 5c7c092d..ba6014a9 100644 --- a/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt +++ b/src/test/kotlin/roomescape/time/implement/TimeFinderTest.kt @@ -10,6 +10,7 @@ import io.mockk.mockk import io.mockk.verify import org.springframework.data.repository.findByIdOrNull import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.time.business.domain.TimeWithAvailability import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException @@ -105,5 +106,39 @@ class TimeFinderTest : FunSpec({ it.all { time -> time.isReservable } } } + + test("날짜, 테마에 맞는 예약이 있으면 예약할 수 없다.") { + val times = listOf( + TimeFixture.create(startAt = LocalTime.now()), + TimeFixture.create(startAt = LocalTime.now().plusMinutes(30)) + ) + every { + themeFinder.findById(themeId) + } returns mockk() + + every { + timeRepository.findAll() + } returns times + + every { + reservationFinder.findAllByDateAndTheme(date, any()) + } returns listOf( + mockk().apply { + every { time.id } returns times[0].id + }, + mockk().apply { + every { time.id } returns 0 + } + ) + + val result: List = + timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId) + + assertSoftly(result) { + it shouldHaveSize 2 + it[0].isReservable shouldBe false + it[1].isReservable shouldBe true + } + } } }) -- 2.47.2 From 6cc7eb680ce556ba8e49b87b3086bb2f362fe33b Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:02:57 +0900 Subject: [PATCH 03/37] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EC=BF=BC=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?IF=20NOT=20EXISTS=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/region-data.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/schema/region-data.sql b/src/main/resources/schema/region-data.sql index bab99e24..dfb041f1 100644 --- a/src/main/resources/schema/region-data.sql +++ b/src/main/resources/schema/region-data.sql @@ -1,4 +1,4 @@ -CREATE UNIQUE INDEX idx_region_sido_sigungu_dong ON region(sido_code, sigungu_code, dong_code); +CREATE UNIQUE INDEX IF NOT EXISTS idx_region_sido_sigungu_dong ON region(sido_code, sigungu_code, dong_code); INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name) VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'), -- 2.47.2 From ed0b81ff4501aab6114c046505707bcdd96ccc1f Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:10:03 +0900 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/schema-h2.sql | 95 +++++++++++++++++++--- src/main/resources/schema/schema-mysql.sql | 79 ++++++++++++++++++ 2 files changed, 164 insertions(+), 10 deletions(-) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 63c10539..75dfa2dc 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -14,8 +14,8 @@ create table if not exists members ( name varchar(255) not null, password varchar(255) not null, role varchar(20) not null, - created_at timestamp null, - last_modified_at timestamp null + created_at timestamp, + last_modified_at timestamp ); create table if not exists themes ( @@ -23,8 +23,8 @@ create table if not exists themes ( description varchar(255) not null, name varchar(255) not null, thumbnail varchar(255) not null, - created_at timestamp null, - last_modified_at timestamp null + created_at timestamp, + last_modified_at timestamp ); create table if not exists times ( @@ -41,8 +41,9 @@ create table if not exists reservations ( theme_id bigint not null, time_id bigint not null, status varchar(30) not null, - created_at timestamp null, - last_modified_at timestamp null, + created_at timestamp, + last_modified_at timestamp, + constraint fk_reservations__themeId foreign key (theme_id) references themes (theme_id), constraint fk_reservations__memberId foreign key (member_id) references members (member_id), constraint fk_reservations__timeId foreign key (time_id) references times (time_id) @@ -55,8 +56,9 @@ create table if not exists payments ( total_amount bigint not null, order_id varchar(255) not null, payment_key varchar(255) not null, - created_at timestamp null, - last_modified_at timestamp null, + created_at timestamp, + last_modified_at timestamp, + constraint uk_payments__reservationId unique (reservation_id), constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id) ); @@ -68,6 +70,79 @@ create table if not exists canceled_payments ( cancel_amount bigint not null, approved_at timestamp not null, canceled_at timestamp not null, - created_at timestamp null, - last_modified_at timestamp null + + created_at timestamp, + last_modified_at timestamp +); + +create table if not exists payment1 ( + id bigint primary key, + reservation_id bigint not null, + type varchar(20) not null, + method varchar(30) not null, + payment_key varchar(255) not null unique, + total_amount integer not null, + status varchar(20) not null, + requested_at timestamp not null, + approved_at timestamp not null, + + constraint uk_payment__reservationId unique (reservation_id), + constraint fk_payment__reservationId foreign key (reservation_id) references reservations (reservation_id) +); + +create table if not exists payment_detail( + id bigint primary key, + payment_id bigint not null unique, + net_amount integer not null, + vat integer not null, + + constraint fk_payment_detail__paymentId foreign key (payment_id) references payment1 (id) +); + +create table if not exists payment_bank_transfer_detail ( + id bigint primary key, + bank_code varchar(10) not null, + + constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) +); + +create table if not exists payment_card_detail ( + id bigint primary key, + issuer_code varchar(10) not null, + card_type varchar(10) not null, + owner_type varchar(10) not null, + amount integer not null, + card_number varchar(20) not null, + approval_number varchar(8) not null unique, + installment_plan_months tinyint not null, + is_interest_free boolean not null, + easypay_provider_code varchar(20), + easypay_discount_amount integer, + + constraint fk_payment_card_detail__id foreign key (id) references payment_detail (id) +); + +create table if not exists payment_easypay_prepaid_detail( + id bigint primary key, + easypay_provider_code varchar(20) not null, + amount integer not null, + discount_amount integer not null, + + constraint fk_payment_easypay_prepaid_detail__id foreign key (id) references payment_detail (id) +); + +create table if not exists canceled_payment1( + id bigint primary key, + payment_id bigint not null, + canceled_at timestamp not null, + canceled_by bigint not null, + cancel_reason varchar(255) not null, + cancel_amount integer not null, + cardDiscountAmount integer not null, + transferDiscountAmount integer not null, + easyPayDiscountAmount integer not null, + + constraint uk_canceled_payment1__paymentId unique (payment_id), + constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment1(id), + constraint fk_canceled_payment__canceledBy foreign key (canceled_by) references members(member_id) ); diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index dcc0771d..ba500fc9 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -77,3 +77,82 @@ create table if not exists canceled_payments created_at datetime(6) null, last_modified_at datetime(6) null ); + + +create table if not exists payment1 +( + id bigint primary key, + reservation_id bigint not null, + type varchar(20) not null, + method varchar(30) not null, + payment_key varchar(255) not null unique, + total_amount integer not null, + status varchar(20) not null, + requested_at datetime(6) not null, + approved_at datetime(6), + + constraint uk_payment__reservationId unique (reservation_id), + constraint fk_payment__reservationId foreign key (reservation_id) references reservations (reservation_id) +); + +create table if not exists payment_detail +( + id bigint primary key, + payment_id bigint not null unique, + net_amount integer not null, + vat integer not null, + + constraint fk_payment_detail__paymentId foreign key (payment_id) references payment1 (id) +); + +create table if not exists payment_bank_transfer_detail +( + id bigint primary key, + bank_code varchar(10) not null, + + constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) +); + +create table if not exists payment_card_detail +( + id bigint primary key, + issuer_code varchar(10) not null, + card_type varchar(10) not null, + owner_type varchar(10) not null, + amount integer not null, + card_number varchar(20) not null, + approval_number varchar(8) not null unique, + installment_plan_months tinyint not null, + is_interest_free boolean not null, + easypay_provider_code varchar(20), + easypay_discount_amount integer, + + constraint fk_payment_card_detail__id foreign key (id) references payment_detail (id) +); + +create table if not exists payment_easypay_prepaid_detail +( + id bigint primary key, + easypay_provider_code varchar(20) not null, + amount integer not null, + discount_amount integer not null, + + constraint fk_payment_easypay_prepaid_detail__id foreign key (id) references payment_detail (id) +); + +create table if not exists canceled_payment1 +( + id bigint primary key, + payment_id bigint not null, + canceled_at datetime(6) not null, + canceled_by bigint not null, + cancel_reason varchar(255) not null, + cancel_amount integer not null, + cardDiscountAmount integer not null, + transferDiscountAmount integer not null, + easyPayDiscountAmount integer not null, + + constraint uk_canceled_payment1__paymentId unique (payment_id), + constraint fk_canceled_payment__paymentId foreign key (payment_id) references payment1(id), + constraint fk_canceled_payment__canceledBy foreign key (canceled_by) references members(member_id) +); -- 2.47.2 From d0f6e0fe0c66880616f267af2da44137c2a0174b Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:22:08 +0900 Subject: [PATCH 05/37] =?UTF-8?q?feat:=20Auditing=20=EC=97=86=EC=9D=B4=20P?= =?UTF-8?q?ersistable=EB=A7=8C=20=EC=A0=95=EC=9D=98=EB=90=9C=20BaseEntity?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/entity/BaseEntity.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/kotlin/roomescape/common/entity/BaseEntity.kt b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt index 439f62ef..ae6d1516 100644 --- a/src/main/kotlin/roomescape/common/entity/BaseEntity.kt +++ b/src/main/kotlin/roomescape/common/entity/BaseEntity.kt @@ -33,3 +33,22 @@ abstract class BaseEntity( abstract override fun getId(): Long? } + +@MappedSuperclass +abstract class PersistableBaseEntity( + @Id + @Column(name = "id") + private val _id: Long, + + @Transient + private var isNewEntity: Boolean = true +): Persistable { + @PostLoad + @PostPersist + fun markNotNew() { + isNewEntity = false + } + + override fun isNew(): Boolean = isNewEntity + override fun getId(): Long = _id +} -- 2.47.2 From af812603553c53187a6502020162f5abacb7460a Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:23:59 +0900 Subject: [PATCH 06/37] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=EC=99=80=EC=9D=98=20PK=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Long=20<->=20String=20ObjectMa?= =?UTF-8?q?pper=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/common/config/JacksonConfig.kt | 39 ++++++++++++++++++- .../common/config/JacksonConfigTest.kt | 14 +++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt index 40232da4..c53a98d8 100644 --- a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt +++ b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt @@ -1,6 +1,9 @@ package roomescape.common.config -import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.* +import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer @@ -9,6 +12,8 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer import com.fasterxml.jackson.module.kotlin.kotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import roomescape.common.exception.CommonErrorCode +import roomescape.common.exception.RoomescapeException import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -20,6 +25,7 @@ class JacksonConfig { fun objectMapper(): ObjectMapper = ObjectMapper() .registerModule(javaTimeModule()) .registerModule(kotlinModule()) + .registerModule(longIdModule()) private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() .addSerializer( @@ -38,4 +44,35 @@ class JacksonConfig { LocalTime::class.java, LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) ) as JavaTimeModule + + private fun longIdModule(): SimpleModule { + val simpleModule = SimpleModule() + simpleModule.addSerializer(Long::class.java, LongToStringSerializer()) + simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer()) + return simpleModule + } +} + +class LongToStringSerializer : JsonSerializer() { + override fun serialize(value: Long?, gen: JsonGenerator, serializers: SerializerProvider) { + if (value == null) { + gen.writeNull() + } else { + gen.writeString(value.toString()) + } + } +} + +class StringToLongDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Long? { + val text = p.text + if (text.isNullOrBlank()) { + return null + } + return try { + text.toLong() + } catch (_: NumberFormatException) { + throw RoomescapeException(CommonErrorCode.INVALID_INPUT_VALUE) + } + } } diff --git a/src/test/kotlin/roomescape/common/config/JacksonConfigTest.kt b/src/test/kotlin/roomescape/common/config/JacksonConfigTest.kt index c290d1d9..98ca4e91 100644 --- a/src/test/kotlin/roomescape/common/config/JacksonConfigTest.kt +++ b/src/test/kotlin/roomescape/common/config/JacksonConfigTest.kt @@ -52,4 +52,18 @@ class JacksonConfigTest( }.message shouldContain "Text '$hour:$minute:$sec' could not be parsed" } } + + context("Long 타입은 문자열로 (역)직렬화된다.") { + val number = 1234567890L + val serialized: String = objectMapper.writeValueAsString(number) + val deserialized: Long = objectMapper.readValue(serialized, Long::class.java) + + test("Long 직렬화") { + serialized shouldBe "$number" + } + + test("Long 역직렬화") { + deserialized shouldBe number + } + } }) -- 2.47.2 From e4611c76b3e4ff32abb1a9dbe5f545c025fdea2b Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:37:03 +0900 Subject: [PATCH 07/37] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EB=A8=BC?= =?UTF-8?q?=EC=B8=A0=20API=EC=97=90=EC=84=9C=20=EC=A0=9C=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=83=80=EC=9E=85=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=B4=EC=9D=80=20Enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/common/PaymentTypes.kt | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/common/PaymentTypes.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/common/PaymentTypes.kt b/src/main/kotlin/roomescape/payment/infrastructure/common/PaymentTypes.kt new file mode 100644 index 00000000..e94a42b0 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/common/PaymentTypes.kt @@ -0,0 +1,243 @@ +package roomescape.payment.infrastructure.common + +import com.fasterxml.jackson.annotation.JsonCreator +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException + +private val log: KLogger = KotlinLogging.logger {} + +enum class PaymentType( + private val koreanName: String +) { + NORMAL("일반결제"), + BILLING("자동결제"), + BRANDPAY("브랜드페이"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.name } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(code: String): PaymentType { + return CACHE[code.uppercase()] ?: run { + log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$code" } + throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND) + } + } + } +} + +enum class PaymentMethod( + val koreanName: String, +) { + CARD("카드"), + EASY_PAY("간편결제"), + VIRTUAL_ACCOUNT("가상계좌"), + MOBILE_PHONE("휴대폰"), + TRANSFER("계좌이체"), + CULTURE_GIFT_CERTIFICATE("문화상품권"), + BOOK_GIFT_CERTIFICATE("도서문화상품권"), + GAME_GIFT_CERTIFICATE("게임문화상품권"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.koreanName } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(koreanName: String): PaymentMethod { + return CACHE[koreanName] + ?: run { + log.warn { "[PaymentTypes.PaymentMethod] 결제 수단 조회 실패: type=$koreanName" } + throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND) + } + } + } +} + +enum class PaymentStatus { + IN_PROGRESS, + DONE, + CANCELED, + ABORTED, + EXPIRED, + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.name } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(name: String): PaymentStatus { + return CACHE[name] ?: run { + log.warn { "[PaymentStatus.get] 결제 상태 조회 실패: name=$name" } + throw PaymentException(PaymentErrorCode.TYPE_NOT_FOUND) + } + } + } +} + +enum class CardType( + private val koreanName: String +) { + CREDIT("신용"), + CHECK("체크"), + GIFT("기프트"), + UNKNOWN("미확인"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.koreanName } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(koreanName: String): CardType { + return CACHE[koreanName] ?: UNKNOWN.also { + log.warn { "[PaymentCode.CardType] 카드 타입 조회 실패: type=$koreanName" } + } + } + } +} + +enum class CardOwnerType( + private val koreanName: String +) { + PERSONAL("개인"), + CORPORATE("법인"), + UNKNOWN("미확인"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.koreanName } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(koreanName: String): CardOwnerType { + return CACHE[koreanName] ?: UNKNOWN.also { + log.warn { "[PaymentCode.CardType] 카드 소유자 타입 조회 실패: type=$koreanName" } + } + } + } +} + +enum class BankCode( + val code: String, + val koreanName: String, +) { + KYONGNAM_BANK("039", "경남"), + GWANGJU_BANK("034", "광주"), + LOCAL_NONGHYEOP("012", "단위농협"), + BUSAN_BANK("032", "부산"), + SAEMAUL("045", "새마을"), + SANLIM("064", "산림"), + SHINHAN("088", "신한"), + SHINHYEOP("048", "신협"), + CITI("027", "씨티"), + WOORI("020", "우리"), + POST("071", "우체국"), + SAVINGBANK("050", "저축"), + JEONBUK_BANK("037", "전북"), + JEJU_BANK("035", "제주"), + KAKAO_BANK("090", "카카오"), + K_BANK("089", "케이"), + TOSS_BANK("092", "토스"), + HANA("081", "하나"), + HSBC("054", "홍콩상하이"), + IBK("003", "기업"), + KOOKMIN("004", "국민"), + DAEGU("031", "대구"), + KDB_BANK("002", "산업"), + NONGHYEOP("011", "농협"), + SC("023", "SC제일"), + SUHYEOP("007", "수협"); + + companion object { + private val CACHE: Map = entries.associateBy { it.code } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(code: String): BankCode { + val parsedCode = if (code.length == 2) "0$code" else code + + return CACHE[parsedCode] ?: run { + log.error { "[PaymentCode.BankCode] 은행 코드 조회 실패: code=$code" } + throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND) + } + } + } +} + +enum class CardIssuerCode( + val code: String, + val koreanName: String, +) { + IBK_BC("3K", "기업 BC"), + GWANGJU_BANK("46", "광주"), + LOTTE("71", "롯데"), + KDB_BANK("30", "산업"), + BC("31", "BC"), + SAMSUNG("51", "삼성"), + SAEMAUL("38", "새마을"), + SHINHAN("41", "신한"), + SHINHYEOP("62", "신협"), + CITI("36", "씨티"), + WOORI_BC("33", "우리"), + WOORI("W1", "우리"), + POST("37", "우체국"), + SAVINGBANK("39", "저축"), + JEONBUK_BANK("35", "전북"), + JEJU_BANK("42", "제주"), + KAKAO_BANK("15", "카카오뱅크"), + K_BANK("3A", "케이뱅크"), + TOSS_BANK("24", "토스뱅크"), + HANA("21", "하나"), + HYUNDAI("61", "현대"), + KOOKMIN("11", "국민"), + NONGHYEOP("91", "농협"), + SUHYEOP("34", "수협"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.code } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(code: String): CardIssuerCode { + return CACHE[code] ?: run { + log.error { "[PaymentCode.CardIssuerCode] 카드사 코드 조회 실패: code=$code" } + throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND) + } + } + } +} + +enum class EasyPayCompanyCode( + val koreanName: String +) { + TOSSPAY("토스페이"), + NAVERPAY("네이버페이"), + SAMSUNGPAY("삼성페이"), + LPAY("엘페이"), + KAKAOPAY("카카오페이"), + PAYCO("페이코"), + SSG("SSG페이"), + APPLEPAY("애플페이"), + PINPAY("핀페이"), + ; + + companion object { + private val CACHE: Map = entries.associateBy { it.koreanName } + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun get(koreanName: String): EasyPayCompanyCode { + return CACHE[koreanName] ?: run { + log.error { "[PaymentCode.EasyPayCompanyCode] 간편결제사 코드 조회 실패: name=$koreanName" } + throw PaymentException(PaymentErrorCode.ORGANIZATION_CODE_NOT_FOUND) + } + } + } +} -- 2.47.2 From 6095813891c2f2409d451a53e01366dbb8315e22 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:40:58 +0900 Subject: [PATCH 08/37] =?UTF-8?q?feat:=20PaymentErrorCode=EC=97=90=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/payment/exception/PaymentErrorCode.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt index 66170bdd..1bd540be 100644 --- a/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt +++ b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt @@ -11,6 +11,9 @@ enum class PaymentErrorCode( PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."), CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."), PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P003", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."), + ORGANIZATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "P004", "은행 / 카드사 정보를 찾을 수 없어요."), + TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "P005", "타입 정보를 찾을 수 없어요."), + PAYMENT_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P998", "결제 과정중 예상치 못한 예외가 발생했어요."), PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.") } -- 2.47.2 From 457cc0947f9a2e4a46a59a2953d4e789b7c82651 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:41:15 +0900 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20Tosspay=20=EC=9A=94=EC=B2=AD=20/?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=9E=AC=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/v2/TosspaymentCancelDTO.kt | 54 +++++++++++++++++++ .../client/v2/TosspaymentConfirmDTO.kt | 47 ++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt new file mode 100644 index 00000000..8d678c01 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt @@ -0,0 +1,54 @@ +package roomescape.payment.infrastructure.client.v2 + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import roomescape.payment.infrastructure.common.PaymentStatus +import java.time.OffsetDateTime + +data class PaymentCancelRequestV2( + val paymentKey: String, + val amount: Long, + val cancelReason: String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PaymentCancelResponseV2( + val status: PaymentStatus, + @JsonDeserialize(using = CancelDetailDeserializer::class) + val cancels: CancelDetail, +) + +data class CancelDetail( + val cancelAmount: Int, + val cardDiscountAmount: Int, + val transferDiscountAmount: Int, + val easyPayDiscountAmount: Int, + val canceledAt: OffsetDateTime, +) + +class CancelDetailDeserializer : JsonDeserializer() { + override fun deserialize( + p: JsonParser, + ctxt: DeserializationContext + ): CancelDetail? { + val node: JsonNode = p.codec.readTree(p) ?: return null + + val targetNode = when { + node.isArray && !node.isEmpty -> node[0] + node.isObject -> node + else -> return null + } + + return CancelDetail( + cancelAmount = targetNode.get("cancelAmount").asInt(), + cardDiscountAmount = targetNode.get("cardDiscountAmount").asInt(), + transferDiscountAmount = targetNode.get("transferDiscountAmount").asInt(), + easyPayDiscountAmount = targetNode.get("easyPayDiscountAmount").asInt(), + canceledAt = OffsetDateTime.parse(targetNode.get("canceledAt").asText()) + ) + } +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt new file mode 100644 index 00000000..9a5254ac --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt @@ -0,0 +1,47 @@ +package roomescape.payment.infrastructure.client.v2 + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import roomescape.payment.infrastructure.common.* +import java.time.OffsetDateTime + +data class PaymentConfirmRequest( + val paymentKey: String, + val orderId: String, + val amount: Long, + val paymentType: String, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PaymentConfirmResponse( + val paymentKey: String, + val orderId: String, + val totalAmount: Int, + val method: PaymentMethod, + val card: CardDetail?, + val easyPay: EasyPayDetail?, + val transfer: TransferDetail?, + val requestedAt: OffsetDateTime, + val approvedAt: OffsetDateTime, +) + +data class CardDetail( + val issuerCode: CardIssuerCode, + val number: String, + val amount: Int, + val cardType: CardType, + val ownerType: CardOwnerType, + val isInterestFree: Boolean, + val approveNo: String, + val installmentPlanMonths: Int +) + +data class EasyPayDetail( + val provider: EasyPayCompanyCode, + val amount: Int, + val discountAmount: Int, +) + +data class TransferDetail( + val bankCode: BankCode, + val settlementStatus: String, +) -- 2.47.2 From 0dd50e2d993abcb4ea7817138336bb6c133458df Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:41:50 +0900 Subject: [PATCH 10/37] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20DTO?= =?UTF-8?q?=20=EC=8A=A4=ED=8E=99=EC=97=90=20=EB=A7=9E=EC=B6=98=20Tosspayme?= =?UTF-8?q?ntClient=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/v2/TosspaymentClientV2.kt | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt new file mode 100644 index 00000000..0d0310f7 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt @@ -0,0 +1,133 @@ +package roomescape.payment.infrastructure.client.v2 + +import com.fasterxml.jackson.databind.ObjectMapper +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatusCode +import org.springframework.http.MediaType +import org.springframework.http.client.ClientHttpResponse +import org.springframework.stereotype.Component +import org.springframework.web.client.ResponseErrorHandler +import org.springframework.web.client.RestClient +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException +import roomescape.payment.infrastructure.client.TossPaymentErrorResponse +import java.net.URI + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class TosspaymentClientV2( + objectMapper: ObjectMapper, + tossPaymentClientBuilder: RestClient.Builder +) { + private val confirmClient = ConfirmClient(objectMapper, tossPaymentClientBuilder.build()) + private val cancelClient = CancelClient(objectMapper, tossPaymentClientBuilder.build()) + + fun confirm(request: PaymentConfirmRequest): PaymentConfirmResponse { + log.info { "[TossPaymentClientV2.confirm] 승인 요청: request=$request" } + + return confirmClient.request(request) + } + + fun cancel(request: PaymentCancelRequestV2): PaymentCancelResponseV2 { + log.info { "[TossPaymentClient.cancel] 취소 요청: request=$request" } + + return cancelClient.request(request) + } +} + +private class ConfirmClient( + objectMapper: ObjectMapper, + private val client: RestClient, +) { + companion object { + private const val CONFIRM_URI: String = "/v1/payments/confirm" + } + + private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper) + + fun request(request: PaymentConfirmRequest): PaymentConfirmResponse = client.post() + .uri(CONFIRM_URI) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .onStatus(errorHandler) + .body(PaymentConfirmResponse::class.java) + ?: run { + log.error { "[TossPaymentConfirmClient.request] 응답 바디 변환 실패" } + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } +} + +private class CancelClient( + objectMapper: ObjectMapper, + private val client: RestClient, +) { + companion object { + private const val CANCEL_URI: String = "/v1/payments/{paymentKey}/cancel" + } + + private val errorHandler: TosspayErrorHandler = TosspayErrorHandler(objectMapper) + + fun request(request: PaymentCancelRequestV2): PaymentCancelResponseV2 = client.post() + .uri(CANCEL_URI, request.paymentKey) + .body( + mapOf( + "cancelReason" to request.cancelReason, + "cancelAmount" to request.amount, + ) + ) + .retrieve() + .onStatus(errorHandler) + .body(PaymentCancelResponseV2::class.java) + ?: run { + log.error { "[TossPaymentClient] 응답 바디 변환 실패" } + throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + } +} + +private class TosspayErrorHandler( + private val objectMapper: ObjectMapper +) : ResponseErrorHandler { + override fun hasError(response: ClientHttpResponse): Boolean { + val statusCode: HttpStatusCode = response.statusCode + + return statusCode.is4xxClientError || statusCode.is5xxServerError + } + + override fun handleError( + url: URI, + method: HttpMethod, + response: ClientHttpResponse + ): Nothing { + val requestType: String = paymentRequestType(url) + log.warn { "[TossPaymentClient] $requestType 요청 실패: response: ${toErrorResponse(response)}" } + + throw PaymentException(paymentErrorCode(response.statusCode)) + } + + private fun paymentRequestType(url: URI): String { + val type = url.path.split("/").last() + + if (type == "cancel") { + return "취소" + } + return "승인" + } + + private fun paymentErrorCode(statusCode: HttpStatusCode) = if (statusCode.is4xxClientError) { + PaymentErrorCode.PAYMENT_CLIENT_ERROR + } else { + PaymentErrorCode.PAYMENT_PROVIDER_ERROR + } + + private fun toErrorResponse(response: ClientHttpResponse): TossPaymentErrorResponse { + val body = response.body + + return objectMapper.readValue(body, TossPaymentErrorResponse::class.java).also { + body.close() + } + } +} -- 2.47.2 From c2906ee430d2b5afc0b1754e07dbda584cd10a87 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:43:21 +0900 Subject: [PATCH 11/37] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20PaymentEntity=20&=20CanceledPaymentEntity?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/CanceledPaymentRepositoryV2.kt | 5 +++ .../persistence/v2/CanceledPaymentV2.kt | 18 +++++++++++ .../persistence/v2/PaymentEntityV2.kt | 32 +++++++++++++++++++ .../persistence/v2/PaymentRepositoryV2.kt | 5 +++ 4 files changed, 60 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt new file mode 100644 index 00000000..a9a02ed2 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt @@ -0,0 +1,5 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import org.springframework.data.jpa.repository.JpaRepository + +interface CanceledPaymentRepositoryV2 : JpaRepository \ No newline at end of file diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt new file mode 100644 index 00000000..40ef0aab --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt @@ -0,0 +1,18 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import jakarta.persistence.Entity +import jakarta.persistence.Table +import roomescape.common.entity.PersistableBaseEntity +import java.time.OffsetDateTime + +@Entity +@Table(name = "canceled_payment1") +class CanceledPaymentV2( + id: Long, + + val paymentId: Long, + val canceledAt: OffsetDateTime, + val canceledBy: Long, + val cancelReason: String?, +) : PersistableBaseEntity(id) + diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt new file mode 100644 index 00000000..dc14eabf --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt @@ -0,0 +1,32 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import roomescape.common.entity.PersistableBaseEntity +import roomescape.payment.infrastructure.common.PaymentMethod +import roomescape.payment.infrastructure.common.PaymentStatus +import roomescape.payment.infrastructure.common.PaymentType +import java.time.OffsetDateTime + +@Entity +@Table(name = "payment1") +class PaymentEntityV2( + id: Long, + + val reservationId: Long, + val paymentKey: String, + val totalAmount: Int, + val requestedAt: OffsetDateTime, + val approvedAt: OffsetDateTime, + + @Enumerated(EnumType.STRING) + val type: PaymentType, + + @Enumerated(EnumType.STRING) + val method: PaymentMethod, + + @Enumerated(EnumType.STRING) + val status: PaymentStatus +) : PersistableBaseEntity(id) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt new file mode 100644 index 00000000..61a7dd95 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt @@ -0,0 +1,5 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentRepositoryV2: JpaRepository \ No newline at end of file -- 2.47.2 From 63d4f93d31e16ca788dfeb10e701c0a8d9503986 Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:43:28 +0900 Subject: [PATCH 12/37] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=8A=94=20PaymentDetailEntity=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/v2/PaymentDetailEntity.kt | 78 +++++++++++++++++++ .../persistence/v2/PaymentDetailRepository.kt | 5 ++ 2 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt create mode 100644 src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailRepository.kt diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt new file mode 100644 index 00000000..e7940e7e --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt @@ -0,0 +1,78 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import jakarta.persistence.* +import roomescape.common.entity.PersistableBaseEntity +import roomescape.payment.infrastructure.common.* +import kotlin.jvm.Transient + +@Entity +@Table(name = "payment_detail") +@Inheritance(strategy = InheritanceType.JOINED) +open class PaymentDetailEntity( + id: Long, + + open val paymentId: Long, + open val netAmount: Int, + open val vat: Int, + + @Transient + private var isNewEntity: Boolean = true +) : PersistableBaseEntity(id) + +@Entity +@Table(name = "payment_card_detail") +class PaymentCardDetailEntity( + id: Long, + paymentId: Long, + netAmount: Int, + vat: Int, + + @Enumerated(EnumType.STRING) + val issuerCode: CardIssuerCode, + + @Enumerated(EnumType.STRING) + val cardType: CardType, + + @Enumerated(EnumType.STRING) + val ownerType: CardOwnerType, + + val amount: Int, + val cardNumber: String, + val approvalNumber: String, + + @Column(name = "installment_plan_months", columnDefinition = "TINYINT") + val installmentPlanMonths: Int, + val isInterestFree: Boolean, + + @Enumerated(EnumType.STRING) + val easypayProviderCode: EasyPayCompanyCode?, + + val easypayDiscountAmount: Int? +) : PaymentDetailEntity(id, paymentId, netAmount, vat) + +@Entity +@Table(name = "payment_bank_transfer_detail") +class PaymentBankTransferDetailEntity( + id: Long, + paymentId: Long, + netAmount: Int, + vat: Int, + + @Enumerated(EnumType.STRING) + val bankCode: BankCode +) : PaymentDetailEntity(id, paymentId, netAmount, vat) + +@Entity +@Table(name = "payment_easypay_prepaid_detail") +class PaymentEasypayPrepaidDetailEntity( + id: Long, + paymentId: Long, + netAmount: Int, + vat: Int, + + @Enumerated(EnumType.STRING) + val easypayProviderCode: EasyPayCompanyCode, + + val amount: Int, + val discountAmount: Int +) : PaymentDetailEntity(id, paymentId, netAmount, vat) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailRepository.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailRepository.kt new file mode 100644 index 00000000..68723420 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailRepository.kt @@ -0,0 +1,5 @@ +package roomescape.payment.infrastructure.persistence.v2 + +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentDetailRepository: JpaRepository \ No newline at end of file -- 2.47.2 From 112836c51c2a7d5046a5c1626f3673e9c41128ea Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:43:48 +0900 Subject: [PATCH 13/37] =?UTF-8?q?fix:=20PK=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=97=90=20=EC=9D=98=ED=95=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=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 --- .../roomescape/reservation/web/ReservationControllerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt index a5fdf245..06d59c98 100644 --- a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt +++ b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt @@ -465,7 +465,7 @@ class ReservationControllerTest( post("/reservations/waiting") }.Then { statusCode(201) - body("data.member.id", equalTo(member.id!!)) + body("data.member.id", equalTo(member.id!!.toString())) body("data.status", equalTo(ReservationStatus.WAITING.name)) } } -- 2.47.2 From 0fc19530d1c8a60127851183b00a4176f8d0213f Mon Sep 17 00:00:00 2001 From: pricelees Date: Fri, 15 Aug 2025 18:50:40 +0900 Subject: [PATCH 14/37] =?UTF-8?q?refactor:=20=EA=B0=9C=EB=B3=84=20\@JsonIg?= =?UTF-8?q?noreProperties=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20ObjectMapper?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EC=A0=84=EC=97=AD=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/roomescape/common/config/JacksonConfig.kt | 1 + .../payment/infrastructure/client/TossPaymentDTO.kt | 2 -- .../payment/infrastructure/client/v2/TosspaymentCancelDTO.kt | 2 -- .../payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt | 4 +--- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt index c53a98d8..9af1bf32 100644 --- a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt +++ b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt @@ -26,6 +26,7 @@ class JacksonConfig { .registerModule(javaTimeModule()) .registerModule(kotlinModule()) .registerModule(longIdModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() .addSerializer( diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentDTO.kt index e437fd82..442bcf98 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentDTO.kt @@ -1,6 +1,5 @@ package roomescape.payment.infrastructure.client -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import java.time.OffsetDateTime data class TossPaymentErrorResponse( @@ -15,7 +14,6 @@ data class PaymentApproveRequest( val paymentType: String ) -@JsonIgnoreProperties(ignoreUnknown = true) data class PaymentApproveResponse( val paymentKey: String, val orderId: String, diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt index 8d678c01..261fda1c 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt @@ -1,6 +1,5 @@ package roomescape.payment.infrastructure.client.v2 -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonDeserializer @@ -15,7 +14,6 @@ data class PaymentCancelRequestV2( val cancelReason: String ) -@JsonIgnoreProperties(ignoreUnknown = true) data class PaymentCancelResponseV2( val status: PaymentStatus, @JsonDeserialize(using = CancelDetailDeserializer::class) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt index 9a5254ac..13859a01 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt @@ -1,6 +1,5 @@ package roomescape.payment.infrastructure.client.v2 -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import roomescape.payment.infrastructure.common.* import java.time.OffsetDateTime @@ -8,10 +7,9 @@ data class PaymentConfirmRequest( val paymentKey: String, val orderId: String, val amount: Long, - val paymentType: String, + val paymentType: PaymentType, ) -@JsonIgnoreProperties(ignoreUnknown = true) data class PaymentConfirmResponse( val paymentKey: String, val orderId: String, -- 2.47.2 From 79527cb7082133eee54f36a52fe277fdffb5fb15 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:15:17 +0900 Subject: [PATCH 15/37] =?UTF-8?q?rename:=20CanceledPaymentV2=20->=20Cancel?= =?UTF-8?q?edPaymentEntityV2=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=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 --- .../v2/{CanceledPaymentV2.kt => CanceledPaymentEntityV2.kt} | 2 +- .../persistence/v2/CanceledPaymentRepositoryV2.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/{CanceledPaymentV2.kt => CanceledPaymentEntityV2.kt} (93%) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentEntityV2.kt similarity index 93% rename from src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt rename to src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentEntityV2.kt index 40ef0aab..b4c99aa5 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentEntityV2.kt @@ -7,7 +7,7 @@ import java.time.OffsetDateTime @Entity @Table(name = "canceled_payment1") -class CanceledPaymentV2( +class CanceledPaymentEntityV2( id: Long, val paymentId: Long, diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt index a9a02ed2..41d6ff65 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/CanceledPaymentRepositoryV2.kt @@ -2,4 +2,4 @@ package roomescape.payment.infrastructure.persistence.v2 import org.springframework.data.jpa.repository.JpaRepository -interface CanceledPaymentRepositoryV2 : JpaRepository \ No newline at end of file +interface CanceledPaymentRepositoryV2 : JpaRepository \ No newline at end of file -- 2.47.2 From 4108dcd01af8d7dd1b8baa2b738c74a5d25defe5 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:15:41 +0900 Subject: [PATCH 16/37] =?UTF-8?q?rename:=20PaymentDeatil=20=EB=82=B4=20'?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EA=B0=80=EC=95=A1'=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95(->suppliedAmount)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/v2/PaymentDetailEntity.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt index e7940e7e..43969ef6 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentDetailEntity.kt @@ -12,7 +12,7 @@ open class PaymentDetailEntity( id: Long, open val paymentId: Long, - open val netAmount: Int, + open val suppliedAmount: Int, open val vat: Int, @Transient @@ -24,7 +24,7 @@ open class PaymentDetailEntity( class PaymentCardDetailEntity( id: Long, paymentId: Long, - netAmount: Int, + suppliedAmount: Int, vat: Int, @Enumerated(EnumType.STRING) @@ -48,26 +48,27 @@ class PaymentCardDetailEntity( val easypayProviderCode: EasyPayCompanyCode?, val easypayDiscountAmount: Int? -) : PaymentDetailEntity(id, paymentId, netAmount, vat) +) : PaymentDetailEntity(id, paymentId, suppliedAmount, vat) @Entity @Table(name = "payment_bank_transfer_detail") class PaymentBankTransferDetailEntity( id: Long, paymentId: Long, - netAmount: Int, + suppliedAmount: Int, vat: Int, @Enumerated(EnumType.STRING) - val bankCode: BankCode -) : PaymentDetailEntity(id, paymentId, netAmount, vat) + val bankCode: BankCode, + val settlementStatus: String, +) : PaymentDetailEntity(id, paymentId, suppliedAmount, vat) @Entity @Table(name = "payment_easypay_prepaid_detail") class PaymentEasypayPrepaidDetailEntity( id: Long, paymentId: Long, - netAmount: Int, + suppliedAmount: Int, vat: Int, @Enumerated(EnumType.STRING) @@ -75,4 +76,4 @@ class PaymentEasypayPrepaidDetailEntity( val amount: Int, val discountAmount: Int -) : PaymentDetailEntity(id, paymentId, netAmount, vat) +) : PaymentDetailEntity(id, paymentId, suppliedAmount, vat) -- 2.47.2 From 349e0372f604346ff501422669bbe2e1d517003a Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:16:10 +0900 Subject: [PATCH 17/37] =?UTF-8?q?refactor:=20PaymentEntityV2=EC=97=90=20or?= =?UTF-8?q?derId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/v2/PaymentEntityV2.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt index dc14eabf..2d9e18cc 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentEntityV2.kt @@ -17,6 +17,7 @@ class PaymentEntityV2( val reservationId: Long, val paymentKey: String, + val orderId: String, val totalAmount: Int, val requestedAt: OffsetDateTime, val approvedAt: OffsetDateTime, @@ -28,5 +29,10 @@ class PaymentEntityV2( val method: PaymentMethod, @Enumerated(EnumType.STRING) - val status: PaymentStatus -) : PersistableBaseEntity(id) + var status: PaymentStatus +) : PersistableBaseEntity(id) { + + fun cancel() { + this.status = PaymentStatus.CANCELED + } +} -- 2.47.2 From 515853c20b06f164978f76dc7030dcf195387d30 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:16:32 +0900 Subject: [PATCH 18/37] =?UTF-8?q?feat:=20PaymentErrorCode=20=EB=82=B4=20'?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=9B=90=20=EA=B2=B0=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EB=8B=A8'=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/roomescape/payment/exception/PaymentErrorCode.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt index 1bd540be..a415af5d 100644 --- a/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt +++ b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt @@ -13,7 +13,8 @@ enum class PaymentErrorCode( PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P003", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."), ORGANIZATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "P004", "은행 / 카드사 정보를 찾을 수 없어요."), TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "P005", "타입 정보를 찾을 수 없어요."), + NOT_SUPPORTED_PAYMENT_TYPE(HttpStatus.BAD_REQUEST, "P006", "지원하지 않는 결제 수단이에요."), PAYMENT_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P998", "결제 과정중 예상치 못한 예외가 발생했어요."), - PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.") + PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."), } -- 2.47.2 From 0fc537da93050df9335e65d46a1b341782d469b9 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:17:22 +0900 Subject: [PATCH 19/37] =?UTF-8?q?feat:=20TosspaymentClient=20=EB=82=B4=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/v2/TosspaymentClientV2.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt index 0d0310f7..35816231 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentClientV2.kt @@ -26,15 +26,19 @@ class TosspaymentClientV2( private val cancelClient = CancelClient(objectMapper, tossPaymentClientBuilder.build()) fun confirm(request: PaymentConfirmRequest): PaymentConfirmResponse { - log.info { "[TossPaymentClientV2.confirm] 승인 요청: request=$request" } + log.info { "[TossPaymentClientV2.confirm] 결제 승인 요청: request=$request" } - return confirmClient.request(request) + return confirmClient.request(request).also { + log.info { "[TossPaymentClientV2.confirm] 결제 승인 완료: response=$it" } + } } fun cancel(request: PaymentCancelRequestV2): PaymentCancelResponseV2 { - log.info { "[TossPaymentClient.cancel] 취소 요청: request=$request" } + log.info { "[TossPaymentClient.cancel] 결제 취소 요청: request=$request" } - return cancelClient.request(request) + return cancelClient.request(request).also { + log.info { "[TossPaymentClient.cancel] 결제 취소 완료: response=$it" } + } } } @@ -54,8 +58,7 @@ private class ConfirmClient( .body(request) .retrieve() .onStatus(errorHandler) - .body(PaymentConfirmResponse::class.java) - ?: run { + .body(PaymentConfirmResponse::class.java) ?: run { log.error { "[TossPaymentConfirmClient.request] 응답 바디 변환 실패" } throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) } @@ -103,7 +106,7 @@ private class TosspayErrorHandler( response: ClientHttpResponse ): Nothing { val requestType: String = paymentRequestType(url) - log.warn { "[TossPaymentClient] $requestType 요청 실패: response: ${toErrorResponse(response)}" } + log.warn { "[TossPaymentClient] $requestType 요청 실패: response: ${parseResponse(response)}" } throw PaymentException(paymentErrorCode(response.statusCode)) } @@ -123,7 +126,7 @@ private class TosspayErrorHandler( PaymentErrorCode.PAYMENT_PROVIDER_ERROR } - private fun toErrorResponse(response: ClientHttpResponse): TossPaymentErrorResponse { + private fun parseResponse(response: ClientHttpResponse): TossPaymentErrorResponse { val body = response.body return objectMapper.readValue(body, TossPaymentErrorResponse::class.java).also { -- 2.47.2 From d19973978f93ed488a2da12e7ce2d10c6eaebcb0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:18:26 +0900 Subject: [PATCH 20/37] =?UTF-8?q?refactor:=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=EC=9D=98=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema/schema-h2.sql | 14 ++++++++------ src/main/resources/schema/schema-mysql.sql | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 75dfa2dc..3916f11e 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -81,6 +81,7 @@ create table if not exists payment1 ( type varchar(20) not null, method varchar(30) not null, payment_key varchar(255) not null unique, + order_id varchar(255) not null unique, total_amount integer not null, status varchar(20) not null, requested_at timestamp not null, @@ -91,17 +92,18 @@ create table if not exists payment1 ( ); create table if not exists payment_detail( - id bigint primary key, - payment_id bigint not null unique, - net_amount integer not null, - vat integer not null, + id bigint primary key, + payment_id bigint not null unique, + supplied_amount integer not null, + vat integer not null, constraint fk_payment_detail__paymentId foreign key (payment_id) references payment1 (id) ); create table if not exists payment_bank_transfer_detail ( - id bigint primary key, - bank_code varchar(10) not null, + id bigint primary key, + bank_code varchar(10) not null, + settlement_status varchar(20) not null, constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) ); diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index ba500fc9..1c225746 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -86,6 +86,7 @@ create table if not exists payment1 type varchar(20) not null, method varchar(30) not null, payment_key varchar(255) not null unique, + order_id varchar(255) not null unique, total_amount integer not null, status varchar(20) not null, requested_at datetime(6) not null, @@ -97,18 +98,19 @@ create table if not exists payment1 create table if not exists payment_detail ( - id bigint primary key, - payment_id bigint not null unique, - net_amount integer not null, - vat integer not null, + id bigint primary key, + payment_id bigint not null unique, + supplied_amount integer not null, + vat integer not null, constraint fk_payment_detail__paymentId foreign key (payment_id) references payment1 (id) ); create table if not exists payment_bank_transfer_detail ( - id bigint primary key, - bank_code varchar(10) not null, + id bigint primary key, + bank_code varchar(10) not null, + settlement_status varchar(20) not null, constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) ); -- 2.47.2 From 5fdb69ac70577745ec2f3c574ed421facd030fb8 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:18:42 +0900 Subject: [PATCH 21/37] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=B6=84=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EC=9C=A0=ED=8B=B8=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/TransactionExecutionUtil.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/kotlin/roomescape/common/util/TransactionExecutionUtil.kt diff --git a/src/main/kotlin/roomescape/common/util/TransactionExecutionUtil.kt b/src/main/kotlin/roomescape/common/util/TransactionExecutionUtil.kt new file mode 100644 index 00000000..28a980e5 --- /dev/null +++ b/src/main/kotlin/roomescape/common/util/TransactionExecutionUtil.kt @@ -0,0 +1,31 @@ +package roomescape.common.util + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate +import roomescape.common.exception.CommonErrorCode +import roomescape.common.exception.RoomescapeException + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class TransactionExecutionUtil( + private val transactionManager: PlatformTransactionManager +) { + + fun withNewTransaction(isReadOnly: Boolean, action: () -> T): T { + val transactionTemplate = TransactionTemplate(transactionManager).apply { + this.isReadOnly = isReadOnly + this.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + + return transactionTemplate.execute { action() } + ?: run { + log.error { "[TransactionExecutionUtil.withNewTransaction] 트랜잭션 작업 중 예상치 못한 null 반환 " } + throw RoomescapeException(CommonErrorCode.UNEXPECTED_SERVER_ERROR) + } + } +} -- 2.47.2 From 4d98b1801632e3b989f643a1b2113b6a10ade244 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:20:06 +0900 Subject: [PATCH 22/37] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=98=88=EC=95=BD-=EA=B2=B0=EC=A0=9C=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/ReservationWithPaymentAPI.kt | 61 +++++++++++++++++++ .../web/ReservationWithPaymentController.kt | 60 ++++++++++++++++++ .../web/ReservationWithPaymentDTO.kt | 55 +++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/main/kotlin/roomescape/reservation/docs/ReservationWithPaymentAPI.kt create mode 100644 src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentController.kt create mode 100644 src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentDTO.kt diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationWithPaymentAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationWithPaymentAPI.kt new file mode 100644 index 00000000..ffeb3716 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationWithPaymentAPI.kt @@ -0,0 +1,61 @@ +package roomescape.reservation.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.headers.Header +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.Valid +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import roomescape.auth.web.support.LoginRequired +import roomescape.auth.web.support.MemberId +import roomescape.common.dto.response.CommonApiResponse +import roomescape.reservation.web.* + +interface ReservationWithPaymentAPI { + + @LoginRequired + @Operation(summary = "예약 추가", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "성공", + useReturnTypeSchema = true, + headers = [Header( + name = HttpHeaders.LOCATION, + description = "생성된 예약 정보 URL", + schema = Schema(example = "/reservations/1") + )] + ) + ) + fun createPendingReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest + ): ResponseEntity> + + @LoginRequired + @Operation(summary = "예약 취소", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse(responseCode = "204", description = "성공"), + ) + fun cancelReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @PathVariable("id") reservationId: Long, + @Valid @RequestBody cancelRequest: ReservationCancelRequest + ): ResponseEntity> + + @LoginRequired + @Operation(summary = "예약 결제", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse(responseCode = "200", description = "성공"), + ) + fun createPaymentAndConfirmReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @PathVariable("id") reservationId: Long, + @Valid @RequestBody request: ReservationPaymentRequest + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentController.kt new file mode 100644 index 00000000..590e71e7 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentController.kt @@ -0,0 +1,60 @@ +package roomescape.reservation.web + +import io.swagger.v3.oas.annotations.Parameter +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import roomescape.auth.web.support.MemberId +import roomescape.common.dto.response.CommonApiResponse +import roomescape.reservation.business.ReservationWithPaymentService +import roomescape.reservation.business.ReservationWithPaymentServiceV2 +import roomescape.reservation.docs.ReservationWithPaymentAPI + +@RestController +class ReservationWithPaymentController( + private val reservationWithPaymentService: ReservationWithPaymentServiceV2 +) : ReservationWithPaymentAPI { + + @PostMapping("/v2/reservations") + override fun createPendingReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @Valid @RequestBody reservationCreateWithPaymentRequest: ReservationCreateRequest + ): ResponseEntity> { + val response = reservationWithPaymentService.createPendingReservation( + memberId = memberId, + request = reservationCreateWithPaymentRequest + ) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/v2/reservations/{id}/pay") + override fun createPaymentAndConfirmReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @PathVariable("id") reservationId: Long, + @Valid @RequestBody request: ReservationPaymentRequest, + ): ResponseEntity> { + val response = reservationWithPaymentService.payReservation( + memberId = memberId, + reservationId = reservationId, + request = request + ) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/v2/reservations/{id}/cancel") + override fun cancelReservation( + @MemberId @Parameter(hidden = true) memberId: Long, + @PathVariable("id") reservationId: Long, + @Valid @RequestBody cancelRequest: ReservationCancelRequest + ): ResponseEntity> { + reservationWithPaymentService.cancelReservation(memberId, reservationId, cancelRequest) + + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentDTO.kt b/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentDTO.kt new file mode 100644 index 00000000..466765d6 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/web/ReservationWithPaymentDTO.kt @@ -0,0 +1,55 @@ +package roomescape.reservation.web + +import roomescape.payment.infrastructure.client.v2.PaymentConfirmRequest +import roomescape.payment.infrastructure.common.PaymentStatus +import roomescape.payment.infrastructure.common.PaymentType +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import java.time.LocalDate +import java.time.LocalTime + +data class ReservationCreateRequest( + val date: LocalDate, + val timeId: Long, + val themeId: Long, +) + +data class ReservationCreateResponseV2( + val reservationId: Long, + val memberEmail: String, + val date: LocalDate, + val startAt: LocalTime, + val themeName: String +) + +fun ReservationEntity.toCreateResponseV2() = ReservationCreateResponseV2( + reservationId = this.id!!, + memberEmail = this.member.email, + date = this.date, + startAt = this.time.startAt, + themeName = this.theme.name +) + +data class ReservationPaymentRequest( + val paymentKey: String, + val orderId: String, + val amount: Long, + val paymentType: PaymentType +) + +fun ReservationPaymentRequest.toPaymentConfirmRequest() = PaymentConfirmRequest( + paymentKey = this.paymentKey, + amount = this.amount, + orderId = this.orderId, +) + +data class ReservationPaymentResponse( + val reservationId: Long, + val reservationStatus: ReservationStatus, + val paymentId: Long, + val paymentStatus: PaymentStatus, +) + +data class ReservationCancelRequest( + val cancelReason: String +) -- 2.47.2 From 4c82ad80c0aeeee5a10a664c7c05256649aaa159 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:22:58 +0900 Subject: [PATCH 23/37] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=EC=97=90=20=EB=A7=9E=EC=B6=98=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=B7=A8=EC=86=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/implement/PaymentWriterV2.kt | 119 ++++++++++++++++++ .../client/v2/TosspaymentCancelDTO.kt | 2 +- .../client/v2/TosspaymentConfirmDTO.kt | 83 +++++++++++- .../persistence/v2/PaymentRepositoryV2.kt | 5 +- .../ReservationWithPaymentServiceV2.kt | 103 +++++++++++++++ .../exception/ReservationErrorCode.kt | 2 + .../implement/ReservationFinder.kt | 10 ++ .../implement/ReservationValidator.kt | 12 ++ .../implement/ReservationWriter.kt | 13 +- .../persistence/ReservationEntity.kt | 11 ++ 10 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt create mode 100644 src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt diff --git a/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt b/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt new file mode 100644 index 00000000..c1ba870d --- /dev/null +++ b/src/main/kotlin/roomescape/payment/implement/PaymentWriterV2.kt @@ -0,0 +1,119 @@ +package roomescape.payment.implement + +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.common.config.next +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException +import roomescape.payment.infrastructure.client.v2.* +import roomescape.payment.infrastructure.common.PaymentMethod +import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentRepositoryV2 +import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2 +import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity +import roomescape.payment.infrastructure.persistence.v2.PaymentDetailRepository +import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2 +import roomescape.payment.infrastructure.persistence.v2.PaymentRepositoryV2 +import roomescape.reservation.web.ReservationCancelRequest +import roomescape.reservation.web.ReservationPaymentRequest +import roomescape.reservation.web.toPaymentConfirmRequest + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class PaymentWriterV2( + private val paymentClient: TosspaymentClientV2, + private val paymentRepository: PaymentRepositoryV2, + private val paymentDetailRepository: PaymentDetailRepository, + private val canceledPaymentRepository: CanceledPaymentRepositoryV2, + private val tsidFactory: TsidFactory, +) { + + fun requestConfirmPayment( + reservationId: Long, + request: ReservationPaymentRequest + ): PaymentConfirmResponse { + log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 승인 요청 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" } + + return paymentClient.confirm(request.toPaymentConfirmRequest()).also { + log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 승인 요청 완료: response=$it" } + } + } + + fun createPayment( + reservationId: Long, + request: ReservationPaymentRequest, + paymentConfirmResponse: PaymentConfirmResponse + ): PaymentEntityV2 { + log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 시작: reservationId=${reservationId}, paymentKey=${request.paymentKey}" } + + return paymentConfirmResponse.toEntity( + id = tsidFactory.next(), reservationId, request.orderId, request.paymentType + ).also { + paymentRepository.save(it) + saveDetail(paymentConfirmResponse, it.id) + log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" } + } + } + + private fun saveDetail( + paymentResponse: PaymentConfirmResponse, + paymentId: Long, + ): PaymentDetailEntity { + val method: PaymentMethod = paymentResponse.method + val id = tsidFactory.next() + + if (method == PaymentMethod.TRANSFER) { + return paymentDetailRepository.save(paymentResponse.toTransferDetailEntity(id, paymentId)) + } + if (method == PaymentMethod.EASY_PAY && paymentResponse.card == null) { + return paymentDetailRepository.save(paymentResponse.toEasypayPrepaidDetailEntity(id, paymentId)) + } + if (paymentResponse.card != null) { + return paymentDetailRepository.save(paymentResponse.toCardDetailEntity(id, paymentId)) + } + throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE) + } + + fun requestCancelPayment( + reservationId: Long, + request: ReservationCancelRequest, + ): PaymentCancelResponseV2 { + log.debug { "[PaymentWriterV2.requestConfirmPayment] 결제 취소 요청 시작: reservationId=$reservationId, request=${request}" } + + val payment: PaymentEntityV2 = paymentRepository.findByReservationId(reservationId) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) + + val paymentCancelRequest = PaymentCancelRequestV2( + paymentKey = payment.paymentKey, + cancelReason = request.cancelReason, + amount = payment.totalAmount + ) + + return paymentClient.cancel(paymentCancelRequest).also { + log.debug { "[PaymentWriterV2.requestCancelPayment] 결제 취소 요청 완료: reservationId=${reservationId}, paymentKey=${payment.paymentKey}" } + } + } + + fun createCanceledPayment( + memberId: Long, + reservationId: Long, + request: ReservationCancelRequest, + paymentCancelResponse: PaymentCancelResponseV2 + ) { + val payment: PaymentEntityV2= paymentRepository.findByReservationId(reservationId) + ?.also { it.cancel() } + ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) + + val cancelDetail: CancelDetail = paymentCancelResponse.cancels + + CanceledPaymentEntityV2( + id = tsidFactory.next(), + canceledAt = cancelDetail.canceledAt, + paymentId = payment.id, + canceledBy = memberId, + cancelReason = request.cancelReason + ).also { canceledPaymentRepository.save(it) } + } +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt index 261fda1c..cb7d535b 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentCancelDTO.kt @@ -10,7 +10,7 @@ import java.time.OffsetDateTime data class PaymentCancelRequestV2( val paymentKey: String, - val amount: Long, + val amount: Int, val cancelReason: String ) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt index 13859a01..819ac4b4 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/v2/TosspaymentConfirmDTO.kt @@ -1,19 +1,26 @@ package roomescape.payment.infrastructure.client.v2 +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.common.* +import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity +import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity +import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity +import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2 import java.time.OffsetDateTime data class PaymentConfirmRequest( val paymentKey: String, val orderId: String, val amount: Long, - val paymentType: PaymentType, ) data class PaymentConfirmResponse( val paymentKey: String, - val orderId: String, + val status: PaymentStatus, val totalAmount: Int, + val vat: Int, + val suppliedAmount: Int, val method: PaymentMethod, val card: CardDetail?, val easyPay: EasyPayDetail?, @@ -22,6 +29,24 @@ data class PaymentConfirmResponse( val approvedAt: OffsetDateTime, ) +fun PaymentConfirmResponse.toEntity( + id: Long, + reservationId: Long, + orderId: String, + paymentType: PaymentType +) = PaymentEntityV2( + id = id, + reservationId = reservationId, + paymentKey = this.paymentKey, + orderId = orderId, + totalAmount = this.totalAmount, + requestedAt = this.requestedAt, + approvedAt = this.approvedAt, + type = paymentType, + method = this.method, + status = this.status, +) + data class CardDetail( val issuerCode: CardIssuerCode, val number: String, @@ -33,13 +58,67 @@ data class CardDetail( val installmentPlanMonths: Int ) +fun PaymentConfirmResponse.toCardDetailEntity(id: Long, paymentId: Long): PaymentCardDetailEntity { + val cardDetail = this.card ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return PaymentCardDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = this.suppliedAmount, + vat = this.vat, + issuerCode = cardDetail.issuerCode, + cardType = cardDetail.cardType, + ownerType = cardDetail.ownerType, + amount = cardDetail.amount, + cardNumber = cardDetail.number, + approvalNumber = cardDetail.approveNo, + installmentPlanMonths = cardDetail.installmentPlanMonths, + isInterestFree = cardDetail.isInterestFree, + easypayProviderCode = this.easyPay?.provider, + easypayDiscountAmount = this.easyPay?.discountAmount, + ) +} + data class EasyPayDetail( val provider: EasyPayCompanyCode, val amount: Int, val discountAmount: Int, ) +fun PaymentConfirmResponse.toEasypayPrepaidDetailEntity( + id: Long, + paymentId: Long +): PaymentEasypayPrepaidDetailEntity { + val easyPayDetail = this.easyPay ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return PaymentEasypayPrepaidDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = this.suppliedAmount, + vat = this.vat, + easypayProviderCode = easyPayDetail.provider, + amount = easyPayDetail.amount, + discountAmount = easyPayDetail.discountAmount + ) +} + data class TransferDetail( val bankCode: BankCode, val settlementStatus: String, ) + +fun PaymentConfirmResponse.toTransferDetailEntity( + id: Long, + paymentId: Long +): PaymentBankTransferDetailEntity { + val transferDetail = this.transfer ?: throw PaymentException(PaymentErrorCode.PAYMENT_UNEXPECTED_ERROR) + + return PaymentBankTransferDetailEntity( + id = id, + paymentId = paymentId, + suppliedAmount = this.suppliedAmount, + vat = this.vat, + bankCode = transferDetail.bankCode, + settlementStatus = transferDetail.settlementStatus + ) +} diff --git a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt index 61a7dd95..6fd16b94 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/persistence/v2/PaymentRepositoryV2.kt @@ -2,4 +2,7 @@ package roomescape.payment.infrastructure.persistence.v2 import org.springframework.data.jpa.repository.JpaRepository -interface PaymentRepositoryV2: JpaRepository \ No newline at end of file +interface PaymentRepositoryV2: JpaRepository { + + fun findByReservationId(reservationId: Long): PaymentEntityV2? +} diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt new file mode 100644 index 00000000..56a7aff8 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceV2.kt @@ -0,0 +1,103 @@ +package roomescape.reservation.business + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.transaction.Transactional +import org.springframework.stereotype.Service +import roomescape.common.util.TransactionExecutionUtil +import roomescape.payment.implement.PaymentWriterV2 +import roomescape.payment.infrastructure.client.v2.PaymentConfirmResponse +import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2 +import roomescape.reservation.implement.ReservationFinder +import roomescape.reservation.implement.ReservationWriter +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.reservation.web.* + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class ReservationWithPaymentServiceV2( + private val reservationWriter: ReservationWriter, + private val reservationFinder: ReservationFinder, + private val paymentWriter: PaymentWriterV2, + private val transactionExecutionUtil: TransactionExecutionUtil +) { + + @Transactional + fun createPendingReservation(memberId: Long, request: ReservationCreateRequest): ReservationCreateResponseV2 { + log.info { + "[ReservationWithPaymentServiceV2.createPendingReservation] " + + "PENDING 예약 저장 시작: memberId=$memberId, request=$request" + } + + val reservation: ReservationEntity = reservationWriter.create( + date = request.date, + timeId = request.timeId, + themeId = request.themeId, + status = ReservationStatus.PENDING, + memberId = memberId, + requesterId = memberId + ) + + return reservation.toCreateResponseV2().also { + log.info { + "[ReservationWithPaymentServiceV2.createPendingReservation] " + + "PENDING 예약 저장 완료: reservationId=${reservation.id}, response=$it" + } + } + } + + fun payReservation( + memberId: Long, + reservationId: Long, + request: ReservationPaymentRequest + ): ReservationPaymentResponse { + log.info { + "[ReservationWithPaymentServiceV2.payReservation] " + + "예약 결제 시작: memberId=$memberId, reservationId=$reservationId, request=$request" + } + + val paymentConfirmResponse = paymentWriter.requestConfirmPayment(reservationId, request) + + return transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + savePayment(memberId, reservationId, request, paymentConfirmResponse) + } + } + + private fun savePayment( + memberId: Long, + reservationId: Long, + request: ReservationPaymentRequest, + paymentConfirmResponse: PaymentConfirmResponse + ): ReservationPaymentResponse { + val reservation = + reservationFinder.findPendingReservation(reservationId, memberId).also { it.confirm() } + val payment: PaymentEntityV2 = paymentWriter.createPayment( + reservationId = reservationId, + request = request, + paymentConfirmResponse = paymentConfirmResponse + ) + + return ReservationPaymentResponse(reservationId, reservation.status, payment.id, payment.status) + .also { log.info { "[ReservationWithPaymentServiceV2.payReservation] 예약 결제 완료: response=${it}" } } + } + + fun cancelReservation( + memberId: Long, + reservationId: Long, + request: ReservationCancelRequest + ) { + log.info { + "[ReservationWithPaymentServiceV2.cancelReservation] " + + "예약 취소 시작: memberId=$memberId, reservationId=$reservationId, request=$request" + } + + val paymentCancelResponse = paymentWriter.requestCancelPayment(reservationId, request) + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + paymentWriter.createCanceledPayment(memberId, reservationId, request, paymentCancelResponse) + reservationFinder.findById(reservationId).also { reservationWriter.cancelByUser(it, memberId) } + } + } +} diff --git a/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt index e1033a6a..1ca04cfe 100644 --- a/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt +++ b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt @@ -17,4 +17,6 @@ enum class ReservationErrorCode( NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."), NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."), + RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."), + ; } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt index adf824d7..0d479a63 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationFinder.kt @@ -91,4 +91,14 @@ class ReservationFinder( return reservationRepository.existsByTime(time) .also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } } } + + fun findPendingReservation(reservationId: Long, memberId: Long): ReservationEntity { + log.debug { "[ReservationFinder.findPendingReservationIfExists] 시작: reservationId=$reservationId, memberId=$memberId" } + + return findById(reservationId).also { + reservationValidator.validateIsReservedByMemberAndPending(it, memberId) + }.also { + log.debug { "[ReservationFinder.findPendingReservationIfExists] 완료: reservationId=${it.id}, status=${it.status}" } + } + } } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt index 257c9815..aef346c8 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationValidator.kt @@ -10,6 +10,7 @@ import roomescape.reservation.exception.ReservationException import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification +import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate @@ -141,4 +142,15 @@ class ReservationValidator( log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" } } + + fun validateIsReservedByMemberAndPending(reservation: ReservationEntity, requesterId: Long) { + if (reservation.member.id != requesterId) { + log.error { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} requesterId=$requesterId" } + throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + } + if (reservation.status != ReservationStatus.PENDING) { + log.warn { "[ReservationValidator.validateIsReservedByMemberAndPending] 예약 상태가 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" } + throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING) + } + } } diff --git a/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt index 23f34de8..6a881242 100644 --- a/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt +++ b/src/main/kotlin/roomescape/reservation/implement/ReservationWriter.kt @@ -6,8 +6,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component import roomescape.common.config.next import roomescape.member.implement.MemberFinder -import roomescape.reservation.exception.ReservationErrorCode -import roomescape.reservation.exception.ReservationException import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationStatus @@ -101,4 +99,15 @@ class ReservationWriter( log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } } + + fun cancelByUser(reservation: ReservationEntity, requesterId: Long) { + log.debug { "[ReservationWriter.cancel] 예약 취소 시작: reservationId=${reservation.id}, requesterId=$requesterId" } + + memberFinder.findById(requesterId) + .also { reservationValidator.validateDeleteAuthority(reservation, requester = it) } + + reservation.cancelByUser().also { + log.debug { "[ReservationWriter.cancel] 예약 취소 완료: reservationId=${reservation.id}" } + } + } } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index 3f8afa87..3ad2949c 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -43,12 +43,23 @@ class ReservationEntity( fun isReservedBy(memberId: Long): Boolean { return this.member.id == memberId } + + fun cancelByUser() { + this.status = ReservationStatus.CANCELED_BY_USER + } + + fun confirm() { + this.status = ReservationStatus.CONFIRMED + } } enum class ReservationStatus { CONFIRMED, CONFIRMED_PAYMENT_REQUIRED, + PENDING, WAITING, + CANCELED_BY_USER, + AUTOMATICALLY_CANCELED, ; companion object { -- 2.47.2 From 817dc9f761660c68f5be631555458df57ad23fa4 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 17 Aug 2025 21:23:37 +0900 Subject: [PATCH 24/37] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=B0=8F=20=EB=82=98=EC=9D=98=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 8 +- frontend/src/App.tsx | 14 +- .../src/api/reservation/reservationAPI.ts | 14 + .../src/api/reservation/reservationAPIV2.ts | 150 +++++++++ .../src/api/reservation/reservationTypes.ts | 124 +++++++- frontend/src/components/ReservationCard.tsx | 137 ++++++++ frontend/src/css/my-reservation-v2.css | 288 +++++++++++++++++ frontend/src/css/reservation-v2.css | 175 +++++++++++ frontend/src/index.css | 18 +- frontend/src/pages/ReservationPage.tsx | 5 +- frontend/src/pages/v2/MyReservationPageV2.tsx | 294 ++++++++++++++++++ .../src/pages/v2/ReservationStep1Page.tsx | 156 ++++++++++ .../src/pages/v2/ReservationStep2Page.tsx | 118 +++++++ .../src/pages/v2/ReservationSuccessPage.tsx | 44 +++ 14 files changed, 1518 insertions(+), 27 deletions(-) create mode 100644 frontend/src/api/reservation/reservationAPIV2.ts create mode 100644 frontend/src/components/ReservationCard.tsx create mode 100644 frontend/src/css/my-reservation-v2.css create mode 100644 frontend/src/css/reservation-v2.css create mode 100644 frontend/src/pages/v2/MyReservationPageV2.tsx create mode 100644 frontend/src/pages/v2/ReservationStep1Page.tsx create mode 100644 frontend/src/pages/v2/ReservationStep2Page.tsx create mode 100644 frontend/src/pages/v2/ReservationSuccessPage.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355df..1afa855c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -33,10 +33,6 @@ } } -.card { - padding: 2em; -} -.read-the-docs { - color: #888; -} + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 225f6538..d31faf7f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,10 @@ import AdminThemePage from './pages/admin/ThemePage'; import AdminWaitingPage from './pages/admin/WaitingPage'; import { AuthProvider } from './context/AuthContext'; import AdminRoute from './components/AdminRoute'; +import ReservationStep1Page from './pages/v2/ReservationStep1Page'; +import ReservationStep2Page from './pages/v2/ReservationStep2Page'; +import ReservationSuccessPage from './pages/v2/ReservationSuccessPage'; +import MyReservationPageV2 from './pages/v2/MyReservationPageV2'; const AdminRoutes = () => ( @@ -43,7 +47,13 @@ function App() { } /> } /> } /> - } /> + } /> + } /> + + {/* V2 Reservation Flow */} + } /> + } /> + } /> } /> @@ -53,4 +63,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationAPI.ts b/frontend/src/api/reservation/reservationAPI.ts index a3f23db9..0006f0d8 100644 --- a/frontend/src/api/reservation/reservationAPI.ts +++ b/frontend/src/api/reservation/reservationAPI.ts @@ -2,6 +2,10 @@ import apiClient from "@_api/apiClient"; import type { AdminReservationCreateRequest, MyReservationRetrieveListResponse, + ReservationPaymentRequest, + ReservationPaymentResponse, + ReservationCreateRequest, + ReservationCreateResponse, ReservationCreateWithPaymentRequest, ReservationRetrieveListResponse, ReservationRetrieveResponse, @@ -68,3 +72,13 @@ export const confirmWaiting = async (id: string): Promise => { export const rejectWaiting = async (id: string): Promise => { return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true); }; + +// POST /v2/reservations +export const createPendingReservation = async (data: ReservationCreateRequest): Promise => { + return await apiClient.post('/v2/reservations', data, true); +}; + +// POST /v2/reservations/{id}/pay +export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise => { + return await apiClient.post(`/v2/reservations/${id}/pay`, data, true); +}; \ No newline at end of file diff --git a/frontend/src/api/reservation/reservationAPIV2.ts b/frontend/src/api/reservation/reservationAPIV2.ts new file mode 100644 index 00000000..2f560898 --- /dev/null +++ b/frontend/src/api/reservation/reservationAPIV2.ts @@ -0,0 +1,150 @@ +import type { ReservationSummaryV2, ReservationDetailV2 } from './reservationTypes'; + +// --- API 호출 함수 --- + +/** + * 내 예약 목록을 가져옵니다. (V2) + */ +export const fetchMyReservationsV2 = async (): Promise => { + // 실제 API 연동 시 아래 코드로 교체 + // const response = await apiClient.get('/v2/reservations'); + // return response.data; + + // 현재는 목업 데이터를 반환합니다. + console.log('[API] fetchMyReservationsV2 호출'); + return new Promise((resolve) => + setTimeout(() => { + resolve([ + { id: 1, date: '2025-08-20', time: '14:00', themeName: '공포의 방', status: 'CONFIRMED' }, + { id: 2, date: '2025-08-22', time: '19:30', themeName: '신비의 숲', status: 'CONFIRMED' }, + { id: 3, date: '2024-12-25', time: '11:00', themeName: '미래 도시', status: 'CANCELLED' }, + ]); + }, 500) + ); +}; + +/** + * 특정 예약의 상세 정보를 가져옵니다. (V2) + * @param id 예약 ID + */ +export const fetchReservationDetailV2 = async (id: number): Promise => { + // 실제 API 연동 시 아래 코드로 교체 + // const response = await apiClient.get(`/v2/reservations/${id}`); + // return response.data; + + // 현재는 목업 데이터를 반환합니다. + console.log(`[API] fetchReservationDetailV2 호출 (id: ${id})`); + console.log(`[API] fetchReservationDetailV2 호출 (id: ${id})`); + + const mockDetails: { [key: number]: ReservationDetailV2 } = { + 1: { + id: 1, + memberName: '박예약', + memberEmail: 'reserve@example.com', + applicationDateTime: '2025-08-20T13:50:00Z', + payment: { + paymentKey: 'reserve-payment-key', + orderId: 'reserve-order-id', + totalAmount: 50000, + method: 'CARD', + status: 'DONE', + requestedAt: '2025-08-20T13:50:00Z', + approvedAt: '2025-08-20T13:50:05Z', + detail: { + type: 'CARD', + issuerCode: 'SHINHAN', + cardType: 'CHECK', + ownerType: 'PERSONAL', + cardNumber: '5423-****-****-1234', + approvalNumber: '12345678', + installmentPlanMonths: 0, + isInterestFree: true, + } + }, + cancellation: null, + }, + 2: { + id: 2, + memberName: '이간편', + memberEmail: 'easypay@example.com', + applicationDateTime: '2025-08-22T19:20:00Z', + payment: { + paymentKey: 'easypay-card-key', + orderId: 'easypay-card-order-id', + totalAmount: 75000, + method: 'CARD', + status: 'DONE', + requestedAt: '2025-08-22T19:20:00Z', + approvedAt: '2025-08-22T19:20:05Z', + detail: { + type: 'CARD', + issuerCode: 'HYUNDAI', + cardType: 'CREDIT', + ownerType: 'PERSONAL', + cardNumber: '4321-****-****-5678', + approvalNumber: '87654321', + installmentPlanMonths: 3, + isInterestFree: false, + easypayProviderCode: 'NAVERPAY', + } + }, + cancellation: null, + }, + 3: { + id: 3, + memberName: '김취소', + memberEmail: 'cancel@example.com', + applicationDateTime: '2024-12-25T10:50:00Z', + payment: { + paymentKey: 'cancel-payment-key', + orderId: 'cancel-order-id', + totalAmount: 52000, + method: 'EASYPAY_PREPAID', + status: 'CANCELED', + requestedAt: '2024-12-25T10:50:00Z', + approvedAt: '2024-12-25T10:50:05Z', + detail: { + type: 'EASYPAY_PREPAID', + easypayProviderCode: 'TOSSPAY', + amount: 52000, + discountAmount: 0, + } + }, + cancellation: { + cancellationRequestedAt: '2025-01-10T14:59:00Z', + cancellationCompletedAt: '2025-01-10T15:00:00Z', + cancelReason: '개인 사정으로 인한 취소', + }, + } + }; + + return new Promise((resolve) => + setTimeout(() => { + const mockDetail = mockDetails[id] || mockDetails[1]; // Fallback to 1 + resolve(mockDetail); + }, 800) + ); +}; + +/** + * 예약을 취소합니다. (V2) + * @param id 예약 ID + * @param cancelReason 취소 사유 + */ +export const cancelReservationV2 = async (id: number, cancelReason: string): Promise => { + // 실제 API 연동 시 아래 코드로 교체 + // await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }); + + console.log(`[API] cancelReservationV2 호출 (id: ${id}, reason: ${cancelReason})`); + return new Promise((resolve, reject) => { + setTimeout(() => { + if (cancelReason && cancelReason.trim().length > 0) { + // 성공 시, 목업 데이터 업데이트 (실제로는 서버가 처리) + console.log(`Reservation ${id} has been cancelled.`); + resolve(); + } else { + reject(new Error('취소 사유를 반드시 입력해야 합니다.')); + } + }, 800); + }); +}; diff --git a/frontend/src/api/reservation/reservationTypes.ts b/frontend/src/api/reservation/reservationTypes.ts index 1ab96518..b73caf8c 100644 --- a/frontend/src/api/reservation/reservationTypes.ts +++ b/frontend/src/api/reservation/reservationTypes.ts @@ -3,15 +3,21 @@ import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes'; import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; export const ReservationStatus = { + PENDING: 'PENDING', CONFIRMED: 'CONFIRMED', CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED', WAITING: 'WAITING', + CANCELED_BY_USER: 'CANCELED_BY_USER', + AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED' } as const; export type ReservationStatus = + | typeof ReservationStatus.PENDING | typeof ReservationStatus.CONFIRMED | typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED - | typeof ReservationStatus.WAITING; + | typeof ReservationStatus.WAITING + | typeof ReservationStatus.CANCELED_BY_USER + | typeof ReservationStatus.AUTOMATICALLY_CANCELED; export interface MyReservationRetrieveResponse { id: string; @@ -70,3 +76,119 @@ export interface ReservationSearchQuery { dateFrom?: string; dateTo?: string; } + +// V2 types +export const PaymentType = { + NORMAL: 'NORMAL', + BILLING: 'BILLING', + BRANDPAY: 'BRANDPAY' +} as const; + +export type PaymentType = + | typeof PaymentType.NORMAL + | typeof PaymentType.BILLING + | typeof PaymentType.BRANDPAY; + +export const PaymentStatus = { + IN_PROGRESS: '결제 진행 중', + DONE: '결제 완료', + CANCELED: '결제 취소', + ABORTED: '결제 중단', + EXPIRED: '시간 만료', +} + +export type PaymentStatus = + | typeof PaymentStatus.IN_PROGRESS + | typeof PaymentStatus.DONE + | typeof PaymentStatus.CANCELED + | typeof PaymentStatus.ABORTED + | typeof PaymentStatus.EXPIRED; + + +export interface ReservationCreateRequest { + date: string; + timeId: string; + themeId: string; +} + +export interface ReservationCreateResponse { + reservationId: string; + memberEmail: string; + date: string; + startAt: string; + themeName: string; +} + +export interface ReservationPaymentRequest { + paymentKey: string; + orderId: string; + amount: number; + paymentType: PaymentType +} + +export interface ReservationPaymentResponse { + reservationId: string; + reservationStatus: ReservationStatus; + paymentId: string; + paymentStatus: PaymentStatus; +} + +export interface ReservationSummaryV2 { + id: number; + themeName: string; + date: string; + time: string; + status: 'CONFIRMED' | 'CANCELLED'; +} + +export interface ReservationDetailV2 { + id: number; + memberName: string; + memberEmail: string; + applicationDateTime: string; // yyyy년 MM월 dd일 HH시 mm분 + payment: PaymentV2; + cancellation: CancellationV2 | null; +} + +export interface PaymentV2 { + paymentKey: string; + orderId: string; + totalAmount: number; + method: 'CARD' | 'BANK_TRANSFER' | 'EASYPAY_PREPAID'; + status: 'DONE' | 'CANCELED'; + requestedAt: string; + approvedAt: string; + detail: CardPaymentDetailV2 | BankTransferPaymentDetailV2 | EasyPayPrepaidPaymentDetailV2; +} + +export interface CardPaymentDetailV2 { + type: 'CARD'; + issuerCode: string; + cardType: 'CREDIT' | 'CHECK' | 'GIFT'; + ownerType: 'PERSONAL' | 'CORPORATE'; + cardNumber: string; + approvalNumber: string; + installmentPlanMonths: number; + isInterestFree: boolean; + easypayProviderCode?: string; + easypayDiscountAmount?: number; +} + +export interface BankTransferPaymentDetailV2 { + type: 'BANK_TRANSFER'; + bankCode: string; + settlementStatus: string; +} + +export interface EasyPayPrepaidPaymentDetailV2 { + type: 'EASYPAY_PREPAID'; + easypayProviderCode: string; + amount: number; + discountAmount: number; +} + +export interface CancellationV2 { + cancellationRequestedAt: string; // ISO 8601 format + cancellationCompletedAt: string; // ISO 8601 format + cancelReason: string; +} diff --git a/frontend/src/components/ReservationCard.tsx b/frontend/src/components/ReservationCard.tsx new file mode 100644 index 00000000..cb880ceb --- /dev/null +++ b/frontend/src/components/ReservationCard.tsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import '../css/my-reservation-v2.css'; +import type { Reservation } from '../pages/v2/MyReservationPageV2'; // 페이지로부터 타입 import + +interface ReservationCardProps { + reservation: Reservation; +} + +// 날짜 및 시간 포맷팅 함수 +const formatDateTime = (dateStr: string, timeStr: string): string => { + const date = new Date(`${dateStr}T${timeStr}`); + const today = new Date(); + + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]; + + let hour = date.getHours(); + const minute = date.getMinutes(); + + const isPastYear = year < today.getFullYear(); + + const ampm = hour < 12 ? '오전' : '오후'; + hour = hour % 12; + if (hour === 0) hour = 12; + + let datePart = ''; + if (isPastYear) { + datePart = `${String(year).slice(-2)}.${month}.${day}`; + } else { + datePart = `${month}.${day}`; + } + + let timePart = `${ampm} ${hour}시`; + if (minute > 0) { + timePart += ` ${minute}분`; + } + + return `${datePart}(${dayOfWeek}) ${timePart}`; +}; + + +const ReservationCard: React.FC = ({ reservation }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [cancelReason, setCancelReason] = useState(''); + + const isCanceled = reservation.status === 'CANCELED'; + + const handleToggleDetails = () => { + setIsExpanded(!isExpanded); + if (isExpanded) { + setIsCancelling(false); + setCancelReason(''); + } + }; + + const handleStartCancel = () => { + if (window.confirm('정말 예약을 취소하시겠어요?')) { + setIsCancelling(true); + } + }; + + const handleSubmitCancellation = () => { + console.log('--- 예약 취소 요청 ---'); + console.log('예약 ID:', reservation.id); + console.log('취소 사유:', cancelReason); + alert(`취소 요청을 접수했어요.`); + setIsCancelling(false); + setIsExpanded(false); + setCancelReason(''); + // 실제 API 연동 시에는 상태를 다시 fetch 해야 함 + }; + + const cardClasses = `reservation-card ${isCanceled ? 'canceled' : ''}`; + const headerClasses = `theme-header ${isCanceled ? 'canceled' : ''}`; + + return ( +
+ {isCanceled &&
취소 완료
} +
+
+

{reservation.theme}

+ {formatDateTime(reservation.date, reservation.time)} +
+ +
+ + {isExpanded && ( +
+
+ 결제수단 + {reservation.payment.method} +
+
+ 결제금액 + {reservation.payment.amount} +
+ +
+ {isCanceled ? ( + + ) : !isCancelling ? ( + + ) : ( + <> +