[#35] 결제 스키마 재정의 & 예약 조회 페이지 개선 (#36)

<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #35

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 운영을 고려하여 조금 더 디테일한 정보가 담기도록 결제 스키마 개선(결제수단, 금액, 카드 사용시 카드번호, 할부 정보 등)
- 회원의 예약 조회 페이지 개선 및 회원의 예약 취소 기능 도입

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
- 현재 테스트가 과연 신뢰성이 있는가 의문. 추후 전체적인 작업 후 전체 테스트를 재조정할 예정

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #36
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
이상진 2025-08-22 06:43:16 +00:00 committed by 이상진
parent c7316b353f
commit ef58752cec
66 changed files with 3338 additions and 96 deletions

View File

@ -21,6 +21,10 @@ java {
} }
} }
tasks.jar {
enabled = false
}
kapt { kapt {
keepJavacAnnotationProcessors = true keepJavacAnnotationProcessors = true
} }

View File

@ -33,10 +33,6 @@
} }
} }
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -13,6 +13,10 @@ import AdminThemePage from './pages/admin/ThemePage';
import AdminWaitingPage from './pages/admin/WaitingPage'; import AdminWaitingPage from './pages/admin/WaitingPage';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import AdminRoute from './components/AdminRoute'; 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 = () => ( const AdminRoutes = () => (
<AdminLayout> <AdminLayout>
@ -43,7 +47,13 @@ function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} /> <Route path="/signup" element={<SignupPage />} />
<Route path="/reservation" element={<ReservationPage />} /> <Route path="/reservation" element={<ReservationPage />} />
<Route path="/reservation-mine" element={<MyReservationPage />} /> <Route path="/my-reservation" element={<MyReservationPage />} />
<Route path="/my-reservation/v2" element={<MyReservationPageV2 />} />
{/* V2 Reservation Flow */}
<Route path="/v2/reservation" element={<ReservationStep1Page />} />
<Route path="/v2/reservation/payment" element={<ReservationStep2Page />} />
<Route path="/v2/reservation/success" element={<ReservationSuccessPage />} />
</Routes> </Routes>
</Layout> </Layout>
} /> } />

View File

@ -1,5 +1,5 @@
export interface MemberRetrieveResponse { export interface MemberRetrieveResponse {
id: number; id: string;
name: string; name: string;
} }
@ -14,6 +14,6 @@ export interface SignupRequest {
} }
export interface SignupResponse { export interface SignupResponse {
id: number; id: string;
name: string; name: string;
} }

View File

@ -2,10 +2,16 @@ import apiClient from "@_api/apiClient";
import type { import type {
AdminReservationCreateRequest, AdminReservationCreateRequest,
MyReservationRetrieveListResponse, MyReservationRetrieveListResponse,
ReservationCreateRequest,
ReservationCreateResponse,
ReservationCreateWithPaymentRequest, ReservationCreateWithPaymentRequest,
ReservationDetailV2,
ReservationPaymentRequest,
ReservationPaymentResponse,
ReservationRetrieveListResponse, ReservationRetrieveListResponse,
ReservationRetrieveResponse, ReservationRetrieveResponse,
ReservationSearchQuery, ReservationSearchQuery,
ReservationSummaryListV2,
WaitingCreateRequest WaitingCreateRequest
} from "./reservationTypes"; } from "./reservationTypes";
@ -30,7 +36,7 @@ export const searchReservations = async (params: ReservationSearchQuery): Promis
}; };
// DELETE /reservations/{id} // DELETE /reservations/{id}
export const cancelReservationByAdmin = async (id: number): Promise<void> => { export const cancelReservationByAdmin = async (id: string): Promise<void> => {
return await apiClient.del(`/reservations/${id}`, true); return await apiClient.del(`/reservations/${id}`, true);
}; };
@ -55,16 +61,41 @@ export const createWaiting = async (data: WaitingCreateRequest): Promise<Reserva
}; };
// DELETE /reservations/waiting/{id} // DELETE /reservations/waiting/{id}
export const cancelWaiting = async (id: number): Promise<void> => { export const cancelWaiting = async (id: string): Promise<void> => {
return await apiClient.del(`/reservations/waiting/${id}`, true); return await apiClient.del(`/reservations/waiting/${id}`, true);
}; };
// POST /reservations/waiting/{id}/confirm // POST /reservations/waiting/{id}/confirm
export const confirmWaiting = async (id: number): Promise<void> => { export const confirmWaiting = async (id: string): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true); return await apiClient.post(`/reservations/waiting/${id}/confirm`, {}, true);
}; };
// POST /reservations/waiting/{id}/reject // POST /reservations/waiting/{id}/reject
export const rejectWaiting = async (id: number): Promise<void> => { export const rejectWaiting = async (id: string): Promise<void> => {
return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true); return await apiClient.post(`/reservations/waiting/${id}/reject`, {}, true);
}; };
// POST /v2/reservations
export const createPendingReservation = async (data: ReservationCreateRequest): Promise<ReservationCreateResponse> => {
return await apiClient.post<ReservationCreateResponse>('/v2/reservations', data, true);
};
// POST /v2/reservations/{id}/pay
export const confirmReservationPayment = async (id: string, data: ReservationPaymentRequest): Promise<ReservationPaymentResponse> => {
return await apiClient.post<ReservationPaymentResponse>(`/v2/reservations/${id}/pay`, data, true);
};
// POST /v2/reservations/{id}/cancel
export const cancelReservationV2 = async (id: string, cancelReason: string): Promise<void> => {
return await apiClient.post(`/v2/reservations/${id}/cancel`, { cancelReason }, true);
};
// GET /v2/reservations
export const fetchMyReservationsV2 = async (): Promise<ReservationSummaryListV2> => {
return await apiClient.get<ReservationSummaryListV2>('/v2/reservations', true);
};
// GET /v2/reservations/{id}/details
export const fetchReservationDetailV2 = async (id: string): Promise<ReservationDetailV2> => {
return await apiClient.get<ReservationDetailV2>(`/v2/reservations/${id}/details`, true);
};

View File

@ -3,18 +3,24 @@ import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeRetrieveResponse } from '@_api/time/timeTypes'; import type { TimeRetrieveResponse } from '@_api/time/timeTypes';
export const ReservationStatus = { export const ReservationStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED', CONFIRMED: 'CONFIRMED',
CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED', CONFIRMED_PAYMENT_REQUIRED: 'CONFIRMED_PAYMENT_REQUIRED',
WAITING: 'WAITING', WAITING: 'WAITING',
CANCELED_BY_USER: 'CANCELED_BY_USER',
AUTOMATICALLY_CANCELED: 'AUTOMATICALLY_CANCELED'
} as const; } as const;
export type ReservationStatus = export type ReservationStatus =
| typeof ReservationStatus.PENDING
| typeof ReservationStatus.CONFIRMED | typeof ReservationStatus.CONFIRMED
| typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED | typeof ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
| typeof ReservationStatus.WAITING; | typeof ReservationStatus.WAITING
| typeof ReservationStatus.CANCELED_BY_USER
| typeof ReservationStatus.AUTOMATICALLY_CANCELED;
export interface MyReservationRetrieveResponse { export interface MyReservationRetrieveResponse {
id: number; id: string;
themeName: string; themeName: string;
date: string; date: string;
time: string; time: string;
@ -29,7 +35,7 @@ export interface MyReservationRetrieveListResponse {
} }
export interface ReservationRetrieveResponse { export interface ReservationRetrieveResponse {
id: number; id: string;
date: string; date: string;
member: MemberRetrieveResponse; member: MemberRetrieveResponse;
time: TimeRetrieveResponse; time: TimeRetrieveResponse;
@ -43,15 +49,15 @@ export interface ReservationRetrieveListResponse {
export interface AdminReservationCreateRequest { export interface AdminReservationCreateRequest {
date: string; date: string;
timeId: number; timeId: string;
themeId: number; themeId: string;
memberId: number; memberId: string;
} }
export interface ReservationCreateWithPaymentRequest { export interface ReservationCreateWithPaymentRequest {
date: string; date: string;
timeId: number; timeId: string;
themeId: number; themeId: string;
paymentKey: string; paymentKey: string;
orderId: string; orderId: string;
amount: number; amount: number;
@ -60,13 +66,142 @@ export interface ReservationCreateWithPaymentRequest {
export interface WaitingCreateRequest { export interface WaitingCreateRequest {
date: string; date: string;
timeId: number; timeId: string;
themeId: number; themeId: string;
} }
export interface ReservationSearchQuery { export interface ReservationSearchQuery {
themeId?: number; themeId?: string;
memberId?: number; memberId?: string;
dateFrom?: string; dateFrom?: string;
dateTo?: 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: string;
themeName: string;
date: string;
startAt: string;
status: string; // 'CONFIRMED', 'CANCELED_BY_USER', etc.
}
export interface ReservationSummaryListV2 {
reservations: ReservationSummaryV2[];
}
export interface ReservationDetailV2 {
id: string;
user: UserDetailV2;
themeName: string;
date: string;
startAt: string;
applicationDateTime: string;
payment: PaymentV2;
cancellation: CancellationV2 | null;
}
export interface UserDetailV2 {
id: string;
name: string;
email: string;
}
export interface PaymentV2 {
orderId: string;
totalAmount: number;
method: string;
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;
amount: number;
approvalNumber: string;
installmentPlanMonths: number;
isInterestFree: boolean;
easypayProviderName?: string;
easypayDiscountAmount?: number;
}
export interface BankTransferPaymentDetailV2 {
type: 'BANK_TRANSFER';
bankName: string;
settlementStatus: string;
}
export interface EasyPayPrepaidPaymentDetailV2 {
type: 'EASYPAY_PREPAID';
providerName: string;
amount: number;
discountAmount: number;
}
export interface CancellationV2 {
cancellationRequestedAt: string; // ISO 8601 format
cancellationApprovedAt: string; // ISO 8601 format
cancelReason: string;
canceledBy: string;
}

View File

@ -13,6 +13,6 @@ export const mostReservedThemes = async (count: number = 10): Promise<ThemeRetri
return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false); return await apiClient.get<ThemeRetrieveListResponse>(`/themes/most-reserved-last-week?count=${count}`, false);
}; };
export const delTheme = async (id: number): Promise<void> => { export const delTheme = async (id: string): Promise<void> => {
return await apiClient.del(`/themes/${id}`, true); return await apiClient.del(`/themes/${id}`, true);
}; };

View File

@ -5,14 +5,14 @@ export interface ThemeCreateRequest {
} }
export interface ThemeCreateResponse { export interface ThemeCreateResponse {
id: number; id: string;
name: string; name: string;
description: string; description: string;
thumbnail: string; thumbnail: string;
} }
export interface ThemeRetrieveResponse { export interface ThemeRetrieveResponse {
id: number; id: string;
name: string; name: string;
description: string; description: string;
thumbnail: string; thumbnail: string;

View File

@ -9,10 +9,10 @@ export const fetchTimes = async (): Promise<TimeRetrieveListResponse> => {
return await apiClient.get<TimeRetrieveListResponse>('/times', true); return await apiClient.get<TimeRetrieveListResponse>('/times', true);
}; };
export const delTime = async (id: number): Promise<void> => { export const delTime = async (id: string): Promise<void> => {
return await apiClient.del(`/times/${id}`, true); return await apiClient.del(`/times/${id}`, true);
}; };
export const fetchTimesWithAvailability = async (date: string, themeId: number): Promise<TimeWithAvailabilityListResponse> => { export const fetchTimesWithAvailability = async (date: string, themeId: string): Promise<TimeWithAvailabilityListResponse> => {
return await apiClient.get<TimeWithAvailabilityListResponse>(`/times/search?date=${date}&themeId=${themeId}`, true); return await apiClient.get<TimeWithAvailabilityListResponse>(`/times/search?date=${date}&themeId=${themeId}`, true);
}; };

View File

@ -3,12 +3,12 @@ export interface TimeCreateRequest {
} }
export interface TimeCreateResponse { export interface TimeCreateResponse {
id: number; id: string;
startAt: string; startAt: string;
} }
export interface TimeRetrieveResponse { export interface TimeRetrieveResponse {
id: number; id: string;
startAt: string; startAt: string;
} }
@ -17,7 +17,7 @@ export interface TimeRetrieveListResponse {
} }
export interface TimeWithAvailabilityResponse { export interface TimeWithAvailabilityResponse {
id: number; id: string;
startAt: string; startAt: string;
isAvailable: boolean; isAvailable: boolean;
} }

View File

@ -27,7 +27,7 @@ const Navbar: React.FC = () => {
<div className="collapse navbar-collapse" id="navbarSupportedContent"> <div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav ms-auto"> <ul className="navbar-nav ms-auto">
<li className="nav-item"> <li className="nav-item">
<Link className="nav-link" to="/reservation">Reservation</Link> <Link className="nav-link" to="/v2/reservation">Reservation</Link>
</li> </li>
{!loggedIn ? ( {!loggedIn ? (
<li className="nav-item"> <li className="nav-item">
@ -40,7 +40,7 @@ const Navbar: React.FC = () => {
<span id="profile-name">{userName}</span> <span id="profile-name">{userName}</span>
</a> </a>
<ul className="dropdown-menu" aria-labelledby="navbarDropdown"> <ul className="dropdown-menu" aria-labelledby="navbarDropdown">
<li><Link className="dropdown-item" to="/reservation-mine">My Reservation</Link></li> <li><Link className="dropdown-item" to="/my-reservation/v2">My Reservation</Link></li>
<li><hr className="dropdown-divider" /></li> <li><hr className="dropdown-divider" /></li>
<li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li> <li><a className="dropdown-item" href="#" onClick={handleLogout}>Logout</a></li>
</ul> </ul>

View File

@ -0,0 +1,288 @@
/* General Container */
.my-reservation-container-v2 {
max-width: 800px;
margin: 40px auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f4f6f8;
border-radius: 16px;
}
.my-reservation-container-v2 h1 {
font-size: 28px;
font-weight: 700;
color: #333d4b;
margin-bottom: 30px;
}
/* Error Message */
.error-message-v2 {
color: #d9534f;
background-color: #f2dede;
border: 1px solid #ebccd1;
padding: 15px;
border-radius: 8px;
text-align: center;
}
/* Reservation List */
.reservation-list-v2 {
display: flex;
flex-direction: column;
gap: 15px;
}
/* Reservation Summary Card */
.reservation-summary-card-v2 {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.reservation-summary-card-v2:hover {
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-details-v2 {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-theme-name-v2 {
font-size: 20px;
font-weight: 700;
color: #333d4b;
margin: 0;
}
.summary-datetime-v2 {
font-size: 16px;
color: #505a67;
margin: 0;
}
/* Canceled Card Style */
.reservation-summary-card-v2.status-canceled_by_user {
background-color: #f8f9fa;
opacity: 0.6;
}
.reservation-summary-card-v2.status-canceled_by_user .summary-theme-name-v2,
.reservation-summary-card-v2.status-canceled_by_user .summary-datetime-v2,
.reservation-summary-card-v2.status-canceled_by_user .summary-details-v2 strong {
color: #6c757d;
}
/* Detail Button */
.detail-button-v2 {
background-color: #007bff;
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.detail-button-v2:hover {
background-color: #0056b3;
}
.detail-button-v2:disabled {
background-color: #cdd3d8;
cursor: not-allowed;
}
/* Modal Styles */
.modal-overlay-v2 {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content-v2 {
background: #ffffff;
padding: 30px;
border-radius: 16px;
width: 90%;
max-width: 500px;
position: relative;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
animation: slide-up 0.3s ease-out;
max-height: 90vh; /* Prevent modal from being too tall */
overflow-y: auto; /* Allow scrolling for long content */
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-close-button-v2 {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #8492a6;
}
.modal-content-v2 h2 {
margin-top: 0;
margin-bottom: 25px;
color: #333d4b;
font-size: 24px;
}
.modal-section-v2 {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e5e8eb;
}
.modal-section-v2:last-child {
border-bottom: none;
}
.modal-section-v2 h3 {
font-size: 18px;
color: #333d4b;
margin-top: 0;
margin-bottom: 15px;
}
.modal-section-v2 p {
margin: 0 0 10px;
color: #505a67;
}
.modal-section-v2 p strong {
color: #333d4b;
font-weight: 600;
min-width: 100px;
display: inline-block;
}
.cancellation-section-v2 {
background-color: #fcf2f2;
padding: 15px;
border-radius: 8px;
border: 1px solid #f0d1d1;
}
.cancellation-section-v2 h3 {
color: #c9302c;
}
/* Modal Actions & Cancellation View */
.modal-actions-v2 {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
.modal-actions-v2 button {
border: none;
border-radius: 8px;
padding: 12px 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.cancel-button-v2 {
background-color: #e53e3e;
color: white;
width: 100%;
}
.cancel-button-v2:hover {
background-color: #c53030;
}
.back-button-v2 {
background-color: #e9ecef;
color: #495057;
}
.back-button-v2:hover {
background-color: #dee2e6;
}
.cancel-submit-button-v2 {
background-color: #007bff;
color: white;
}
.cancel-submit-button-v2:hover {
background-color: #0056b3;
}
.cancel-submit-button-v2:disabled,
.back-button-v2:disabled {
background-color: #cdd3d8;
cursor: not-allowed;
}
.cancellation-view-v2 h3 {
font-size: 18px;
color: #333d4b;
margin-bottom: 15px;
}
.cancellation-summary-v2 {
background-color: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.cancellation-summary-v2 p {
margin: 0 0 8px;
}
.cancellation-summary-v2 p:last-child {
margin: 0;
}
.cancellation-reason-textarea-v2 {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 8px;
font-size: 16px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box; /* Ensures padding doesn't add to width */
}
.cancellation-reason-textarea-v2:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

View File

@ -0,0 +1,175 @@
#root .flatpickr-input {
display: none;
}
#root .modal-backdrop {
position: fixed;
top: 0;
left: 0;
z-index: 1050;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5) !important;
display: flex;
justify-content: center;
align-items: center;
}
#root .modal-dialog {
max-width: 500px;
width: 90%;
margin: 1.75rem auto;
}
/* Toss-style Modal */
#root .modal-content {
background-color: #fff !important;
border-radius: 16px;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1.5rem;
display: flex;
flex-direction: column;
pointer-events: auto;
position: relative;
}
#root .modal-header {
border-bottom: none;
padding: 0 0 1rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
#root .modal-title {
font-size: 1.25rem;
font-weight: 600;
}
#root .btn-close {
background: transparent;
border: 0;
font-size: 1.5rem;
opacity: 0.5;
}
#root .modal-body {
padding: 1rem 0;
color: #333;
}
#root .modal-body p {
margin-bottom: 0.5rem;
}
#root .modal-footer {
border-top: none;
padding: 1rem 0 0 0;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* --- Generic Button Styles --- */
#root .btn-primary,
#root .modal-footer .btn-primary,
#root .btn-wrapper .btn-primary,
#root .button-group .btn-primary,
#root .success-page-actions .btn-primary {
background-color: #007bff;
border-color: #007bff;
color: #fff;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
}
#root .btn-secondary,
#root .modal-footer .btn-secondary,
#root .success-page-actions .btn-secondary {
background-color: #f0f2f5;
border-color: #f0f2f5;
color: #333;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
transition: background-color 0.15s ease-in-out;
}
#root .btn-primary:hover,
#root .modal-footer .btn-primary:hover,
#root .btn-wrapper .btn-primary:hover,
#root .button-group .btn-primary:hover,
#root .success-page-actions .btn-primary:hover {
background-color: #0069d9;
border-color: #0062cc;
}
#root .btn-secondary:hover,
#root .modal-footer .btn-secondary:hover,
#root .success-page-actions .btn-secondary:hover {
background-color: #e2e6ea;
}
/* --- Reservation Success Page Styles --- */
.reservation-success-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
width: 100%;
max-width: 100%;
}
.reservation-success-page .content-container-title {
font-size: 2rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #333;
}
.reservation-info-box {
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 2rem;
background-color: #fff;
min-width: 380px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
text-align: left;
}
.reservation-info-box h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
text-align: center;
}
.reservation-info-box .info-item {
font-size: 1.1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
}
.reservation-info-box .info-item strong {
font-weight: 500;
color: #495057;
width: 70px;
flex-shrink: 0;
}
.success-page-actions {
margin-top: 2.5rem;
display: flex;
gap: 1rem;
}

View File

@ -3,9 +3,8 @@
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color: #213547;
color: rgba(255, 255, 255, 0.87); background-color: #ffffff;
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -53,15 +52,4 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto -webkit-focus-ring-color;
} }
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -26,11 +26,11 @@ const MyReservationPage: React.FC = () => {
.catch(handleError); .catch(handleError);
}, []); }, []);
const _cancelWaiting = (id: number) => { const _cancelWaiting = (id: string) => {
cancelWaiting(id) cancelWaiting(id)
.then(() => { .then(() => {
alert('예약 대기가 취소되었습니다.'); alert('예약 대기가 취소되었습니다.');
setReservations(reservations.filter(r => r.id !== id)); setReservations(reservations.filter(r => r.id.toString() !== id));
}) })
.catch(handleError); .catch(handleError);
}; };
@ -74,7 +74,7 @@ const MyReservationPage: React.FC = () => {
<td>{getStatusText(r.status, r.rank)}</td> <td>{getStatusText(r.status, r.rank)}</td>
<td> <td>
{r.status === ReservationStatus.WAITING && {r.status === ReservationStatus.WAITING &&
<button className="btn btn-danger" onClick={() => _cancelWaiting(r.id)}></button>} <button className="btn btn-danger" onClick={() => _cancelWaiting(r.id.toString())}></button>}
</td> </td>
<td>{r.paymentKey}</td> <td>{r.paymentKey}</td>
<td>{r.amount}</td> <td>{r.amount}</td>

View File

@ -18,9 +18,9 @@ declare global {
const ReservationPage: React.FC = () => { const ReservationPage: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date()); const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]); const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<number | null>(null); const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]); const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<{ id: number, isAvailable: boolean } | null>(null); const [selectedTime, setSelectedTime] = useState<{ id: string, isAvailable: boolean } | null>(null);
const paymentWidgetRef = useRef<any>(null); const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null); const paymentMethodsRef = useRef<any>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@ -84,11 +84,10 @@ const ReservationPage: React.FC = () => {
}; };
const generateRandomString = () => const generateRandomString = () =>
window.btoa(Math.random().toString()).slice(0, 20); crypto.randomUUID().replace(/-/g, '');
const orderIdPrefix = "WTEST";
paymentWidgetRef.current.requestPayment({ paymentWidgetRef.current.requestPayment({
orderId: orderIdPrefix + generateRandomString(), orderId: generateRandomString(),
orderName: "테스트 방탈출 예약 결제 1건", orderName: "테스트 방탈출 예약 결제 1건",
amount: 1000, amount: 1000,
}).then(function (data: any) { }).then(function (data: any) {

View File

@ -57,8 +57,8 @@ const AdminReservationPage: React.FC = () => {
const applyFilter = (e: React.FormEvent) => { const applyFilter = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const params = { const params = {
memberId: filter.memberId ? Number(filter.memberId) : undefined, memberId: filter.memberId ? filter.memberId : undefined,
themeId: filter.themeId ? Number(filter.themeId) : undefined, themeId: filter.themeId ? filter.themeId : undefined,
dateFrom: filter.dateFrom, dateFrom: filter.dateFrom,
dateTo: filter.dateTo, dateTo: filter.dateTo,
}; };
@ -76,10 +76,10 @@ const AdminReservationPage: React.FC = () => {
return; return;
} }
const request = { const request = {
memberId: Number(newReservation.memberId), memberId: newReservation.memberId,
themeId: Number(newReservation.themeId), themeId: newReservation.themeId,
date: newReservation.date, date: newReservation.date,
timeId: Number(newReservation.timeId), timeId: newReservation.timeId,
}; };
await createReservationByAdmin(request) await createReservationByAdmin(request)
.then(() => { .then(() => {
@ -90,7 +90,7 @@ const AdminReservationPage: React.FC = () => {
.catch(handleError); .catch(handleError);
}; };
const deleteReservation = async(id: number) => { const deleteReservation = async(id: string) => {
if (!window.confirm('정말 삭제하시겠어요?')) { if (!window.confirm('정말 삭제하시겠어요?')) {
return; return;
} }

View File

@ -49,7 +49,7 @@ const AdminThemePage: React.FC = () => {
.catch(handleError); .catch(handleError);
} }
const deleteTheme = async (id: number) => { const deleteTheme = async (id: string) => {
if (!window.confirm('정말 삭제하시겠어요?')) { if (!window.confirm('정말 삭제하시겠어요?')) {
return; return;
} }

View File

@ -62,7 +62,7 @@ const AdminTimePage: React.FC = () => {
.catch(handleError); .catch(handleError);
}; };
const deleteTime = async (id: number) => { const deleteTime = async (id: string) => {
if (!window.confirm('정말 삭제하시겠어요?')) { if (!window.confirm('정말 삭제하시겠어요?')) {
return; return;
} }

View File

@ -29,7 +29,7 @@ const AdminWaitingPage: React.FC = () => {
fetchData(); fetchData();
}, []); }, []);
const approveWaiting = async (id: number) => { const approveWaiting = async (id: string) => {
await confirmWaiting(id) await confirmWaiting(id)
.then(() => { .then(() => {
alert('대기 중인 예약을 승인했어요. 결제는 별도로 진행해주세요.'); alert('대기 중인 예약을 승인했어요. 결제는 별도로 진행해주세요.');
@ -38,7 +38,7 @@ const AdminWaitingPage: React.FC = () => {
.catch(handleError); .catch(handleError);
}; };
const denyWaiting = async (id: number) => { const denyWaiting = async (id: string) => {
await rejectWaiting(id) await rejectWaiting(id)
.then(() => { .then(() => {
alert('대기 중인 예약을 거절했어요.'); alert('대기 중인 예약을 거절했어요.');

View File

@ -0,0 +1,337 @@
import React, { useEffect, useState } from 'react';
import {
cancelReservationV2,
fetchMyReservationsV2,
fetchReservationDetailV2
} from '../../api/reservation/reservationAPI';
import type { PaymentV2, ReservationDetailV2, ReservationSummaryV2 } from '../../api/reservation/reservationTypes';
import '../../css/my-reservation-v2.css';
const formatDisplayDateTime = (dateTime: any): string => {
let date: Date;
if (typeof dateTime === 'string') {
// ISO 문자열 형식 처리 (LocalDateTime, OffsetDateTime 모두 포함)
date = new Date(dateTime);
} else if (typeof dateTime === 'number') {
// Unix 타임스탬프(초) 형식 처리
date = new Date(dateTime * 1000);
} else if (Array.isArray(dateTime) && dateTime.length >= 6) {
// 배열 형식 처리: [year, month, day, hour, minute, second, nanosecond?]
const year = dateTime[0];
const month = dateTime[1] - 1; // JS Date의 월은 0부터 시작
const day = dateTime[2];
const hour = dateTime[3];
const minute = dateTime[4];
const second = dateTime[5];
const millisecond = dateTime.length > 6 ? Math.floor(dateTime[6] / 1000000) : 0;
date = new Date(year, month, day, hour, minute, second, millisecond);
} else {
return '유효하지 않은 날짜 형식';
}
if (isNaN(date.getTime())) {
return '유효하지 않은 날짜';
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
second: 'numeric'
};
return new Intl.DateTimeFormat('ko-KR', options).format(date);
};
const formatCardDateTime = (dateStr: string, timeStr: string): string => {
const date = new Date(`${dateStr}T${timeStr}`);
const currentYear = new Date().getFullYear();
const reservationYear = date.getFullYear();
const days = ['일', '월', '화', '수', '목', '금', '토'];
const dayOfWeek = days[date.getDay()];
const month = date.getMonth() + 1;
const day = date.getDate();
let hours = date.getHours();
const minutes = date.getMinutes();
const ampm = hours >= 12 ? '오후' : '오전';
hours = hours % 12;
hours = hours ? hours : 12;
let datePart = '';
if (currentYear === reservationYear) {
datePart = `${month}${day}일(${dayOfWeek})`;
} else {
datePart = `${reservationYear}${month}${day}일(${dayOfWeek})`;
}
let timePart = `${ampm} ${hours}`;
if (minutes !== 0) {
timePart += ` ${minutes}`;
}
return `${datePart} ${timePart}`;
};
// --- Cancellation View Component ---
const CancellationView: React.FC<{
reservation: ReservationDetailV2;
onCancelSubmit: (reason: string) => void;
onBack: () => void;
isCancelling: boolean;
}> = ({ reservation, onCancelSubmit, onBack, isCancelling }) => {
const [reason, setReason] = useState('');
const handleSubmit = () => {
if (!reason.trim()) {
alert('취소 사유를 입력해주세요.');
return;
}
onCancelSubmit(reason);
};
return (
<div className="cancellation-view-v2">
<h3> </h3>
<div className="cancellation-summary-v2">
<p><strong>:</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
<p><strong> :</strong> {reservation.payment.totalAmount.toLocaleString()}</p>
</div>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="취소 사유를 입력해주세요."
className="cancellation-reason-textarea-v2"
rows={4}
/>
<div className="modal-actions-v2">
<button onClick={onBack} className="back-button-v2" disabled={isCancelling}></button>
<button onClick={handleSubmit} className="cancel-submit-button-v2" disabled={isCancelling}>
{isCancelling ? '취소 중...' : '취소 요청'}
</button>
</div>
</div>
);
};
// --- Reservation Detail View Component ---
const ReservationDetailView: React.FC<{
reservation: ReservationDetailV2;
onGoToCancel: () => void;
}> = ({ reservation, onGoToCancel }) => {
const renderPaymentDetails = (payment: PaymentV2) => {
const { detail } = payment;
switch (detail.type) {
case 'CARD':
return (
<>
<p><strong> ID:</strong> {payment.orderId}</p>
{payment.totalAmount === detail.amount ? (
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
) : (
<>
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
<p><strong> :</strong> {detail.amount.toLocaleString()}</p>
{detail.easypayDiscountAmount && (
<p><strong> :</strong> {detail.easypayDiscountAmount.toLocaleString()}</p>
)}
</>
)}
<p><strong> :</strong> {detail.easypayProviderName ? `간편결제 / ${detail.easypayProviderName}` : '카드'}</p>
<p><strong> / :</strong> {detail.issuerCode}({detail.ownerType}) / {detail.cardType}</p>
<p><strong> :</strong> {detail.cardNumber}</p>
<p><strong> :</strong> {detail.installmentPlanMonths === 0 ? '일시불' : `${detail.installmentPlanMonths}개월`}</p>
<p><strong> :</strong> {detail.approvalNumber}</p>
</>
);
case 'BANK_TRANSFER':
return (
<>
<p><strong> :</strong> </p>
<p><strong>:</strong> {detail.bankName}</p>
</>
);
case 'EASYPAY_PREPAID':
return (
<>
<p><strong> :</strong> / {detail.providerName}</p>
<p><strong> :</strong> {payment.totalAmount.toLocaleString()}</p>
<p><strong> :</strong> {detail.amount.toLocaleString()}</p>
{detail.discountAmount > 0 && <p><strong>:</strong> {detail.discountAmount.toLocaleString()}</p>}
</>
);
default:
return <p><strong> :</strong> {payment.method}</p>;
}
};
return (
<>
<div className="modal-section-v2">
<h3> </h3>
<p><strong> :</strong> {reservation.themeName}</p>
<p><strong> :</strong> {formatCardDateTime(reservation.date, reservation.startAt)}</p>
<p><strong> :</strong> {reservation.user.name}</p>
<p><strong> :</strong> {reservation.user.email}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.applicationDateTime)}</p>
</div>
<div className="modal-section-v2">
<h3> </h3>
{/* <p><strong>결제금액:</strong> {reservation.payment.totalAmount.toLocaleString()}원</p> */}
{renderPaymentDetails(reservation.payment)}
<p><strong> :</strong> {formatDisplayDateTime(reservation.payment.approvedAt)}</p>
</div>
{reservation.cancellation && (
<div className="modal-section-v2 cancellation-section-v2">
<h3> </h3>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationRequestedAt)}</p>
<p><strong> :</strong> {formatDisplayDateTime(reservation.cancellation.cancellationApprovedAt)}</p>
<p><strong> :</strong> {reservation.cancellation.cancelReason}</p>
<p><strong> :</strong> {reservation.cancellation.canceledBy == reservation.user.id ? '회원 본인' : '관리자'}</p>
</div>
)}
{reservation.payment.status !== 'CANCELED' && (
<div className="modal-actions-v2">
<button onClick={onGoToCancel} className="cancel-button-v2"> </button>
</div>
)}
</>
);
};
// --- Main Page Component ---
const MyReservationPageV2: React.FC = () => {
const [reservations, setReservations] = useState<ReservationSummaryV2[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedReservation, setSelectedReservation] = useState<ReservationDetailV2 | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [modalView, setModalView] = useState<'detail' | 'cancel'>('detail');
const [isCancelling, setIsCancelling] = useState(false);
const loadReservations = async () => {
try {
setIsLoading(true);
const data = await fetchMyReservationsV2();
setReservations(data.reservations);
setError(null);
} catch (err) {
setError('예약 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadReservations();
}, []);
const handleShowDetail = async (id: string) => {
try {
setIsDetailLoading(true);
setDetailError(null);
setModalView('detail');
const detailData = await fetchReservationDetailV2(id);
console.log('상세 정보:', detailData);
setSelectedReservation(detailData);
setIsModalOpen(true);
} catch (err) {
setDetailError('예약 상세 정보를 불러오는 데 실패했습니다.');
} finally {
setIsDetailLoading(false);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedReservation(null);
};
const handleCancelSubmit = async (reason: string) => {
if (!selectedReservation) return;
if (!window.confirm('정말 취소하시겠어요?')) {
return;
}
try {
setIsCancelling(true);
setDetailError(null);
await cancelReservationV2(selectedReservation.id, reason);
alert('예약을 취소했어요. 결제 취소까지는 3-5일 정도 소요될 수 있어요.');
handleCloseModal();
loadReservations(); // Refresh the list
} catch (err) {
setDetailError(err instanceof Error ? err.message : '예약 취소에 실패했습니다.');
} finally {
setIsCancelling(false);
}
};
return (
<div className="my-reservation-container-v2">
<h1> V2</h1>
{isLoading && <p> ...</p>}
{error && <p className="error-message-v2">{error}</p>}
{!isLoading && !error && (
<div className="reservation-list-v2">
{reservations.map((res) => (
console.log(res),
<div key={res.id} className={`reservation-summary-card-v2 status-${res.status.toLowerCase()}`}>
<div className="summary-details-v2">
<h3 className="summary-theme-name-v2">{res.themeName}</h3>
<p className="summary-datetime-v2">{formatCardDateTime(res.date, res.startAt)}</p>
</div>
<button
onClick={() => handleShowDetail(res.id)}
disabled={isDetailLoading}
className="detail-button-v2"
>
{isDetailLoading && selectedReservation?.id === res.id ? '로딩중...' : '상세보기'}
</button>
</div>
))}
</div>
)}
{isModalOpen && selectedReservation && (
<div className="modal-overlay-v2" onClick={handleCloseModal}>
<div className="modal-content-v2" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button-v2" onClick={handleCloseModal}>×</button>
<h2>{modalView === 'detail' ? '예약 상세 정보' : '예약 취소'}</h2>
{detailError && <p className="error-message-v2">{detailError}</p>}
{modalView === 'detail' ? (
<ReservationDetailView
reservation={selectedReservation}
onGoToCancel={() => setModalView('cancel')}
/>
) : (
<CancellationView
reservation={selectedReservation}
onCancelSubmit={handleCancelSubmit}
onBack={() => setModalView('detail')}
isCancelling={isCancelling}
/>
)}
</div>
</div>
)}
</div>
);
};
export default MyReservationPageV2;

View File

@ -0,0 +1,156 @@
import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Flatpickr from 'react-flatpickr';
import 'flatpickr/dist/flatpickr.min.css';
import '@_css/reservation-v2.css';
import { fetchThemes } from '@_api/theme/themeAPI';
import { fetchTimesWithAvailability } from '@_api/time/timeAPI';
import { createPendingReservation } from '@_api/reservation/reservationAPI';
import type { ThemeRetrieveResponse } from '@_api/theme/themeTypes';
import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes';
import { isLoginRequiredError } from '@_api/apiClient';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [themes, setThemes] = useState<ThemeRetrieveResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeRetrieveResponse | null>(null);
const [times, setTimes] = useState<TimeWithAvailabilityResponse[]>([]);
const [selectedTime, setSelectedTime] = useState<TimeWithAvailabilityResponse | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
fetchThemes().then(res => setThemes(res.themes)).catch(handleError);
}, []);
useEffect(() => {
if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
fetchTimesWithAvailability(dateStr, selectedTheme.id)
.then(res => {
setTimes(res.times);
setSelectedTime(null);
})
.catch(handleError);
}
}, [selectedDate, selectedTheme]);
const handleNextStep = () => {
if (!selectedDate || !selectedTheme || !selectedTime) {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
if (!selectedTime.isAvailable) {
alert('예약할 수 없는 시간입니다.');
return;
}
setIsModalOpen(true);
};
const handleConfirmPayment = () => {
if (!selectedDate || !selectedTheme || !selectedTime) return;
const reservationData = {
date: selectedDate.toLocaleDateString('en-CA'),
themeId: selectedTheme.id,
timeId: selectedTime.id,
};
createPendingReservation(reservationData)
.then((res) => {
navigate('/v2/reservation/payment', { state: { reservation: res } });
})
.catch(handleError)
.finally(() => setIsModalOpen(false));
};
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable;
return (
<div className="content-container col-md-10 offset-md-1 p-5">
<h2 className="content-container-title"> </h2>
<div className="d-flex" id="reservation-container">
<div className="section border rounded col-md-4 p-3" id="date-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="d-flex justify-content-center">
<Flatpickr
value={selectedDate || undefined}
onChange={([date]) => setSelectedDate(date)}
options={{ inline: true, defaultDate: new Date(), minDate: "today" }}
/>
</div>
</div>
<div className={`section border rounded col-md-4 p-3 ${!selectedDate ? 'disabled' : ''}`} id="theme-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="p-3" id="theme-slots">
{themes.map(theme => (
<div key={theme.id}
className={`theme-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTheme?.id === theme.id ? 'active' : ''}`}
onClick={() => setSelectedTheme(theme)}>
{theme.name}
</div>
))}
</div>
</div>
<div className={`section border rounded col-md-4 p-3 ${!selectedTheme ? 'disabled' : ''}`} id="time-section">
<h3 className="fs-5 text-center mb-3"> </h3>
<div className="p-3" id="time-slots">
{times.length > 0 ? times.map(time => (
<div key={time.id}
className={`time-slot cursor-pointer bg-light border rounded p-3 mb-2 ${selectedTime?.id === time.id ? 'active' : ''} ${!time.isAvailable ? 'disabled' : ''}`}
onClick={() => time.isAvailable && setSelectedTime(time)}>
{time.startAt} {!time.isAvailable && '(예약불가)'}
</div>
)) : <div className="no-times"> .</div>}
</div>
</div>
</div>
<div className="button-group float-end mt-3">
<button className="btn btn-primary" disabled={isButtonDisabled} onClick={handleNextStep}>
</button>
</div>
{isModalOpen && (
<div className="modal-backdrop">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title"> </h5>
<button type="button" className="btn-close" onClick={() => setIsModalOpen(false)}></button>
</div>
<div className="modal-body">
<p><strong>:</strong> {selectedDate?.toLocaleDateString('ko-KR')}</p>
<p><strong>:</strong> {selectedTheme?.name}</p>
<p><strong>:</strong> {selectedTime?.startAt}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setIsModalOpen(false)}></button>
<button type="button" className="btn btn-primary" onClick={handleConfirmPayment}></button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ReservationStep1Page;

View File

@ -0,0 +1,118 @@
import React, { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { confirmReservationPayment } from '@_api/reservation/reservationAPI';
import { isLoginRequiredError } from '@_api/apiClient';
import { PaymentType, type ReservationCreateResponse, type ReservationPaymentRequest } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2.css';
declare global {
interface Window {
PaymentWidget: any;
}
}
const ReservationStep2Page: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const paymentWidgetRef = useRef<any>(null);
const paymentMethodsRef = useRef<any>(null);
const reservation: ReservationCreateResponse | undefined = location.state?.reservation;
const handleError = (err: any) => {
if (isLoginRequiredError(err)) {
alert('로그인이 필요해요.');
navigate('/login', { state: { from: location } });
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
};
useEffect(() => {
if (!reservation) {
alert('잘못된 접근입니다.');
navigate('/v2/reservation');
return;
}
const script = document.createElement('script');
script.src = 'https://js.tosspayments.com/v1/payment-widget';
script.async = true;
document.head.appendChild(script);
script.onload = () => {
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const paymentWidget = window.PaymentWidget(widgetClientKey, window.PaymentWidget.ANONYMOUS);
paymentWidgetRef.current = paymentWidget;
const paymentMethods = paymentWidget.renderPaymentMethods(
"#payment-method",
{ value: 1000 }, // TODO: 테마별 가격 적용
{ variantKey: "DEFAULT" }
);
paymentMethodsRef.current = paymentMethods;
};
}, [reservation, navigate]);
const handlePayment = () => {
if (!paymentWidgetRef.current || !reservation) {
alert('결제 위젯이 로드되지 않았거나 예약 정보가 없습니다.');
return;
}
const generateRandomString = () =>
crypto.randomUUID().replace(/-/g, '');
paymentWidgetRef.current.requestPayment({
orderId: generateRandomString(),
orderName: "테스트 방탈출 예약 결제 1건",
amount: 1000,
}).then((data: any) => {
const paymentData: ReservationPaymentRequest = {
paymentKey: data.paymentKey,
orderId: data.orderId,
amount: data.amount,
paymentType: data.paymentType || PaymentType.NORMAL,
};
confirmReservationPayment(reservation.reservationId, paymentData)
.then((res) => {
navigate('/v2/reservation/success', {
state: {
reservation: res,
themeName: reservation.themeName,
date: reservation.date,
startAt: reservation.startAt,
}
});
})
.catch(handleError);
}).catch((error: any) => {
console.error("Payment request error:", error);
alert("결제 요청 중 오류가 발생했습니다.");
});
};
if (!reservation) {
return null; // or a loading spinner
}
return (
<div className="content-container col-md-10 offset-md-1 p-5">
<div className="wrapper w-100">
<div className="max-w-540 w-100">
<div id="payment-method" className="w-100"></div>
<div id="agreement" className="w-100"></div>
<div className="btn-wrapper w-100 mt-3">
<button onClick={handlePayment} className="btn btn-primary w-100">
1,000
</button>
</div>
</div>
</div>
</div>
);
};
export default ReservationStep2Page;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import type { ReservationPaymentResponse } from '@_api/reservation/reservationTypes';
import '@_css/reservation-v2.css';
const ReservationSuccessPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { reservation, themeName, date, startAt } = (location.state as {
reservation: ReservationPaymentResponse;
themeName: string;
date: string;
startAt: string;
}) || {};
if (!reservation) {
React.useEffect(() => {
navigate('/');
}, [navigate]);
return null;
}
return (
<div className="reservation-success-page">
<h2 className="content-container-title"> !</h2>
<div className="reservation-info-box">
<h3> </h3>
<div className="info-item"><strong>:</strong> <span>{themeName}</span></div>
<div className="info-item"><strong>:</strong> <span>{date}</span></div>
<div className="info-item"><strong>:</strong> <span>{startAt}</span></div>
</div>
<div className="success-page-actions">
<Link to="/my-reservation/v2" className="btn btn-secondary">
</Link>
<Link to="/" className="btn btn-secondary">
</Link>
</div>
</div>
);
};
export default ReservationSuccessPage;

View File

@ -1,6 +1,9 @@
package roomescape.common.config 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.JavaTimeModule
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer
@ -9,17 +12,26 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer
import com.fasterxml.jackson.module.kotlin.kotlinModule import com.fasterxml.jackson.module.kotlin.kotlinModule
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import java.time.LocalDate import roomescape.common.exception.CommonErrorCode
import java.time.LocalTime import roomescape.common.exception.RoomescapeException
import java.time.*
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@Configuration @Configuration
class JacksonConfig { class JacksonConfig {
companion object {
private val ISO_OFFSET_DATE_TIME_FORMATTER: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
}
@Bean @Bean
fun objectMapper(): ObjectMapper = ObjectMapper() fun objectMapper(): ObjectMapper = ObjectMapper()
.registerModule(javaTimeModule()) .registerModule(javaTimeModule())
.registerModule(dateTimeModule())
.registerModule(kotlinModule()) .registerModule(kotlinModule())
.registerModule(longIdModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() private fun javaTimeModule(): JavaTimeModule = JavaTimeModule()
.addSerializer( .addSerializer(
@ -38,4 +50,66 @@ class JacksonConfig {
LocalTime::class.java, LocalTime::class.java,
LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm"))
) as JavaTimeModule ) as JavaTimeModule
private fun longIdModule(): SimpleModule {
val simpleModule = SimpleModule()
simpleModule.addSerializer(Long::class.java, LongToStringSerializer())
simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer())
return simpleModule
}
private fun dateTimeModule(): SimpleModule {
val simpleModule = SimpleModule()
simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer())
simpleModule.addSerializer(OffsetDateTime::class.java, OffsetDateTimeSerializer())
return simpleModule
}
class LongToStringSerializer : JsonSerializer<Long>() {
override fun serialize(value: Long?, gen: JsonGenerator, serializers: SerializerProvider) {
if (value == null) {
gen.writeNull()
} else {
gen.writeString(value.toString())
}
}
}
class StringToLongDeserializer : JsonDeserializer<Long>() {
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)
}
}
}
class LocalDateTimeSerializer : JsonSerializer<LocalDateTime>() {
override fun serialize(
value: LocalDateTime,
gen: JsonGenerator,
serializers: SerializerProvider
) {
value.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
.also {
gen.writeString(it.format(ISO_OFFSET_DATE_TIME_FORMATTER))
}
}
}
class OffsetDateTimeSerializer : JsonSerializer<OffsetDateTime>() {
override fun serialize(
value: OffsetDateTime,
gen: JsonGenerator,
serializers: SerializerProvider
) {
gen.writeString(value.format(ISO_OFFSET_DATE_TIME_FORMATTER))
}
}
} }

View File

@ -33,3 +33,22 @@ abstract class BaseEntity(
abstract override fun getId(): Long? abstract override fun getId(): Long?
} }
@MappedSuperclass
abstract class PersistableBaseEntity(
@Id
@Column(name = "id")
private val _id: Long,
@Transient
private var isNewEntity: Boolean = true
): Persistable<Long> {
@PostLoad
@PostPersist
fun markNotNew() {
isNewEntity = false
}
override fun isNew(): Boolean = isNewEntity
override fun getId(): Long = _id
}

View File

@ -47,14 +47,19 @@ class ControllerLoggingAspect(
private fun logSuccess(startTime: Long, result: Any) { private fun logSuccess(startTime: Long, result: Any) {
val responseEntity = result as ResponseEntity<*> val responseEntity = result as ResponseEntity<*>
val logMessage = messageConverter.convertToResponseMessage( var convertResponseMessageRequest = ConvertResponseMessageRequest(
ConvertResponseMessageRequest( type = LogType.CONTROLLER_SUCCESS,
type = LogType.CONTROLLER_SUCCESS, httpStatus = responseEntity.statusCode.value(),
httpStatus = responseEntity.statusCode.value(), startTime = startTime,
startTime = startTime, )
if (log.isDebugEnabled()) {
convertResponseMessageRequest = convertResponseMessageRequest.copy(
body = responseEntity.body body = responseEntity.body
) )
) }
val logMessage = messageConverter.convertToResponseMessage(convertResponseMessageRequest)
log.info { logMessage } log.info { logMessage }
} }

View File

@ -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 <T> 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)
}
}
}

View File

@ -9,8 +9,13 @@ enum class PaymentErrorCode(
override val message: String override val message: String
) : ErrorCode { ) : ErrorCode {
PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."), PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."),
CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."), PAYMENT_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "결제 상세 정보를 찾을 수 없어요."),
PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P003", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."), CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P003", "취소된 결제 정보를 찾을 수 없어요."),
PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P004", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."),
ORGANIZATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "P005", "은행 / 카드사 정보를 찾을 수 없어요."),
TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "P006", "타입 정보를 찾을 수 없어요."),
NOT_SUPPORTED_PAYMENT_TYPE(HttpStatus.BAD_REQUEST, "P007", "지원하지 않는 결제 수단이에요."),
PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.") PAYMENT_UNEXPECTED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P998", "결제 과정중 예상치 못한 예외가 발생했어요."),
PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요."),
} }

View File

@ -0,0 +1,53 @@
package roomescape.payment.implement
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Component
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentRepositoryV2
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
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentFinderV2(
private val paymentRepository: PaymentRepositoryV2,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2
) {
fun findPaymentByReservationId(reservationId: Long): PaymentEntityV2 {
log.debug { "[PaymentFinderV2.findByReservationId] 시작: reservationId=$reservationId" }
return paymentRepository.findByReservationId(reservationId)?.also {
log.debug { "[PaymentFinderV2.findByReservationId] 완료: reservationId=$reservationId, paymentId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findByReservationId] 실패: reservationId=$reservationId" }
throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
}
}
fun findPaymentDetailByPaymentId(paymentId: Long): PaymentDetailEntity {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 시작: paymentId=$paymentId" }
return paymentDetailRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findPaymentDetailByPaymentId] 완료: paymentId=$paymentId, detailId=${it.id}" }
} ?: run {
log.warn { "[PaymentFinderV2.findPaymentDetailByPaymentId] 실패: paymentId=$paymentId" }
throw PaymentException(PaymentErrorCode.PAYMENT_DETAIL_NOT_FOUND)
}
}
fun findCanceledPaymentByPaymentIdOrNull(paymentId: Long): CanceledPaymentEntityV2? {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 시작: paymentId=$paymentId" }
return canceledPaymentRepository.findByPaymentId(paymentId)?.also {
log.debug { "[PaymentFinderV2.findCanceledPaymentByKey] 완료: paymentId=$paymentId, canceledPaymentId=${it.id}" }
}
}
}

View File

@ -0,0 +1,21 @@
package roomescape.payment.implement
import org.springframework.stereotype.Component
import roomescape.payment.infrastructure.client.v2.*
@Component
class PaymentRequester(
private val client: TosspaymentClientV2
) {
fun requestConfirmPayment(paymentKey: String, orderId: String, amount: Int): PaymentConfirmResponse {
val request = PaymentConfirmRequest(paymentKey, orderId, amount)
return client.confirm(request)
}
fun requestCancelPayment(paymentKey: String, amount: Int, cancelReason: String): PaymentCancelResponseV2 {
val request = PaymentCancelRequestV2(paymentKey, amount, cancelReason)
return client.cancel(request)
}
}

View File

@ -0,0 +1,80 @@
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.*
import roomescape.reservation.web.ReservationPaymentRequest
import java.time.LocalDateTime
private val log: KLogger = KotlinLogging.logger {}
@Component
class PaymentWriterV2(
private val paymentRepository: PaymentRepositoryV2,
private val paymentDetailRepository: PaymentDetailRepository,
private val canceledPaymentRepository: CanceledPaymentRepositoryV2,
private val tsidFactory: TsidFactory,
) {
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)
createDetail(paymentConfirmResponse, it.id)
log.debug { "[PaymentWriterV2.createPayment] 결제 승인 및 결제 정보 저장 완료: reservationId=${reservationId}, paymentId=${it.id}" }
}
}
private fun createDetail(
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 createCanceledPayment(
memberId: Long,
payment: PaymentEntityV2,
requestedAt: LocalDateTime,
cancelResponse: PaymentCancelResponseV2
) {
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 시작: paymentId=${payment.id}, paymentKey=${payment.paymentKey}" }
val canceledPayment: CanceledPaymentEntityV2 = cancelResponse.cancels.toEntity(
id = tsidFactory.next(),
paymentId = payment.id,
cancelRequestedAt = requestedAt,
canceledBy = memberId
)
canceledPaymentRepository.save(canceledPayment).also {
payment.cancel()
log.debug { "[PaymentWriterV2.cancelPayment] 취소된 결제 정보 저장 완료: paymentId=${payment.id}, canceledPaymentId=${it.id}, paymentKey=${payment.paymentKey}" }
}
}
}

View File

@ -1,6 +1,5 @@
package roomescape.payment.infrastructure.client package roomescape.payment.infrastructure.client
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import java.time.OffsetDateTime import java.time.OffsetDateTime
data class TossPaymentErrorResponse( data class TossPaymentErrorResponse(
@ -15,7 +14,6 @@ data class PaymentApproveRequest(
val paymentType: String val paymentType: String
) )
@JsonIgnoreProperties(ignoreUnknown = true)
data class PaymentApproveResponse( data class PaymentApproveResponse(
val paymentKey: String, val paymentKey: String,
val orderId: String, val orderId: String,

View File

@ -0,0 +1,74 @@
package roomescape.payment.infrastructure.client.v2
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 roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import java.time.LocalDateTime
import java.time.OffsetDateTime
data class PaymentCancelRequestV2(
val paymentKey: String,
val amount: Int,
val cancelReason: String
)
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,
val cancelReason: String
)
fun CancelDetail.toEntity(
id: Long,
paymentId: Long,
canceledBy: Long,
cancelRequestedAt: LocalDateTime
) = CanceledPaymentEntityV2(
id = id,
canceledAt = this.canceledAt,
requestedAt = cancelRequestedAt,
paymentId = paymentId,
canceledBy = canceledBy,
cancelReason = this.cancelReason,
cancelAmount = this.cancelAmount,
cardDiscountAmount = this.cardDiscountAmount,
transferDiscountAmount = this.transferDiscountAmount,
easypayDiscountAmount = this.easyPayDiscountAmount
)
class CancelDetailDeserializer : JsonDeserializer<CancelDetail>() {
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()),
cancelReason = targetNode.get("cancelReason").asText()
)
}
}

View File

@ -0,0 +1,136 @@
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).also {
log.info { "[TossPaymentClientV2.confirm] 결제 승인 완료: response=$it" }
}
}
fun cancel(request: PaymentCancelRequestV2): PaymentCancelResponseV2 {
log.info { "[TossPaymentClient.cancel] 결제 취소 요청: request=$request" }
return cancelClient.request(request).also {
log.info { "[TossPaymentClient.cancel] 결제 취소 완료: response=$it" }
}
}
}
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: ${parseResponse(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 parseResponse(response: ClientHttpResponse): TossPaymentErrorResponse {
val body = response.body
return objectMapper.readValue(body, TossPaymentErrorResponse::class.java).also {
body.close()
}
}
}

View File

@ -0,0 +1,124 @@
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: Int,
)
data class PaymentConfirmResponse(
val paymentKey: String,
val status: PaymentStatus,
val totalAmount: Int,
val vat: Int,
val suppliedAmount: Int,
val method: PaymentMethod,
val card: CardDetail?,
val easyPay: EasyPayDetail?,
val transfer: TransferDetail?,
val requestedAt: OffsetDateTime,
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,
val amount: Int,
val cardType: CardType,
val ownerType: CardOwnerType,
val isInterestFree: Boolean,
val approveNo: String,
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
)
}

View File

@ -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(
val koreanName: String
) {
NORMAL("일반결제"),
BILLING("자동결제"),
BRANDPAY("브랜드페이"),
;
companion object {
private val CACHE: Map<String, PaymentType> = entries.associateBy { it.name }
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun get(name: String): PaymentType {
return CACHE[name.uppercase()] ?: run {
log.warn { "[PaymentTypes.PaymentType] 결제 타입 조회 실패: type=$name" }
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<String, PaymentMethod> = 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<String, PaymentStatus> = 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(
val koreanName: String
) {
CREDIT("신용"),
CHECK("체크"),
GIFT("기프트"),
UNKNOWN("미확인"),
;
companion object {
private val CACHE: Map<String, CardType> = 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(
val koreanName: String
) {
PERSONAL("개인"),
CORPORATE("법인"),
UNKNOWN("미확인"),
;
companion object {
private val CACHE: Map<String, CardOwnerType> = 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<String, BankCode> = 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<String, CardIssuerCode> = 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<String, EasyPayCompanyCode> = 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)
}
}
}
}

View File

@ -0,0 +1,24 @@
package roomescape.payment.infrastructure.persistence.v2
import jakarta.persistence.Entity
import jakarta.persistence.Table
import roomescape.common.entity.PersistableBaseEntity
import java.time.LocalDateTime
import java.time.OffsetDateTime
@Entity
@Table(name = "canceled_payment1")
class CanceledPaymentEntityV2(
id: Long,
val paymentId: Long,
val requestedAt: LocalDateTime,
val canceledAt: OffsetDateTime,
val canceledBy: Long,
val cancelReason: String,
val cancelAmount: Int,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easypayDiscountAmount: Int,
) : PersistableBaseEntity(id)

View File

@ -0,0 +1,7 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface CanceledPaymentRepositoryV2 : JpaRepository<CanceledPaymentEntityV2, Long> {
fun findByPaymentId(paymentId: Long): CanceledPaymentEntityV2?
}

View File

@ -0,0 +1,79 @@
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 suppliedAmount: 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,
suppliedAmount: 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, suppliedAmount, vat)
@Entity
@Table(name = "payment_bank_transfer_detail")
class PaymentBankTransferDetailEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int,
@Enumerated(EnumType.STRING)
val bankCode: BankCode,
val settlementStatus: String,
) : PaymentDetailEntity(id, paymentId, suppliedAmount, vat)
@Entity
@Table(name = "payment_easypay_prepaid_detail")
class PaymentEasypayPrepaidDetailEntity(
id: Long,
paymentId: Long,
suppliedAmount: Int,
vat: Int,
@Enumerated(EnumType.STRING)
val easypayProviderCode: EasyPayCompanyCode,
val amount: Int,
val discountAmount: Int
) : PaymentDetailEntity(id, paymentId, suppliedAmount, vat)

View File

@ -0,0 +1,7 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentDetailRepository: JpaRepository<PaymentDetailEntity, Long> {
fun findByPaymentId(paymentId: Long) : PaymentDetailEntity?
}

View File

@ -0,0 +1,38 @@
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 orderId: 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)
var status: PaymentStatus
) : PersistableBaseEntity(id) {
fun cancel() {
this.status = PaymentStatus.CANCELED
}
}

View File

@ -0,0 +1,8 @@
package roomescape.payment.infrastructure.persistence.v2
import org.springframework.data.jpa.repository.JpaRepository
interface PaymentRepositoryV2: JpaRepository<PaymentEntityV2, Long> {
fun findByReservationId(reservationId: Long): PaymentEntityV2?
}

View File

@ -0,0 +1,54 @@
package roomescape.reservation.business
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.payment.implement.PaymentFinderV2
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
import roomescape.reservation.web.toCancelDetailResponse
import roomescape.reservation.web.toPaymentDetailResponse
import roomescape.reservation.web.toReservationDetailRetrieveResponse
import roomescape.reservation.web.toRetrieveResponse
import roomescape.reservation.web.toSummaryListResponse
private val log: KLogger = KotlinLogging.logger {}
@Service
class MyReservationFindService(
private val reservationFinder: ReservationFinder,
private val paymentFinder: PaymentFinderV2
) {
@Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): ReservationSummaryRetrieveListResponse {
log.debug { "[ReservationFindServiceV2.findReservationsByMemberId] 시작: memberId=$memberId" }
return reservationFinder.findAllByMemberIdV2(memberId)
.toSummaryListResponse()
.also { log.info { "[ReservationFindServiceV2.findReservationsByMemberId] ${it.reservations.size}개의 예약 조회 완료: memberId=$memberId" } }
}
@Transactional(readOnly = true)
fun showReservationDetails(reservationId: Long): ReservationDetailRetrieveResponse {
log.debug { "[ReservationFindServiceV2.showReservationDetails] 시작: reservationId=$reservationId" }
val reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentDetail: PaymentDetailEntity = paymentFinder.findPaymentDetailByPaymentId(payment.id)
val canceledPayment: CanceledPaymentEntityV2? = paymentFinder.findCanceledPaymentByPaymentIdOrNull(payment.id)
return reservation.toReservationDetailRetrieveResponse(
payment = payment.toRetrieveResponse(detail = paymentDetail.toPaymentDetailResponse()),
cancellation = canceledPayment?.toCancelDetailResponse()
).also {
log.info { "[ReservationFindServiceV2.showReservationDetails] 예약 상세 조회 완료: reservationId=$reservationId" }
}
}
}

View File

@ -0,0 +1,108 @@
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.PaymentFinderV2
import roomescape.payment.implement.PaymentRequester
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 paymentRequester: PaymentRequester,
private val paymentFinder: PaymentFinderV2,
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: PaymentConfirmResponse = paymentRequester.requestConfirmPayment(
paymentKey = request.paymentKey,
orderId = request.orderId,
amount = request.amount
)
return transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
val payment: PaymentEntityV2 = paymentWriter.createPayment(reservationId, request, paymentConfirmResponse)
val reservation: ReservationEntity =
reservationWriter.modifyStatusFromPendingToConfirmed(reservationId, memberId)
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 reservation: ReservationEntity = reservationFinder.findById(reservationId)
val payment: PaymentEntityV2 = paymentFinder.findPaymentByReservationId(reservationId)
val paymentCancelResponse = paymentRequester.requestCancelPayment(
paymentKey = payment.paymentKey,
amount = payment.totalAmount,
cancelReason = request.cancelReason
)
transactionExecutionUtil.withNewTransaction(isReadOnly = false) {
paymentWriter.createCanceledPayment(memberId, payment, request.requestedAt, paymentCancelResponse)
reservationWriter.modifyStatusToCanceledByUser(reservation, memberId)
}.also {
log.info {
"[ReservationWithPaymentServiceV2.cancelReservation] " +
"예약 취소 완료: reservationId=$reservationId, memberId=$memberId, cancelReason=${request.cancelReason}"
}
}
}
}

View File

@ -0,0 +1,33 @@
package roomescape.reservation.docs
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PathVariable
import roomescape.auth.web.support.LoginRequired
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.ReservationDetailRetrieveResponse
import roomescape.reservation.web.ReservationSummaryRetrieveListResponse
interface MyReservationAPI {
@LoginRequired
@Operation(summary = "내 예약 개요 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun findAllMyReservations(
@MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>>
@LoginRequired
@Operation(summary = "예약 상세 조회", tags = ["로그인이 필요한 API"])
@ApiResponses(
ApiResponse(responseCode = "200", description = "성공"),
)
fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>>
}

View File

@ -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<CommonApiResponse<ReservationCreateResponseV2>>
@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<CommonApiResponse<Unit>>
@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<CommonApiResponse<ReservationPaymentResponse>>
}

View File

@ -17,4 +17,6 @@ enum class ReservationErrorCode(
NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."), NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."),
INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."), INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."),
NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."), NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."),
RESERVATION_NOT_PENDING(HttpStatus.BAD_REQUEST, "R009", "결제 대기 중인 예약이 아니에요."),
;
} }

View File

@ -54,6 +54,15 @@ class ReservationFinder(
.also { log.debug { "[ReservationFinder.findAllByDateAndTheme] ${it.size}개 조회 완료: date=$date, themeId=${theme.id}" } } .also { log.debug { "[ReservationFinder.findAllByDateAndTheme] ${it.size}개 조회 완료: date=$date, themeId=${theme.id}" } }
} }
fun findAllByMemberIdV2(memberId: Long): List<ReservationEntity> {
log.debug { "[ReservationFinder.findAllByMember] 시작: memberId=${memberId}" }
return reservationRepository.findAllByMember_Id(memberId)
.filter { it.status == ReservationStatus.CONFIRMED || it.status == ReservationStatus.CANCELED_BY_USER }
.sortedByDescending { it.date }
.also { log.debug { "[ReservationFinder.findAllByMember] ${it.size}개 예약 조회 완료: memberId=${memberId}" } }
}
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> { fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> {
log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" } log.debug { "[ReservationFinder.findAllByMemberId] 시작: memberId=${memberId}" }
@ -91,4 +100,14 @@ class ReservationFinder(
return reservationRepository.existsByTime(time) return reservationRepository.existsByTime(time)
.also { log.debug { "[ReservationFinder.isTimeReserved] 완료: isExist=$it, timeId=${time.id}, startAt=${time.startAt}" } } .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}" }
}
}
} }

View File

@ -10,6 +10,7 @@ import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
@ -141,4 +142,37 @@ class ReservationValidator(
log.debug { "[ReservationValidator.validateAlreadyConfirmed] 완료: reservationId=$reservationId" } 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)
}
}
fun validateIsPending(reservation: ReservationEntity) {
log.debug { "[ReservationValidator.validateIsPending] 시작: reservationId=${reservation.id}, status=${reservation.status}" }
if (reservation.status != ReservationStatus.PENDING) {
log.warn { "[ReservationValidator.validateIsPending] 예약 상태가 결제 대기 중이 아님: reservationId=${reservation.id}, status=${reservation.status}" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_PENDING)
}
log.debug { "[ReservationValidator.validateIsPending] 완료: reservationId=${reservation.id}, status=${reservation.status}" }
}
fun validateModifyAuthority(reservation: ReservationEntity, memberId: Long) {
log.debug { "[ReservationValidator.validateModifyAuthority] 시작: reservationId=${reservation.id}, memberId=$memberId" }
if (reservation.member.id != memberId) {
log.error { "[ReservationValidator.validateModifyAuthority] 예약자 본인이 아님: reservationId=${reservation.id}, reservation.memberId=${reservation.member.id} memberId=$memberId" }
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
log.debug { "[ReservationValidator.validateModifyAuthority] 완료: reservationId=${reservation.id}, memberId=$memberId" }
}
} }

View File

@ -3,6 +3,7 @@ package roomescape.reservation.implement
import com.github.f4b6a3.tsid.TsidFactory import com.github.f4b6a3.tsid.TsidFactory
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.common.config.next import roomescape.common.config.next
import roomescape.member.implement.MemberFinder import roomescape.member.implement.MemberFinder
@ -101,4 +102,30 @@ class ReservationWriter(
log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" } log.debug { "[ReservationWriter.confirm] 완료: reservationId=$reservationId, status=${ReservationStatus.CONFIRMED_PAYMENT_REQUIRED}" }
} }
fun modifyStatusToCanceledByUser(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}" }
}
}
fun modifyStatusFromPendingToConfirmed(reservationId: Long, memberId: Long): ReservationEntity {
log.debug { "[ReservationWriter.confirmPendingReservation] 시작: reservationId=$reservationId, memberId=$memberId" }
return reservationRepository.findByIdOrNull(reservationId)?.also {
reservationValidator.validateIsPending(it)
reservationValidator.validateModifyAuthority(it, memberId)
it.confirm()
log.debug { "[ReservationWriter.confirmPendingReservation] 완료: reservationId=${it.id}, status=${it.status}" }
} ?: run {
log.warn { "[ReservationWriter.confirmPendingReservation] 예약을 찾을 수 없음: reservationId=$reservationId" }
throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
}
}
} }

View File

@ -43,12 +43,23 @@ class ReservationEntity(
fun isReservedBy(memberId: Long): Boolean { fun isReservedBy(memberId: Long): Boolean {
return this.member.id == memberId return this.member.id == memberId
} }
fun cancelByUser() {
this.status = ReservationStatus.CANCELED_BY_USER
}
fun confirm() {
this.status = ReservationStatus.CONFIRMED
}
} }
enum class ReservationStatus { enum class ReservationStatus {
CONFIRMED, CONFIRMED,
CONFIRMED_PAYMENT_REQUIRED, CONFIRMED_PAYMENT_REQUIRED,
PENDING,
WAITING, WAITING,
CANCELED_BY_USER,
AUTOMATICALLY_CANCELED,
; ;
companion object { companion object {

View File

@ -70,4 +70,6 @@ interface ReservationRepository
) )
fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse> fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List<ReservationEntity> fun findAllByDateAndTheme(date: LocalDate, theme: ThemeEntity): List<ReservationEntity>
fun findAllByMember_Id(memberId: Long): List<ReservationEntity>
} }

View File

@ -0,0 +1,35 @@
package roomescape.reservation.web
import io.swagger.v3.oas.annotations.Parameter
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.MyReservationFindService
import roomescape.reservation.docs.MyReservationAPI
@RestController
class MyReservationController(
private val reservationFindService: MyReservationFindService
) : MyReservationAPI {
@GetMapping("/v2/reservations")
override fun findAllMyReservations(
@MemberId @Parameter(hidden=true) memberId: Long
): ResponseEntity<CommonApiResponse<ReservationSummaryRetrieveListResponse>> {
val response = reservationFindService.findReservationsByMemberId(memberId)
return ResponseEntity.ok(CommonApiResponse(response))
}
@GetMapping("/v2/reservations/{id}/details")
override fun showReservationDetails(
@PathVariable("id") reservationId: Long
): ResponseEntity<CommonApiResponse<ReservationDetailRetrieveResponse>> {
val response = reservationFindService.showReservationDetails(reservationId)
return ResponseEntity.ok(CommonApiResponse(response))
}
}

View File

@ -0,0 +1,196 @@
package roomescape.reservation.web
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.payment.exception.PaymentErrorCode
import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.common.PaymentStatus
import roomescape.payment.infrastructure.persistence.v2.CanceledPaymentEntityV2
import roomescape.payment.infrastructure.persistence.v2.PaymentBankTransferDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentCardDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEasypayPrepaidDetailEntity
import roomescape.payment.infrastructure.persistence.v2.PaymentEntityV2
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.web.PaymentDetailResponse.BankTransferDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.CardDetailResponse
import roomescape.reservation.web.PaymentDetailResponse.EasyPayPrepaidDetailResponse
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.OffsetDateTime
import kotlin.Int
data class ReservationSummaryRetrieveResponse(
val id: Long,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val status: ReservationStatus
)
fun ReservationEntity.toReservationSummaryRetrieveResponse(): ReservationSummaryRetrieveResponse {
return ReservationSummaryRetrieveResponse(
id = this.id!!,
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
status = this.status
)
}
data class ReservationSummaryRetrieveListResponse(
val reservations: List<ReservationSummaryRetrieveResponse>
)
fun List<ReservationEntity>.toSummaryListResponse(): ReservationSummaryRetrieveListResponse {
return ReservationSummaryRetrieveListResponse(
reservations = this.map { it.toReservationSummaryRetrieveResponse() }
)
}
data class ReservationDetailRetrieveResponse(
val id: Long,
val user: UserDetailRetrieveResponse,
val themeName: String,
val date: LocalDate,
val startAt: LocalTime,
val applicationDateTime: LocalDateTime,
val payment: PaymentRetrieveResponse,
val cancellation: PaymentCancelDetailResponse? = null
)
data class UserDetailRetrieveResponse(
val id: Long,
val name: String,
val email: String
)
fun MemberEntity.toUserDetailRetrieveResponse(): UserDetailRetrieveResponse {
return UserDetailRetrieveResponse(
id = this.id!!,
name = this.name,
email = this.email
)
}
fun ReservationEntity.toReservationDetailRetrieveResponse(
payment: PaymentRetrieveResponse,
cancellation: PaymentCancelDetailResponse? = null
): ReservationDetailRetrieveResponse {
return ReservationDetailRetrieveResponse(
id = this.id!!,
user = this.member.toUserDetailRetrieveResponse(),
themeName = this.theme.name,
date = this.date,
startAt = this.time.startAt,
applicationDateTime = this.createdAt!!,
payment = payment,
cancellation = cancellation,
)
}
data class PaymentRetrieveResponse(
val orderId: String,
val totalAmount: Int,
val method: String,
val status: PaymentStatus,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val detail: PaymentDetailResponse,
)
fun PaymentEntityV2.toRetrieveResponse(detail: PaymentDetailResponse): PaymentRetrieveResponse {
return PaymentRetrieveResponse(
orderId = this.orderId,
totalAmount = this.totalAmount,
method = this.method.koreanName,
status = this.status,
requestedAt = this.requestedAt,
approvedAt = this.approvedAt,
detail = detail
)
}
sealed class PaymentDetailResponse {
data class CardDetailResponse(
val type: String = "CARD",
val issuerCode: String,
val cardType: String,
val ownerType: String,
val cardNumber: String,
val amount: Int,
val approvalNumber: String,
val installmentPlanMonths: Int,
val easypayProviderName: String?,
val easypayDiscountAmount: Int?,
) : PaymentDetailResponse()
data class BankTransferDetailResponse(
val type: String = "BANK_TRANSFER",
val bankName: String,
) : PaymentDetailResponse()
data class EasyPayPrepaidDetailResponse(
val type: String = "EASYPAY_PREPAID",
val providerName: String,
val amount: Int,
val discountAmount: Int,
) : PaymentDetailResponse()
}
fun PaymentDetailEntity.toPaymentDetailResponse(): PaymentDetailResponse {
return when (this) {
is PaymentCardDetailEntity -> this.toCardDetailResponse()
is PaymentBankTransferDetailEntity -> this.toBankTransferDetailResponse()
is PaymentEasypayPrepaidDetailEntity -> this.toEasyPayPrepaidDetailResponse()
else -> throw PaymentException(PaymentErrorCode.NOT_SUPPORTED_PAYMENT_TYPE)
}
}
fun PaymentCardDetailEntity.toCardDetailResponse(): CardDetailResponse {
return CardDetailResponse(
issuerCode = this.issuerCode.koreanName,
cardType = this.cardType.koreanName,
ownerType = this.ownerType.koreanName,
cardNumber = this.cardNumber,
amount = this.amount,
approvalNumber = this.approvalNumber,
installmentPlanMonths = this.installmentPlanMonths,
easypayProviderName = this.easypayProviderCode?.koreanName,
easypayDiscountAmount = this.easypayDiscountAmount
)
}
fun PaymentBankTransferDetailEntity.toBankTransferDetailResponse(): BankTransferDetailResponse {
return BankTransferDetailResponse(
bankName = this.bankCode.koreanName
)
}
fun PaymentEasypayPrepaidDetailEntity.toEasyPayPrepaidDetailResponse(): EasyPayPrepaidDetailResponse {
return EasyPayPrepaidDetailResponse(
providerName = this.easypayProviderCode.koreanName,
amount = this.amount,
discountAmount = this.discountAmount
)
}
data class PaymentCancelDetailResponse(
val cancellationRequestedAt: LocalDateTime,
val cancellationApprovedAt: OffsetDateTime?,
val cancelReason: String,
val canceledBy: Long,
)
fun CanceledPaymentEntityV2.toCancelDetailResponse(): PaymentCancelDetailResponse {
return PaymentCancelDetailResponse(
cancellationRequestedAt = this.requestedAt,
cancellationApprovedAt = this.canceledAt,
cancelReason = this.cancelReason,
canceledBy = this.canceledBy
)
}

View File

@ -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<CommonApiResponse<ReservationCreateResponseV2>> {
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<CommonApiResponse<ReservationPaymentResponse>> {
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<CommonApiResponse<Unit>> {
reservationWithPaymentService.cancelReservation(memberId, reservationId, cancelRequest)
return ResponseEntity.noContent().build()
}
}

View File

@ -0,0 +1,57 @@
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.LocalDateTime
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: Int,
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,
val requestedAt: LocalDateTime = LocalDateTime.now()
)

View File

@ -51,7 +51,7 @@ class TimeFinder(
val allTimes: List<TimeEntity> = findAll() val allTimes: List<TimeEntity> = findAll()
return allTimes.map { time -> 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) TimeWithAvailability(time.id!!, time.startAt, date, themeId, isReservable)
}.also { }.also {
log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" } log.debug { "[TimeFinder.findAllWithAvailabilityByDateAndThemeId] ${it.size}개 조회 완료: date:$date, themeId=$themeId" }

View File

@ -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) INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name)
VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'), VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'),

View File

@ -14,8 +14,8 @@ create table if not exists members (
name varchar(255) not null, name varchar(255) not null,
password varchar(255) not null, password varchar(255) not null,
role varchar(20) not null, role varchar(20) not null,
created_at timestamp null, created_at timestamp,
last_modified_at timestamp null last_modified_at timestamp
); );
create table if not exists themes ( create table if not exists themes (
@ -23,8 +23,8 @@ create table if not exists themes (
description varchar(255) not null, description varchar(255) not null,
name varchar(255) not null, name varchar(255) not null,
thumbnail varchar(255) not null, thumbnail varchar(255) not null,
created_at timestamp null, created_at timestamp,
last_modified_at timestamp null last_modified_at timestamp
); );
create table if not exists times ( create table if not exists times (
@ -41,8 +41,9 @@ create table if not exists reservations (
theme_id bigint not null, theme_id bigint not null,
time_id bigint not null, time_id bigint not null,
status varchar(30) not null, status varchar(30) not null,
created_at timestamp null, created_at timestamp,
last_modified_at timestamp null, last_modified_at timestamp,
constraint fk_reservations__themeId foreign key (theme_id) references themes (theme_id), 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__memberId foreign key (member_id) references members (member_id),
constraint fk_reservations__timeId foreign key (time_id) references times (time_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, total_amount bigint not null,
order_id varchar(255) not null, order_id varchar(255) not null,
payment_key varchar(255) not null, payment_key varchar(255) not null,
created_at timestamp null, created_at timestamp,
last_modified_at timestamp null, last_modified_at timestamp,
constraint uk_payments__reservationId unique (reservation_id), constraint uk_payments__reservationId unique (reservation_id),
constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id) constraint fk_payments__reservationId foreign key (reservation_id) references reservations (reservation_id)
); );
@ -68,6 +70,82 @@ create table if not exists canceled_payments (
cancel_amount bigint not null, cancel_amount bigint not null,
approved_at timestamp not null, approved_at timestamp not null,
canceled_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,
order_id 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,
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,
settlement_status varchar(20) 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 이지만 테스트 결제 위젯에서는 항상 000000으로 동일한 값이 나옴.
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,
requested_at timestamp not null,
canceled_at timestamp not null,
canceled_by bigint not null,
cancel_reason varchar(255) not null,
cancel_amount integer not null,
card_discount_amount integer not null,
transfer_discount_amount integer not null,
easypay_discount_amount 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)
); );

View File

@ -77,3 +77,85 @@ create table if not exists canceled_payments
created_at datetime(6) null, created_at datetime(6) null,
last_modified_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,
order_id 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,
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,
settlement_status varchar(20) 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 이지만 테스트 결제 위젯에서는 항상 000000으로 동일한 값이 나옴.
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,
requested_at datetime(6) not null,
canceled_at datetime(6) not null,
canceled_by bigint not null,
cancel_reason varchar(255) not null,
cancel_amount integer not null,
card_discount_amount integer not null,
transfer_discount_amount integer not null,
easypay_discount_amount 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)
);

View File

@ -0,0 +1,4 @@
### GET request to example server
POST localhost:8080/savetest
###

View File

@ -7,7 +7,10 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldContain
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class JacksonConfigTest( class JacksonConfigTest(
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper() private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
@ -52,4 +55,38 @@ class JacksonConfigTest(
}.message shouldContain "Text '$hour:$minute:$sec' could not be parsed" }.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
}
}
context("OffsetDateTime은 ISO 8601 형식으로 직렬화된다.") {
val date = LocalDate.of(2025, 7, 14)
val time = LocalTime.of(12, 30, 0)
val dateTime = OffsetDateTime.of(date, time, ZoneOffset.ofHours(9))
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("OffsetDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
context("LocalDateTime은 ISO 8601 형식으로 직렬화된다.") {
val dateTime = LocalDateTime.of(2025, 7, 14, 12, 30, 0)
val serialized: String = objectMapper.writeValueAsString(dateTime)
test("LocalDateTime 직렬화") {
serialized shouldBe "\"2025-07-14T12:30:00+09:00\""
}
}
}) })

View File

@ -465,7 +465,7 @@ class ReservationControllerTest(
post("/reservations/waiting") post("/reservations/waiting")
}.Then { }.Then {
statusCode(201) statusCode(201)
body("data.member.id", equalTo(member.id!!)) body("data.member.id", equalTo(member.id!!.toString()))
body("data.status", equalTo(ReservationStatus.WAITING.name)) body("data.status", equalTo(ReservationStatus.WAITING.name))
} }
} }

View File

@ -10,6 +10,7 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import roomescape.reservation.implement.ReservationFinder import roomescape.reservation.implement.ReservationFinder
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.time.business.domain.TimeWithAvailability import roomescape.time.business.domain.TimeWithAvailability
import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.exception.ThemeException import roomescape.theme.exception.ThemeException
@ -105,5 +106,39 @@ class TimeFinderTest : FunSpec({
it.all { time -> time.isReservable } 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<ReservationEntity>().apply {
every { time.id } returns times[0].id
},
mockk<ReservationEntity>().apply {
every { time.id } returns 0
}
)
val result: List<TimeWithAvailability> =
timeFinder.findAllWithAvailabilityByDateAndThemeId(date, themeId)
assertSoftly(result) {
it shouldHaveSize 2
it[0].isReservable shouldBe false
it[1].isReservable shouldBe true
}
}
} }
}) })